diff --git a/Cargo.lock b/Cargo.lock index 3f29b076..f6245b32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3500,6 +3500,7 @@ dependencies = [ "dirs", "gitlawb-core", "icaptcha-client", + "libc", "mockito", "reqwest", "serde", diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 66bfa096..68030db9 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1364,6 +1364,23 @@ fn load_or_create_keypair(config: &Config) -> Result { let key_path = config.resolved_key_path(); if key_path.exists() { + #[cfg(unix)] + let pem = { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + // O_NOFOLLOW so a planted symlink can't redirect the read to an + // attacker-chosen file presented as the node identity. + let mut f = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&key_path) + .with_context(|| format!("failed to read key from {}", key_path.display()))?; + let mut pem = String::new(); + f.read_to_string(&mut pem) + .with_context(|| format!("failed to read key from {}", key_path.display()))?; + pem + }; + #[cfg(not(unix))] let pem = std::fs::read_to_string(&key_path) .with_context(|| format!("failed to read key from {}", key_path.display()))?; let kp = Keypair::from_pem(&pem).map_err(|e| anyhow::anyhow!("invalid PEM key: {e}"))?; @@ -1375,18 +1392,46 @@ fn load_or_create_keypair(config: &Config) -> Result { .to_pem() .map_err(|e| anyhow::anyhow!("failed to serialize key: {e}"))?; - if let Some(parent) = key_path.parent() { - std::fs::create_dir_all(parent)?; - } - #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - std::fs::write(&key_path, pem.as_bytes())?; - std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?; + use std::io::Write; + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; + + if let Some(parent) = key_path.parent() { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent)?; + // Re-pin a pre-existing permissive dir, but never through a + // symlink: chmod would land on the link's target. + if !std::fs::symlink_metadata(parent)?.file_type().is_symlink() { + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + } + + // Pin the mode at creation: a plain write then chmod leaves the + // key world-readable between the two syscalls, and O_NOFOLLOW + // refuses to write through a pre-planted symlink. + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&key_path)?; + // mode() only applies when the file is created; pin on the + // descriptor before truncating so a pre-existing loose file is + // tightened before the new contents land in it. + f.set_permissions(std::fs::Permissions::from_mode(0o600))?; + f.set_len(0)?; + f.write_all(pem.as_bytes())?; } #[cfg(not(unix))] - std::fs::write(&key_path, pem.as_bytes())?; + { + if let Some(parent) = key_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&key_path, pem.as_bytes())?; + } info!(path = %key_path.display(), did = %kp.did(), "generated new node identity"); Ok(kp) diff --git a/crates/gl/Cargo.toml b/crates/gl/Cargo.toml index 2b973a4c..b0ecc387 100644 --- a/crates/gl/Cargo.toml +++ b/crates/gl/Cargo.toml @@ -27,6 +27,7 @@ dirs = "5" reqwest = { workspace = true } uuid = { workspace = true } urlencoding = "2" +libc = "0.2" alloy = { version = "1", default-features = false, features = [ "contract", "provider-http", diff --git a/crates/gl/src/identity.rs b/crates/gl/src/identity.rs index bde5c94c..872bd782 100644 --- a/crates/gl/src/identity.rs +++ b/crates/gl/src/identity.rs @@ -131,23 +131,13 @@ async fn cmd_new_with_reader( } } - fs::create_dir_all(&dir) + crate::secret_file::create_dir(&dir) .with_context(|| format!("failed to create directory {}", dir.display()))?; let keypair = Keypair::generate(); let pem = keypair.to_pem()?; - // Write with restricted permissions - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::write(&path, pem.as_bytes())?; - fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; - } - #[cfg(not(unix))] - { - fs::write(&path, pem.as_bytes())?; - } + crate::secret_file::write(&path, pem.as_bytes())?; let did = keypair.did(); println!("✓ Generated new identity"); @@ -202,16 +192,7 @@ async fn cmd_backup(out: Option, dir: Option) -> Result<()> { .join("identity.pem.bak") }); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::write(&dest, pem.as_bytes())?; - fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?; - } - #[cfg(not(unix))] - { - fs::write(&dest, pem.as_bytes())?; - } + crate::secret_file::write(&dest, pem.as_bytes())?; println!("✓ Identity backed up"); println!(" DID: {}", keypair.did()); @@ -262,19 +243,10 @@ async fn cmd_restore_with_reader( } } - fs::create_dir_all(&base) + crate::secret_file::create_dir(&base) .with_context(|| format!("failed to create directory {}", base.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::write(&dest, pem.as_bytes())?; - fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?; - } - #[cfg(not(unix))] - { - fs::write(&dest, pem.as_bytes())?; - } + crate::secret_file::write(&dest, pem.as_bytes())?; println!("✓ Identity restored"); println!(" DID: {}", keypair.did()); diff --git a/crates/gl/src/init.rs b/crates/gl/src/init.rs index 1bc3c406..d33fff89 100644 --- a/crates/gl/src/init.rs +++ b/crates/gl/src/init.rs @@ -105,16 +105,16 @@ pub async fn run(args: InitArgs) -> Result<()> { .dir .clone() .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb")); - std::fs::create_dir_all(&ucan_dir)?; + crate::secret_file::create_dir(&ucan_dir)?; let record = json!({ "ucan": ucan, "node": args.node, "did": did.to_string(), "saved_at": chrono::Utc::now().to_rfc3339(), }); - std::fs::write( - ucan_dir.join("ucan.json"), - serde_json::to_string_pretty(&record)?, + crate::secret_file::write( + &ucan_dir.join("ucan.json"), + serde_json::to_string_pretty(&record)?.as_bytes(), )?; } } @@ -228,22 +228,13 @@ fn generate_identity(dir: Option<&std::path::Path>) -> Result Result<()> { let payload: Value = resp.json().await.unwrap_or_default(); let ucan = payload["ucan"].as_str().unwrap_or(""); if !ucan.is_empty() { - std::fs::create_dir_all(&dir)?; + crate::secret_file::create_dir(&dir)?; let record = json!({ "ucan": ucan, "node": args.node, "did": did, "saved_at": chrono::Utc::now().to_rfc3339(), }); - std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?; + crate::secret_file::write( + &ucan_path, + serde_json::to_string_pretty(&record)?.as_bytes(), + )?; } let trust = payload["trust_score"].as_f64().unwrap_or(0.0); println!(" ✓ Registered successfully"); @@ -226,23 +229,15 @@ pub async fn run(args: QuickstartArgs) -> Result<()> { // ── Helpers ─────────────────────────────────────────────────────────────── -fn generate_identity(dir: &PathBuf) -> Result { - std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?; +fn generate_identity(dir: &std::path::Path) -> Result { + crate::secret_file::create_dir(dir) + .with_context(|| format!("failed to create {}", dir.display()))?; let keypair = gitlawb_core::identity::Keypair::generate(); let pem = keypair.to_pem()?; let path = dir.join("identity.pem"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::write(&path, pem.as_bytes())?; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; - } - #[cfg(not(unix))] - { - std::fs::write(&path, pem.as_bytes())?; - } + crate::secret_file::write(&path, pem.as_bytes())?; let did = keypair.did(); println!(" ✓ Generated new identity"); diff --git a/crates/gl/src/register.rs b/crates/gl/src/register.rs index 8a17a77e..71aa2baa 100644 --- a/crates/gl/src/register.rs +++ b/crates/gl/src/register.rs @@ -77,7 +77,10 @@ pub async fn run(args: RegisterArgs) -> Result<()> { "did": did.to_string(), "saved_at": chrono::Utc::now().to_rfc3339(), }); - std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?; + crate::secret_file::write( + &ucan_path, + serde_json::to_string_pretty(&record)?.as_bytes(), + )?; tracing::debug!("saved UCAN to {}", ucan_path.display()); } @@ -113,7 +116,7 @@ fn ucan_path(dir: Option<&std::path::Path>) -> Result { .context("could not determine home directory")? .join(".gitlawb") }; - std::fs::create_dir_all(&base)?; + crate::secret_file::create_dir(&base)?; Ok(base.join("ucan.json")) } diff --git a/crates/gl/src/secret_file.rs b/crates/gl/src/secret_file.rs new file mode 100644 index 00000000..6742cc70 --- /dev/null +++ b/crates/gl/src/secret_file.rs @@ -0,0 +1,137 @@ +//! Writes and directories that hold key material or bearer tokens. + +use std::io::Write; +use std::path::Path; + +/// Write `contents` to `path` with owner-only permissions. +/// +/// On unix the mode is pinned at creation, so the file never exists with a +/// permissive mode between open and chmod. `O_NOFOLLOW` refuses to write +/// through a pre-planted symlink. `mode()` applies only when the file is +/// created, so the mode is pinned on the descriptor before the file is +/// truncated: a pre-existing loose file is tightened before, not after, the +/// new contents land in it. +pub(crate) fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + f.set_permissions(std::fs::Permissions::from_mode(0o600))?; + f.set_len(0)?; + f.write_all(contents)?; + } + #[cfg(not(unix))] + std::fs::write(path, contents)?; + Ok(()) +} + +/// Create `path` (and missing parents) as an owner-only directory. +/// +/// An existing directory is also re-pinned: a `~/.gitlawb` created before +/// this helper stays group- and world-listable otherwise. A symlinked +/// `path` is left alone rather than chmodded through, which would strip +/// group and world access from whatever the link points at. +pub(crate) fn create_dir(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + if !std::fs::symlink_metadata(path)?.file_type().is_symlink() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + } + #[cfg(not(unix))] + std::fs::create_dir_all(path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + #[test] + #[cfg(unix)] + fn write_creates_file_with_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let path = dir.path().join("key.pem"); + super::write(&path, b"secret").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"secret"); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + #[cfg(unix)] + fn write_tightens_preexisting_loose_file() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let path = dir.path().join("key.pem"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + super::write(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + #[cfg(unix)] + fn write_refuses_symlink() { + let dir = TempDir::new().unwrap(); + let target = dir.path().join("target.pem"); + std::fs::write(&target, b"planted").unwrap(); + let link = dir.path().join("link.pem"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(super::write(&link, b"secret").is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"planted"); + } + + #[test] + #[cfg(unix)] + fn create_dir_modes_0700_and_tightens_existing() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a").join("b"); + super::create_dir(&path).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + super::create_dir(&path).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + + #[test] + #[cfg(unix)] + fn create_dir_does_not_chmod_through_symlink() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let target = dir.path().join("shared"); + std::fs::create_dir(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + super::create_dir(&link).unwrap(); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } +} diff --git a/crates/gl/src/ucan_cmd.rs b/crates/gl/src/ucan_cmd.rs index 99d8841c..ad7cc736 100644 --- a/crates/gl/src/ucan_cmd.rs +++ b/crates/gl/src/ucan_cmd.rs @@ -90,7 +90,7 @@ async fn cmd_delegate( let encoded = ucan.encode()?; if let Some(path) = out { - std::fs::write(&path, &encoded)?; + crate::secret_file::write(&path, encoded.as_bytes())?; println!("UCAN saved to {}", path.display()); return Ok(()); }