diff --git a/Cargo.lock b/Cargo.lock index f09147c6..7479e5ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3601,6 +3601,7 @@ dependencies = [ "ed25519-dalek 2.2.0", "gitlawb-core", "icaptcha-client", + "libc", "mockito", "percent-encoding", "reqwest", diff --git a/crates/gl/Cargo.toml b/crates/gl/Cargo.toml index bc870e6d..28c3ef89 100644 --- a/crates/gl/Cargo.toml +++ b/crates/gl/Cargo.toml @@ -29,6 +29,7 @@ uuid = { workspace = true } url = { workspace = true } percent-encoding = { 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 ae3d475f..754de125 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 8736e791..bc4a1dd8 100644 --- a/crates/gl/src/init.rs +++ b/crates/gl/src/init.rs @@ -109,16 +109,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(), )?; } } @@ -232,22 +232,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"); println!(" Trust score: {trust:.2}"); @@ -233,23 +236,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 7537a490..4cdc450d 100644 --- a/crates/gl/src/register.rs +++ b/crates/gl/src/register.rs @@ -72,7 +72,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()); } @@ -108,7 +111,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..0dd102b2 --- /dev/null +++ b/crates/gl/src/secret_file.rs @@ -0,0 +1,180 @@ +//! 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, OpenOptionsExt}; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + // Re-pin through the opened directory, not the path: O_NOFOLLOW + // refuses a swapped-in symlink and fchmod acts on the checked inode, + // so a final-component swap between check and chmod cannot retarget + // the mode change at whatever the link points at. + match std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + { + Ok(dir) => { + use std::os::unix::io::AsRawFd; + let fd = dir.as_raw_fd(); + // Re-pin only a leaf this process owns. A --dir the caller + // can write but cannot chmod (foreign-owned 0775 leaf, ACL + // grant) is adopted unchanged: create_dir_all allowed the + // write before, and aborting turns a usable directory into + // a command failure. An owned directory that still refuses + // chmod (EPERM) is adopted the same way. + let owned = unsafe { + let mut st: libc::stat = std::mem::zeroed(); + libc::fstat(fd, &mut st) == 0 && st.st_uid == libc::geteuid() + }; + if owned && unsafe { libc::fchmod(fd, 0o700) } != 0 { + let e = std::io::Error::last_os_error(); + if e.raw_os_error() != Some(libc::EPERM) { + return Err(e); + } + } + } + // ELOOP: the path is a symlink and is left alone per contract. + Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {} + Err(e) => return Err(e), + } + } + #[cfg(not(unix))] + std::fs::create_dir_all(path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + #[test] + #[cfg(unix)] + fn create_dir_adopts_writable_leaf_it_cannot_chmod() { + use std::os::unix::fs::PermissionsExt; + // /tmp is root-owned 1777: writable by this euid, not chmod-able. + // The pre-helper create_dir_all allowed the write; the re-pin must + // adopt the leaf unchanged rather than abort on EPERM. + let before = std::fs::metadata("/tmp").unwrap().permissions().mode(); + super::create_dir(std::path::Path::new("/tmp")).unwrap(); + let after = std::fs::metadata("/tmp").unwrap().permissions().mode(); + assert_eq!(before, after); + } + + #[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(()); }