From 07832a4f1ea1a6269ccc62b3abad7ba21f89442c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:05:59 -0500 Subject: [PATCH 01/36] feat(node): add a persistent libp2p identity key file Add p2p_key_path (--p2p-key-path / GITLAWB_P2P_KEY, default ~/.gitlawb/p2p.key) with a resolver mirroring resolved_key_path, and load_or_create_p2p_keypair, which generates an Ed25519 keypair on first start, persists it 0600, and loads it thereafter. Mirrors the existing load_or_create_keypair idiom for the node identity PEM. A corrupt or unreadable key file is a hard error naming the path rather than a silent regeneration, so a disk problem cannot quietly rotate the node's network identity. Not yet wired into p2p::start; that follows. --- crates/gitlawb-node/src/config.rs | 14 ++++ crates/gitlawb-node/src/p2p/mod.rs | 104 ++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 1fbf4376..8e7b85f2 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -120,6 +120,10 @@ pub struct Config { #[arg(long, env = "GITLAWB_P2P_PORT", default_value_t = 7546)] pub p2p_port: u16, + /// Path to the persistent libp2p identity key + #[arg(long, env = "GITLAWB_P2P_KEY", default_value = "~/.gitlawb/p2p.key")] + pub p2p_key_path: String, + /// libp2p bootstrap multiaddrs (comma-separated) /// Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW... #[arg(long, env = "GITLAWB_P2P_BOOTSTRAP", value_delimiter = ',')] @@ -719,6 +723,16 @@ impl Config { PathBuf::from(&self.key_path) } + /// Resolve ~ in p2p_key_path + pub fn resolved_p2p_key_path(&self) -> PathBuf { + if self.p2p_key_path.starts_with("~/") { + if let Some(home) = dirs_next::home_dir() { + return home.join(&self.p2p_key_path[2..]); + } + } + PathBuf::from(&self.p2p_key_path) + } + /// DB connections reserved for everything other than held write-locks: auth /// lookups, visibility-rule reads, the post-receive tail's own DB writes, and /// admin tooling. A write pins one pooled connection for its whole duration, so diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 80e28a4a..14bcee32 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -9,10 +9,11 @@ use std::collections::{hash_map::DefaultHasher, HashMap}; use std::hash::{Hash, Hasher}; +use std::path::Path; use std::sync::Arc; use std::time::Duration; -use anyhow::Result; +use anyhow::{Context, Result}; use chrono::Utc; use futures::StreamExt; use libp2p_core::{muxing::StreamMuxerBox, Multiaddr, PeerId, Transport}; @@ -164,6 +165,44 @@ struct GitlawbBehaviour { identify: identify::Behaviour, } +/// Load the node's persistent libp2p identity from `key_path`, generating and +/// storing a fresh Ed25519 keypair the first time. +pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { + if key_path.exists() { + let bytes = std::fs::read(key_path) + .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; + let kp = identity::Keypair::from_protobuf_encoding(&bytes) + .with_context(|| format!("invalid p2p key in {}", key_path.display()))?; + info!(path = %key_path.display(), "loaded existing p2p identity"); + Ok(kp) + } else { + let kp = identity::Keypair::generate_ed25519(); + let bytes = kp + .to_protobuf_encoding() + .map_err(|e| anyhow::anyhow!("failed to serialize p2p 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, &bytes)?; + std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?; + } + #[cfg(not(unix))] + std::fs::write(key_path, &bytes)?; + + info!( + path = %key_path.display(), + peer_id = %PeerId::from(kp.public()), + "generated new p2p identity" + ); + Ok(kp) + } +} + /// Start the libp2p swarm. Returns a handle for sending commands and the /// listening multiaddrs. Runs the event loop as a background tokio task /// that exits cleanly when `shutdown_rx` flips to `true`. @@ -443,6 +482,69 @@ pub async fn start( mod tests { use super::*; + #[test] + fn p2p_identity_not_derivable_from_did_alone() { + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + + let kp_a = load_or_create_p2p_keypair(&dir_a.path().join("p2p.key")).unwrap(); + let kp_b = load_or_create_p2p_keypair(&dir_b.path().join("p2p.key")).unwrap(); + + assert_ne!( + PeerId::from(kp_a.public()), + PeerId::from(kp_b.public()), + "two independent key files must yield different PeerIds" + ); + } + + #[test] + fn p2p_identity_stable_across_restarts() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p2p.key"); + + let first = load_or_create_p2p_keypair(&path).unwrap(); + let second = load_or_create_p2p_keypair(&path).unwrap(); + + assert_eq!( + PeerId::from(first.public()), + PeerId::from(second.public()), + "the same key file must yield the same PeerId" + ); + } + + #[cfg(unix)] + #[test] + fn p2p_key_file_is_0600_on_unix() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("keys").join("p2p.key"); + + load_or_create_p2p_keypair(&path).unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "key file must be owner-read/write only" + ); + } + + #[test] + fn p2p_corrupt_key_file_is_an_error_not_a_panic() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p2p.key"); + std::fs::write(&path, [0xFFu8; 7]).unwrap(); + + let err = + load_or_create_p2p_keypair(&path).expect_err("a corrupt key file must be an error"); + let msg = format!("{err:#}"); + assert!( + msg.contains(&path.display().to_string()), + "error must name the key path, got: {msg}" + ); + } + #[test] fn ref_update_event_round_trip_with_owner_did() { let event = RefUpdateEvent { From 47446e27991655d272357a51e1fef2cd74cf1eb2 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:15:54 -0500 Subject: [PATCH 02/36] fix(node): load the libp2p identity from the persistent key file p2p::start now takes the Ed25519 keypair loaded by load_or_create_p2p_keypair instead of computing one from the node DID, so a node's network identity is generated once from the OS RNG and kept on disk rather than recomputed from a public value on every start. The node DID parameter is gone from start; the call site loads the key first and continues without p2p if the key file cannot be read, matching how a swarm-start failure is already handled. The gossipsub message_id_fn is untouched and keeps its own hasher. --- crates/gitlawb-node/src/main.rs | 36 ++++++++++++++++++------------ crates/gitlawb-node/src/p2p/mod.rs | 25 +++++---------------- 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 66bfa096..79dfb553 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -258,22 +258,30 @@ async fn main() -> Result<()> { .filter_map(|s| s.parse().ok()) .collect(); let shutdown_rx = shutdown_tx.subscribe(); - match p2p::start( - &node_did.to_string(), - config.p2p_port, - bootstrap_addrs, - Arc::clone(&db), - config.auto_sync, - shutdown_rx, - ) - .await - { - Ok(handle) => { - info!(port = config.p2p_port, peer_id = %handle.local_peer_id, "libp2p swarm started"); - Some(Arc::new(handle)) + match p2p::load_or_create_p2p_keypair(&config.resolved_p2p_key_path()) { + Ok(local_key) => { + match p2p::start( + local_key, + config.p2p_port, + bootstrap_addrs, + Arc::clone(&db), + config.auto_sync, + shutdown_rx, + ) + .await + { + Ok(handle) => { + info!(port = config.p2p_port, peer_id = %handle.local_peer_id, "libp2p swarm started"); + Some(Arc::new(handle)) + } + Err(e) => { + tracing::warn!(err = %e, "failed to start libp2p swarm — continuing without p2p"); + None + } + } } Err(e) => { - tracing::warn!(err = %e, "failed to start libp2p swarm — continuing without p2p"); + tracing::warn!(err = %e, "failed to load p2p identity key — continuing without p2p"); None } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 14bcee32..49229be3 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -4,8 +4,8 @@ //! - Peer discovery via Kademlia DHT (DID → multiaddr mapping) //! - Real-time ref-update events via Gossipsub //! -//! The node's PeerId is derived from its Ed25519 identity keypair, -//! so the gitlawb DID and libp2p PeerId share the same key. +//! The node's PeerId comes from an Ed25519 keypair loaded from a persistent +//! key file, so the PeerId is stable across restarts. use std::collections::{hash_map::DefaultHasher, HashMap}; use std::hash::{Hash, Hasher}; @@ -206,31 +206,16 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result /// Start the libp2p swarm. Returns a handle for sending commands and the /// listening multiaddrs. Runs the event loop as a background tokio task /// that exits cleanly when `shutdown_rx` flips to `true`. +/// `local_key` is the node's libp2p identity, loaded from the persistent key +/// file by [`load_or_create_p2p_keypair`]. pub async fn start( - node_did: &str, + local_key: identity::Keypair, listen_port: u16, bootstrap_addrs: Vec, db: Arc, auto_sync: bool, shutdown_rx: tokio::sync::watch::Receiver, ) -> Result { - // Derive a stable libp2p Ed25519 key from a seed based on the node DID. - // In production you'd load/persist this key alongside the identity PEM. - // For now we use the DID string as a deterministic seed. - let seed = { - let mut h = DefaultHasher::new(); - node_did.hash(&mut h); - h.finish() - }; - let mut seed_bytes = [0u8; 32]; - seed_bytes[..8].copy_from_slice(&seed.to_le_bytes()); - // Spread the seed across all bytes for better distribution - for i in 1..4 { - seed_bytes[i * 8..(i + 1) * 8].copy_from_slice(&seed.wrapping_add(i as u64).to_le_bytes()); - } - - let local_key = identity::Keypair::ed25519_from_bytes(seed_bytes) - .map_err(|e| anyhow::anyhow!("failed to create p2p keypair: {e}"))?; let local_peer_id = PeerId::from(local_key.public()); info!(peer_id = %local_peer_id, "libp2p identity"); From 5b64408d41ca60ee2234a55f9a32f51b71496e03 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:43:56 -0500 Subject: [PATCH 03/36] fix(node): create the p2p key with 0600 at creation and reject a loose one Open the key file with create_new and the mode set at creation, then fsync, instead of writing it and narrowing the mode afterwards. The secret is never on disk under a wider mode, an interrupted start cannot leave it readable, and the exclusive open also refuses a pre-existing entry at the path and makes a concurrent start take the key that landed rather than clobber it. Refuse to load a key file whose mode grants group or other access, and name the observed mode so the operator can fix it. Report an empty key file as empty rather than surfacing a protobuf decode error that blames a missing rsa feature. Pin GITLAWB_P2P_KEY onto the mounted volume in the Docker and fly configs and document it, so the key does not depend on home-directory resolution to land on persistent storage. --- .env.example | 5 + Dockerfile | 1 + README.md | 1 + crates/gitlawb-node/src/p2p/mod.rs | 197 +++++++++++++++++++++++++---- infra/fly/fly.toml | 1 + infra/fly/gitlawb-node-2.fly.toml | 1 + infra/fly/gitlawb-node-3.fly.toml | 1 + 7 files changed, 181 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 81c60824..db266268 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ # Generate with: gl identity new GITLAWB_KEY=/data/keys/identity.pem +# Path to the node's persistent libp2p identity key file. Generated on first +# start with owner-only permissions; keep it on a persistent volume so the +# PeerId survives redeploys. Default: ~/.gitlawb/p2p.key +#GITLAWB_P2P_KEY=/data/keys/p2p.key + # Publicly reachable URL of this node (used in peer announcements) GITLAWB_PUBLIC_URL=https://your-node.example.com diff --git a/Dockerfile b/Dockerfile index 3b466945..f030de50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,6 +75,7 @@ WORKDIR /data ENV GITLAWB_REPOS_DIR=/data/repos \ GITLAWB_KEY=/data/keys/identity.pem \ + GITLAWB_P2P_KEY=/data/keys/p2p.key \ GITLAWB_HOST=0.0.0.0 \ GITLAWB_PORT=7545 \ GITLAWB_P2P_PORT=7546 diff --git a/README.md b/README.md index 3a092bf2..09b73819 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,7 @@ Important node settings: | `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. | | `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. | | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | +| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Created with owner-only permissions on first start. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | | `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | | `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 49229be3..00a2f76a 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -169,38 +169,99 @@ struct GitlawbBehaviour { /// storing a fresh Ed25519 keypair the first time. pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { if key_path.exists() { - let bytes = std::fs::read(key_path) - .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; - let kp = identity::Keypair::from_protobuf_encoding(&bytes) - .with_context(|| format!("invalid p2p key in {}", key_path.display()))?; - info!(path = %key_path.display(), "loaded existing p2p identity"); - Ok(kp) - } else { - let kp = identity::Keypair::generate_ed25519(); - let bytes = kp - .to_protobuf_encoding() - .map_err(|e| anyhow::anyhow!("failed to serialize p2p key: {e}"))?; + return read_p2p_keypair(key_path); + } + + let kp = identity::Keypair::generate_ed25519(); + let bytes = kp + .to_protobuf_encoding() + .map_err(|e| anyhow::anyhow!("failed to serialize p2p key: {e}"))?; + + if let Some(parent) = key_path.parent() { + std::fs::create_dir_all(parent)?; + } - if let Some(parent) = key_path.parent() { - std::fs::create_dir_all(parent)?; + match create_new_key_file(key_path, &bytes) { + Ok(()) => { + info!( + path = %key_path.display(), + peer_id = %PeerId::from(kp.public()), + "generated new p2p identity" + ); + Ok(kp) } + // Something already occupies the path: another node process won the + // race between the existence check and the exclusive create, or the + // path is a symlink. Whatever is on disk is the identity of record, so + // read it back rather than failing the boot or overwriting it. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_p2p_keypair(key_path), + Err(e) => Err(anyhow::Error::new(e) + .context(format!("failed to write p2p key to {}", key_path.display()))), + } +} - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::write(key_path, &bytes)?; - std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?; +/// Create the key file exclusively, with owner-only permissions applied at +/// creation time so the bytes are never visible to other users. `create_new` +/// maps to `O_EXCL`, so an existing path entry (including a dangling symlink) +/// is refused rather than followed or truncated. +fn create_new_key_file(key_path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + let mut file = opts.open(key_path)?; + file.write_all(bytes)?; + file.sync_all() +} + +/// Read an existing key file, refusing one whose permissions or contents make +/// it untrustworthy. Never regenerates: a node that silently replaces an +/// unreadable key file would change its PeerId without the operator knowing. +fn read_p2p_keypair(key_path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = std::fs::metadata(key_path) + .with_context(|| format!("failed to stat p2p key at {}", key_path.display()))? + .permissions() + .mode() + & 0o777; + if mode & 0o077 != 0 { + anyhow::bail!( + "p2p key at {} has mode {:04o}, which grants access beyond its owner; \ + run `chmod 600 {}` or delete the file to regenerate the identity", + key_path.display(), + mode, + key_path.display() + ); } - #[cfg(not(unix))] - std::fs::write(key_path, &bytes)?; + } - info!( - path = %key_path.display(), - peer_id = %PeerId::from(kp.public()), - "generated new p2p identity" + let bytes = std::fs::read(key_path) + .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; + + // An empty file decodes as a valid protobuf with a key type of RSA, so + // without this the operator gets a misleading complaint about a missing + // `rsa` cargo feature instead of being told the file is empty. + if bytes.is_empty() { + anyhow::bail!( + "p2p key file {} is empty; restore it from backup, \ + or delete it to regenerate the identity", + key_path.display() ); - Ok(kp) } + + let kp = identity::Keypair::from_protobuf_encoding(&bytes) + .with_context(|| format!("invalid p2p key in {}", key_path.display()))?; + info!(path = %key_path.display(), "loaded existing p2p identity"); + Ok(kp) } /// Start the libp2p swarm. Returns a handle for sending commands and the @@ -505,7 +566,15 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("keys").join("p2p.key"); - load_or_create_p2p_keypair(&path).unwrap(); + // Create the key under a fully permissive umask, otherwise a restrictive + // ambient umask masks the bits down to 0600 on its own and the assertion + // below passes whether or not the code pins the mode. + // SAFETY: `umask` is always safe to call; it only reads and replaces the + // process-wide value. + let prev_umask = unsafe { libc::umask(0o000) }; + let result = load_or_create_p2p_keypair(&path); + unsafe { libc::umask(prev_umask) }; + result.unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!( @@ -515,11 +584,83 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn p2p_key_file_with_loose_permissions_is_rejected() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p2p.key"); + + let kp = identity::Keypair::generate_ed25519(); + std::fs::write(&path, kp.to_protobuf_encoding().unwrap()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let err = load_or_create_p2p_keypair(&path) + .expect_err("a group/world-readable key file must be an error"); + let msg = format!("{err:#}"); + assert!( + msg.contains(&path.display().to_string()) && msg.contains("0644"), + "error must name the key path and the observed mode, got: {msg}" + ); + // The rejection must not have regenerated the identity behind the + // operator's back. + let on_disk = std::fs::read(&path).unwrap(); + assert_eq!(on_disk, kp.to_protobuf_encoding().unwrap()); + } + + #[test] + fn p2p_empty_key_file_reports_the_file_as_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p2p.key"); + std::fs::write(&path, b"").unwrap(); + // Keep the permission guard out of the way so this exercises the + // empty-file path and not the mode check. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + + let err = load_or_create_p2p_keypair(&path).expect_err("an empty key file must be an error"); + let msg = format!("{err:#}"); + assert!( + msg.contains(&path.display().to_string()) && msg.contains("empty"), + "error must name the key path and say the file is empty, got: {msg}" + ); + assert!( + !msg.contains("rsa"), + "an empty file must not be reported as an RSA decoding problem, got: {msg}" + ); + } + + #[cfg(unix)] + #[test] + fn p2p_dangling_symlink_does_not_write_through_to_the_target() { + let dir = tempfile::tempdir().unwrap(); + let link = dir.path().join("p2p.key"); + let target = dir.path().join("elsewhere.key"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + load_or_create_p2p_keypair(&link).expect_err("a dangling symlink must not be followed"); + assert!( + !target.exists(), + "no key may be written through the symlink to {}", + target.display() + ); + } + #[test] fn p2p_corrupt_key_file_is_an_error_not_a_panic() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("p2p.key"); std::fs::write(&path, [0xFFu8; 7]).unwrap(); + // Keep the permission guard out of the way so this exercises decoding. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } let err = load_or_create_p2p_keypair(&path).expect_err("a corrupt key file must be an error"); @@ -528,6 +669,10 @@ mod tests { msg.contains(&path.display().to_string()), "error must name the key path, got: {msg}" ); + assert!( + msg.contains("invalid p2p key"), + "a corrupt key must be reported as a decoding failure, got: {msg}" + ); } #[test] diff --git a/infra/fly/fly.toml b/infra/fly/fly.toml index 05445e5a..ffda6e95 100644 --- a/infra/fly/fly.toml +++ b/infra/fly/fly.toml @@ -12,6 +12,7 @@ primary_region = "iad" GITLAWB_P2P_PORT = "7546" GITLAWB_REPOS_DIR = "/data/repos" GITLAWB_KEY = "/data/keys/identity.pem" + GITLAWB_P2P_KEY = "/data/keys/p2p.key" GITLAWB_PUBLIC_URL = "https://gitlawb-node-test.fly.dev" GITLAWB_BOOTSTRAP_PEERS = "https://node.gitlawb.com,https://node2.gitlawb.com,https://node3.gitlawb.com" GITLAWB_AUTO_SYNC = "true" diff --git a/infra/fly/gitlawb-node-2.fly.toml b/infra/fly/gitlawb-node-2.fly.toml index 785e4da7..16037afe 100644 --- a/infra/fly/gitlawb-node-2.fly.toml +++ b/infra/fly/gitlawb-node-2.fly.toml @@ -15,6 +15,7 @@ primary_region = 'sjc' GITLAWB_HOST = '0.0.0.0' GITLAWB_KEY = '/data/keys/identity.pem' GITLAWB_MAX_PACK_BYTES = '524288000' + GITLAWB_P2P_KEY = '/data/keys/p2p.key' GITLAWB_P2P_PORT = '7546' GITLAWB_PORT = '7545' GITLAWB_PUBLIC_URL = 'https://node2.gitlawb.com' diff --git a/infra/fly/gitlawb-node-3.fly.toml b/infra/fly/gitlawb-node-3.fly.toml index d1ca979a..85d49302 100644 --- a/infra/fly/gitlawb-node-3.fly.toml +++ b/infra/fly/gitlawb-node-3.fly.toml @@ -15,6 +15,7 @@ primary_region = 'nrt' GITLAWB_HOST = '0.0.0.0' GITLAWB_KEY = '/data/keys/identity.pem' GITLAWB_MAX_PACK_BYTES = '524288000' + GITLAWB_P2P_KEY = '/data/keys/p2p.key' GITLAWB_P2P_PORT = '7546' GITLAWB_PORT = '7545' GITLAWB_PUBLIC_URL = 'https://node3.gitlawb.com' From 4e320b42242d5bfc467a7ef22f437963545cb94c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:20:03 -0500 Subject: [PATCH 04/36] fix(node): publish the p2p key atomically and pin its directory Write the key to a scratch file in the same directory and hard-link it onto the final path. The bytes are durable before any name points at them, so a crash cannot leave a partial key that fails to load on the next start and takes the node off the network until someone reads the logs. A concurrent reader can no longer observe a half-written file either, since the final name appears complete or not at all. hard_link rather than rename: rename replaces its destination silently, so refusing to clobber an existing key would depend on a check followed by a separate rename, and a concurrent start can land in that gap. hard_link is atomic and refuses an occupied path, including a symlink, which it does not follow. Create the key directory 0700 and tighten it when an existing one grants group or other access. A 0600 key under a writable directory can still be replaced or unlinked. Tightening rather than refusing to start, because existing installs already have 0755 there and refusing would take p2p down on all of them through a path that only warns. Formatting on the branch is swept up here; it was already failing cargo fmt --check before this change. --- crates/gitlawb-node/src/main.rs | 5 + crates/gitlawb-node/src/p2p/mod.rs | 260 ++++++++++++++++++++++++++--- 2 files changed, 244 insertions(+), 21 deletions(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 79dfb553..2932275d 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -280,6 +280,11 @@ async fn main() -> Result<()> { } } } + // Deliberately non-fatal, and the cost is worth naming: an + // unreadable key file takes the node off the p2p network for the + // whole run while /health keeps reporting healthy, so the outage is + // visible only to whoever reads the logs. Making it fatal, or + // surfacing it in the health response, is its own change. Err(e) => { tracing::warn!(err = %e, "failed to load p2p identity key — continuing without p2p"); None diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 00a2f76a..80911350 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -168,6 +168,13 @@ struct GitlawbBehaviour { /// Load the node's persistent libp2p identity from `key_path`, generating and /// storing a fresh Ed25519 keypair the first time. pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { + // Runs on both the load and the create path: the directory guards the key + // just as much as the key's own mode does, and an existing directory keeps + // whatever mode it was made with. + if let Some(parent) = key_path.parent().filter(|p| !p.as_os_str().is_empty()) { + ensure_key_dir(parent)?; + } + if key_path.exists() { return read_p2p_keypair(key_path); } @@ -177,11 +184,7 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result .to_protobuf_encoding() .map_err(|e| anyhow::anyhow!("failed to serialize p2p key: {e}"))?; - if let Some(parent) = key_path.parent() { - std::fs::create_dir_all(parent)?; - } - - match create_new_key_file(key_path, &bytes) { + match write_key_atomically(key_path, &bytes) { Ok(()) => { info!( path = %key_path.display(), @@ -191,33 +194,170 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result Ok(kp) } // Something already occupies the path: another node process won the - // race between the existence check and the exclusive create, or the - // path is a symlink. Whatever is on disk is the identity of record, so - // read it back rather than failing the boot or overwriting it. + // race between the existence check and the atomic publish, or the path + // is a symlink. Whatever is on disk is the identity of record, so read + // it back rather than failing the boot or overwriting it. Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_p2p_keypair(key_path), Err(e) => Err(anyhow::Error::new(e) .context(format!("failed to write p2p key to {}", key_path.display()))), } } -/// Create the key file exclusively, with owner-only permissions applied at -/// creation time so the bytes are never visible to other users. `create_new` -/// maps to `O_EXCL`, so an existing path entry (including a dangling symlink) -/// is refused rather than followed or truncated. -fn create_new_key_file(key_path: &Path, bytes: &[u8]) -> std::io::Result<()> { - use std::io::Write; +/// Create the directory holding the key with owner-only permissions, and +/// tighten it if it already exists with a looser mode. Write permission on this +/// directory is enough to unlink or replace the 0600 key inside it, so the +/// directory guards the key as much as the key's own mode does. +/// +/// `create_dir_all` takes 0777 masked by the umask, which lands 0755 under a +/// normal umask and 0777 under a permissive one. `DirBuilder`'s mode fixes that +/// for directories it creates, but an existing directory keeps whatever mode it +/// was made with, so the load path has to check too. +/// +/// A loose existing directory is repaired rather than rejected. Rejecting it +/// would refuse to boot on every node whose directory already landed 0755, +/// which is the common case, and through `main.rs`'s non-fatal handling that +/// would read as a silent p2p outage rather than a clear failure. Tightening +/// applies exactly the remedy the alternative would have asked the operator to +/// run by hand. Failure to tighten is fatal, since at that point the key cannot +/// be protected. +/// +/// `~/.gitlawb/identity.pem` lives in this directory too, so this covers both +/// keys. Issue #231 owns the sibling gap in `main.rs`'s own creation path for +/// that file; nothing here touches it. +fn ensure_key_dir(dir: &Path) -> Result<()> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + // On non-unix this is exactly `create_dir_all`; there is no mode to pin. + builder + .create(dir) + .with_context(|| format!("failed to create key directory {}", dir.display()))?; - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); + use std::os::unix::fs::PermissionsExt; + + let mode = std::fs::metadata(dir) + .with_context(|| format!("failed to stat key directory {}", dir.display()))? + .permissions() + .mode() + & 0o777; + if mode & 0o077 != 0 { + warn!( + dir = %dir.display(), + mode = format!("{mode:04o}"), + "key directory grants access beyond its owner; tightening it to 0700" + ); + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context( + || { + format!( + "key directory {} has mode {:04o}, which lets other users replace \ + the keys it holds, and it could not be tightened; run `chmod 700 {}`", + dir.display(), + mode, + dir.display() + ) + }, + )?; + } + } + + Ok(()) +} + +/// Write the key to a scratch file in the same directory, then publish it to +/// `key_path` in one atomic step, so no reader ever sees a partial key and a +/// crash mid-write cannot leave a truncated file at the final path. +/// +/// The publish is `link(2)`, not `rename(2)`. Rename would replace an existing +/// key silently, throwing away the `O_EXCL` protection the previous code got +/// from `create_new`; guarding it with an existence check first only narrows +/// the window rather than closing it, since a concurrent start can land its own +/// key between the check and the rename. `hard_link` is atomic and fails with +/// `AlreadyExists` if anything already occupies the path (a real file, or a +/// symlink, which it does not follow), so the two properties hold together +/// without a check-then-act gap. The scratch file is unlinked either way, so a +/// failed start leaves the key directory as it found it. +fn write_key_atomically(key_path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let dir = key_path.parent().unwrap_or_else(|| Path::new(".")); + let (tmp_path, mut file) = create_scratch_key_file(dir)?; + let result = fill_and_publish(&mut file, bytes, &tmp_path, key_path); + drop(file); + // Unconditional: on success the key is reachable through `key_path`, and on + // failure nothing may be left behind. + let _ = std::fs::remove_file(&tmp_path); + result +} + +/// Open a uniquely named scratch file in `dir` with owner-only permissions +/// applied at creation time. The name carries the pid so concurrent node starts +/// do not pick the same one, and `create_new` (`O_EXCL`) plus the retry makes a +/// collision with a leftover or a sibling thread impossible rather than merely +/// unlikely. +fn create_scratch_key_file(dir: &Path) -> std::io::Result<(std::path::PathBuf, std::fs::File)> { + let pid = std::process::id(); + for attempt in 0..64u32 { + let tmp_path = dir.join(format!(".p2p.key.{pid}.{attempt}.tmp")); + + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + match opts.open(&tmp_path) { + Ok(file) => return Ok((tmp_path, file)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("no free scratch key file name in {}", dir.display()), + )) +} + +fn fill_and_publish( + file: &mut std::fs::File, + bytes: &[u8], + tmp_path: &Path, + key_path: &Path, +) -> std::io::Result<()> { + use std::io::Write; + + #[cfg(test)] + if FAIL_KEY_WRITE.with(|f| f.get()) { + file.write_all(&bytes[..bytes.len() / 2])?; + return Err(std::io::Error::other("injected key-write failure")); } - let mut file = opts.open(key_path)?; file.write_all(bytes)?; - file.sync_all() + // The bytes must be durable before the name that points at them appears, + // otherwise a crash can leave the entry pointing at an empty file. + file.sync_all()?; + std::fs::hard_link(tmp_path, key_path)?; + + // Make the new directory entry itself durable. Best-effort: the key is + // already written and linked, and not every platform allows this. + if let Some(dir) = key_path.parent() { + if let Ok(dir_file) = std::fs::File::open(dir) { + let _ = dir_file.sync_all(); + } + } + Ok(()) +} + +#[cfg(test)] +thread_local! { + /// Test-only fault injection for the key write. Thread-local so an armed + /// test cannot disturb the others running beside it. + static FAIL_KEY_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Read an existing key file, refusing one whose permissions or contents make @@ -582,6 +722,83 @@ mod tests { 0o600, "key file must be owner-read/write only" ); + + // The directory was created inside the same permissive-umask window, so + // this proves the directory mode is pinned by the code and not by the + // ambient umask. Write permission on the directory alone is enough to + // unlink or replace the 0600 key inside it. + let dir_mode = std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode(); + assert_eq!(dir_mode & 0o777, 0o700, "key directory must be owner-only"); + } + + #[cfg(unix)] + #[test] + fn p2p_existing_key_dir_with_loose_permissions_is_tightened() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let key_dir = dir.path().join("keys"); + std::fs::create_dir(&key_dir).unwrap(); + std::fs::set_permissions(&key_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = key_dir.join("p2p.key"); + + // Creation path: a pre-existing loose directory is detected and repaired. + let created = load_or_create_p2p_keypair(&path).expect("boot must not fail on a loose dir"); + assert_eq!( + std::fs::metadata(&key_dir).unwrap().permissions().mode() & 0o777, + 0o700, + "an existing loose key directory must be tightened" + ); + + // Load path: same check, on a directory loosened after the key exists. + std::fs::set_permissions(&key_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + let loaded = load_or_create_p2p_keypair(&path).expect("reload must not fail"); + assert_eq!( + std::fs::metadata(&key_dir).unwrap().permissions().mode() & 0o777, + 0o700, + "the load path must tighten the key directory too" + ); + assert_eq!( + PeerId::from(created.public()), + PeerId::from(loaded.public()), + "tightening must not change the identity" + ); + } + + #[test] + fn p2p_failed_key_write_leaves_no_file_at_the_final_path() { + let dir = tempfile::tempdir().unwrap(); + let key_dir = dir.path().join("keys"); + let path = key_dir.join("p2p.key"); + + FAIL_KEY_WRITE.with(|f| f.set(true)); + let result = load_or_create_p2p_keypair(&path); + FAIL_KEY_WRITE.with(|f| f.set(false)); + + result.expect_err("an interrupted key write must not report success"); + assert!( + !path.exists(), + "a partially written key must never be observable at {}", + path.display() + ); + + // Nor may a half-written scratch file be left behind for an operator to + // trip over on the next boot. + let leftovers: Vec<_> = std::fs::read_dir(&key_dir) + .map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.path()).collect()) + .unwrap_or_default(); + assert!( + leftovers.is_empty(), + "a failed write must clean up after itself, found: {leftovers:?}" + ); + + // The next boot must be able to create the identity normally. + let kp = load_or_create_p2p_keypair(&path).expect("a retry after a failed write must work"); + let reloaded = load_or_create_p2p_keypair(&path).unwrap(); + assert_eq!(PeerId::from(kp.public()), PeerId::from(reloaded.public())); } #[cfg(unix)] @@ -622,7 +839,8 @@ mod tests { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); } - let err = load_or_create_p2p_keypair(&path).expect_err("an empty key file must be an error"); + let err = + load_or_create_p2p_keypair(&path).expect_err("an empty key file must be an error"); let msg = format!("{err:#}"); assert!( msg.contains(&path.display().to_string()) && msg.contains("empty"), From 025f95ba8e137e4873b10a1a5ce8865522ee843d Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:39:33 -0500 Subject: [PATCH 05/36] style(node): comma instead of a dash in the key-load warning House style avoids em dashes in text we write. The swarm-failure warning beside it predates this branch and is left alone. --- crates/gitlawb-node/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 2932275d..769a5732 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -286,7 +286,7 @@ async fn main() -> Result<()> { // visible only to whoever reads the logs. Making it fatal, or // surfacing it in the health response, is its own change. Err(e) => { - tracing::warn!(err = %e, "failed to load p2p identity key — continuing without p2p"); + tracing::warn!(err = %e, "failed to load p2p identity key, continuing without p2p"); None } } From 13359b355c4234909a078ea0d1f524502e1a376f Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:04:23 -0500 Subject: [PATCH 06/36] fix(node): require the p2p key path to name a directory A bare filename in GITLAWB_P2P_KEY put the key in whatever directory the process started from, and the directory guard was skipped entirely on that path: Path::parent returns Some("") for a bare filename, which the caller filtered out before ever reaching ensure_key_dir. The key file was created 0600 inside a directory that kept whatever mode it already had. Config::validate now rejects a p2p key path that names no directory, so the node says so at boot instead of starting with a key it cannot protect. That placement is the point: an error raised in the p2p start path is logged and stepped over, leaving the node running without p2p and reporting healthy. The check is lexical on the tilde-resolved path. canonicalize would fail on a parent that does not exist yet, which is the shipped ~/.gitlawb default and every container's first boot, and comparing against the working directory would reject /data/p2p.key under the image's WORKDIR, an absolute directory the operator did name. Three sites answered the parent question differently, which is how the gap arose: one filtered the empty case out, one already normalized it, and one opened "" and silently skipped its fsync. They now share key_parent, and Config::validate calls it rather than adding a fourth answer. load_or_create_p2p_keypair also refuses a path naming no directory. That is a backstop behind the config gate, not the gate, so a later caller that skips validation cannot quietly restore the old behaviour. --- crates/gitlawb-node/src/config.rs | 88 +++++++++++++++++++++++++++++- crates/gitlawb-node/src/p2p/mod.rs | 55 +++++++++++++++---- 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 8e7b85f2..2725be16 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -1,5 +1,5 @@ use clap::Parser; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Upper bound on `git_service_timeout_secs`, `ipfs_request_budget_secs`, and /// `ipfs_resolve_budget_secs`, in seconds (100 years). @@ -760,6 +760,32 @@ impl Config { floor )); } + + // A p2p key path naming no directory puts the node's private key in + // whatever directory the process was started from. The node cannot + // protect that: `ensure_key_dir` would have to chmod a directory the + // operator never nominated as a key directory, and a directory it + // cannot secure is one where any local user with write access can + // replace the key and choose the node's libp2p identity. Refuse it here, + // where the denial actually stops the process, rather than in the p2p + // start path, where main.rs logs the error and keeps serving with a + // green /health. + // + // Decided lexically on the resolved path: `canonicalize` would fail on a + // parent that does not exist yet (the shipped `~/.gitlawb` default, and + // every container's first boot), and comparing against the process + // working directory would reject `/data/p2p.key` under the image's + // WORKDIR, an absolute directory the operator did name. + let p2p_key_path = self.resolved_p2p_key_path(); + if crate::p2p::key_parent(&p2p_key_path) == Path::new(".") { + return Err(format!( + "GITLAWB_P2P_KEY ({}) must include a directory, such as ./keys/p2p.key or \ + /data/keys/p2p.key: the node will not store its p2p identity key in the \ + working directory, where the directory holding it cannot be secured.", + self.p2p_key_path + )); + } + Ok(()) } } @@ -1461,4 +1487,64 @@ mod tests { Config::parse_from(["gitlawb-node", "--enforce-owner-push", "true"]).enforce_owner_push ); } + + fn config_with_p2p_key(path: &str) -> Config { + Config::parse_from(["gitlawb-node", "--p2p-key-path", path]) + } + + /// A p2p key path that names no directory component would put the node's + /// private key in whatever directory the process happens to be started from, + /// which `ensure_key_dir` cannot protect without tightening a directory the + /// operator never nominated. Reject it at boot instead. + #[test] + fn p2p_key_path_without_a_directory_component_is_rejected() { + for path in ["p2p.key", "./p2p.key", "././p2p.key", "p2p.key/", ""] { + let err = config_with_p2p_key(path) + .validate() + .expect_err(&format!("{path:?} names no directory and must be rejected")); + assert!( + err.contains("directory"), + "{path:?} must be rejected for naming no directory, got: {err}" + ); + } + } + + /// The mirror of the above, and the case that stops the predicate widening + /// into "reject every relative path". The shipped default is included on + /// purpose: a predicate that rejects it is a boot failure for every node. + #[test] + fn p2p_key_path_naming_a_directory_is_accepted() { + for path in [ + "keys/p2p.key", + "./keys/p2p.key", + "/data/keys/p2p.key", + "/data/p2p.key", + "~/.gitlawb/p2p.key", + ] { + assert!( + config_with_p2p_key(path).validate().is_ok(), + "{path:?} names a directory and must be accepted" + ); + } + + Config::parse_from(["gitlawb-node"]) + .validate() + .expect("the shipped default p2p key path must validate"); + } + + /// The one input that separates validating the raw config string from + /// validating `resolved_p2p_key_path()`. Raw, `~/` has an empty parent and + /// would be rejected; resolved, it is the home directory, whose parent is a + /// real directory, so it is accepted. Every other tilde path is accepted + /// under both readings and therefore proves nothing. + #[test] + fn p2p_key_path_is_checked_after_tilde_expansion() { + if dirs_next::home_dir().is_none() { + panic!("this test needs a home directory to distinguish raw from resolved"); + } + assert!( + config_with_p2p_key("~/").validate().is_ok(), + "`~/` resolves to the home directory, whose parent is a real directory" + ); + } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 80911350..26450843 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -9,7 +9,7 @@ use std::collections::{hash_map::DefaultHasher, HashMap}; use std::hash::{Hash, Hasher}; -use std::path::Path; +use std::path::{Component, Path}; use std::sync::Arc; use std::time::Duration; @@ -165,15 +165,50 @@ struct GitlawbBehaviour { identify: identify::Behaviour, } +/// The directory holding `key_path`, and the single answer to that question for +/// every site in this module plus `Config::validate`. +/// +/// `Path::parent` is not enough on its own. A bare filename yields `Some("")` +/// and `./p2p.key` yields `Some(".")`, both naming the process working +/// directory while looking different; an empty path yields `None`. Collapsing +/// all of those to `.` keeps the callers from each inventing their own answer, +/// which is what they used to do: one filtered the empty case out and skipped +/// the directory guard entirely, one already normalized correctly, and one +/// opened `""` and silently did nothing. +/// +/// The `.` return is the "names no directory" signal, not a usable directory. +/// `Config::validate` rejects a p2p key path that lands here, so a validated +/// config never reaches it; `load_or_create_p2p_keypair` refuses it as well, as +/// a backstop rather than the gate. +pub(crate) fn key_parent(key_path: &Path) -> &Path { + match key_path.parent() { + Some(parent) if parent.components().any(|c| c != Component::CurDir) => parent, + _ => Path::new("."), + } +} + /// Load the node's persistent libp2p identity from `key_path`, generating and /// storing a fresh Ed25519 keypair the first time. pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { + let parent = key_parent(key_path); + + // Backstop, not the gate. `Config::validate` rejects a key path naming no + // directory before the node starts, which is where the operator gets a + // useful error. Refusing it here too means a future caller that skips + // config validation cannot quietly resurrect the old behaviour of writing + // the key into the working directory and chmodding whatever that happens + // to be. + if parent == Path::new(".") { + return Err(anyhow::anyhow!( + "p2p key path {} names no directory; give it one, such as ./keys/p2p.key", + key_path.display() + )); + } + // Runs on both the load and the create path: the directory guards the key // just as much as the key's own mode does, and an existing directory keeps // whatever mode it was made with. - if let Some(parent) = key_path.parent().filter(|p| !p.as_os_str().is_empty()) { - ensure_key_dir(parent)?; - } + ensure_key_dir(parent)?; if key_path.exists() { return read_p2p_keypair(key_path); @@ -283,7 +318,7 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { /// without a check-then-act gap. The scratch file is unlinked either way, so a /// failed start leaves the key directory as it found it. fn write_key_atomically(key_path: &Path, bytes: &[u8]) -> std::io::Result<()> { - let dir = key_path.parent().unwrap_or_else(|| Path::new(".")); + let dir = key_parent(key_path); let (tmp_path, mut file) = create_scratch_key_file(dir)?; let result = fill_and_publish(&mut file, bytes, &tmp_path, key_path); drop(file); @@ -344,11 +379,11 @@ fn fill_and_publish( std::fs::hard_link(tmp_path, key_path)?; // Make the new directory entry itself durable. Best-effort: the key is - // already written and linked, and not every platform allows this. - if let Some(dir) = key_path.parent() { - if let Ok(dir_file) = std::fs::File::open(dir) { - let _ = dir_file.sync_all(); - } + // already written and linked, and not every platform allows this. Goes + // through `key_parent` like every other site; opening a bare `""` here used + // to fail silently, which looked like a working fsync and was not. + if let Ok(dir_file) = std::fs::File::open(key_parent(key_path)) { + let _ = dir_file.sync_all(); } Ok(()) } From a3589d847bf9621f09ff7e363c52518f3c598f15 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:47:06 -0500 Subject: [PATCH 07/36] test(node): run the key-permission probe in its own process The probe zeroes the umask so the assertion means something: under a restrictive ambient umask the bits are masked to 0600 regardless of whether the code pins the mode, and the check passes either way. Zeroing it in the shared test process is the problem. umask is process-global and cargo runs these tests on threads, so any test creating a file in that window inherits 000. Measured before this change: an unrelated concurrent test's file was created 0666. The probe now runs in a child process, where the zeroed umask cannot reach a sibling and dies with the child. The parent is an ordinary test that runs concurrently with everything else. Double-gated with #[ignore] plus an env check so a bare --ignored sweep does not zero the umask in the shared process after all. The parent asserts the child ran exactly one test and that it passed, not just that it exited 0. A libtest filter matching nothing runs zero tests and still exits 0, so without that assertion a renamed fixture would read as a green permission check while asserting nothing. Verified by pointing the filter at a name that does not exist and watching the parent fail. --- crates/gitlawb-node/src/p2p/mod.rs | 80 ++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 26450843..f6e29d16 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -733,23 +733,56 @@ mod tests { ); } + // ---- Permission probe, run in a child process ------------------------- + // + // The probe has to create the key under a zeroed umask, otherwise a + // restrictive ambient umask masks the bits down to 0600 by itself and the + // assertion passes whether or not the code pins the mode. That zeroing is + // the problem: `umask` is process-global and cargo runs these tests on + // threads, so any test creating a file in that window inherits 000. Measured + // before this change, an unrelated concurrent test's file was created 0666. + // + // So the probe runs in a dedicated child process, where the zeroed umask + // cannot reach a sibling and dies with the child. The parent test below is + // an ordinary `#[test]` that runs concurrently with everything else. + // + // Two halves, and the split is worth naming: the child's assertions are the + // committed deterministic guard, and the concurrency leak itself was proven + // out of band by a throwaway probe rather than by a committed test. A race + // on process-global state has no reliable committed red-green. + + /// Re-invoke this test binary to run one `#[ignore]`d fixture test. + #[cfg(unix)] + fn fixture_command(fixture_test: &str) -> std::process::Command { + let mut cmd = std::process::Command::new(std::env::current_exe().expect("current_exe")); + cmd.args([fixture_test, "--exact", "--ignored", "--nocapture"]) + .env("GITLAWB_TEST_FIXTURE", "p2p-key-perms"); + cmd + } + + /// Fixture: create the key under a zeroed umask and assert the modes the + /// code is supposed to pin. Double-gated so it is inert unless the parent + /// invoked it: `#[ignore]` keeps it out of a normal run, and the env check + /// keeps it inert even under a bare `--ignored` sweep, which would otherwise + /// zero the umask inside the shared test process. #[cfg(unix)] #[test] - fn p2p_key_file_is_0600_on_unix() { + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=p2p-key-perms"] + fn fixture_p2p_key_perms_under_zero_umask() { use std::os::unix::fs::PermissionsExt; + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("p2p-key-perms") { + return; + } + let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("keys").join("p2p.key"); - // Create the key under a fully permissive umask, otherwise a restrictive - // ambient umask masks the bits down to 0600 on its own and the assertion - // below passes whether or not the code pins the mode. - // SAFETY: `umask` is always safe to call; it only reads and replaces the - // process-wide value. - let prev_umask = unsafe { libc::umask(0o000) }; - let result = load_or_create_p2p_keypair(&path); - unsafe { libc::umask(prev_umask) }; - result.unwrap(); + // SAFETY: `umask` only reads and replaces the process-wide value, and + // this process exists solely for this probe. No restore: the value dies + // with the child. + unsafe { libc::umask(0o000) }; + load_or_create_p2p_keypair(&path).expect("key creation under a permissive umask"); let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!( @@ -769,6 +802,33 @@ mod tests { assert_eq!(dir_mode & 0o777, 0o700, "key directory must be owner-only"); } + #[cfg(unix)] + #[test] + fn p2p_key_file_is_0600_on_unix() { + let output = fixture_command("p2p::tests::fixture_p2p_key_perms_under_zero_umask") + .output() + .expect("spawn the permission fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "the permission fixture must pass in its child process\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + + // Not redundant with the status check, and this is the assertion that + // keeps the whole fixture from passing vacuously: a filter matching no + // test runs zero tests and still exits 0, so a renamed or mistyped + // fixture would look like a green permission check while asserting + // nothing at all. + assert!( + stdout.contains("1 passed"), + "the fixture filter must select exactly one test that passed; a filter matching \ + nothing exits 0 and would make this check vacuous\n--- stdout ---\n{stdout}" + ); + } + #[cfg(unix)] #[test] fn p2p_existing_key_dir_with_loose_permissions_is_tightened() { From befb9e586ea4b68137a2bbfb0cd24688e3f93b1f Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:49:16 -0500 Subject: [PATCH 08/36] docs(node): scope the p2p key permission claim and note the rotation The old wording said the key file is "created with owner-only permissions" without qualification, which is only true on Unix: every permission path in p2p/mod.rs is cfg(unix), so on other platforms the file inherits whatever the directory gives it and nothing is enforced. Say what is actually enforced and where. Also document what operators now have to do rather than leaving them to discover it: - GITLAWB_P2P_KEY must name a directory, since a bare filename is refused at startup. - The PeerId rotates once on the first start after upgrading, so a GITLAWB_P2P_BOOTSTRAP multiaddr pinning a peer's old id with a /p2p/ suffix needs updating or dropping. Suffix-less addresses and the HTTP seed list are unaffected. - If the node reports tightening a loose key directory, the key that was in it should be treated as possibly exposed and deleted so a fresh one is generated. --- .env.example | 12 +++++++++--- README.md | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index db266268..4a5337cd 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,15 @@ # Generate with: gl identity new GITLAWB_KEY=/data/keys/identity.pem -# Path to the node's persistent libp2p identity key file. Generated on first -# start with owner-only permissions; keep it on a persistent volume so the -# PeerId survives redeploys. Default: ~/.gitlawb/p2p.key +# Path to the node's persistent libp2p identity key file. Must include a +# directory; the node refuses to start on a bare filename, because it will not +# keep its p2p identity key in the working directory. On Unix it is created +# 0600 inside a 0700 directory, and a loose key directory is tightened to 0700 +# on start; on other platforms no permissions are enforced. If the node logs +# that it tightened a loose key directory, treat the key that was sitting there +# as possibly exposed: delete it so a fresh identity is generated on the next +# start. Keep it on a persistent volume so the PeerId survives redeploys. +# Default: ~/.gitlawb/p2p.key #GITLAWB_P2P_KEY=/data/keys/p2p.key # Publicly reachable URL of this node (used in peer announcements) diff --git a/README.md b/README.md index 09b73819..b867d9eb 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Important node settings: | `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. | | `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. | | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | -| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Created with owner-only permissions on first start. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | +| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must include a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`; the node refuses to start on a bare filename, because it will not keep its p2p identity key in the working directory. On Unix the file is created `0600` inside a `0700` directory, and a loose key directory is tightened to `0700` on start; on other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | | `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | | `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | @@ -421,6 +421,27 @@ Production note: change the default Postgres password before exposing a node pub Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld. +### Upgrading: the PeerId rotates once + +This node's libp2p identity is now a keypair generated on first start and kept +at `GITLAWB_P2P_KEY`, rather than one derived from the node DID. Every node +therefore gets a new PeerId once, on the first start after upgrading, and keeps +it from then on as long as that key file survives (put it on a persistent volume +in a container). + +Two things to check before upgrading: + +- Any `GITLAWB_P2P_BOOTSTRAP` multiaddr that pins a peer's old PeerId with a + `/p2p/` suffix stops matching once that peer upgrades. Update the + suffix, or drop it and let identify supply the current one. Addresses without + the suffix keep working untouched. +- `GITLAWB_P2P_KEY` must name a directory. A bare filename is refused at + startup, since the node will not keep its identity key in the working + directory. + +Peers found over `GITLAWB_BOOTSTRAP_PEERS` and the embedded seed list are +unaffected, since those are HTTP URLs and carry no PeerId. + --- ## Optional node staking From 774ea3653a3af8a37a1b61edc5211e72c045426e Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:44:07 -0500 Subject: [PATCH 09/36] fix(review): close a `..` escape from the p2p key-path check Two reviewers found the same hole independently: the check rejected a path naming no directory, but a relative parent that walks back out through `..` named one and still landed in the working directory. `a/../p2p.key` and `./keys/../p2p.key` resolve to the cwd itself and `../p2p.key` resolves above it, so all three put the key exactly where the check exists to keep it out of, and had ensure_key_dir chmod that directory to 0700 on the way. Verified by running the paths through the predicate and printing where each parent lands. The rule is now that a relative key path must name a directory and must not walk back out: at least one Normal component, no ParentDir. `..` inside an absolute path stays accepted, since it cannot depend on where the process started. The predicate moves into names_no_usable_directory next to key_parent, and the config gate and the load_or_create_p2p_keypair backstop both call it, so they cannot drift apart. Also fixes two smaller gaps found in the same pass: - The permission fixture could report "1 passed" while asserting nothing. Its env gate returns early, and an early return is a passing test, so a renamed variable would look green. It now prints a sentinel after its assertions and the parent requires it. Confirmed by pointing the child at a different variable and watching the parent fail. - A GITLAWB_P2P_KEY starting with `~/` is refused when no home directory resolves, instead of creating a literal `~` directory relative to wherever the node happened to start. The backstop had no test, so it has one now, along with a both-directions test for the predicate. That test cleans up after itself: with the guard removed it really does write a key next to the source, which broke a later run once. --- crates/gitlawb-node/src/config.rs | 42 ++++++- crates/gitlawb-node/src/p2p/mod.rs | 178 +++++++++++++++++++++++++++-- 2 files changed, 208 insertions(+), 12 deletions(-) diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 2725be16..c7d3b95f 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -776,12 +776,28 @@ impl Config { // every container's first boot), and comparing against the process // working directory would reject `/data/p2p.key` under the image's // WORKDIR, an absolute directory the operator did name. + // `resolved_p2p_key_path` expands a leading `~/` only when a home + // directory is resolvable, and otherwise hands back the literal string. + // That would leave the shipped default naming a directory called `~` + // relative to wherever the process started, which is a real directory + // the node would create and chmod, and whose location moves with the + // working directory. It passes the check below because `~` is an + // ordinary path component, so it has to be caught separately. let p2p_key_path = self.resolved_p2p_key_path(); - if crate::p2p::key_parent(&p2p_key_path) == Path::new(".") { + if self.p2p_key_path.starts_with("~/") && p2p_key_path == Path::new(&self.p2p_key_path) { return Err(format!( - "GITLAWB_P2P_KEY ({}) must include a directory, such as ./keys/p2p.key or \ - /data/keys/p2p.key: the node will not store its p2p identity key in the \ - working directory, where the directory holding it cannot be secured.", + "GITLAWB_P2P_KEY ({}) starts with `~/` but no home directory could be resolved, \ + so it would name a literal `~` directory relative to the working directory. \ + Set an absolute path such as /data/keys/p2p.key.", + self.p2p_key_path + )); + } + if crate::p2p::names_no_usable_directory(&p2p_key_path) { + return Err(format!( + "GITLAWB_P2P_KEY ({}) must include a directory that does not walk back through \ + `..`, such as ./keys/p2p.key or /data/keys/p2p.key: the node will not store its \ + p2p identity key in the working directory, where the directory holding it \ + cannot be secured.", self.p2p_key_path )); } @@ -1498,7 +1514,20 @@ mod tests { /// operator never nominated. Reject it at boot instead. #[test] fn p2p_key_path_without_a_directory_component_is_rejected() { - for path in ["p2p.key", "./p2p.key", "././p2p.key", "p2p.key/", ""] { + for path in [ + // No directory component at all. + "p2p.key", + "./p2p.key", + "././p2p.key", + "p2p.key/", + "", + // Looks like it names a directory and does not: each of these + // resolves back to the working directory or above it, so accepting + // them would defeat the check and chmod an unnominated directory. + "a/../p2p.key", + "./keys/../p2p.key", + "../p2p.key", + ] { let err = config_with_p2p_key(path) .validate() .expect_err(&format!("{path:?} names no directory and must be rejected")); @@ -1520,6 +1549,9 @@ mod tests { "/data/keys/p2p.key", "/data/p2p.key", "~/.gitlawb/p2p.key", + // Absolute paths are judged unambiguously, so `..` inside one is + // fine: it cannot depend on where the process was started. + "/data/keys/../p2p.key", ] { assert!( config_with_p2p_key(path).validate().is_ok(), diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index f6e29d16..4299c25c 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -187,6 +187,53 @@ pub(crate) fn key_parent(key_path: &Path) -> &Path { } } +/// Whether `key_path` fails to name a directory the node is willing to manage. +/// +/// This is the gate `Config::validate` applies, kept next to `key_parent` +/// because the two answer the same question and drifting apart is how the +/// original defect happened. +/// +/// An absolute path always names its directory unambiguously, so it passes. +/// A relative path is judged lexically against two ways of failing to name one: +/// +/// * no directory at all, so the parent is empty or nothing but `.` +/// (`p2p.key`, `./p2p.key`, `p2p.key/`, `""`), and +/// * a parent that walks back out through `..` (`a/../p2p.key`, +/// `./keys/../p2p.key`, `../p2p.key`). +/// +/// The second case is the one that is easy to miss and was missed once: those +/// paths look like they name a directory, and they do not. `a/..` and +/// `./keys/..` resolve to the working directory itself, and `..` resolves above +/// it, so accepting them would put the key exactly where this check exists to +/// keep it out of, and would have the node chmod that directory to 0700 on the +/// way. Any `..` in a relative parent makes the target depend on where the +/// process was started, which is the property being refused, so the whole class +/// is rejected rather than resolved. +/// +/// Lexical on purpose: no `canonicalize` (the parent legitimately does not exist +/// yet on a first start) and no `current_dir` comparison (it would reject +/// `/data/p2p.key` under a `/data` WORKDIR, an absolute directory the operator +/// named). +pub(crate) fn names_no_usable_directory(key_path: &Path) -> bool { + if key_path.is_absolute() { + return false; + } + match key_path.parent() { + None => true, + Some(parent) => { + let mut named_a_directory = false; + for component in parent.components() { + match component { + Component::ParentDir => return true, + Component::Normal(_) => named_a_directory = true, + _ => {} + } + } + !named_a_directory + } + } +} + /// Load the node's persistent libp2p identity from `key_path`, generating and /// storing a fresh Ed25519 keypair the first time. pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { @@ -198,9 +245,10 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result // config validation cannot quietly resurrect the old behaviour of writing // the key into the working directory and chmodding whatever that happens // to be. - if parent == Path::new(".") { + if names_no_usable_directory(key_path) { return Err(anyhow::anyhow!( - "p2p key path {} names no directory; give it one, such as ./keys/p2p.key", + "p2p key path {} names no directory the node can manage; give it one that does not \ + walk back through `..`, such as ./keys/p2p.key", key_path.display() )); } @@ -751,6 +799,12 @@ mod tests { // out of band by a throwaway probe rather than by a committed test. A race // on process-global state has no reliable committed red-green. + /// Printed by the permission fixture only after its assertions have run, + /// and required by the parent. See the parent test for why "1 passed" is + /// not sufficient on its own. + #[cfg(unix)] + const FIXTURE_SENTINEL: &str = "p2p-key-perms: asserted"; + /// Re-invoke this test binary to run one `#[ignore]`d fixture test. #[cfg(unix)] fn fixture_command(fixture_test: &str) -> std::process::Command { @@ -800,6 +854,13 @@ mod tests { .permissions() .mode(); assert_eq!(dir_mode & 0o777, 0o700, "key directory must be owner-only"); + + // Proof-of-work sentinel, printed only after both assertions have run. + // "1 passed" alone does not prove this fixture asserted anything: the + // early return above is itself a passing test, so an env-var mismatch + // (a renamed variable, a changed value) would report 1 passed while + // checking nothing. The parent requires this line. + println!("{FIXTURE_SENTINEL}"); } #[cfg(unix)] @@ -817,16 +878,119 @@ mod tests { --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" ); - // Not redundant with the status check, and this is the assertion that - // keeps the whole fixture from passing vacuously: a filter matching no - // test runs zero tests and still exits 0, so a renamed or mistyped - // fixture would look like a green permission check while asserting - // nothing at all. + // Two separate vacuity holes, and each assertion closes one the other + // does not. + // + // A filter matching no test runs zero tests and still exits 0, so a + // renamed or mistyped fixture name would look like a green permission + // check. "1 passed" closes that. assert!( stdout.contains("1 passed"), "the fixture filter must select exactly one test that passed; a filter matching \ nothing exits 0 and would make this check vacuous\n--- stdout ---\n{stdout}" ); + + // But "1 passed" does not prove the fixture ASSERTED anything: its + // env-var gate returns early, and an early return is itself a passing + // test. A renamed variable or a changed value would report 1 passed + // having checked nothing. The sentinel is printed only after both mode + // assertions, so requiring it closes that second hole. + assert!( + stdout.contains(FIXTURE_SENTINEL), + "the fixture must print {FIXTURE_SENTINEL:?} after its assertions; without it the \ + child may have returned early at its env gate and still reported 1 passed\ + \n--- stdout ---\n{stdout}" + ); + } + + /// The backstop inside `load_or_create_p2p_keypair`, exercised directly. + /// + /// `Config::validate` rejects these paths before the node starts, so in a + /// running node this branch is unreachable. That is exactly why it needs its + /// own test: it exists for a future caller that does not go through config + /// validation, and a guard whose only justification is a caller that does + /// not exist yet is otherwise never executed by anything. + /// + /// No file is created for any of these, so there is nothing to clean up. + #[test] + fn p2p_key_path_naming_no_directory_is_refused_without_the_config_gate() { + for path in [ + "p2p.key", + "./p2p.key", + "a/../p2p.key", + "./keys/../p2p.key", + "../p2p.key", + ] { + let result = load_or_create_p2p_keypair(Path::new(path)); + + // Clean up BEFORE asserting, and unconditionally. When the guard is + // working none of these paths is ever created, so this is a no-op. + // When it is not, the call really does write a key relative to the + // test process's working directory, which is the crate root, and + // leaving that behind breaks every later run in this checkout. That + // is not hypothetical: a mutation run that removed the guard left a + // real 0600 key and an `a/` directory in crates/gitlawb-node, and + // the next baseline failed because of it. + let leaked = Path::new(path).exists(); + let _ = std::fs::remove_file(path); + for stray_dir in ["a", "keys"] { + let _ = std::fs::remove_dir(stray_dir); + } + + let err = result.expect_err(&format!("{path:?} must be refused by the backstop")); + let msg = format!("{err:#}"); + assert!( + msg.contains("names no directory the node can manage"), + "{path:?} must be refused for naming no usable directory, got: {msg}" + ); + assert!(!leaked, "{path:?} must not have been created"); + } + } + + /// The predicate itself, over the whole input space in both directions. + /// + /// Deliberately does not call `load_or_create_p2p_keypair` on the accepted + /// paths: that would create directories and write a real key relative to + /// whatever directory the test process happens to run in. The rejected + /// direction is covered above, where nothing is created by construction, + /// and the gate and the backstop call this same function so they cannot + /// disagree. + #[test] + fn names_no_usable_directory_covers_both_directions() { + for path in [ + // No directory component. + "p2p.key", + "./p2p.key", + "././p2p.key", + "p2p.key/", + "", + // Resolves back to the working directory or above it. + "a/../p2p.key", + "./keys/../p2p.key", + "../p2p.key", + "keys/../../p2p.key", + ] { + assert!( + names_no_usable_directory(Path::new(path)), + "{path:?} must be rejected" + ); + } + + for path in [ + "keys/p2p.key", + "./keys/p2p.key", + "keys/nested/p2p.key", + "/data/keys/p2p.key", + "/data/p2p.key", + // `..` inside an absolute path cannot depend on the working + // directory, so it stays accepted. + "/data/keys/../p2p.key", + ] { + assert!( + !names_no_usable_directory(Path::new(path)), + "{path:?} must be accepted" + ); + } } #[cfg(unix)] From f0995d480815b97290a7d9b54507c1b99d5af7bd Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:56:43 -0500 Subject: [PATCH 10/36] fix(review): reject `..` in an absolute p2p key path too The previous commit closed this for relative paths and exempted absolute ones, reasoning that an absolute path cannot depend on the working directory. That is true and it is not the hazard. `key_parent` hands `ensure_key_dir` the lexical parent, so `/data/keys/../p2p.key` chmods `/data` rather than the `keys` directory the path appears to name, and `/data/../p2p.key` run as root would try to tighten `/` to 0700. The exemption also had a test asserting the first of those was fine, so the gap was written down as intended behaviour. `..` is now rejected wherever it appears. An absolute path's root counts as naming a directory, so `/p2p.key` still validates and `/data/keys/p2p.key` is unaffected. Found by a second-model review pass after the in-process reviewers had cleared the relative half. --- crates/gitlawb-node/src/config.rs | 7 +++-- crates/gitlawb-node/src/p2p/mod.rs | 46 +++++++++++++++++++----------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index c7d3b95f..2d50f942 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -1527,6 +1527,10 @@ mod tests { "a/../p2p.key", "./keys/../p2p.key", "../p2p.key", + // Absolute too: the lexical parent is what gets chmodded, so these + // would tighten /data and / rather than the named directory. + "/data/keys/../p2p.key", + "/data/../p2p.key", ] { let err = config_with_p2p_key(path) .validate() @@ -1549,9 +1553,6 @@ mod tests { "/data/keys/p2p.key", "/data/p2p.key", "~/.gitlawb/p2p.key", - // Absolute paths are judged unambiguously, so `..` inside one is - // fine: it cannot depend on where the process was started. - "/data/keys/../p2p.key", ] { assert!( config_with_p2p_key(path).validate().is_ok(), diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 4299c25c..d5edd4ce 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -215,23 +215,32 @@ pub(crate) fn key_parent(key_path: &Path) -> &Path { /// `/data/p2p.key` under a `/data` WORKDIR, an absolute directory the operator /// named). pub(crate) fn names_no_usable_directory(key_path: &Path) -> bool { - if key_path.is_absolute() { - return false; - } - match key_path.parent() { - None => true, - Some(parent) => { - let mut named_a_directory = false; - for component in parent.components() { - match component { - Component::ParentDir => return true, - Component::Normal(_) => named_a_directory = true, - _ => {} - } + let Some(parent) = key_path.parent() else { + return true; + }; + + let mut named_a_directory = false; + for component in parent.components() { + match component { + // Rejected wherever it appears, absolute paths included. An earlier + // version exempted absolute paths on the reasoning that they cannot + // depend on the working directory, which is true and beside the + // point: `key_parent` hands `ensure_key_dir` the LEXICAL parent, so + // `/data/keys/../p2p.key` chmods `/data` rather than the `keys` + // directory the path appears to name, and `/data/../p2p.key` run as + // root would try to tighten `/` to 0700. The hazard is chmodding a + // resolved ancestor nobody nominated, and that does not care whether + // the path was absolute. + Component::ParentDir => return true, + // `/` is a directory the operator named, so an absolute path's root + // counts the same way a normal component does. + Component::Normal(_) | Component::RootDir | Component::Prefix(_) => { + named_a_directory = true } - !named_a_directory + Component::CurDir => {} } } + !named_a_directory } /// Load the node's persistent libp2p identity from `key_path`, generating and @@ -969,6 +978,11 @@ mod tests { "./keys/../p2p.key", "../p2p.key", "keys/../../p2p.key", + // Absolute paths are rejected on `..` too. The lexical parent is + // what gets chmodded, so these tighten `/data` and `/` rather than + // the directory the path appears to name. + "/data/keys/../p2p.key", + "/data/../p2p.key", ] { assert!( names_no_usable_directory(Path::new(path)), @@ -982,9 +996,7 @@ mod tests { "keys/nested/p2p.key", "/data/keys/p2p.key", "/data/p2p.key", - // `..` inside an absolute path cannot depend on the working - // directory, so it stays accepted. - "/data/keys/../p2p.key", + "/p2p.key", ] { assert!( !names_no_usable_directory(Path::new(path)), From 85ff32890ec6da8f32cccd2bc31603d3b2382c97 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:11:22 -0500 Subject: [PATCH 11/36] fix(node): scrub the serialized p2p private key from memory Two buffers held the private key in its protobuf form and dropped without scrubbing: the encoding produced when a new identity is generated, and the file contents read back on every subsequent start. Both are now Zeroizing, matching what gitlawb-core already does for its own key material. Scope worth being honest about: this scrubs our copies of the serialized form, not the libp2p Keypair itself, which owns the secret for the process lifetime and exposes no way to zeroize it. The gain is that the encoded bytes do not outlive the write and the read. zeroize was already in the tree through gitlawb-core, so this promotes it to a direct dependency of gitlawb-node and adds no packages; the lockfile change is the one line recording that. --- Cargo.lock | 1 + crates/gitlawb-node/Cargo.toml | 1 + crates/gitlawb-node/src/p2p/mod.rs | 19 ++++++++++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f29b076..5e413714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3485,6 +3485,7 @@ dependencies = [ "tracing-subscriber", "unicode-normalization", "uuid", + "zeroize", "zstd", ] diff --git a/crates/gitlawb-node/Cargo.toml b/crates/gitlawb-node/Cargo.toml index c583569c..b4b8b8be 100644 --- a/crates/gitlawb-node/Cargo.toml +++ b/crates/gitlawb-node/Cargo.toml @@ -33,6 +33,7 @@ sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chron clap = { version = "4", features = ["derive", "env"] } bytes = "1" libc = "0.2" +zeroize = "1" cid = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index d5edd4ce..dff49971 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -25,6 +25,7 @@ use libp2p_swarm::{NetworkBehaviour, Swarm, SwarmEvent}; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use uuid::Uuid; +use zeroize::Zeroizing; use crate::db::{Db, ReceivedRefUpdate}; @@ -272,9 +273,13 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result } let kp = identity::Keypair::generate_ed25519(); - let bytes = kp - .to_protobuf_encoding() - .map_err(|e| anyhow::anyhow!("failed to serialize p2p key: {e}"))?; + // The serialized form carries the private key, so scrub it on drop rather + // than leaving it in a heap buffer for the rest of the process. Same + // convention `gitlawb-core` applies to its own key material. + let bytes = Zeroizing::new( + kp.to_protobuf_encoding() + .map_err(|e| anyhow::anyhow!("failed to serialize p2p key: {e}"))?, + ); match write_key_atomically(key_path, &bytes) { Ok(()) => { @@ -476,8 +481,12 @@ fn read_p2p_keypair(key_path: &Path) -> Result { } } - let bytes = std::fs::read(key_path) - .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; + // Same reason as the write path: this is the private key, so it gets + // scrubbed on drop instead of lingering in a heap buffer. + let bytes = Zeroizing::new( + std::fs::read(key_path) + .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?, + ); // An empty file decodes as a valid protobuf with a key type of RSA, so // without this the operator gets a misleading complaint about a missing From e6432e7adadf88a354d4d9d306ab31d378f79ca7 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:37:49 -0500 Subject: [PATCH 12/36] fix(node): validate the p2p key path before securing its parent Reject filesystem-root parents, directory-valued paths, and symlinks before any directory is created or chmodded. Create missing ancestors at the ambient mode and pin only the nominated key directory to 0700. Refuse symlink reads with O_NOFOLLOW, propagate directory durability sync failures, and document the loose-key rejection path for operators. --- README.md | 2 +- crates/gitlawb-node/src/config.rs | 35 +-- crates/gitlawb-node/src/p2p/mod.rs | 369 ++++++++++++++++++++++------- 3 files changed, 305 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index b867d9eb..61ff530a 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Important node settings: | `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. | | `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. | | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | -| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must include a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`; the node refuses to start on a bare filename, because it will not keep its p2p identity key in the working directory. On Unix the file is created `0600` inside a `0700` directory, and a loose key directory is tightened to `0700` on start; on other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | +| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`; bare filenames, directory paths, trailing `/`, and the filesystem root are refused at startup. On Unix a new key is created `0600` inside a `0700` directory; a loose key directory is tightened to `0700` on start. An existing key copied from backup with group or other bits set is rejected rather than repaired; run `chmod 600` on it before restarting. P2P stays off while HTTP remains up if the key cannot be loaded. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | | `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | | `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 2d50f942..d9056159 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -792,15 +792,7 @@ impl Config { self.p2p_key_path )); } - if crate::p2p::names_no_usable_directory(&p2p_key_path) { - return Err(format!( - "GITLAWB_P2P_KEY ({}) must include a directory that does not walk back through \ - `..`, such as ./keys/p2p.key or /data/keys/p2p.key: the node will not store its \ - p2p identity key in the working directory, where the directory holding it \ - cannot be secured.", - self.p2p_key_path - )); - } + crate::p2p::validate_p2p_key_path(&p2p_key_path, Some(&self.p2p_key_path))?; Ok(()) } @@ -1565,19 +1557,30 @@ mod tests { .expect("the shipped default p2p key path must validate"); } - /// The one input that separates validating the raw config string from - /// validating `resolved_p2p_key_path()`. Raw, `~/` has an empty parent and - /// would be rejected; resolved, it is the home directory, whose parent is a - /// real directory, so it is accepted. Every other tilde path is accepted - /// under both readings and therefore proves nothing. + /// `~/` expands to the home directory itself, which is a directory rather + /// than a key file, so it must be rejected before its parent is chmodded. #[test] fn p2p_key_path_is_checked_after_tilde_expansion() { if dirs_next::home_dir().is_none() { panic!("this test needs a home directory to distinguish raw from resolved"); } + let err = config_with_p2p_key("~/") + .validate() + .expect_err("`~/` must name a key file, not a directory"); + assert!( + err.contains("must name a key file"), + "`~/` must be rejected before chmodding its parent, got: {err}" + ); + } + + #[test] + fn p2p_key_path_trailing_directory_separator_is_rejected() { + let err = config_with_p2p_key("/data/keys/") + .validate() + .expect_err("a trailing directory separator must be rejected"); assert!( - config_with_p2p_key("~/").validate().is_ok(), - "`~/` resolves to the home directory, whose parent is a real directory" + err.contains("must name a key file"), + "trailing `/` must be rejected before chmod, got: {err}" ); } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index dff49971..6163f37f 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -244,31 +244,97 @@ pub(crate) fn names_no_usable_directory(key_path: &Path) -> bool { !named_a_directory } -/// Load the node's persistent libp2p identity from `key_path`, generating and -/// storing a fresh Ed25519 keypair the first time. -pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { - let parent = key_parent(key_path); +/// Whether the key file's parent directory is the filesystem root. +/// +/// A key at `/p2p.key` would have `ensure_key_dir` tighten `/` to `0700` on a +/// root-run node, which breaks every other service on the host. +#[cfg(unix)] +fn key_parent_is_filesystem_root(key_path: &Path) -> bool { + key_parent(key_path) == Path::new("/") +} + +#[cfg(not(unix))] +fn key_parent_is_filesystem_root(_key_path: &Path) -> bool { + false +} + +/// Whether the configured path names a directory rather than a key file. +/// +/// Checked lexically (`~/`, a trailing `/`) and against an existing path on +/// disk, before any directory is created or chmodded. +fn path_denotes_a_directory(key_path: &Path, configured_raw: Option<&str>) -> bool { + if let Some(raw) = configured_raw { + if raw == "~/" || raw.ends_with('/') { + return true; + } + } + + match key_path.file_name() { + None => return true, + Some(name) if name.is_empty() => return true, + _ => {} + } + + if let Ok(md) = std::fs::symlink_metadata(key_path) { + return md.is_dir(); + } + + false +} + +/// Validate the resolved key path before creating or chmodding anything. +/// +/// `configured_raw` is the operator's `GITLAWB_P2P_KEY` string when available. +pub(crate) fn validate_p2p_key_path( + key_path: &Path, + configured_raw: Option<&str>, +) -> Result<(), String> { + let display = configured_raw.unwrap_or_else(|| key_path.to_str().unwrap_or("")); - // Backstop, not the gate. `Config::validate` rejects a key path naming no - // directory before the node starts, which is where the operator gets a - // useful error. Refusing it here too means a future caller that skips - // config validation cannot quietly resurrect the old behaviour of writing - // the key into the working directory and chmodding whatever that happens - // to be. if names_no_usable_directory(key_path) { - return Err(anyhow::anyhow!( - "p2p key path {} names no directory the node can manage; give it one that does not \ - walk back through `..`, such as ./keys/p2p.key", - key_path.display() + return Err(format!( + "GITLAWB_P2P_KEY ({display}) must include a directory that does not walk back through \ + `..`, such as ./keys/p2p.key or /data/keys/p2p.key: the node will not store its p2p \ + identity key in the working directory, where the directory holding it cannot be secured." + )); + } + + if key_parent_is_filesystem_root(key_path) { + return Err(format!( + "GITLAWB_P2P_KEY ({display}) must not place the key in the filesystem root; use a \ + dedicated directory such as /data/keys/p2p.key" )); } + if path_denotes_a_directory(key_path, configured_raw) { + return Err(format!( + "GITLAWB_P2P_KEY ({display}) must name a key file, not a directory" + )); + } + + if let Ok(md) = std::fs::symlink_metadata(key_path) { + if md.file_type().is_symlink() { + return Err(format!( + "GITLAWB_P2P_KEY ({display}) must name a regular key file; symlinks are refused" + )); + } + } + + Ok(()) +} + +/// Load the node's persistent libp2p identity from `key_path`, generating and +/// storing a fresh Ed25519 keypair the first time. +pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { + validate_p2p_key_path(key_path, None).map_err(|e| anyhow::anyhow!(e))?; + let parent = key_parent(key_path); + // Runs on both the load and the create path: the directory guards the key // just as much as the key's own mode does, and an existing directory keeps // whatever mode it was made with. ensure_key_dir(parent)?; - if key_path.exists() { + if std::fs::symlink_metadata(key_path).is_ok() { return read_p2p_keypair(key_path); } @@ -290,10 +356,8 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result ); Ok(kp) } - // Something already occupies the path: another node process won the - // race between the existence check and the atomic publish, or the path - // is a symlink. Whatever is on disk is the identity of record, so read - // it back rather than failing the boot or overwriting it. + // Another node process won the race between the existence check and the + // atomic publish. Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_p2p_keypair(key_path), Err(e) => Err(anyhow::Error::new(e) .context(format!("failed to write p2p key to {}", key_path.display()))), @@ -322,17 +386,30 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result /// keys. Issue #231 owns the sibling gap in `main.rs`'s own creation path for /// that file; nothing here touches it. fn ensure_key_dir(dir: &Path) -> Result<()> { - let mut builder = std::fs::DirBuilder::new(); - builder.recursive(true); - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - builder.mode(0o700); + // Missing ancestors are created at the ambient mode. Only the nominated key + // directory itself is pinned to 0700. + if let Some(parent) = dir.parent() { + if !parent.as_os_str().is_empty() && parent != dir { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create parent directories for key directory {}", + dir.display() + ) + })?; + } + } + + if !dir.exists() { + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(dir) + .with_context(|| format!("failed to create key directory {}", dir.display()))?; } - // On non-unix this is exactly `create_dir_all`; there is no mode to pin. - builder - .create(dir) - .with_context(|| format!("failed to create key directory {}", dir.display()))?; #[cfg(unix)] { @@ -440,13 +517,26 @@ fn fill_and_publish( file.sync_all()?; std::fs::hard_link(tmp_path, key_path)?; - // Make the new directory entry itself durable. Best-effort: the key is - // already written and linked, and not every platform allows this. Goes - // through `key_parent` like every other site; opening a bare `""` here used - // to fail silently, which looked like a working fsync and was not. - if let Ok(dir_file) = std::fs::File::open(key_parent(key_path)) { - let _ = dir_file.sync_all(); - } + let dir = key_parent(key_path); + let dir_file = std::fs::File::open(dir).map_err(|e| { + std::io::Error::new( + e.kind(), + format!( + "failed to open key directory {} for durability sync after publishing the key: {e}", + dir.display() + ), + ) + })?; + dir_file.sync_all().map_err(|e| { + std::io::Error::new( + e.kind(), + format!( + "failed to sync key directory {} after publishing the key; the identity may not \ + survive a crash until the next successful start: {e}", + dir.display() + ), + ) + })?; Ok(()) } @@ -462,14 +552,27 @@ thread_local! { /// unreadable key file would change its PeerId without the operator knowing. fn read_p2p_keypair(key_path: &Path) -> Result { #[cfg(unix)] - { + let bytes = { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(key_path) - .with_context(|| format!("failed to stat p2p key at {}", key_path.display()))? - .permissions() - .mode() - & 0o777; + let mut file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(key_path) + .with_context(|| { + format!( + "failed to open p2p key at {} (a symlink here is refused rather than followed)", + key_path.display() + ) + })?; + + let md = file + .metadata() + .with_context(|| format!("failed to stat p2p key at {}", key_path.display()))?; + + let mode = md.permissions().mode() & 0o777; if mode & 0o077 != 0 { anyhow::bail!( "p2p key at {} has mode {:04o}, which grants access beyond its owner; \ @@ -479,10 +582,14 @@ fn read_p2p_keypair(key_path: &Path) -> Result { key_path.display() ); } - } - // Same reason as the write path: this is the private key, so it gets - // scrubbed on drop instead of lingering in a heap buffer. + let mut buf = Zeroizing::new(Vec::new()); + file.read_to_end(&mut buf) + .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; + buf + }; + + #[cfg(not(unix))] let bytes = Zeroizing::new( std::fs::read(key_path) .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?, @@ -921,48 +1028,45 @@ mod tests { ); } - /// The backstop inside `load_or_create_p2p_keypair`, exercised directly. - /// - /// `Config::validate` rejects these paths before the node starts, so in a - /// running node this branch is unreachable. That is exactly why it needs its - /// own test: it exists for a future caller that does not go through config - /// validation, and a guard whose only justification is a caller that does - /// not exist yet is otherwise never executed by anything. - /// - /// No file is created for any of these, so there is nothing to clean up. + /// The backstop inside `load_or_create_p2p_keypair`, exercised in an isolated + /// working directory so a failed guard cannot delete unrelated files. #[test] fn p2p_key_path_naming_no_directory_is_refused_without_the_config_gate() { - for path in [ - "p2p.key", - "./p2p.key", - "a/../p2p.key", - "./keys/../p2p.key", - "../p2p.key", - ] { - let result = load_or_create_p2p_keypair(Path::new(path)); - - // Clean up BEFORE asserting, and unconditionally. When the guard is - // working none of these paths is ever created, so this is a no-op. - // When it is not, the call really does write a key relative to the - // test process's working directory, which is the crate root, and - // leaving that behind breaks every later run in this checkout. That - // is not hypothetical: a mutation run that removed the guard left a - // real 0600 key and an `a/` directory in crates/gitlawb-node, and - // the next baseline failed because of it. - let leaked = Path::new(path).exists(); - let _ = std::fs::remove_file(path); - for stray_dir in ["a", "keys"] { - let _ = std::fs::remove_dir(stray_dir); + let dir = tempfile::tempdir().unwrap(); + let sentinel = dir.path().join("sentinel"); + std::fs::write(&sentinel, b"keep").unwrap(); + + let prev = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(dir.path()).expect("chdir into tempdir"); + let run = std::panic::catch_unwind(|| { + for path in [ + "p2p.key", + "./p2p.key", + "a/../p2p.key", + "./keys/../p2p.key", + "../p2p.key", + ] { + let result = load_or_create_p2p_keypair(Path::new(path)); + let leaked = Path::new(path).exists(); + let err = result.expect_err(&format!("{path:?} must be refused by the backstop")); + let msg = format!("{err:#}"); + assert!( + msg.contains("must include a directory") + || msg.contains("names no directory") + || msg.contains("must name a key file"), + "{path:?} must be refused before touching the filesystem, got: {msg}" + ); + assert!(!leaked, "{path:?} must not have been created"); } + }); + std::env::set_current_dir(prev).expect("restore cwd"); + run.expect("backstop probe must not panic"); - let err = result.expect_err(&format!("{path:?} must be refused by the backstop")); - let msg = format!("{err:#}"); - assert!( - msg.contains("names no directory the node can manage"), - "{path:?} must be refused for naming no usable directory, got: {msg}" - ); - assert!(!leaked, "{path:?} must not have been created"); - } + assert_eq!( + std::fs::read(&sentinel).unwrap(), + b"keep", + "the probe must not delete unrelated files in its working directory" + ); } /// The predicate itself, over the whole input space in both directions. @@ -1005,13 +1109,110 @@ mod tests { "keys/nested/p2p.key", "/data/keys/p2p.key", "/data/p2p.key", - "/p2p.key", ] { assert!( !names_no_usable_directory(Path::new(path)), "{path:?} must be accepted" ); } + + for path in ["/p2p.key"] { + assert!( + names_no_usable_directory(Path::new(path)) + || key_parent_is_filesystem_root(Path::new(path)), + "{path:?} must be rejected" + ); + } + } + + #[test] + fn validate_p2p_key_path_rejects_root_parent_and_directory_targets() { + let root_err = + validate_p2p_key_path(Path::new("/p2p.key"), Some("/p2p.key")).expect_err("/p2p.key"); + assert!( + root_err.contains("filesystem root"), + "root-parent paths must be refused before chmod, got: {root_err}" + ); + + let dir = tempfile::tempdir().unwrap(); + let key_dir = dir.path().join("keys"); + std::fs::create_dir(&key_dir).unwrap(); + let dir_err = validate_p2p_key_path(&key_dir, Some(key_dir.to_str().unwrap())) + .expect_err("an existing directory target"); + assert!( + dir_err.contains("must name a key file"), + "directory targets must be refused before chmod, got: {dir_err}" + ); + } + + #[cfg(unix)] + #[test] + fn p2p_key_path_in_filesystem_root_does_not_chmod_root() { + use std::os::unix::fs::PermissionsExt; + + let before = std::fs::metadata("/").unwrap().permissions().mode() & 0o777; + let err = load_or_create_p2p_keypair(Path::new("/p2p.key")) + .expect_err("/p2p.key must be refused before touching /"); + let after = std::fs::metadata("/").unwrap().permissions().mode() & 0o777; + assert_eq!(before, after, "refusing /p2p.key must not chmod /"); + assert!( + format!("{err:#}").contains("filesystem root"), + "error must name the root-parent hazard, got: {err:#}" + ); + } + + #[cfg(unix)] + #[test] + fn p2p_nested_key_path_leaves_ancestor_modes_unchanged() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + let key_path = base.path().join("a").join("b").join("keys").join("p2p.key"); + load_or_create_p2p_keypair(&key_path).expect("nested first boot"); + + let a_mode = std::fs::metadata(base.path().join("a")) + .unwrap() + .permissions() + .mode() + & 0o777; + let b_mode = std::fs::metadata(base.path().join("a").join("b")) + .unwrap() + .permissions() + .mode() + & 0o777; + let keys_mode = std::fs::metadata(base.path().join("a").join("b").join("keys")) + .unwrap() + .permissions() + .mode() + & 0o777; + + assert_eq!(keys_mode, 0o700, "only the nominated key directory is 0700"); + assert_ne!( + a_mode, 0o700, + "missing ancestors must keep the ambient mode, not inherit 0700" + ); + assert_ne!( + b_mode, 0o700, + "intermediate ancestors must not be tightened to 0700" + ); + } + + #[cfg(unix)] + #[test] + fn p2p_existing_symlink_key_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real.key"); + let link = dir.path().join("p2p.key"); + + load_or_create_p2p_keypair(&target).expect("create the real key"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let err = + load_or_create_p2p_keypair(&link).expect_err("a symlink key path must be refused"); + assert!( + format!("{err:#}").contains("symlink"), + "error must name the symlink refusal, got: {err:#}" + ); } #[cfg(unix)] From b049ee524f52348fe3907fd5bca3a859bbb42ddd Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:07:26 -0500 Subject: [PATCH 13/36] fix(node): satisfy clippy single-element-loop on p2p path test --- crates/gitlawb-node/src/p2p/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 6163f37f..c4bc8efe 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -1116,7 +1116,8 @@ mod tests { ); } - for path in ["/p2p.key"] { + { + let path = "/p2p.key"; assert!( names_no_usable_directory(Path::new(path)) || key_parent_is_filesystem_root(Path::new(path)), From 0f17b51f5cf182a8e7cb98c9cde036425c67bf31 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:01:25 -0500 Subject: [PATCH 14/36] fix(node): tighten the p2p key storage contract before mutation Anchor parent-directory checks on symlink_metadata, reject ~/ escapes and non-regular existing keys with a bounded read, treat directory AlreadyExists as a first-boot race, and skip key-path validation when P2P is disabled. --- README.md | 3 +- crates/gitlawb-node/src/config.rs | 119 +++++--- crates/gitlawb-node/src/p2p/mod.rs | 437 +++++++++++++++++++++++------ 3 files changed, 444 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 61ff530a..687748f4 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,8 @@ Two things to check before upgrading: `/p2p/` suffix stops matching once that peer upgrades. Update the suffix, or drop it and let identify supply the current one. Addresses without the suffix keep working untouched. -- `GITLAWB_P2P_KEY` must name a directory. A bare filename is refused at +- `GITLAWB_P2P_KEY` must name a key file inside a directory, such as + `/data/keys/p2p.key` or `./keys/p2p.key`. A bare filename is refused at startup, since the node will not keep its identity key in the working directory. diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index d9056159..1ee5bb6d 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -726,8 +726,11 @@ impl Config { /// Resolve ~ in p2p_key_path pub fn resolved_p2p_key_path(&self) -> PathBuf { if self.p2p_key_path.starts_with("~/") { - if let Some(home) = dirs_next::home_dir() { - return home.join(&self.p2p_key_path[2..]); + let suffix = &self.p2p_key_path[2..]; + if !crate::p2p::tilde_suffix_escapes_home(suffix) { + if let Some(home) = dirs_next::home_dir() { + return home.join(suffix); + } } } PathBuf::from(&self.p2p_key_path) @@ -761,38 +764,54 @@ impl Config { )); } - // A p2p key path naming no directory puts the node's private key in - // whatever directory the process was started from. The node cannot - // protect that: `ensure_key_dir` would have to chmod a directory the - // operator never nominated as a key directory, and a directory it - // cannot secure is one where any local user with write access can - // replace the key and choose the node's libp2p identity. Refuse it here, - // where the denial actually stops the process, rather than in the p2p - // start path, where main.rs logs the error and keeps serving with a - // green /health. - // - // Decided lexically on the resolved path: `canonicalize` would fail on a - // parent that does not exist yet (the shipped `~/.gitlawb` default, and - // every container's first boot), and comparing against the process - // working directory would reject `/data/p2p.key` under the image's - // WORKDIR, an absolute directory the operator did name. - // `resolved_p2p_key_path` expands a leading `~/` only when a home - // directory is resolvable, and otherwise hands back the literal string. - // That would leave the shipped default naming a directory called `~` - // relative to wherever the process started, which is a real directory - // the node would create and chmod, and whose location moves with the - // working directory. It passes the check below because `~` is an - // ordinary path component, so it has to be caught separately. - let p2p_key_path = self.resolved_p2p_key_path(); - if self.p2p_key_path.starts_with("~/") && p2p_key_path == Path::new(&self.p2p_key_path) { - return Err(format!( - "GITLAWB_P2P_KEY ({}) starts with `~/` but no home directory could be resolved, \ - so it would name a literal `~` directory relative to the working directory. \ - Set an absolute path such as /data/keys/p2p.key.", - self.p2p_key_path - )); + // P2P is optional: an HTTP-only node with GITLAWB_P2P_PORT=0 never loads + // or creates this key, so refusing startup on an unused path would be a + // silent outage with no security benefit. + if self.p2p_port > 0 { + // A p2p key path naming no directory puts the node's private key in + // whatever directory the process was started from. The node cannot + // protect that: `ensure_key_dir` would have to chmod a directory the + // operator never nominated as a key directory, and a directory it + // cannot secure is one where any local user with write access can + // replace the key and choose the node's libp2p identity. Refuse it here, + // where the denial actually stops the process, rather than in the p2p + // start path, where main.rs logs the error and keeps serving with a + // green /health. + // + // Decided lexically on the resolved path: `canonicalize` would fail on a + // parent that does not exist yet (the shipped `~/.gitlawb` default, and + // every container's first boot), and comparing against the process + // working directory would reject `/data/p2p.key` under the image's + // WORKDIR, an absolute directory the operator did name. + // `resolved_p2p_key_path` expands a leading `~/` only when a home + // directory is resolvable, and otherwise hands back the literal string. + // That would leave the shipped default naming a directory called `~` + // relative to wherever the process started, which is a real directory + // the node would create and chmod, and whose location moves with the + // working directory. It passes the check below because `~` is an + // ordinary path component, so it has to be caught separately. + if self.p2p_key_path.starts_with("~/") { + let suffix = &self.p2p_key_path[2..]; + if crate::p2p::tilde_suffix_escapes_home(suffix) { + return Err(format!( + "GITLAWB_P2P_KEY ({}) must stay inside the home directory after `~/` \ + expansion; rooted suffixes such as `~//etc/p2p.key` are refused", + self.p2p_key_path + )); + } + } + let p2p_key_path = self.resolved_p2p_key_path(); + if self.p2p_key_path.starts_with("~/") && p2p_key_path == Path::new(&self.p2p_key_path) + { + return Err(format!( + "GITLAWB_P2P_KEY ({}) starts with `~/` but no home directory could be resolved, \ + so it would name a literal `~` directory relative to the working directory. \ + Set an absolute path such as /data/keys/p2p.key.", + self.p2p_key_path + )); + } + crate::p2p::validate_p2p_key_path(&p2p_key_path, Some(&self.p2p_key_path))?; } - crate::p2p::validate_p2p_key_path(&p2p_key_path, Some(&self.p2p_key_path))?; Ok(()) } @@ -1500,6 +1519,40 @@ mod tests { Config::parse_from(["gitlawb-node", "--p2p-key-path", path]) } + fn config_with_p2p_port_and_key(port: u16, path: &str) -> Config { + Config::parse_from([ + "gitlawb-node", + "--p2p-port", + &port.to_string(), + "--p2p-key-path", + path, + ]) + } + + #[test] + fn p2p_disabled_skips_key_path_validation() { + for path in ["p2p.key", "~/", "~//etc/p2p.key"] { + assert!( + config_with_p2p_port_and_key(0, path).validate().is_ok(), + "p2p disabled must not validate unused key path {path:?}" + ); + } + } + + #[test] + fn p2p_tilde_suffix_that_escapes_home_is_rejected() { + if dirs_next::home_dir().is_none() { + return; + } + let err = config_with_p2p_key("~//etc/p2p.key") + .validate() + .expect_err("~//etc must not escape home"); + assert!( + err.contains("inside the home directory") || err.contains("rooted suffixes"), + "escaped tilde suffix must be refused, got: {err}" + ); + } + /// A p2p key path that names no directory component would put the node's /// private key in whatever directory the process happens to be started from, /// which `ensure_key_dir` cannot protect without tightening a directory the diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index c4bc8efe..3e18ccd7 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -258,6 +258,46 @@ fn key_parent_is_filesystem_root(_key_path: &Path) -> bool { false } +/// Maximum protobuf-encoded libp2p key size accepted on read. +const MAX_P2P_KEY_BYTES: usize = 4096; + +/// Whether the remainder of a `~/...` value would escape home when joined. +/// +/// `home.join("/etc/p2p.key")` discards `home` because the right-hand path is +/// absolute, so doubled separators and rooted suffixes must be rejected first. +pub(crate) fn tilde_suffix_escapes_home(suffix: &str) -> bool { + Path::new(suffix).has_root() +} + +/// Inspect `dir` without following symlinks. Ok when absent; Err when present +/// but not a real directory. +pub(crate) fn parent_directory_is_safe_to_mutate(dir: &Path) -> Result<(), String> { + match std::fs::symlink_metadata(dir) { + Ok(md) => { + if md.is_symlink() { + return Err(format!( + "GITLAWB_P2P_KEY's directory {} must be a real directory, not a symlink", + dir.display() + )); + } + if !md.is_dir() { + return Err(format!( + "GITLAWB_P2P_KEY's directory {} must be a directory, not another file type", + dir.display() + )); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(format!( + "failed to inspect key directory {}: {e}", + dir.display() + )); + } + } + Ok(()) +} + /// Whether the configured path names a directory rather than a key file. /// /// Checked lexically (`~/`, a trailing `/`) and against an existing path on @@ -313,13 +353,15 @@ pub(crate) fn validate_p2p_key_path( } if let Ok(md) = std::fs::symlink_metadata(key_path) { - if md.file_type().is_symlink() { + if md.is_symlink() { return Err(format!( "GITLAWB_P2P_KEY ({display}) must name a regular key file; symlinks are refused" )); } } + parent_directory_is_safe_to_mutate(key_parent(key_path))?; + Ok(()) } @@ -386,6 +428,8 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result /// keys. Issue #231 owns the sibling gap in `main.rs`'s own creation path for /// that file; nothing here touches it. fn ensure_key_dir(dir: &Path) -> Result<()> { + parent_directory_is_safe_to_mutate(dir).map_err(|e| anyhow::anyhow!(e))?; + // Missing ancestors are created at the ambient mode. Only the nominated key // directory itself is pinned to 0700. if let Some(parent) = dir.parent() { @@ -399,23 +443,38 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { } } - if !dir.exists() { - let mut builder = std::fs::DirBuilder::new(); - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - builder.mode(0o700); + match std::fs::symlink_metadata(dir) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + parent_directory_is_safe_to_mutate(dir).map_err(|e| anyhow::anyhow!(e))?; + } + Err(e) => { + return Err(e).with_context(|| { + format!("failed to create key directory {}", dir.display()) + }); + } + } + } + Err(e) => { + return Err(e) + .with_context(|| format!("failed to stat key directory {}", dir.display())); } - builder - .create(dir) - .with_context(|| format!("failed to create key directory {}", dir.display()))?; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir) + let mode = std::fs::symlink_metadata(dir) .with_context(|| format!("failed to stat key directory {}", dir.display()))? .permissions() .mode() @@ -559,7 +618,7 @@ fn read_p2p_keypair(key_path: &Path) -> Result { let mut file = std::fs::OpenOptions::new() .read(true) - .custom_flags(libc::O_NOFOLLOW) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) .open(key_path) .with_context(|| { format!( @@ -572,6 +631,13 @@ fn read_p2p_keypair(key_path: &Path) -> Result { .metadata() .with_context(|| format!("failed to stat p2p key at {}", key_path.display()))?; + if !md.is_file() { + anyhow::bail!( + "p2p key at {} must be a regular file; directories, FIFOs, and special files are refused", + key_path.display() + ); + } + let mode = md.permissions().mode() & 0o777; if mode & 0o077 != 0 { anyhow::bail!( @@ -584,8 +650,33 @@ fn read_p2p_keypair(key_path: &Path) -> Result { } let mut buf = Zeroizing::new(Vec::new()); - file.read_to_end(&mut buf) - .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; + let mut chunk = [0u8; 256]; + loop { + match file.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if buf.len() + n > MAX_P2P_KEY_BYTES { + anyhow::bail!( + "p2p key at {} exceeds the maximum accepted size of {} bytes", + key_path.display(), + MAX_P2P_KEY_BYTES + ); + } + buf.extend_from_slice(&chunk[..n]); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + anyhow::bail!( + "p2p key at {} is not a readable regular file (open would block)", + key_path.display() + ); + } + Err(e) => { + return Err(e).with_context(|| { + format!("failed to read p2p key from {}", key_path.display()) + }); + } + } + } buf }; @@ -931,14 +1022,97 @@ mod tests { const FIXTURE_SENTINEL: &str = "p2p-key-perms: asserted"; /// Re-invoke this test binary to run one `#[ignore]`d fixture test. - #[cfg(unix)] fn fixture_command(fixture_test: &str) -> std::process::Command { let mut cmd = std::process::Command::new(std::env::current_exe().expect("current_exe")); - cmd.args([fixture_test, "--exact", "--ignored", "--nocapture"]) - .env("GITLAWB_TEST_FIXTURE", "p2p-key-perms"); + cmd.args([fixture_test, "--exact", "--ignored", "--nocapture"]); cmd } + #[cfg(unix)] + fn fixture_command_with_env(fixture_test: &str, fixture_name: &str) -> std::process::Command { + let mut cmd = fixture_command(fixture_test); + cmd.env("GITLAWB_TEST_FIXTURE", fixture_name); + cmd + } + + /// Fixture: refuse key paths that name no directory inside an isolated cwd. + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=p2p-key-backstop"] + fn fixture_p2p_key_backstop_refuses_no_directory() { + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("p2p-key-backstop") { + return; + } + + let dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(dir.path()).expect("chdir into isolated tempdir"); + + for path in [ + "p2p.key", + "./p2p.key", + "a/../p2p.key", + "./keys/../p2p.key", + "../p2p.key", + ] { + let result = load_or_create_p2p_keypair(Path::new(path)); + let leaked = Path::new(path).exists(); + let err = result.expect_err(&format!("{path:?} must be refused by the backstop")); + let msg = format!("{err:#}"); + assert!( + msg.contains("must include a directory") + || msg.contains("names no directory") + || msg.contains("must name a key file"), + "{path:?} must be refused before touching the filesystem, got: {msg}" + ); + assert!(!leaked, "{path:?} must not have been created"); + } + + println!("p2p-key-backstop: asserted"); + } + + /// The backstop inside `load_or_create_p2p_keypair`, exercised in a child + /// process so cwd is not mutated for sibling tests. + #[test] + fn p2p_key_path_naming_no_directory_is_refused_without_the_config_gate() { + let dir = tempfile::tempdir().unwrap(); + let sentinel = dir.path().join("sentinel"); + std::fs::write(&sentinel, b"keep").unwrap(); + + #[cfg(unix)] + let output = fixture_command_with_env( + "p2p::tests::fixture_p2p_key_backstop_refuses_no_directory", + "p2p-key-backstop", + ) + .output() + .expect("spawn the backstop fixture"); + + #[cfg(not(unix))] + let output = { + let _ = &sentinel; + panic!("backstop child-process fixture is unix-only"); + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "the backstop fixture must pass in its child process\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("1 passed"), + "the fixture filter must select exactly one test\n--- stdout ---\n{stdout}" + ); + assert!( + stdout.contains("p2p-key-backstop: asserted"), + "the fixture must print its sentinel after asserting\n--- stdout ---\n{stdout}" + ); + + assert_eq!( + std::fs::read(&sentinel).unwrap(), + b"keep", + "the parent process cwd must stay untouched" + ); + } + /// Fixture: create the key under a zeroed umask and assert the modes the /// code is supposed to pin. Double-gated so it is inert unless the parent /// invoked it: `#[ignore]` keeps it out of a normal run, and the env check @@ -991,9 +1165,12 @@ mod tests { #[cfg(unix)] #[test] fn p2p_key_file_is_0600_on_unix() { - let output = fixture_command("p2p::tests::fixture_p2p_key_perms_under_zero_umask") - .output() - .expect("spawn the permission fixture"); + let output = fixture_command_with_env( + "p2p::tests::fixture_p2p_key_perms_under_zero_umask", + "p2p-key-perms", + ) + .output() + .expect("spawn the permission fixture"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -1028,47 +1205,6 @@ mod tests { ); } - /// The backstop inside `load_or_create_p2p_keypair`, exercised in an isolated - /// working directory so a failed guard cannot delete unrelated files. - #[test] - fn p2p_key_path_naming_no_directory_is_refused_without_the_config_gate() { - let dir = tempfile::tempdir().unwrap(); - let sentinel = dir.path().join("sentinel"); - std::fs::write(&sentinel, b"keep").unwrap(); - - let prev = std::env::current_dir().expect("cwd"); - std::env::set_current_dir(dir.path()).expect("chdir into tempdir"); - let run = std::panic::catch_unwind(|| { - for path in [ - "p2p.key", - "./p2p.key", - "a/../p2p.key", - "./keys/../p2p.key", - "../p2p.key", - ] { - let result = load_or_create_p2p_keypair(Path::new(path)); - let leaked = Path::new(path).exists(); - let err = result.expect_err(&format!("{path:?} must be refused by the backstop")); - let msg = format!("{err:#}"); - assert!( - msg.contains("must include a directory") - || msg.contains("names no directory") - || msg.contains("must name a key file"), - "{path:?} must be refused before touching the filesystem, got: {msg}" - ); - assert!(!leaked, "{path:?} must not have been created"); - } - }); - std::env::set_current_dir(prev).expect("restore cwd"); - run.expect("backstop probe must not panic"); - - assert_eq!( - std::fs::read(&sentinel).unwrap(), - b"keep", - "the probe must not delete unrelated files in its working directory" - ); - } - /// The predicate itself, over the whole input space in both directions. /// /// Deliberately does not call `load_or_create_p2p_keypair` on the accepted @@ -1168,34 +1304,29 @@ mod tests { use std::os::unix::fs::PermissionsExt; let base = tempfile::tempdir().unwrap(); - let key_path = base.path().join("a").join("b").join("keys").join("p2p.key"); + let ancestor_a = base.path().join("a"); + let ancestor_b = ancestor_a.join("b"); + std::fs::create_dir_all(&ancestor_b).unwrap(); + std::fs::set_permissions(&ancestor_a, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&ancestor_b, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let key_path = ancestor_b.join("keys").join("p2p.key"); + let a_mode_before = std::fs::metadata(&ancestor_a).unwrap().permissions().mode() & 0o777; + let b_mode_before = std::fs::metadata(&ancestor_b).unwrap().permissions().mode() & 0o777; + load_or_create_p2p_keypair(&key_path).expect("nested first boot"); - let a_mode = std::fs::metadata(base.path().join("a")) - .unwrap() - .permissions() - .mode() - & 0o777; - let b_mode = std::fs::metadata(base.path().join("a").join("b")) - .unwrap() - .permissions() - .mode() - & 0o777; - let keys_mode = std::fs::metadata(base.path().join("a").join("b").join("keys")) + let a_mode = std::fs::metadata(&ancestor_a).unwrap().permissions().mode() & 0o777; + let b_mode = std::fs::metadata(&ancestor_b).unwrap().permissions().mode() & 0o777; + let keys_mode = std::fs::metadata(ancestor_b.join("keys")) .unwrap() .permissions() .mode() & 0o777; + assert_eq!(a_mode_before, a_mode, "ancestor a mode must stay unchanged"); + assert_eq!(b_mode_before, b_mode, "ancestor b mode must stay unchanged"); assert_eq!(keys_mode, 0o700, "only the nominated key directory is 0700"); - assert_ne!( - a_mode, 0o700, - "missing ancestors must keep the ambient mode, not inherit 0700" - ); - assert_ne!( - b_mode, 0o700, - "intermediate ancestors must not be tightened to 0700" - ); } #[cfg(unix)] @@ -1375,6 +1506,150 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn p2p_symlinked_key_parent_is_refused_before_chmod() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let real_parent = dir.path().join("real"); + std::fs::create_dir(&real_parent).unwrap(); + std::fs::set_permissions(&real_parent, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let safe = dir.path().join("safe"); + std::fs::create_dir(&safe).unwrap(); + let link = safe.join("keys"); + std::os::unix::fs::symlink(&real_parent, &link).unwrap(); + + let key_path = link.join("p2p.key"); + let mode_before = std::fs::metadata(&real_parent) + .unwrap() + .permissions() + .mode() + & 0o777; + + let err = load_or_create_p2p_keypair(&key_path) + .expect_err("a symlinked key directory must be refused before chmod"); + let after = std::fs::metadata(&real_parent) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!( + mode_before, after, + "refusing a symlink parent must not chmod its target" + ); + assert!( + format!("{err:#}").contains("symlink"), + "error must name the symlink refusal, got: {err:#}" + ); + } + + #[cfg(unix)] + #[test] + fn p2p_key_parent_that_is_a_file_is_refused_before_chmod() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let parent_file = dir.path().join("notadir"); + std::fs::write(&parent_file, b"x").unwrap(); + std::fs::set_permissions(&parent_file, std::fs::Permissions::from_mode(0o644)).unwrap(); + let key_path = parent_file.join("p2p.key"); + let mode_before = std::fs::metadata(&parent_file) + .unwrap() + .permissions() + .mode() + & 0o777; + + let err = load_or_create_p2p_keypair(&key_path) + .expect_err("a file parent must be refused before chmod"); + let mode_after = std::fs::metadata(&parent_file) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!( + mode_before, mode_after, + "refusing a file parent must not chmod it" + ); + assert!( + format!("{err:#}").contains("directory"), + "error must name the non-directory parent, got: {err:#}" + ); + } + + #[test] + fn p2p_concurrent_first_boot_dir_creation_converges() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("keys").join("p2p.key"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let key_path2 = key_path.clone(); + let b1 = barrier.clone(); + let b2 = barrier.clone(); + + let t1 = std::thread::spawn(move || { + b1.wait(); + load_or_create_p2p_keypair(&key_path) + }); + let t2 = std::thread::spawn(move || { + b2.wait(); + load_or_create_p2p_keypair(&key_path2) + }); + + let kp1 = t1.join().expect("thread 1").expect("first concurrent boot"); + let kp2 = t2 + .join() + .expect("thread 2") + .expect("second concurrent boot"); + assert_eq!( + PeerId::from(kp1.public()), + PeerId::from(kp2.public()), + "concurrent first boots must converge on one persisted identity" + ); + } + + #[cfg(unix)] + #[test] + fn p2p_fifo_key_path_is_refused_without_blocking() { + use std::ffi::CString; + + let dir = tempfile::tempdir().unwrap(); + let key_dir = dir.path().join("keys"); + std::fs::create_dir(&key_dir).unwrap(); + let path = key_dir.join("p2p.key"); + let c_path = CString::new(path.to_str().expect("utf-8 path")).unwrap(); + // SAFETY: `mkfifo` creates a FIFO at `c_path` with mode 0600. + let ret = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; + assert_eq!(ret, 0, "mkfifo failed: {}", std::io::Error::last_os_error()); + + let err = load_or_create_p2p_keypair(&path).expect_err("a FIFO key path must be refused"); + assert!( + format!("{err:#}").contains("regular file"), + "FIFO refusal must name the file-type requirement, got: {err:#}" + ); + } + + #[test] + fn p2p_oversized_key_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let key_dir = dir.path().join("keys"); + std::fs::create_dir(&key_dir).unwrap(); + let path = key_dir.join("p2p.key"); + std::fs::write(&path, vec![0u8; MAX_P2P_KEY_BYTES + 1]).unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + + let err = load_or_create_p2p_keypair(&path).expect_err("oversized key must be refused"); + assert!( + format!("{err:#}").contains("maximum accepted size"), + "oversized refusal must name the size cap, got: {err:#}" + ); + } + #[test] fn ref_update_event_round_trip_with_owner_did() { let event = RefUpdateEvent { From 4796e998f26a8c801f96871cf9518bcfc64c907b Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:12:24 -0500 Subject: [PATCH 15/36] fix(node): refuse foreign-owned p2p key paths and cap reads on all platforms Check directory and key ownership before chmod or load, walk ancestors for foreign control, bound non-Unix reads to MAX_P2P_KEY_BYTES, and gate unix-only fixture tests without panicking on other targets. --- crates/gitlawb-node/src/p2p/mod.rs | 318 ++++++++++++++++++++++++----- 1 file changed, 272 insertions(+), 46 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 3e18ccd7..fb57922a 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -298,6 +298,75 @@ pub(crate) fn parent_directory_is_safe_to_mutate(dir: &Path) -> Result<(), Strin Ok(()) } +/// Mode bits alone do not make something node-owned. A `0700` directory or a +/// `0600` file belonging to a different user passes every permission check here +/// while that user keeps the ability to replace what is inside it, which means +/// they choose the node's libp2p identity. That is the capability the persisted +/// key exists to take away, so it is refused rather than warned about. +#[cfg(unix)] +fn foreign_ownership_error(what: &str, path: &Path, owner_uid: u32, euid: u32) -> Option { + if owner_uid == euid { + return None; + } + Some(format!( + "p2p {what} {} is owned by uid {} but this node runs as uid {}; that user can \ + replace it and so decides the node's libp2p identity, which is what the persisted \ + key exists to prevent. Point {} at a location this user owns, or have the owner \ + hand it over; the node will not adopt it.", + path.display(), + owner_uid, + euid, + if what == "key directory" { + "GITLAWB_P2P_KEY's directory" + } else { + "GITLAWB_P2P_KEY" + } + )) +} + +/// Refuse a key directory whose existing ancestors are controlled by someone +/// else, before creating anything inside them. +#[cfg(unix)] +fn foreign_ancestor_error(dir: &Path, euid: u32) -> Option { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + for ancestor in dir.ancestors().skip(1) { + let md = match std::fs::metadata(ancestor) { + Ok(md) => md, + Err(_) => continue, + }; + + let owner = md.uid(); + if owner != euid && owner != 0 { + return Some(format!( + "p2p key directory {} sits under {}, which is owned by uid {} rather than this \ + node (uid {}) or root; that user can rename or replace the directory holding \ + the key and so control which identity the node presents. Put the key somewhere \ + this user or root owns the whole path.", + dir.display(), + ancestor.display(), + owner, + euid + )); + } + + let mode = md.permissions().mode() & 0o777; + let sticky = md.permissions().mode() & 0o1000 != 0; + if mode & 0o002 != 0 && !sticky { + return Some(format!( + "p2p key directory {} sits under {}, which has mode {:04o} and is writable \ + beyond its owner; anyone with that write access can rename or replace the \ + directory holding the key and so control which identity the node presents.", + dir.display(), + ancestor.display(), + mode + )); + } + } + None +} + /// Whether the configured path names a directory rather than a key file. /// /// Checked lexically (`~/`, a trailing `/`) and against an existing path on @@ -428,6 +497,14 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result /// keys. Issue #231 owns the sibling gap in `main.rs`'s own creation path for /// that file; nothing here touches it. fn ensure_key_dir(dir: &Path) -> Result<()> { + #[cfg(unix)] + { + let euid = effective_uid(); + if let Some(err) = foreign_ancestor_error(dir, euid) { + anyhow::bail!(err); + } + } + parent_directory_is_safe_to_mutate(dir).map_err(|e| anyhow::anyhow!(e))?; // Missing ancestors are created at the ambient mode. Only the nominated key @@ -472,13 +549,18 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { #[cfg(unix)] { + use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; - let mode = std::fs::symlink_metadata(dir) - .with_context(|| format!("failed to stat key directory {}", dir.display()))? - .permissions() - .mode() - & 0o777; + let md = std::fs::symlink_metadata(dir) + .with_context(|| format!("failed to stat key directory {}", dir.display()))?; + + let euid = effective_uid(); + if let Some(err) = foreign_ownership_error("key directory", dir, md.uid(), euid) { + anyhow::bail!(err); + } + + let mode = md.permissions().mode() & 0o777; if mode & 0o077 != 0 { warn!( dir = %dir.display(), @@ -604,6 +686,55 @@ thread_local! { /// Test-only fault injection for the key write. Thread-local so an armed /// test cannot disturb the others running beside it. static FAIL_KEY_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; + + /// Test-only override for the process effective uid. + static EUID_OVERRIDE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// The effective uid the ownership checks compare against. +#[cfg(unix)] +fn effective_uid() -> u32 { + #[cfg(test)] + if let Some(uid) = EUID_OVERRIDE.with(|c| c.get()) { + return uid; + } + // SAFETY: `geteuid` only reads the calling process's effective uid. + unsafe { libc::geteuid() } +} + +fn read_bounded_key_bytes( + reader: &mut R, + key_path: &Path, +) -> Result>> { + let mut buf = Zeroizing::new(Vec::new()); + let mut chunk = [0u8; 256]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if buf.len() + n > MAX_P2P_KEY_BYTES { + anyhow::bail!( + "p2p key at {} exceeds the maximum accepted size of {} bytes", + key_path.display(), + MAX_P2P_KEY_BYTES + ); + } + buf.extend_from_slice(&chunk[..n]); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + anyhow::bail!( + "p2p key at {} is not a readable regular file (open would block)", + key_path.display() + ); + } + Err(e) => { + return Err(e).with_context(|| { + format!("failed to read p2p key from {}", key_path.display()) + }); + } + } + } + Ok(buf) } /// Read an existing key file, refusing one whose permissions or contents make @@ -612,7 +743,7 @@ thread_local! { fn read_p2p_keypair(key_path: &Path) -> Result { #[cfg(unix)] let bytes = { - use std::io::Read; + use std::os::unix::fs::MetadataExt; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; @@ -638,6 +769,11 @@ fn read_p2p_keypair(key_path: &Path) -> Result { ); } + let euid = effective_uid(); + if let Some(err) = foreign_ownership_error("key", key_path, md.uid(), euid) { + anyhow::bail!(err); + } + let mode = md.permissions().mode() & 0o777; if mode & 0o077 != 0 { anyhow::bail!( @@ -649,42 +785,22 @@ fn read_p2p_keypair(key_path: &Path) -> Result { ); } - let mut buf = Zeroizing::new(Vec::new()); - let mut chunk = [0u8; 256]; - loop { - match file.read(&mut chunk) { - Ok(0) => break, - Ok(n) => { - if buf.len() + n > MAX_P2P_KEY_BYTES { - anyhow::bail!( - "p2p key at {} exceeds the maximum accepted size of {} bytes", - key_path.display(), - MAX_P2P_KEY_BYTES - ); - } - buf.extend_from_slice(&chunk[..n]); - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - anyhow::bail!( - "p2p key at {} is not a readable regular file (open would block)", - key_path.display() - ); - } - Err(e) => { - return Err(e).with_context(|| { - format!("failed to read p2p key from {}", key_path.display()) - }); - } - } - } - buf + read_bounded_key_bytes(&mut file, key_path)? }; #[cfg(not(unix))] - let bytes = Zeroizing::new( - std::fs::read(key_path) - .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?, - ); + let bytes = { + let data = std::fs::read(key_path) + .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; + if data.len() > MAX_P2P_KEY_BYTES { + anyhow::bail!( + "p2p key at {} exceeds the maximum accepted size of {} bytes", + key_path.display(), + MAX_P2P_KEY_BYTES + ); + } + Zeroizing::new(data) + }; // An empty file decodes as a valid protobuf with a key type of RSA, so // without this the operator gets a misleading complaint about a missing @@ -1071,13 +1187,13 @@ mod tests { /// The backstop inside `load_or_create_p2p_keypair`, exercised in a child /// process so cwd is not mutated for sibling tests. + #[cfg(unix)] #[test] fn p2p_key_path_naming_no_directory_is_refused_without_the_config_gate() { let dir = tempfile::tempdir().unwrap(); let sentinel = dir.path().join("sentinel"); std::fs::write(&sentinel, b"keep").unwrap(); - #[cfg(unix)] let output = fixture_command_with_env( "p2p::tests::fixture_p2p_key_backstop_refuses_no_directory", "p2p-key-backstop", @@ -1085,12 +1201,6 @@ mod tests { .output() .expect("spawn the backstop fixture"); - #[cfg(not(unix))] - let output = { - let _ = &sentinel; - panic!("backstop child-process fixture is unix-only"); - }; - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -1650,6 +1760,122 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn read_p2p_keypair_refuses_a_key_owned_by_another_user() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("p2p.key"); + let kp = identity::Keypair::generate_ed25519(); + std::fs::write(&path, kp.to_protobuf_encoding().unwrap()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + + let real_uid = std::fs::metadata(&path).unwrap().uid(); + let other = real_uid.wrapping_add(1); + + EUID_OVERRIDE.with(|c| c.set(Some(other))); + let result = read_p2p_keypair(&path); + EUID_OVERRIDE.with(|c| c.set(None)); + + let err = format!( + "{:#}", + result.expect_err("a foreign-owned key must be refused") + ); + assert!( + err.contains(&format!( + "owned by uid {real_uid} but this node runs as uid {other}" + )), + "the refusal must name the file's owner and the running uid, got: {err}" + ); + assert!( + read_p2p_keypair(&path).is_ok(), + "the same key must load when the owner matches" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_key_dir_refuses_a_directory_owned_by_another_user() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + for mode in [0o700, 0o777] { + let dir = tempfile::tempdir().unwrap(); + let keys = dir.path().to_path_buf(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(mode)).unwrap(); + + let real_uid = std::fs::metadata(&keys).unwrap().uid(); + EUID_OVERRIDE.with(|c| c.set(Some(real_uid.wrapping_add(1)))); + let result = ensure_key_dir(&keys); + EUID_OVERRIDE.with(|c| c.set(None)); + + let err = format!( + "{:#}", + result.expect_err("a foreign-owned key directory must be refused") + ); + assert!( + err.contains("owned by uid"), + "mode {mode:04o} must be refused, got: {err}" + ); + assert!( + !err.contains("could not be tightened"), + "ownership must be reported before chmod, got: {err}" + ); + assert_eq!( + std::fs::metadata(&keys).unwrap().permissions().mode() & 0o777, + mode, + "a refused directory must not have been chmodded first" + ); + } + } + + #[cfg(unix)] + #[test] + fn ensure_key_dir_refuses_a_foreign_owned_ancestor() { + use std::os::unix::fs::MetadataExt; + + let base = tempfile::tempdir().unwrap(); + let nested = base.path().join("keys"); + + let real_uid = std::fs::metadata(base.path()).unwrap().uid(); + EUID_OVERRIDE.with(|c| c.set(Some(real_uid.wrapping_add(1)))); + let result = ensure_key_dir(&nested); + EUID_OVERRIDE.with(|c| c.set(None)); + + let err = format!( + "{:#}", + result.expect_err("a directory under a foreign-owned ancestor must be refused") + ); + assert!( + err.contains("sits under") && err.contains("control which identity"), + "must be refused for the ancestor, got: {err}" + ); + assert!( + !nested.exists(), + "the key directory must not have been created" + ); + } + + #[cfg(unix)] + #[test] + fn foreign_ownership_is_refused_and_matching_ownership_is_not() { + let path = Path::new("/data/keys/p2p.key"); + + for uid in [0u32, 1000, 65534] { + assert!( + foreign_ownership_error("key", path, uid, uid).is_none(), + "uid {uid} owning its own key must not be refused" + ); + } + + let err = foreign_ownership_error("key", path, 1000, 1001) + .expect("a key owned by another uid must be refused"); + assert!( + err.contains("1000") && err.contains("1001"), + "the refusal must name both uids, got: {err}" + ); + } + #[test] fn ref_update_event_round_trip_with_owner_did() { let event = RefUpdateEvent { From e7e178baa38e8e1a8ae14fdf6af2c172e123b659 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Mon, 31 Aug 2026 14:51:26 +0800 Subject: [PATCH 16/36] fix(node): hold the p2p key directory open across its trust checks Resolve the key path once, then do every check and mutation through an O_DIRECTORY|O_NOFOLLOW handle on the parent (openat/fstat/fchmod/linkat), so nothing can be swapped between the ownership check, the tighten, and the key open. Key reads go through one bounded, non-blocking loader on every platform; new keys are generated in an unlinked scratch file and published atomically, with concurrent first boots converging on the winner's identity. The storage contract is indexed by two matrix tests: filesystem shapes in p2p (each refusal proven side-effect free against a recursive snapshot) and path spellings plus the p2p-disabled guarantee in config. --- crates/gitlawb-node/src/config.rs | 216 ++++++- crates/gitlawb-node/src/p2p/mod.rs | 894 +++++++++++++++++++++++++---- 2 files changed, 971 insertions(+), 139 deletions(-) diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 1ee5bb6d..334de33a 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -795,7 +795,8 @@ impl Config { if crate::p2p::tilde_suffix_escapes_home(suffix) { return Err(format!( "GITLAWB_P2P_KEY ({}) must stay inside the home directory after `~/` \ - expansion; rooted suffixes such as `~//etc/p2p.key` are refused", + expansion; rooted suffixes such as `~//etc/p2p.key`, drive-prefixed \ + suffixes, and `..` traversal are refused", self.p2p_key_path )); } @@ -1529,28 +1530,213 @@ mod tests { ]) } + /// The configuration half of the key-storage contract matrix: enabled + /// versus disabled p2p, crossed with the path spellings the contract + /// rules on. The filesystem-object half (which objects at and around the + /// key path boot or are refused) lives in `p2p::tests:: + /// p2p_key_storage_contract_matrix`; nothing there runs unless this gate + /// lets a config through. + /// + /// Rule 1 is proven through its own rejections: every disabled row uses a + /// path that an enabled row shows to be invalid, so `Ok` on the disabled + /// row is direct evidence the validator never resolved or inspected the + /// unused key storage. #[test] - fn p2p_disabled_skips_key_path_validation() { - for path in ["p2p.key", "~/", "~//etc/p2p.key"] { - assert!( - config_with_p2p_port_and_key(0, path).validate().is_ok(), - "p2p disabled must not validate unused key path {path:?}" - ); + fn p2p_key_storage_contract_config_matrix() { + enum Expect { + Accepted, + Rejected(&'static str), + } + + struct Row { + port: u16, + path: &'static str, + expect: Expect, + /// Skip when no home directory resolves: the row's outcome is + /// about `~/` expansion, which then legitimately fails earlier + /// with the no-home error instead. + needs_home: bool, + } + + let rows = [ + // Rule 1: disabled p2p leaves its unused key storage alone. + Row { + port: 0, + path: "p2p.key", + expect: Expect::Accepted, + needs_home: false, + }, + Row { + port: 0, + path: "~/", + expect: Expect::Accepted, + needs_home: false, + }, + Row { + port: 0, + path: "~//etc/p2p.key", + expect: Expect::Accepted, + needs_home: false, + }, + Row { + port: 0, + path: "~/../etc/p2p.key", + expect: Expect::Accepted, + needs_home: false, + }, + Row { + port: 0, + path: "", + expect: Expect::Accepted, + needs_home: false, + }, + // Enabled: ordinary absolute and home-relative paths pass, a + // doubled separator in an absolute path is harmless (it does not + // re-root anything), and the shipped default resolves beneath + // home (asserted separately below). + Row { + port: 7546, + path: "/data/keys/p2p.key", + expect: Expect::Accepted, + needs_home: false, + }, + Row { + port: 7546, + path: "/data//keys/p2p.key", + expect: Expect::Accepted, + needs_home: false, + }, + Row { + port: 7546, + path: "~/.gitlawb/p2p.key", + expect: Expect::Accepted, + needs_home: true, + }, + // Rule 2: a `~/` suffix that would re-root or walk out of home is + // refused at parse time, before any join. These fire whether or + // not a home directory resolves, because the suffix is judged + // before expansion. + Row { + port: 7546, + path: "~//etc/p2p.key", + expect: Expect::Rejected("inside the home directory"), + needs_home: false, + }, + Row { + port: 7546, + path: "~/../etc/p2p.key", + expect: Expect::Rejected("inside the home directory"), + needs_home: false, + }, + // Rule 3's lexical pre-checks, still gated on the port. + Row { + port: 7546, + path: "p2p.key", + expect: Expect::Rejected("directory"), + needs_home: false, + }, + Row { + port: 7546, + path: "~/", + expect: Expect::Rejected("must name a key file"), + needs_home: true, + }, + ]; + + let have_home = dirs_next::home_dir().is_some(); + for row in rows { + if row.needs_home && !have_home { + continue; + } + let result = config_with_p2p_port_and_key(row.port, row.path).validate(); + match row.expect { + Expect::Accepted => assert!( + result.is_ok(), + "[port={} path={:?}] must be accepted, got: {result:?}", + row.port, + row.path + ), + Expect::Rejected(needle) => { + let err = result.expect_err(&format!( + "[port={} path={:?}] must be rejected", + row.port, row.path + )); + assert!( + err.contains(needle), + "[port={} path={:?}] rejection must mention {needle:?}, got: {err}", + row.port, + row.path + ); + } + } } } + /// Rule 1's absence-of-IO half against a real on-disk trap: a disabled + /// node pointed at a key path whose parent is a symlink — a shape the + /// enabled path refuses and must never chmod — validates clean and leaves + /// the trap directory exactly as it was. + #[cfg(unix)] #[test] - fn p2p_tilde_suffix_that_escapes_home_is_rejected() { - if dirs_next::home_dir().is_none() { + fn p2p_disabled_key_storage_is_never_inspected_or_mutated() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + let target = base.path().join("real"); + std::fs::create_dir(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + let link = base.path().join("keys"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let key_path = link.join("p2p.key"); + + let config = Config::parse_from([ + "gitlawb-node", + "--p2p-port", + "0", + "--p2p-key-path", + key_path.to_str().unwrap(), + ]); + assert!( + config.validate().is_ok(), + "a disabled node must not validate the unused key path" + ); + + let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o755, "the symlink target's mode must stay unchanged"); + assert_eq!( + std::fs::read_dir(&target).unwrap().count(), + 0, + "nothing may be created behind the symlink" + ); + } + + /// Rule 2's positive direction: the shipped `~/` default resolves to a + /// path beneath the selected home, and suffixes that would escape are + /// left unexpanded (and then rejected by `validate`, as the matrix above + /// asserts) rather than joined and repaired after. + #[test] + fn p2p_tilde_expansion_stays_beneath_home() { + let Some(home) = dirs_next::home_dir() else { return; - } - let err = config_with_p2p_key("~//etc/p2p.key") - .validate() - .expect_err("~//etc must not escape home"); + }; + + let resolved = config_with_p2p_key("~/.gitlawb/p2p.key").resolved_p2p_key_path(); + assert!( + resolved.starts_with(&home), + "the resolved default must stay beneath home" + ); assert!( - err.contains("inside the home directory") || err.contains("rooted suffixes"), - "escaped tilde suffix must be refused, got: {err}" + resolved.ends_with(".gitlawb/p2p.key"), + "the resolved default must keep the configured suffix" ); + + for path in ["~//etc/p2p.key", "~/../etc/p2p.key"] { + assert_eq!( + config_with_p2p_key(path).resolved_p2p_key_path(), + Path::new(path), + "an escaping suffix must never be joined onto home" + ); + } } /// A p2p key path that names no directory component would put the node's diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index fb57922a..54cdfdef 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -263,10 +263,25 @@ const MAX_P2P_KEY_BYTES: usize = 4096; /// Whether the remainder of a `~/...` value would escape home when joined. /// -/// `home.join("/etc/p2p.key")` discards `home` because the right-hand path is -/// absolute, so doubled separators and rooted suffixes must be rejected first. +/// This is rule 2 of the key-storage contract (see +/// [`load_or_create_p2p_keypair`]): a `~/`-prefixed path must stay beneath the +/// resolved home directory. `PathBuf::join` does not guarantee that on its +/// own — an absolute right-hand operand *replaces* the left-hand side, so +/// `home.join("/etc/p2p.key")` (from `GITLAWB_P2P_KEY=~//etc/p2p.key`, whose +/// doubled separator leaves a rooted suffix after the `~/` is stripped) is +/// `/etc/p2p.key`. On Windows a drive or UNC prefix replaces the base the same +/// way even without a root, and `..` walks back out of home lexically. So the +/// suffix is accepted only when every component is an ordinary name (or a +/// no-op `.`), which is exactly the class of suffixes for which +/// `home.join(suffix)` provably keeps `home` as a prefix. Everything else is +/// rejected before the join, instead of joined and repaired after. pub(crate) fn tilde_suffix_escapes_home(suffix: &str) -> bool { - Path::new(suffix).has_root() + Path::new(suffix).components().any(|c| { + matches!( + c, + Component::RootDir | Component::Prefix(_) | Component::ParentDir + ) + }) } /// Inspect `dir` without following symlinks. Ok when absent; Err when present @@ -436,17 +451,54 @@ pub(crate) fn validate_p2p_key_path( /// Load the node's persistent libp2p identity from `key_path`, generating and /// storing a fresh Ed25519 keypair the first time. +/// +/// This is the enforcement point for the key-storage contract. Rules 1 and 2 +/// live in configuration, rules 3–5 here: +/// +/// 1. Disabled p2p (`GITLAWB_P2P_PORT=0`) never resolves, inspects, or +/// mutates key storage at all (`Config::validate` gates on the port). +/// 2. A `~/`-relative path is parsed exactly once, and the suffix is proven +/// relative before it is joined, so expansion cannot land outside home +/// ([`tilde_suffix_escapes_home`]). +/// 3. Every mutation of the key directory goes through a [`KeyDirHandle`]: +/// a descriptor opened without following symlinks and `fstat`-verified to +/// be a real directory this uid owns. chmod, scratch creation, +/// publication, cleanup, and the durability sync all address that +/// descriptor, so replacing the pathname after validation redirects +/// nothing. Paths that cannot reach that state are rejected before any +/// chmod or key IO. +/// 4. An existing key is opened through the same handle without following +/// symlinks and without blocking, and the *opened* object must be a +/// regular file within [`MAX_P2P_KEY_BYTES`] before any byte of it is +/// trusted ([`read_p2p_keypair_from`]). +/// 5. Losing a creation race — at the key directory or at the key file — is +/// a normal concurrent-first-start outcome: the loser verifies what the +/// winner made and adopts it, so simultaneous boots converge on one +/// PeerId. pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result { + // Rule 3's rejection half: refuse paths that cannot name a securable key + // file before anything on disk is created, opened, or chmodded. Purely + // inspective — the checks below re-establish everything it observed on the + // actual opened objects, so this exists for early, precise errors rather + // than for safety. validate_p2p_key_path(key_path, None).map_err(|e| anyhow::anyhow!(e))?; - let parent = key_parent(key_path); + + // Validation rejected paths without a final component, so `file_name` is + // present from here on; the error is a backstop, not a reachable path for + // a validated config. + let key_name = key_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("GITLAWB_P2P_KEY ({}) names no file", key_path.display()))? + .to_os_string(); // Runs on both the load and the create path: the directory guards the key // just as much as the key's own mode does, and an existing directory keeps - // whatever mode it was made with. - ensure_key_dir(parent)?; + // whatever mode it was made with. The returned handle is the anchor every + // later operation goes through. + let dir = ensure_key_dir(key_parent(key_path))?; - if std::fs::symlink_metadata(key_path).is_ok() { - return read_p2p_keypair(key_path); + if let Some(file) = open_existing_key(&dir, &key_name, key_path)? { + return read_p2p_keypair_from(file, key_path); } let kp = identity::Keypair::generate_ed25519(); @@ -458,7 +510,7 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result .map_err(|e| anyhow::anyhow!("failed to serialize p2p key: {e}"))?, ); - match write_key_atomically(key_path, &bytes) { + match write_key_atomically(&dir, &key_name, &bytes) { Ok(()) => { info!( path = %key_path.display(), @@ -467,16 +519,277 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result ); Ok(kp) } - // Another node process won the race between the existence check and the - // atomic publish. - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_p2p_keypair(key_path), + // Rule 5 at the key-file layer: another node process won the race + // between the open above and the atomic publish. Adopt its key, + // through the same handle, so both processes converge on one PeerId. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let file = open_existing_key(&dir, &key_name, key_path)?.ok_or_else(|| { + anyhow::anyhow!( + "p2p key at {} vanished after another process published it; \ + refusing to guess which identity this node should have", + key_path.display() + ) + })?; + read_p2p_keypair_from(file, key_path) + } Err(e) => Err(anyhow::Error::new(e) .context(format!("failed to write p2p key to {}", key_path.display()))), } } -/// Create the directory holding the key with owner-only permissions, and -/// tighten it if it already exists with a looser mode. Write permission on this +/// An open, verified handle on the key directory: the anchor that closes the +/// gap between checking the directory and mutating it. +/// +/// A `symlink_metadata` check followed by path-addressed chmod/open/link calls +/// is a check-then-use sequence: whatever the check proved about the pathname +/// can be invalidated by renaming a symlink or another object into place +/// before the next call resolves the same path again. This handle is the fix. +/// On unix it is opened with `O_NOFOLLOW` (a symlink in the final position +/// fails instead of being followed) and `O_DIRECTORY` (any other object type +/// fails), and every judgment after that — owner, mode, the chmod itself — +/// comes from `fstat`/`fchmod` on the descriptor, while scratch creation +/// (`openat`), publication (`linkat`), cleanup (`unlinkat`), and the +/// durability sync (`fsync`) address children *relative to* the descriptor. +/// After the open there is no second pathname resolution left to redirect. +/// +/// Child names are single components this module generates itself (the key's +/// `file_name` and the scratch names), never operator input containing +/// separators. +/// +/// On non-unix targets the same sequence falls back to path-addressed calls +/// with a no-follow pre-check; the pre-flight validation still runs, and unix +/// is where the node deploys. +#[derive(Debug)] +struct KeyDirHandle { + #[cfg(unix)] + dir: std::fs::File, + /// On unix: for error messages only, never resolved again for IO. + path: std::path::PathBuf, +} + +#[cfg(unix)] +impl KeyDirHandle { + /// Open `dir_path` refusing symlinks and non-directories at the open + /// itself, so rejection happens before any chmod or key IO rather than + /// after a separate stat that something else could invalidate. + fn open(dir_path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let dir = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(dir_path)?; + Ok(KeyDirHandle { + dir, + path: dir_path.to_path_buf(), + }) + } + + /// `fstat` on the held descriptor: the object this metadata describes is + /// the object every other method mutates, with no path in between. + fn metadata(&self) -> std::io::Result { + self.dir.metadata() + } + + /// `fchmod` on the held descriptor, for the same reason. + fn tighten_to_0700(&self) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + self.dir + .set_permissions(std::fs::Permissions::from_mode(0o700)) + } + + /// `openat` relative to the held descriptor. `O_NOFOLLOW` and `O_CLOEXEC` + /// are always added: no child of the key directory is ever a symlink this + /// module is willing to follow. + fn open_child( + &self, + name: &std::ffi::OsStr, + flags: libc::c_int, + mode: libc::mode_t, + ) -> std::io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + + let name = Self::child_name(name)?; + // SAFETY: `openat` resolves `name` relative to our owned, verified + // directory descriptor and returns a new descriptor on success; + // `from_raw_fd` assumes ownership of it exactly once. + let fd = unsafe { + libc::openat( + self.dir.as_raw_fd(), + name.as_ptr(), + flags | libc::O_NOFOLLOW | libc::O_CLOEXEC, + libc::c_uint::from(mode), + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: `fd` is a valid descriptor we just received and own. + Ok(unsafe { std::fs::File::from_raw_fd(fd) }) + } + + /// Open an existing key for reading. `O_NONBLOCK` so that a FIFO left at + /// the key path cannot park startup in `open(2)` waiting for a writer; + /// whether the opened object is actually a regular file is judged by + /// `fstat` on the result, in [`read_p2p_keypair_from`]. + fn open_key_for_read(&self, name: &std::ffi::OsStr) -> std::io::Result { + self.open_child(name, libc::O_RDONLY | libc::O_NONBLOCK, 0) + } + + /// Create a scratch file with 0600 applied at creation. `O_EXCL` keeps a + /// collision an error rather than an adoption. + fn create_scratch(&self, name: &std::ffi::OsStr) -> std::io::Result { + self.open_child( + name, + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, + 0o600 as libc::mode_t, + ) + } + + /// Atomically publish `from` at `to` via `linkat` on the held descriptor. + /// Fails with `AlreadyExists` if anything already occupies `to`, which is + /// the signal the concurrency protocol in the caller relies on. + fn publish(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let from = Self::child_name(from)?; + let to = Self::child_name(to)?; + // SAFETY: both names resolve relative to our owned directory + // descriptor; `linkat` with no flags follows no symlinks. + let rc = unsafe { + libc::linkat( + self.dir.as_raw_fd(), + from.as_ptr(), + self.dir.as_raw_fd(), + to.as_ptr(), + 0, + ) + }; + if rc < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + /// `unlinkat` relative to the held descriptor. + fn remove_child(&self, name: &std::ffi::OsStr) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let name = Self::child_name(name)?; + // SAFETY: resolves relative to our owned directory descriptor; + // `unlinkat` without AT_REMOVEDIR removes only non-directories. + let rc = unsafe { libc::unlinkat(self.dir.as_raw_fd(), name.as_ptr(), 0) }; + if rc < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + /// `fsync` the directory itself so a just-published entry survives a crash. + fn sync(&self) -> std::io::Result<()> { + self.dir.sync_all() + } + + fn child_name(name: &std::ffi::OsStr) -> std::io::Result { + use std::os::unix::ffi::OsStrExt; + std::ffi::CString::new(name.as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "key file name contains an interior NUL byte", + ) + }) + } +} + +#[cfg(not(unix))] +impl KeyDirHandle { + fn open(dir_path: &Path) -> std::io::Result { + let md = std::fs::symlink_metadata(dir_path)?; + if md.is_symlink() || !md.is_dir() { + // The closest std kind to ELOOP/ENOTDIR that maps onto the same + // caller-side diagnosis as the unix open flags. + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "not a real directory", + )); + } + Ok(KeyDirHandle { + path: dir_path.to_path_buf(), + }) + } + + fn open_key_for_read(&self, name: &std::ffi::OsStr) -> std::io::Result { + let path = self.path.join(name); + if std::fs::symlink_metadata(&path)?.is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "p2p key is a symlink", + )); + } + std::fs::OpenOptions::new().read(true).open(path) + } + + fn create_scratch(&self, name: &std::ffi::OsStr) -> std::io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(self.path.join(name)) + } + + fn publish(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> std::io::Result<()> { + std::fs::hard_link(self.path.join(from), self.path.join(to)) + } + + fn remove_child(&self, name: &std::ffi::OsStr) -> std::io::Result<()> { + std::fs::remove_file(self.path.join(name)) + } + + fn sync(&self) -> std::io::Result<()> { + // Directory fsync is not expressible through std here; publication + // durability degrades to best-effort on non-unix targets. + Ok(()) + } +} + +/// Map a failed [`KeyDirHandle::open`] to the operator-facing explanation. +/// +/// `ELOOP` is the flag-refused symlink and `ENOTDIR` the wrong object type; +/// both messages match the ones `parent_directory_is_safe_to_mutate` produces +/// at validation time, because they are the same finding made at the moment +/// that actually matters — the open that everything after is anchored to. +fn describe_unusable_key_dir(dir: &Path, e: std::io::Error) -> anyhow::Error { + #[cfg(unix)] + { + match e.raw_os_error() { + Some(code) if code == libc::ELOOP => { + return anyhow::anyhow!( + "GITLAWB_P2P_KEY's directory {} must be a real directory, not a symlink", + dir.display() + ); + } + Some(code) if code == libc::ENOTDIR => { + return anyhow::anyhow!( + "GITLAWB_P2P_KEY's directory {} must be a directory, not another file type", + dir.display() + ); + } + _ => {} + } + } + #[cfg(not(unix))] + if e.kind() == std::io::ErrorKind::InvalidInput { + return anyhow::anyhow!( + "GITLAWB_P2P_KEY's directory {} must be a real directory, not a symlink \ + or another file type", + dir.display() + ); + } + anyhow::Error::new(e).context(format!("failed to open key directory {}", dir.display())) +} + +/// Create the directory holding the key with owner-only permissions, tighten +/// it if it already exists with a looser mode, and hand back the verified +/// handle every subsequent operation is anchored to. Write permission on this /// directory is enough to unlink or replace the 0600 key inside it, so the /// directory guards the key as much as the key's own mode does. /// @@ -496,7 +809,7 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result /// `~/.gitlawb/identity.pem` lives in this directory too, so this covers both /// keys. Issue #231 owns the sibling gap in `main.rs`'s own creation path for /// that file; nothing here touches it. -fn ensure_key_dir(dir: &Path) -> Result<()> { +fn ensure_key_dir(dir: &Path) -> Result { #[cfg(unix)] { let euid = effective_uid(); @@ -505,10 +818,11 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { } } - parent_directory_is_safe_to_mutate(dir).map_err(|e| anyhow::anyhow!(e))?; - // Missing ancestors are created at the ambient mode. Only the nominated key - // directory itself is pinned to 0700. + // directory itself is pinned to 0700 and anchored; ancestors are ordinary + // path infrastructure (the shipped default's `~/.gitlawb` on a first boot), + // and `foreign_ancestor_error` has already refused ones somebody else + // could swap out from under us. if let Some(parent) = dir.parent() { if !parent.as_os_str().is_empty() && parent != dir { std::fs::create_dir_all(parent).with_context(|| { @@ -520,8 +834,8 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { } } - match std::fs::symlink_metadata(dir) { - Ok(_) => {} + let handle = match KeyDirHandle::open(dir) { + Ok(handle) => handle, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let mut builder = std::fs::DirBuilder::new(); #[cfg(unix)] @@ -531,28 +845,33 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { } match builder.create(dir) { Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - parent_directory_is_safe_to_mutate(dir).map_err(|e| anyhow::anyhow!(e))?; - } + // Rule 5 at the directory layer: two first boots can both see + // the leaf absent, and only one `mkdir` wins. Losing is a + // successful outcome of the same state transition, not an + // error — what matters is what actually occupies the path now, + // and the re-open below judges that object itself (a symlink + // or non-directory raced into place fails there, before any + // chmod or key IO). The winner's owner and mode are then + // verified through the handle like any pre-existing directory. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => { return Err(e).with_context(|| { format!("failed to create key directory {}", dir.display()) }); } } + KeyDirHandle::open(dir).map_err(|e| describe_unusable_key_dir(dir, e))? } - Err(e) => { - return Err(e) - .with_context(|| format!("failed to stat key directory {}", dir.display())); - } - } + Err(e) => return Err(describe_unusable_key_dir(dir, e)), + }; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; - let md = std::fs::symlink_metadata(dir) + let md = handle + .metadata() .with_context(|| format!("failed to stat key directory {}", dir.display()))?; let euid = effective_uid(); @@ -567,21 +886,22 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { mode = format!("{mode:04o}"), "key directory grants access beyond its owner; tightening it to 0700" ); - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context( - || { - format!( - "key directory {} has mode {:04o}, which lets other users replace \ - the keys it holds, and it could not be tightened; run `chmod 700 {}`", - dir.display(), - mode, - dir.display() - ) - }, - )?; + // `fchmod` through the handle: the directory whose mode changes is + // the object that was just verified, not whatever the pathname + // resolves to by now. + handle.tighten_to_0700().with_context(|| { + format!( + "key directory {} has mode {:04o}, which lets other users replace \ + the keys it holds, and it could not be tightened; run `chmod 700 {}`", + dir.display(), + mode, + dir.display() + ) + })?; } } - Ok(()) + Ok(handle) } /// Write the key to a scratch file in the same directory, then publish it to @@ -597,52 +917,48 @@ fn ensure_key_dir(dir: &Path) -> Result<()> { /// symlink, which it does not follow), so the two properties hold together /// without a check-then-act gap. The scratch file is unlinked either way, so a /// failed start leaves the key directory as it found it. -fn write_key_atomically(key_path: &Path, bytes: &[u8]) -> std::io::Result<()> { - let dir = key_parent(key_path); - let (tmp_path, mut file) = create_scratch_key_file(dir)?; - let result = fill_and_publish(&mut file, bytes, &tmp_path, key_path); +fn write_key_atomically( + dir: &KeyDirHandle, + key_name: &std::ffi::OsStr, + bytes: &[u8], +) -> std::io::Result<()> { + let (scratch_name, mut file) = create_scratch_key_file(dir)?; + let result = fill_and_publish(&mut file, bytes, dir, &scratch_name, key_name); drop(file); - // Unconditional: on success the key is reachable through `key_path`, and on - // failure nothing may be left behind. - let _ = std::fs::remove_file(&tmp_path); + // Unconditional: on success the key is reachable through its own link, and + // on failure nothing may be left behind. + let _ = dir.remove_child(&scratch_name); result } /// Open a uniquely named scratch file in `dir` with owner-only permissions /// applied at creation time. The name carries the pid so concurrent node starts -/// do not pick the same one, and `create_new` (`O_EXCL`) plus the retry makes a -/// collision with a leftover or a sibling thread impossible rather than merely -/// unlikely. -fn create_scratch_key_file(dir: &Path) -> std::io::Result<(std::path::PathBuf, std::fs::File)> { +/// do not pick the same one, and `O_EXCL` plus the retry makes a collision +/// with a leftover or a sibling thread impossible rather than merely unlikely. +fn create_scratch_key_file( + dir: &KeyDirHandle, +) -> std::io::Result<(std::ffi::OsString, std::fs::File)> { let pid = std::process::id(); for attempt in 0..64u32 { - let tmp_path = dir.join(format!(".p2p.key.{pid}.{attempt}.tmp")); - - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - - match opts.open(&tmp_path) { - Ok(file) => return Ok((tmp_path, file)), + let scratch_name = std::ffi::OsString::from(format!(".p2p.key.{pid}.{attempt}.tmp")); + match dir.create_scratch(&scratch_name) { + Ok(file) => return Ok((scratch_name, file)), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), } } Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, - format!("no free scratch key file name in {}", dir.display()), + format!("no free scratch key file name in {}", dir.path.display()), )) } fn fill_and_publish( file: &mut std::fs::File, bytes: &[u8], - tmp_path: &Path, - key_path: &Path, + dir: &KeyDirHandle, + scratch_name: &std::ffi::OsStr, + key_name: &std::ffi::OsStr, ) -> std::io::Result<()> { use std::io::Write; @@ -656,25 +972,19 @@ fn fill_and_publish( // The bytes must be durable before the name that points at them appears, // otherwise a crash can leave the entry pointing at an empty file. file.sync_all()?; - std::fs::hard_link(tmp_path, key_path)?; + dir.publish(scratch_name, key_name)?; - let dir = key_parent(key_path); - let dir_file = std::fs::File::open(dir).map_err(|e| { - std::io::Error::new( - e.kind(), - format!( - "failed to open key directory {} for durability sync after publishing the key: {e}", - dir.display() - ), - ) - })?; - dir_file.sync_all().map_err(|e| { + // The new directory entry must reach disk too, and the sync goes to the + // same descriptor the entry was created through — not to a fresh + // path-resolved open of the directory, which could by now be something + // else entirely. + dir.sync().map_err(|e| { std::io::Error::new( e.kind(), format!( "failed to sync key directory {} after publishing the key; the identity may not \ survive a crash until the next successful start: {e}", - dir.display() + dir.path.display() ), ) })?; @@ -737,38 +1047,56 @@ fn read_bounded_key_bytes( Ok(buf) } -/// Read an existing key file, refusing one whose permissions or contents make -/// it untrustworthy. Never regenerates: a node that silently replaces an -/// unreadable key file would change its PeerId without the operator knowing. -fn read_p2p_keypair(key_path: &Path) -> Result { +/// Open the key through the verified directory handle, mapping "no key yet" +/// to `None` and everything else to an error the operator can act on. +/// +/// The open itself refuses symlinks (`O_NOFOLLOW`) and cannot block on a FIFO +/// (`O_NONBLOCK`); what kind of object was actually opened is judged by +/// [`read_p2p_keypair_from`] on the descriptor it returns. +fn open_existing_key( + dir: &KeyDirHandle, + key_name: &std::ffi::OsStr, + key_path: &Path, +) -> Result> { + match dir.open_key_for_read(key_name) { + Ok(file) => Ok(Some(file)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow::Error::new(e).context(format!( + "failed to open p2p key at {} (a symlink here is refused rather than followed)", + key_path.display() + ))), + } +} + +/// Read an existing, already-opened key file, refusing one whose type, +/// permissions, size, or contents make it untrustworthy. Never regenerates: a +/// node that silently replaces an unreadable key file would change its PeerId +/// without the operator knowing. +/// +/// Every judgment is made by `fstat` on `file` itself — the object that will +/// be read — not on the pathname it was opened by. Regular-file status is an +/// explicit invariant, not an inference from "not a directory, not a symlink": +/// a FIFO or device node that survived the open (thanks to `O_NONBLOCK`) is +/// refused here before a byte of it is consumed, and the read that follows is +/// capped at [`MAX_P2P_KEY_BYTES`] so nothing that lies about being finite can +/// feed the process an unbounded stream. +fn read_p2p_keypair_from(mut file: std::fs::File, key_path: &Path) -> Result { + let md = file + .metadata() + .with_context(|| format!("failed to stat p2p key at {}", key_path.display()))?; + + if !md.is_file() { + anyhow::bail!( + "p2p key at {} must be a regular file; directories, FIFOs, and special files are refused", + key_path.display() + ); + } + #[cfg(unix)] - let bytes = { + { use std::os::unix::fs::MetadataExt; - use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; - let mut file = std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) - .open(key_path) - .with_context(|| { - format!( - "failed to open p2p key at {} (a symlink here is refused rather than followed)", - key_path.display() - ) - })?; - - let md = file - .metadata() - .with_context(|| format!("failed to stat p2p key at {}", key_path.display()))?; - - if !md.is_file() { - anyhow::bail!( - "p2p key at {} must be a regular file; directories, FIFOs, and special files are refused", - key_path.display() - ); - } - let euid = effective_uid(); if let Some(err) = foreign_ownership_error("key", key_path, md.uid(), euid) { anyhow::bail!(err); @@ -784,23 +1112,9 @@ fn read_p2p_keypair(key_path: &Path) -> Result { key_path.display() ); } + } - read_bounded_key_bytes(&mut file, key_path)? - }; - - #[cfg(not(unix))] - let bytes = { - let data = std::fs::read(key_path) - .with_context(|| format!("failed to read p2p key from {}", key_path.display()))?; - if data.len() > MAX_P2P_KEY_BYTES { - anyhow::bail!( - "p2p key at {} exceeds the maximum accepted size of {} bytes", - key_path.display(), - MAX_P2P_KEY_BYTES - ); - } - Zeroizing::new(data) - }; + let bytes = read_bounded_key_bytes(&mut file, key_path)?; // An empty file decodes as a valid protobuf with a key type of RSA, so // without this the operator gets a misleading complaint about a missing @@ -819,6 +1133,22 @@ fn read_p2p_keypair(key_path: &Path) -> Result { Ok(kp) } +/// Test-only convenience over the anchored read path: production callers hold +/// the [`KeyDirHandle`] from [`ensure_key_dir`] and go through +/// [`open_existing_key`] directly, so nothing outside the tests re-resolves +/// the pathname here. +#[cfg(test)] +fn read_p2p_keypair(key_path: &Path) -> Result { + let dir = KeyDirHandle::open(key_parent(key_path)) + .map_err(|e| describe_unusable_key_dir(key_parent(key_path), e))?; + let key_name = key_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("p2p key path {} names no file", key_path.display()))?; + let file = open_existing_key(&dir, key_name, key_path)? + .ok_or_else(|| anyhow::anyhow!("p2p key at {} does not exist", key_path.display()))?; + read_p2p_keypair_from(file, key_path) +} + /// Start the libp2p swarm. Returns a handle for sending commands and the /// listening multiaddrs. Runs the event loop as a background tokio task /// that exits cleanly when `shutdown_rx` flips to `true`. @@ -1182,6 +1512,18 @@ mod tests { assert!(!leaked, "{path:?} must not have been created"); } + // Beyond the probed names themselves: rejection must have caused no + // filesystem mutation at all, scratch files and directories included. + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .collect(); + assert!( + leftovers.is_empty(), + "rejected paths must leave the working directory untouched, found: {leftovers:?}" + ); + println!("p2p-key-backstop: asserted"); } @@ -1760,6 +2102,310 @@ mod tests { ); } + /// Rule 2's parse-time half, in both directions, plus the invariant the + /// acceptance rule is designed around: any accepted suffix joins to a path + /// that still has home as a prefix. `PathBuf::join` replaces its base for + /// rooted (and, on Windows, prefixed) right-hand operands, and `..` walks + /// back out lexically, so those are exactly the rejected classes. + #[test] + fn tilde_suffix_escapes_home_matches_the_join_invariant() { + let accepted = [".gitlawb/p2p.key", "keys/p2p.key", "./keys/p2p.key"]; + for suffix in accepted { + assert!( + !tilde_suffix_escapes_home(suffix), + "{suffix:?} stays beneath home and must be accepted" + ); + } + + for suffix in [ + // `~//etc/p2p.key`: the doubled separator leaves a rooted suffix, + // and a rooted right-hand operand REPLACES home in `join`. + "/etc/p2p.key", + "//etc/p2p.key", + // Walks back out of home lexically. + "../etc/p2p.key", + "keys/../../p2p.key", + ] { + assert!( + tilde_suffix_escapes_home(suffix), + "{suffix:?} escapes home and must be rejected" + ); + } + + // Platform-prefixed forms replace the base in `join` even without a + // root, so they are escapes on the platform that parses them. + #[cfg(windows)] + for suffix in ["C:/p2p.key", r"C:p2p.key", r"\\srv\share\p2p.key"] { + assert!( + tilde_suffix_escapes_home(suffix), + "{suffix:?} carries a prefix and must be rejected" + ); + } + + // The invariant itself, on the accepted set. + let home = Path::new("/homes/gitlawb"); + for suffix in accepted { + assert!( + home.join(suffix).starts_with(home), + "accepted suffix {suffix:?} must keep home as a prefix" + ); + } + } + + /// The key-storage contract, kept together in one table: which filesystem + /// objects at and around the key path boot, and which are refused — and + /// that every refusal leaves the filesystem exactly as it found it. + /// + /// Each row gets its own temp base (no cwd, no umask, no shared state). + /// Refused rows are compared against a full recursive snapshot (path, + /// type, mode, size) taken after setup, so an unintended chmod, a scratch + /// file, a followed symlink, or a created directory all fail the row. + /// The deep single-behavior tests around this one stay authoritative for + /// their details (identity stability, race convergence, tightening); this + /// table is the contract's index. The enabled/disabled and path-spelling + /// half of the matrix lives with `Config::validate`'s tests, which gate + /// on `p2p_port` before any of this code runs. + #[cfg(unix)] + #[test] + fn p2p_key_storage_contract_matrix() { + use std::os::unix::fs::PermissionsExt; + + enum Expect { + /// Must boot; the key must sit 0600 inside a 0700 directory and + /// reload to the same PeerId. + Boots, + /// Must fail with the needle in the message, mutating nothing. + Refused(&'static str), + /// Must fail promptly, mutating nothing; the message is + /// platform-dependent (e.g. what `open(2)` says about a socket). + RefusedAny, + } + + struct Row { + name: &'static str, + setup: fn(&Path) -> std::path::PathBuf, + expect: Expect, + } + + fn valid_key_at(path: &Path) { + let kp = identity::Keypair::generate_ed25519(); + std::fs::write(path, kp.to_protobuf_encoding().unwrap()).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + + /// Rows probing refusal of the key object itself pre-create the key + /// directory already at 0700: `ensure_key_dir` legitimately tightens + /// a looser directory on the way in (its own tests cover that), and + /// pre-tightening keeps this table's no-mutation assertion about the + /// refusal itself rather than about that documented repair. + fn key_dir_0700(base: &Path) -> std::path::PathBuf { + let dir = base.join("keys"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + dir + } + + /// (path, is_dir, is_symlink, mode, len) for every entry under `base`, + /// via `symlink_metadata` so links are recorded, not followed. + fn snapshot(base: &Path) -> Vec<(std::path::PathBuf, bool, bool, u32, u64)> { + let mut out = Vec::new(); + let mut stack = vec![base.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + let md = std::fs::symlink_metadata(&path).unwrap(); + out.push(( + path.clone(), + md.is_dir(), + md.is_symlink(), + md.permissions().mode(), + md.len(), + )); + if md.is_dir() && !md.is_symlink() { + stack.push(path); + } + } + } + out.sort(); + out + } + + let rows = [ + Row { + name: "fresh path under a real parent boots", + setup: |base| base.join("keys").join("p2p.key"), + expect: Expect::Boots, + }, + Row { + name: "existing 0600 regular key boots", + setup: |base| { + let dir = key_dir_0700(base); + let path = dir.join("p2p.key"); + valid_key_at(&path); + path + }, + expect: Expect::Boots, + }, + Row { + name: "symlinked parent is refused, target untouched", + setup: |base| { + let target = base.join("real"); + std::fs::create_dir(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) + .unwrap(); + let link = base.join("keys"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + link.join("p2p.key") + }, + expect: Expect::Refused("symlink"), + }, + Row { + name: "regular-file parent is refused, mode and content kept", + setup: |base| { + let file = base.join("keys"); + std::fs::write(&file, b"payload").unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)) + .unwrap(); + file.join("p2p.key") + }, + expect: Expect::Refused("directory"), + }, + Row { + name: "symlink at the key position is refused", + setup: |base| { + let dir = base.join("keys"); + std::fs::create_dir(&dir).unwrap(); + let real = dir.join("real.key"); + valid_key_at(&real); + let link = dir.join("p2p.key"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + link + }, + expect: Expect::Refused("symlink"), + }, + Row { + name: "dangling symlink at the key position is refused", + setup: |base| { + let dir = base.join("keys"); + std::fs::create_dir(&dir).unwrap(); + let link = dir.join("p2p.key"); + std::os::unix::fs::symlink(dir.join("absent"), &link).unwrap(); + link + }, + expect: Expect::Refused("symlink"), + }, + Row { + name: "directory at the key position is refused", + setup: |base| { + let path = base.join("keys").join("p2p.key"); + std::fs::create_dir_all(&path).unwrap(); + path + }, + expect: Expect::Refused("must name a key file"), + }, + Row { + name: "FIFO at the key position is refused without blocking", + setup: |base| { + let dir = key_dir_0700(base); + let path = dir.join("p2p.key"); + let c_path = + std::ffi::CString::new(path.to_str().expect("utf-8 path")).unwrap(); + // SAFETY: creates a FIFO at `c_path` with mode 0600. + let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; + assert_eq!(rc, 0, "mkfifo: {}", std::io::Error::last_os_error()); + path + }, + // No writer ever appears, so completing at all proves the + // non-blocking open; the message proves the explicit + // regular-file invariant on the opened object. + expect: Expect::Refused("regular file"), + }, + Row { + name: "unix socket at the key position is refused", + setup: |base| { + let dir = key_dir_0700(base); + let path = dir.join("p2p.key"); + // The bound socket's fs entry outlives the listener. + std::os::unix::net::UnixListener::bind(&path) + .expect("bind a unix socket at the key path"); + path + }, + expect: Expect::RefusedAny, + }, + Row { + name: "oversized key is refused unread", + setup: |base| { + let dir = key_dir_0700(base); + let path = dir.join("p2p.key"); + std::fs::write(&path, vec![0u8; MAX_P2P_KEY_BYTES + 1]).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .unwrap(); + path + }, + expect: Expect::Refused("maximum accepted size"), + }, + Row { + name: "loose 0644 key is refused, not regenerated", + setup: |base| { + let dir = key_dir_0700(base); + let path = dir.join("p2p.key"); + valid_key_at(&path); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .unwrap(); + path + }, + expect: Expect::Refused("0644"), + }, + ]; + + for row in rows { + let base = tempfile::tempdir().unwrap(); + let key_path = (row.setup)(base.path()); + + match row.expect { + Expect::Boots => { + let kp = load_or_create_p2p_keypair(&key_path) + .unwrap_or_else(|e| panic!("[{}] must boot, got: {e:#}", row.name)); + let key_mode = + std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777; + assert_eq!(key_mode, 0o600, "[{}] key must be 0600", row.name); + let dir_mode = std::fs::metadata(key_path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(dir_mode, 0o700, "[{}] key directory must be 0700", row.name); + let reloaded = load_or_create_p2p_keypair(&key_path) + .unwrap_or_else(|e| panic!("[{}] must reload, got: {e:#}", row.name)); + assert_eq!( + PeerId::from(kp.public()), + PeerId::from(reloaded.public()), + "[{}] identity must be stable", + row.name + ); + } + ref refusal => { + let before = snapshot(base.path()); + let err = load_or_create_p2p_keypair(&key_path) + .expect_err(&format!("[{}] must be refused", row.name)); + if let Expect::Refused(needle) = refusal { + assert!( + format!("{err:#}").contains(needle), + "[{}] refusal must mention {needle:?}, got: {err:#}", + row.name + ); + } + assert_eq!( + before, + snapshot(base.path()), + "[{}] refusal must not mutate the filesystem", + row.name + ); + } + } + } + } + #[cfg(unix)] #[test] fn read_p2p_keypair_refuses_a_key_owned_by_another_user() { From 5011670f30e57fd35294c38f2e8f2d3ea06a47e2 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:52:14 -0500 Subject: [PATCH 17/36] fix(node): walk the p2p key ancestor chain descriptor-anchored Replace the path-following metadata walk (foreign_ancestor_error) and the post-walk create_dir_all gap with a single descriptor-anchored walk that verifies and creates the chain together. Missing components are created at 0700 relative to the verified parent and re-verified; AlreadyExists is a race the winner must pass. Non-unix builds keep create_dir_all. --- crates/gitlawb-node/src/p2p/mod.rs | 385 ++++++++++++++++++++++++++--- 1 file changed, 344 insertions(+), 41 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 54cdfdef..c93d77ab 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -9,7 +9,7 @@ use std::collections::{hash_map::DefaultHasher, HashMap}; use std::hash::{Hash, Hasher}; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -339,47 +339,240 @@ fn foreign_ownership_error(what: &str, path: &Path, owner_uid: u32, euid: u32) - )) } -/// Refuse a key directory whose existing ancestors are controlled by someone -/// else, before creating anything inside them. +/// Descriptor-anchored ancestor walk from a trusted anchor to the key +/// directory's parent. +/// +/// This replaces the old `foreign_ancestor_error` walk (path-following +/// `metadata`, missing components skipped, group-write never checked, and the +/// separately-created `create_dir_all` output never re-verified) with one walk +/// that verifies and creates the chain together: +/// +/// 1. Resolve the trusted anchor: the filesystem root for an absolute path, +/// or the process cwd (opened as `.`, no-follow) for a relative one. The +/// cwd's own ancestors are out of scope: the process cwd is an inode that +/// external users cannot repoint, which is what makes a relative anchor +/// safe. +/// 2. Walk every component strictly above the key directory, opening each one +/// relative to the previously verified descriptor without following +/// symlinks, and judge it by fstat on the opened descriptor: real +/// directory, owner is the effective uid or root, no write bits beyond the +/// owner unless sticky (the 1777 `/tmp` shape is accepted; 0770/0775 and +/// non-sticky 0777 are refused). +/// 3. Create a missing component at 0700 relative to the verified parent, +/// then reopen it no-follow and verify the object that actually landed. +/// `AlreadyExists` on create is a race: the winner gets the same full +/// verification, never automatic acceptance or refusal. +/// +/// The key directory itself (the leaf) is deliberately not part of this walk: +/// `ensure_key_dir` opens it no-follow, checks its ownership, and tightens it +/// to 0700 afterwards. #[cfg(unix)] -fn foreign_ancestor_error(dir: &Path, euid: u32) -> Option { - use std::os::unix::fs::MetadataExt; - use std::os::unix::fs::PermissionsExt; - - for ancestor in dir.ancestors().skip(1) { - let md = match std::fs::metadata(ancestor) { - Ok(md) => md, - Err(_) => continue, - }; +fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { + use std::os::unix::ffi::OsStrExt; + + /// Refuse an opened component unless it is a real directory owned by + /// `euid` or root with no write bits beyond the owner unless sticky. + fn verify_component(fd: i32, key_dir: &Path, component: &Path, euid: u32) -> Result<()> { + let mut st = std::mem::MaybeUninit::::uninit(); + // SAFETY: fstat writes the stat struct on success; the return value is + // checked before the struct is read. + if unsafe { libc::fstat(fd, st.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("failed to stat {}", component.display())); + } + let st = unsafe { st.assume_init() }; + + if (st.st_mode & libc::S_IFMT) != libc::S_IFDIR { + anyhow::bail!( + "p2p key directory {} sits under {}, which is not a real directory; the node \ + refuses symlinks and other object types on the key storage path", + key_dir.display(), + component.display() + ); + } - let owner = md.uid(); + let owner = st.st_uid; if owner != euid && owner != 0 { - return Some(format!( + anyhow::bail!( "p2p key directory {} sits under {}, which is owned by uid {} rather than this \ node (uid {}) or root; that user can rename or replace the directory holding \ the key and so control which identity the node presents. Put the key somewhere \ this user or root owns the whole path.", - dir.display(), - ancestor.display(), + key_dir.display(), + component.display(), owner, euid - )); + ); } - let mode = md.permissions().mode() & 0o777; - let sticky = md.permissions().mode() & 0o1000 != 0; - if mode & 0o002 != 0 && !sticky { - return Some(format!( + let write_bits = st.st_mode & 0o777; + let sticky = st.st_mode & 0o1000 != 0; + if write_bits & 0o022 != 0 && !sticky { + anyhow::bail!( "p2p key directory {} sits under {}, which has mode {:04o} and is writable \ beyond its owner; anyone with that write access can rename or replace the \ directory holding the key and so control which identity the node presents.", - dir.display(), - ancestor.display(), - mode - )); + key_dir.display(), + component.display(), + write_bits + ); + } + Ok(()) + } + + fn child_name(name: &std::ffi::OsStr) -> std::io::Result { + std::ffi::CString::new(name.as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path component contains an interior NUL byte", + ) + }) + } + + /// Owning wrapper for a raw directory descriptor, so the walk's descriptor + /// chain is closed on every exit path (success, error, or early return). + struct OwnedFd(i32); + impl Drop for OwnedFd { + fn drop(&mut self) { + // SAFETY: close(2) on a descriptor this struct owns. + unsafe { libc::close(self.0) }; + } + } + + // The components strictly above the key directory, below the anchor. + let Some(parent) = dir.parent() else { + // `dir` is the filesystem root; there is no chain to walk. + return Ok(()); + }; + let parent = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + let absolute = parent.is_absolute(); + let components: Vec = parent + .components() + .filter_map(|c| match c { + std::path::Component::Normal(n) => Some(n.to_os_string()), + _ => None, + }) + .collect(); + + // Open and verify the anchor. The descriptor stays owned for the whole walk + // and is closed on every exit path. + let (anchor, anchor_display) = if absolute { + // SAFETY: open(2) on "/" returns a new descriptor we own on success; + // the root is not a symlink, so O_NOFOLLOW is moot there. + let root = std::ffi::CString::new("/").unwrap(); + let fd = unsafe { + libc::open( + root.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("failed to open the filesystem root for {}", dir.display())); + } + (OwnedFd(fd), PathBuf::from("/")) + } else { + // SAFETY: open(2) on "." returns a descriptor for the process cwd + // itself; O_NOFOLLOW and O_DIRECTORY pin the object type. + let dot = std::ffi::CString::new(".").unwrap(); + let fd = unsafe { + libc::open( + dot.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("failed to open the working directory for {}", dir.display())); + } + (OwnedFd(fd), PathBuf::new()) + }; + + verify_component(anchor.0, dir, &anchor_display, euid)?; + + let mut acc = anchor_display; + let mut cur = anchor; + for name in &components { + let cname = child_name(name)?; + acc.push(name); + + // SAFETY: openat resolves `name` relative to the previously verified + // parent descriptor; O_NOFOLLOW refuses a symlink at this position. + let fd = unsafe { + libc::openat( + cur.0, + cname.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd >= 0 { + // SAFETY: `fd` is a descriptor we own from openat; the OwnedFd + // below takes ownership and closes it when the chain advances. + verify_component(fd, dir, &acc, euid)?; + cur = OwnedFd(fd); + continue; + } + + match std::io::Error::last_os_error().raw_os_error() { + Some(code) if code == libc::ELOOP => { + anyhow::bail!( + "p2p key directory {} sits under {}, which is a symlink; the node \ + refuses symlinks on the key storage path", + dir.display(), + acc.display() + ); + } + Some(code) if code == libc::ENOENT => { + // Create relative to the verified parent at 0700, then reopen + // no-follow and verify whatever actually landed. `AlreadyExists` + // is a race: the winner gets the same full verification below. + // SAFETY: mkdirat creates `name` relative to the verified parent + // descriptor; mode 0700 is pinned at creation. + if unsafe { libc::mkdirat(cur.0, cname.as_ptr(), 0o700) } != 0 { + let mk_err = std::io::Error::last_os_error(); + if mk_err.kind() != std::io::ErrorKind::AlreadyExists { + return Err(mk_err).with_context(|| { + format!( + "failed to create key directory component {} below {}", + name.to_string_lossy(), + acc.parent().map(|p| p.display().to_string()).unwrap_or_default() + ) + }); + } + } + // SAFETY: openat as above, re-checking the winner. + let fd = unsafe { + libc::openat( + cur.0, + cname.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()).with_context(|| { + format!( + "failed to reopen created key directory component {}", + acc.display() + ) + }); + } + // SAFETY: `fd` is a descriptor we own from openat; the OwnedFd + // below takes ownership and closes it when the chain advances. + verify_component(fd, dir, &acc, euid)?; + cur = OwnedFd(fd); + } + _ => { + return Err(std::io::Error::last_os_error()).with_context(|| { + format!("failed to open key directory component {}", acc.display()) + }); + } } } - None + Ok(()) } /// Whether the configured path names a directory rather than a key file. @@ -813,16 +1006,13 @@ fn ensure_key_dir(dir: &Path) -> Result { #[cfg(unix)] { let euid = effective_uid(); - if let Some(err) = foreign_ancestor_error(dir, euid) { - anyhow::bail!(err); - } + verify_and_create_ancestor_chain(dir, euid)?; } - // Missing ancestors are created at the ambient mode. Only the nominated key - // directory itself is pinned to 0700 and anchored; ancestors are ordinary - // path infrastructure (the shipped default's `~/.gitlawb` on a first boot), - // and `foreign_ancestor_error` has already refused ones somebody else - // could swap out from under us. + // Non-unix builds keep the platform-neutral ancestor creation: the + // descriptor-anchored walk above is unix-only, so without this a non-unix + // first boot would have no way to create missing ancestors at all. + #[cfg(not(unix))] if let Some(parent) = dir.parent() { if !parent.as_os_str().is_empty() && parent != dir { std::fs::create_dir_all(parent).with_context(|| { @@ -1443,6 +1633,31 @@ mod tests { ); } + /// A tempdir base whose mode is 0700, for tests that place the key + /// DIRECTORY beneath it. + /// + /// `tempfile::tempdir()` creates the base at `0777 & !umask` (0775 under + /// the suite's umask 0002), and the descriptor-anchored ancestor walk + /// refuses any group-writable ancestor, so a key tree built under a plain + /// tempdir base would be refused for the base's mode rather than for + /// whatever the test is actually exercising. Chmodding the base to 0700 + /// keeps the ancestor contract intact while giving the test a safe parent. + #[cfg(unix)] + fn key_base_0700() -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + dir + } + + /// Non-unix builds have no ancestor walk, so the plain tempdir is a safe + /// base already. + #[cfg(not(unix))] + fn key_base_0700() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + // ---- Permission probe, run in a child process ------------------------- // // The probe has to create the key under a zeroed umask, otherwise a @@ -1583,6 +1798,12 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("keys").join("p2p.key"); + // The tempdir base is itself an ancestor of the key directory under + // umask 0000, and the ancestor walk refuses any group/world-writable + // component; pin it to 0700 so the fixture measures the CREATED + // directory/key modes rather than the tempdir's own mode. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + // SAFETY: `umask` only reads and replaces the process-wide value, and // this process exists solely for this probe. No restore: the value dies // with the child. @@ -1614,6 +1835,88 @@ mod tests { println!("{FIXTURE_SENTINEL}"); } + /// Fixture: create a MULTI-LEVEL missing ancestor chain under a zeroed + /// umask and assert every created ancestor lands 0700, not 0777. + /// + /// This is the r4-F2 shape: `create_dir_all` creates missing intermediates + /// at `0777 & !umask`, so under umask 0000 the intermediate directories + /// would land world-writable. Only the nominated key directory is pinned + /// today. The fix must make the ancestor walk create every missing + /// component at 0700 and verify it. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=p2p-key-multilevel"] + fn fixture_p2p_key_multilevel_ancestors_under_zero_umask() { + use std::os::unix::fs::PermissionsExt; + + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("p2p-key-multilevel") { + return; + } + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a").join("b").join("keys").join("p2p.key"); + + // Pin the tempdir base to 0700 (it is the anchor's child and would + // otherwise land 0775 under umask 0000, which the ancestor walk + // correctly refuses); the fixture measures the CREATED `a`/`b` + // ancestors, not the tempdir's own mode. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + // SAFETY: `umask` only reads and replaces the process-wide value, and + // this process exists solely for this probe. No restore: the value dies + // with the child. + unsafe { libc::umask(0o000) }; + load_or_create_p2p_keypair(&path).expect("key creation under a permissive umask"); + + for ancestor in [dir.path().join("a"), dir.path().join("a").join("b")] { + let mode = std::fs::metadata(&ancestor).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o700, + "created ancestor {} must be owner-only, not world-writable under umask 0000", + ancestor.display() + ); + } + + let keys_mode = std::fs::metadata(dir.path().join("a").join("b").join("keys")) + .unwrap() + .permissions() + .mode(); + assert_eq!(keys_mode & 0o777, 0o700, "key directory must be owner-only"); + + let key_mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(key_mode & 0o777, 0o600, "key file must be owner-read/write only"); + + println!("p2p-key-multilevel: asserted"); + } + + #[cfg(unix)] + #[test] + fn p2p_multilevel_missing_ancestors_are_created_0700_under_zero_umask() { + let output = fixture_command_with_env( + "p2p::tests::fixture_p2p_key_multilevel_ancestors_under_zero_umask", + "p2p-key-multilevel", + ) + .output() + .expect("spawn the multilevel fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "the multilevel fixture must pass in its child process\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("1 passed"), + "the fixture filter must select exactly one test that passed\n--- stdout ---\n{stdout}" + ); + assert!( + stdout.contains("p2p-key-multilevel: asserted"), + "the fixture must print its sentinel after asserting\n--- stdout ---\n{stdout}" + ); + } + #[cfg(unix)] #[test] fn p2p_key_file_is_0600_on_unix() { @@ -1755,7 +2058,7 @@ mod tests { fn p2p_nested_key_path_leaves_ancestor_modes_unchanged() { use std::os::unix::fs::PermissionsExt; - let base = tempfile::tempdir().unwrap(); + let base = key_base_0700(); let ancestor_a = base.path().join("a"); let ancestor_b = ancestor_a.join("b"); std::fs::create_dir_all(&ancestor_b).unwrap(); @@ -1804,7 +2107,7 @@ mod tests { fn p2p_existing_key_dir_with_loose_permissions_is_tightened() { use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); + let dir = key_base_0700(); let key_dir = dir.path().join("keys"); std::fs::create_dir(&key_dir).unwrap(); std::fs::set_permissions(&key_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -1835,7 +2138,7 @@ mod tests { #[test] fn p2p_failed_key_write_leaves_no_file_at_the_final_path() { - let dir = tempfile::tempdir().unwrap(); + let dir = key_base_0700(); let key_dir = dir.path().join("keys"); let path = key_dir.join("p2p.key"); @@ -2032,7 +2335,7 @@ mod tests { #[test] fn p2p_concurrent_first_boot_dir_creation_converges() { - let dir = tempfile::tempdir().unwrap(); + let dir = key_base_0700(); let key_path = dir.path().join("keys").join("p2p.key"); let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); let key_path2 = key_path.clone(); @@ -2065,7 +2368,7 @@ mod tests { fn p2p_fifo_key_path_is_refused_without_blocking() { use std::ffi::CString; - let dir = tempfile::tempdir().unwrap(); + let dir = key_base_0700(); let key_dir = dir.path().join("keys"); std::fs::create_dir(&key_dir).unwrap(); let path = key_dir.join("p2p.key"); @@ -2083,7 +2386,7 @@ mod tests { #[test] fn p2p_oversized_key_file_is_refused() { - let dir = tempfile::tempdir().unwrap(); + let dir = key_base_0700(); let key_dir = dir.path().join("keys"); std::fs::create_dir(&key_dir).unwrap(); let path = key_dir.join("p2p.key"); @@ -2359,7 +2662,7 @@ mod tests { ]; for row in rows { - let base = tempfile::tempdir().unwrap(); + let base = key_base_0700(); let key_path = (row.setup)(base.path()); match row.expect { From 1404aeb16af3a9199fc2ec27af1b1369dca2b3df Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:59:27 -0500 Subject: [PATCH 18/36] fix(node): refuse group-writable and non-directory p2p key ancestors The ancestor walk now refuses any component with group or world write bits unless sticky, and any intermediate component that is a symlink or another object type. Tests drive the walk through real boots: 0770/0775 ancestors refused before any key IO, 0755 and 1777-sticky ancestors accepted with stable reload, and an intermediate symlink refused with its target untouched. Both guards proven load-bearing by neuter revert-checks. --- crates/gitlawb-node/src/p2p/mod.rs | 84 ++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index c93d77ab..2382539d 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -518,10 +518,10 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { } match std::io::Error::last_os_error().raw_os_error() { - Some(code) if code == libc::ELOOP => { + Some(code) if code == libc::ELOOP || code == libc::ENOTDIR => { anyhow::bail!( - "p2p key directory {} sits under {}, which is a symlink; the node \ - refuses symlinks on the key storage path", + "p2p key directory {} sits under {}, which is a symlink or another object \ + type; the node refuses anything but a real directory on the key storage path", dir.display(), acc.display() ); @@ -2709,6 +2709,84 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn ancestor_walk_refuses_group_writable_ancestors() { + use std::os::unix::fs::PermissionsExt; + + for mode in [0o770u32, 0o775] { + let base = key_base_0700(); + let ancestor = base.path().join("g"); + std::fs::create_dir(&ancestor).unwrap(); + std::fs::set_permissions(&ancestor, std::fs::Permissions::from_mode(mode)).unwrap(); + + let path = ancestor.join("keys").join("p2p.key"); + let err = load_or_create_p2p_keypair(&path) + .expect_err("a group-writable ancestor must be refused"); + let msg = format!("{err:#}"); + assert!( + msg.contains("writable beyond its owner"), + "mode {mode:04o} must be refused for group write, got: {msg}" + ); + // Refusal must not create the key directory or the key. + assert!(!ancestor.join("keys").exists(), "key dir must not be created"); + assert!(!path.exists(), "key must not be created"); + } + } + + #[cfg(unix)] + #[test] + fn ancestor_walk_accepts_safe_and_sticky_ancestors() { + use std::os::unix::fs::PermissionsExt; + + // A 0755 ancestor (no group/world write) is accepted and the key boots. + let base = key_base_0700(); + let safe = base.path().join("safe"); + std::fs::create_dir(&safe).unwrap(); + std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let kp = load_or_create_p2p_keypair(&safe.join("keys").join("p2p.key")) + .expect("a 0755 ancestor must boot"); + assert_eq!( + PeerId::from( + load_or_create_p2p_keypair(&safe.join("keys").join("p2p.key")) + .unwrap() + .public() + ), + PeerId::from(kp.public()), + "the identity must reload stably" + ); + + // A 1777 sticky ancestor (the /tmp shape) is accepted: sticky blocks + // rename/delete of others' entries even though the directory is + // world-writable. + let sticky = base.path().join("sticky"); + std::fs::create_dir(&sticky).unwrap(); + std::fs::set_permissions(&sticky, std::fs::Permissions::from_mode(0o1777)).unwrap(); + let _ = load_or_create_p2p_keypair(&sticky.join("keys").join("p2p.key")) + .expect("a 1777 sticky ancestor must boot"); + } + + #[cfg(unix)] + #[test] + fn ancestor_walk_refuses_intermediate_symlink() { + let base = key_base_0700(); + let real = base.path().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = base.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let path = link.join("keys").join("p2p.key"); + let err = load_or_create_p2p_keypair(&path) + .expect_err("an intermediate symlink component must be refused"); + assert!( + format!("{err:#}").contains("symlink"), + "intermediate symlink refusal must name the symlink, got: {err:#}" + ); + // The symlink target must be untouched: no keys dir, no key inside. + assert!(!real.join("keys").exists(), "symlink target must be untouched"); + } + #[cfg(unix)] #[test] fn read_p2p_keypair_refuses_a_key_owned_by_another_user() { From fd9216f2e45b0fc037b6a0fb9917c8687b990a8b Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:05:14 -0500 Subject: [PATCH 19/36] fix(node): verify the cwd anchor for relative p2p key paths A bare-relative key path is now walked from a verified working directory: the cwd is opened as '.' with O_NOFOLLOW|O_DIRECTORY, fstat-verified for ownership and write bits before any relative component is touched, and a writable or foreign-owned cwd is refused before anything is created. The refusal names the working directory path. Child-process fixtures prove both directions without mutating the shared process cwd. --- crates/gitlawb-node/src/p2p/mod.rs | 157 ++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 3 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 2382539d..1a314cc2 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -477,7 +477,8 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { (OwnedFd(fd), PathBuf::from("/")) } else { // SAFETY: open(2) on "." returns a descriptor for the process cwd - // itself; O_NOFOLLOW and O_DIRECTORY pin the object type. + // itself; O_NOFOLLOW and O_DIRECTORY pin the object type. The display + // path is only for error messages (no pathname is re-resolved for IO). let dot = std::ffi::CString::new(".").unwrap(); let fd = unsafe { libc::open( @@ -489,7 +490,16 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { return Err(std::io::Error::last_os_error()) .with_context(|| format!("failed to open the working directory for {}", dir.display())); } - (OwnedFd(fd), PathBuf::new()) + let display = std::env::current_dir() + .map(|d| { + if d.as_os_str().is_empty() { + PathBuf::from(".") + } else { + d + } + }) + .unwrap_or_else(|_| PathBuf::from(".")); + (OwnedFd(fd), display) }; verify_component(anchor.0, dir, &anchor_display, euid)?; @@ -1917,6 +1927,113 @@ mod tests { ); } + /// Fixture: a relative key path under a WORLD-WRITABLE non-sticky cwd must + /// be refused before anything is created. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=p2p-key-cwd-writable"] + fn fixture_p2p_key_relative_under_writable_cwd_is_refused() { + use std::os::unix::fs::PermissionsExt; + + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("p2p-key-cwd-writable") { + return; + } + + let dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(dir.path()).expect("chdir into isolated tempdir"); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + + let err = load_or_create_p2p_keypair(Path::new("keys/p2p.key")) + .expect_err("a relative key path under a writable cwd must be refused"); + let cwd = std::env::current_dir().expect("cwd still readable"); + assert!( + format!("{err:#}").contains(&cwd.display().to_string()), + "the refusal must name the working directory, got: {err:#}" + ); + + // Nothing may be created in the cwd. + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .collect(); + assert!( + leftovers.is_empty(), + "a refused cwd must not have `keys` created, found: {leftovers:?}" + ); + + println!("p2p-key-cwd-writable: asserted"); + } + + /// Fixture: a relative key path under a safe 0700 cwd boots and reloads to + /// the same PeerId. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=p2p-key-cwd-safe"] + fn fixture_p2p_key_relative_under_safe_cwd_boots() { + use std::os::unix::fs::PermissionsExt; + + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("p2p-key-cwd-safe") { + return; + } + + let dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(dir.path()).expect("chdir into isolated tempdir"); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + let kp = load_or_create_p2p_keypair(Path::new("keys/p2p.key")) + .expect("a relative key path under a safe cwd must boot"); + let reloaded = load_or_create_p2p_keypair(Path::new("keys/p2p.key")) + .expect("the same relative key path must reload"); + assert_eq!( + PeerId::from(kp.public()), + PeerId::from(reloaded.public()), + "the relative-path identity must be stable across reloads" + ); + + println!("p2p-key-cwd-safe: asserted"); + } + + #[cfg(unix)] + #[test] + fn p2p_relative_key_path_verifies_the_cwd() { + for (fixture, env, label) in [ + ( + "p2p::tests::fixture_p2p_key_relative_under_writable_cwd_is_refused", + "p2p-key-cwd-writable", + "writable-cwd refusal", + ), + ( + "p2p::tests::fixture_p2p_key_relative_under_safe_cwd_boots", + "p2p-key-cwd-safe", + "safe-cwd boot", + ), + ] { + let output = fixture_command_with_env(fixture, env) + .output() + .expect("spawn the cwd fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "the {label} fixture must pass in its child process\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("1 passed"), + "the {label} fixture filter must select exactly one test\n--- stdout ---\n{stdout}" + ); + assert!( + stdout.contains(if env == "p2p-key-cwd-writable" { + "p2p-key-cwd-writable: asserted" + } else { + "p2p-key-cwd-safe: asserted" + }), + "the {label} fixture must print its sentinel\n--- stdout ---\n{stdout}" + ); + } + } + #[cfg(unix)] #[test] fn p2p_key_file_is_0600_on_unix() { @@ -2868,7 +2985,6 @@ mod tests { EUID_OVERRIDE.with(|c| c.set(Some(real_uid.wrapping_add(1)))); let result = ensure_key_dir(&nested); EUID_OVERRIDE.with(|c| c.set(None)); - let err = format!( "{:#}", result.expect_err("a directory under a foreign-owned ancestor must be refused") @@ -2883,6 +2999,41 @@ mod tests { ); } + /// The cwd anchor's ownership is verified the same way as any other + /// component: with an overridden euid the real cwd is foreign and a + /// relative key path must be refused before anything is created. + #[cfg(unix)] + #[test] + fn ensure_key_dir_refuses_a_foreign_owned_cwd_anchor() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let real_uid = std::fs::metadata(base.path()).unwrap().uid(); + + // Drive the walk on a relative key directory; the anchor is the cwd, + // which is `base` only if the test process chdir'd there. Instead of + // touching the process-global cwd, point the walk at a path whose + // anchor walk sees the cwd: with the euid override armed, the cwd is + // foreign and must be refused. + EUID_OVERRIDE.with(|c| c.set(Some(real_uid.wrapping_add(1)))); + let result = ensure_key_dir(Path::new("keys")); + EUID_OVERRIDE.with(|c| c.set(None)); + + let err = format!( + "{:#}", + result.expect_err("a foreign-owned cwd anchor must be refused") + ); + assert!( + err.contains("owned by uid") || err.contains("control which identity"), + "the cwd-anchor refusal must name the ownership hazard, got: {err}" + ); + assert!( + !Path::new("keys").exists(), + "the key directory must not have been created under a foreign cwd" + ); + } + #[cfg(unix)] #[test] fn foreign_ownership_is_refused_and_matching_ownership_is_not() { From db994e88026bc007b93f25d838728a5b38afb0b6 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:07:13 -0500 Subject: [PATCH 20/36] test(node): stop rendering uids in p2p assert-message strings CodeQL flags the uid-interpolating assert messages as cleartext logging to a console sink. Every assert message in the p2p test module now avoids interpolating uid values or the uid-bearing error text, while the substantive assertions (the refusal names both uids, the ownership check fires before the chmod) stay intact and are verified by the same assertions. --- crates/gitlawb-node/src/p2p/mod.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 1a314cc2..239d843b 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -2930,7 +2930,7 @@ mod tests { err.contains(&format!( "owned by uid {real_uid} but this node runs as uid {other}" )), - "the refusal must name the file's owner and the running uid, got: {err}" + "the refusal must name the file's owner and the running uid" ); assert!( read_p2p_keypair(&path).is_ok(), @@ -2959,11 +2959,11 @@ mod tests { ); assert!( err.contains("owned by uid"), - "mode {mode:04o} must be refused, got: {err}" + "mode {mode:04o} must be refused" ); assert!( !err.contains("could not be tightened"), - "ownership must be reported before chmod, got: {err}" + "ownership must be reported before chmod" ); assert_eq!( std::fs::metadata(&keys).unwrap().permissions().mode() & 0o777, @@ -2991,7 +2991,7 @@ mod tests { ); assert!( err.contains("sits under") && err.contains("control which identity"), - "must be refused for the ancestor, got: {err}" + "must be refused for the ancestor" ); assert!( !nested.exists(), @@ -3026,7 +3026,7 @@ mod tests { ); assert!( err.contains("owned by uid") || err.contains("control which identity"), - "the cwd-anchor refusal must name the ownership hazard, got: {err}" + "the cwd-anchor refusal must name the ownership hazard" ); assert!( !Path::new("keys").exists(), @@ -3042,7 +3042,7 @@ mod tests { for uid in [0u32, 1000, 65534] { assert!( foreign_ownership_error("key", path, uid, uid).is_none(), - "uid {uid} owning its own key must not be refused" + "a uid owning its own key must not be refused" ); } @@ -3050,7 +3050,7 @@ mod tests { .expect("a key owned by another uid must be refused"); assert!( err.contains("1000") && err.contains("1001"), - "the refusal must name both uids, got: {err}" + "the refusal must name both uids" ); } From 0a92145c89156ff68ef48ca6ed094ba30f411a76 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:14:50 -0500 Subject: [PATCH 21/36] test(node): add walk rows to the p2p key-storage contract matrix The matrix now proves the ancestor-walk refusals table-driven: a group-writable (0775) ancestor, a world-writable non-sticky (0777) ancestor, and an intermediate symlink component are each refused with the no-mutation snapshot discipline, while the existing boot rows still reload to a stable PeerId. The fchmod-on-create path pins newly created ancestors to 0700 regardless of the ambient umask. --- crates/gitlawb-node/src/p2p/mod.rs | 59 +++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 239d843b..92279404 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -541,7 +541,11 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { // no-follow and verify whatever actually landed. `AlreadyExists` // is a race: the winner gets the same full verification below. // SAFETY: mkdirat creates `name` relative to the verified parent - // descriptor; mode 0700 is pinned at creation. + // descriptor; mode 0700 is the requested mode, but the umask can + // still mask it down (mkdirat applies the process umask), so the + // created directory is fchmod'd to exactly 0700 after it is + // reopened below. + let mut created = false; if unsafe { libc::mkdirat(cur.0, cname.as_ptr(), 0o700) } != 0 { let mk_err = std::io::Error::last_os_error(); if mk_err.kind() != std::io::ErrorKind::AlreadyExists { @@ -553,6 +557,8 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { ) }); } + } else { + created = true; } // SAFETY: openat as above, re-checking the winner. let fd = unsafe { @@ -570,6 +576,24 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { ) }); } + // We created this directory ourselves, so pin its mode to + // exactly 0700 (the requested mode, unmasked). A race winner + // (`created == false`) is not fchmod'd: it is verified as-is. + if created { + // SAFETY: `fd` is the descriptor of the directory this + // process just created and owns. + if unsafe { libc::fchmod(fd, 0o700) } != 0 { + let ch_err = std::io::Error::last_os_error(); + // SAFETY: `fd` is a descriptor we own from openat. + unsafe { libc::close(fd) }; + return Err(ch_err).with_context(|| { + format!( + "failed to pin mode 0700 on created key directory component {}", + acc.display() + ) + }); + } + } // SAFETY: `fd` is a descriptor we own from openat; the OwnedFd // below takes ownership and closes it when the chain advances. verify_component(fd, dir, &acc, euid)?; @@ -2666,6 +2690,39 @@ mod tests { }, expect: Expect::Boots, }, + Row { + name: "group-writable ancestor is refused", + setup: |base| { + let g = base.join("g"); + std::fs::create_dir(&g).unwrap(); + std::fs::set_permissions(&g, std::fs::Permissions::from_mode(0o775)).unwrap(); + g.join("keys").join("p2p.key") + }, + expect: Expect::Refused("writable beyond its owner"), + }, + Row { + name: "world-writable non-sticky ancestor is refused", + setup: |base| { + let w = base.join("w"); + std::fs::create_dir(&w).unwrap(); + std::fs::set_permissions(&w, std::fs::Permissions::from_mode(0o777)).unwrap(); + w.join("keys").join("p2p.key") + }, + expect: Expect::Refused("writable beyond its owner"), + }, + Row { + name: "intermediate symlink component is refused, target untouched", + setup: |base| { + let real = base.join("real"); + std::fs::create_dir(&real).unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755)) + .unwrap(); + let link = base.join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + link.join("keys").join("p2p.key") + }, + expect: Expect::Refused("symlink"), + }, Row { name: "symlinked parent is refused, target untouched", setup: |base| { From 1de4e6af4a67622736aa65fe23c829f77825c23f Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:40:59 -0500 Subject: [PATCH 22/36] docs(node): note the verified working directory for relative p2p key paths README, .env.example, and the clap help for GITLAWB_P2P_KEY now state that a relative key path is anchored at a verified working directory, and that an unsafe cwd or ancestor is refused with p2p off while HTTP stays up. Also normalizes two pre-existing em dashes in the README settings table. --- .env.example | 15 +++++++++------ README.md | 2 +- crates/gitlawb-node/src/config.rs | 4 +++- crates/gitlawb-node/src/p2p/mod.rs | 30 ++++++++++++++++++++++-------- 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 4a5337cd..eecd4c0a 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,15 @@ GITLAWB_KEY=/data/keys/identity.pem # Path to the node's persistent libp2p identity key file. Must include a # directory; the node refuses to start on a bare filename, because it will not -# keep its p2p identity key in the working directory. On Unix it is created -# 0600 inside a 0700 directory, and a loose key directory is tightened to 0700 -# on start; on other platforms no permissions are enforced. If the node logs -# that it tightened a loose key directory, treat the key that was sitting there -# as possibly exposed: delete it so a fresh identity is generated on the next -# start. Keep it on a persistent volume so the PeerId survives redeploys. +# keep its p2p identity key in the working directory. With a relative path the +# node verifies the working directory (ownership and write permissions) before +# using it; an unsafe cwd or ancestor is refused and p2p stays off while HTTP +# remains up. On Unix it is created 0600 inside a 0700 directory, and a loose +# key directory is tightened to 0700 on start; on other platforms no +# permissions are enforced. If the node logs that it tightened a loose key +# directory, treat the key that was sitting there as possibly exposed: delete +# it so a fresh identity is generated on the next start. Keep it on a +# persistent volume so the PeerId survives redeploys. # Default: ~/.gitlawb/p2p.key #GITLAWB_P2P_KEY=/data/keys/p2p.key diff --git a/README.md b/README.md index 687748f4..75dad411 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Important node settings: | `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. | | `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. | | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | -| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`; bare filenames, directory paths, trailing `/`, and the filesystem root are refused at startup. On Unix a new key is created `0600` inside a `0700` directory; a loose key directory is tightened to `0700` on start. An existing key copied from backup with group or other bits set is rejected rather than repaired; run `chmod 600` on it before restarting. P2P stays off while HTTP remains up if the key cannot be loaded. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | +| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`; bare filenames, directory paths, trailing `/`, and the filesystem root are refused at startup. With a relative path the node verifies the working directory (ownership and write permissions) before using it. On Unix a new key is created `0600` inside a `0700` directory; a loose key directory is tightened to `0700` on start. An existing key copied from backup with group or other bits set is rejected rather than repaired; run `chmod 600` on it before restarting. P2P stays off while HTTP remains up if the key cannot be loaded, including when an unsafe ancestor or working directory is refused. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | | `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | | `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 334de33a..2d5f8f0e 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -120,7 +120,9 @@ pub struct Config { #[arg(long, env = "GITLAWB_P2P_PORT", default_value_t = 7546)] pub p2p_port: u16, - /// Path to the persistent libp2p identity key + /// Path to the persistent libp2p identity key. With a relative path the + /// node verifies the working directory (ownership and write permissions) + /// before using it. #[arg(long, env = "GITLAWB_P2P_KEY", default_value = "~/.gitlawb/p2p.key")] pub p2p_key_path: String, diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 92279404..5b2f07f8 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -471,8 +471,9 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { ) }; if fd < 0 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("failed to open the filesystem root for {}", dir.display())); + return Err(std::io::Error::last_os_error()).with_context(|| { + format!("failed to open the filesystem root for {}", dir.display()) + }); } (OwnedFd(fd), PathBuf::from("/")) } else { @@ -487,8 +488,9 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { ) }; if fd < 0 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("failed to open the working directory for {}", dir.display())); + return Err(std::io::Error::last_os_error()).with_context(|| { + format!("failed to open the working directory for {}", dir.display()) + }); } let display = std::env::current_dir() .map(|d| { @@ -553,7 +555,9 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { format!( "failed to create key directory component {} below {}", name.to_string_lossy(), - acc.parent().map(|p| p.display().to_string()).unwrap_or_default() + acc.parent() + .map(|p| p.display().to_string()) + .unwrap_or_default() ) }); } @@ -1919,7 +1923,11 @@ mod tests { assert_eq!(keys_mode & 0o777, 0o700, "key directory must be owner-only"); let key_mode = std::fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(key_mode & 0o777, 0o600, "key file must be owner-read/write only"); + assert_eq!( + key_mode & 0o777, + 0o600, + "key file must be owner-read/write only" + ); println!("p2p-key-multilevel: asserted"); } @@ -2903,7 +2911,10 @@ mod tests { "mode {mode:04o} must be refused for group write, got: {msg}" ); // Refusal must not create the key directory or the key. - assert!(!ancestor.join("keys").exists(), "key dir must not be created"); + assert!( + !ancestor.join("keys").exists(), + "key dir must not be created" + ); assert!(!path.exists(), "key must not be created"); } } @@ -2958,7 +2969,10 @@ mod tests { "intermediate symlink refusal must name the symlink, got: {err:#}" ); // The symlink target must be untouched: no keys dir, no key inside. - assert!(!real.join("keys").exists(), "symlink target must be untouched"); + assert!( + !real.join("keys").exists(), + "symlink target must be untouched" + ); } #[cfg(unix)] From be626ba188abb9397d08d00aa9f0a332ea9da32c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:52:52 -0500 Subject: [PATCH 23/36] fix(node): pin and verify the mode of every key object this process creates POSIX applies the process umask to every requested creation mode, so the 0700 passed to mkdirat and the 0600 passed to openat were requests rather than results. Under a mask that strips owner bits the created directory landed 0000 and the no-follow reopen failed EACCES before the repairing fchmod was reached, so that half failed loudly. The scratch key was worse: it was published unreadable, the boot that created it succeeded on its already-open descriptor, and only the next boot lost p2p, behind a health check that still reported healthy. Every object this process creates now goes through one rule: create, pin the mode on the object just created, reopen, and verify the achieved mode by fstat. The directory pin is issued by name off the verified parent before the reopen, because a directory that landed 0000 cannot be opened at all, so a pin that waits for the reopen is unreachable in exactly the case it exists for. A race winner is verified as-is and never chmodded, which is the rule the ancestor walk already applied. Pinned's field is private to the pin module and no constructor is exported, so a descriptor that skipped the pin cannot be turned into a key-directory handle or reach publication. That is what keeps the rule from depending on review. Two adjacent corrections the same defect exposed. The leaf mode predicate was supersets-only: mode & 0o077 != 0 asks whether anything is granted beyond the owner, so 0000, 0100 and 0400 all passed it while being unusable, and an inherited setgid bit was judged on the wrong bit width. A loose directory is still tightened; an over-closed one is now refused with its remedy rather than widened, matching how an over-closed key file is already handled. And an unreadable key or key directory now names its own cause: every open failure previously reported a refused symlink, which is what an operator saw for a key masked at creation. The matrix runs first boot and fresh-process reload across umask 0000, 0022 and 0777, crossed with missing and existing ancestors and leaf, with concurrent creators and an injected write failure. It runs in child processes because umask is process global, and it requires at least one successful concurrent creator so a row where every creator fails cannot pass. All 21 rows were RED under umask 0777 before this change. --- crates/gitlawb-node/src/p2p/mod.rs | 1344 ++++++++++++++++++++++++---- 1 file changed, 1154 insertions(+), 190 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 5b2f07f8..59260004 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -339,6 +339,303 @@ fn foreign_ownership_error(what: &str, path: &Path, owner_uid: u32, euid: u32) - )) } +/// Pinned-creation primitives: the only way to obtain a `Pinned`. +/// +/// POSIX applies the process umask to every requested creation mode, so the +/// mode argument to `mkdirat`, `openat(O_CREAT)`, `mkdir` or `create_dir_all` +/// is a REQUEST and not a result. Under a mask that removes owner bits a +/// requested 0700 directory lands 0000 and cannot be reopened at all, and a +/// requested 0600 key is published unreadable, so the boot that created it +/// succeeds on its already-open descriptor and the next boot loses p2p. +/// +/// Every object this process creates therefore goes through one rule: create, +/// pin the mode on the object we just made, reopen or keep the descriptor, and +/// verify the ACHIEVED mode by `fstat` before anything relies on it. A race +/// winner is never pinned; it is verified as-is, which is the rule the ancestor +/// walk already applied and this module preserves. +/// +/// `Pinned`'s field is private to this module and no constructor is +/// exported, so a descriptor that did not go through one of these helpers +/// cannot be handed to key-directory construction or to publication. That is +/// what makes the rule compile-enforced rather than reviewed. +#[cfg(unix)] +pub(crate) mod pin { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::path::Path; + + /// A file or directory whose achieved mode has been verified on this + /// descriptor. Constructible only by the helpers below. + #[derive(Debug)] + pub(crate) struct Pinned(T); + + impl Pinned { + pub(crate) fn get(&self) -> &T { + &self.0 + } + pub(crate) fn get_mut(&mut self) -> &mut T { + &mut self.0 + } + pub(crate) fn into_inner(self) -> T { + self.0 + } + } + + // Test-only injection points, thread-local so an armed test cannot + // disturb the ones running beside it. + // SKIP_MODE_PIN makes the pin a no-op, so the exact-mode verification + // is observed refusing rather than silently masked by a + // pin that was doing the work (INV-21(i)). + // RACE_CREATE_MODE creates the component by path between the ENOENT and + // the mkdirat, so the race-lost arm is deterministic. + #[cfg(test)] + thread_local! { + pub(crate) static SKIP_MODE_PIN: std::cell::Cell = + const { std::cell::Cell::new(false) }; + pub(crate) static RACE_CREATE_MODE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + } + + fn io_err(kind: std::io::ErrorKind, msg: String) -> std::io::Error { + std::io::Error::new(kind, msg) + } + + /// `fstat` the descriptor and require the exact mode, the expected object + /// type, and ownership by this uid. The error names the ACHIEVED mode + /// against the REQUESTED one, which is what lets a failure be attributed to + /// the mode rather than read as a generic open failure. + pub(crate) fn verify_exact_mode( + fd: std::os::fd::RawFd, + want: libc::mode_t, + want_dir: bool, + display: &Path, + euid: u32, + ) -> std::io::Result<()> { + let mut st = std::mem::MaybeUninit::::uninit(); + // SAFETY: fstat writes the struct on success; the return value is + // checked before the struct is read. + if unsafe { libc::fstat(fd, st.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: fstat returned 0, so the struct is initialised. + let st = unsafe { st.assume_init() }; + + let is_dir = (st.st_mode & libc::S_IFMT) == libc::S_IFDIR; + if is_dir != want_dir { + return Err(io_err( + std::io::ErrorKind::InvalidInput, + format!( + "{} is not {}", + display.display(), + if want_dir { + "a directory" + } else { + "a regular file" + } + ), + )); + } + if st.st_uid != euid { + return Err(io_err( + std::io::ErrorKind::PermissionDenied, + format!( + "{} is owned by uid {} rather than this node (uid {})", + display.display(), + st.st_uid, + euid + ), + )); + } + let achieved = st.st_mode & 0o7777; + if achieved != want { + return Err(io_err( + std::io::ErrorKind::InvalidInput, + format!( + "{} achieved mode {:04o}, requested {:04o}; the process umask masks every \ + requested creation mode, so the mode must be pinned on the descriptor and \ + verified rather than assumed", + display.display(), + achieved, + want + ), + )); + } + Ok(()) + } + + /// Create `name` below `parent_fd` at exactly 0700, or adopt the winner of + /// a creation race. + /// + /// Returns the descriptor and whether this process created it. The pin is + /// issued BEFORE the reopen, by name off the verified parent, because a + /// directory that landed 0000 cannot be opened for reading at all, so a pin + /// that waits for the reopen is unreachable in exactly the case it exists + /// for. That by-name step is safe because `parent_fd` is verified here + /// against the same predicate the ancestor walk applies (real directory, + /// owned by this uid or root, no write beyond the owner unless sticky), and + /// sticky forbids a non-owner from renaming our entry. The verdict is still + /// the `fstat` on the reopened descriptor, not the chmod. + pub(crate) fn create_dir_pinned_at( + parent_fd: std::os::fd::RawFd, + name: &std::ffi::OsStr, + display: &Path, + euid: u32, + ) -> std::io::Result<(Pinned, bool)> { + use std::os::unix::ffi::OsStrExt; + + // The helper carries its own precondition rather than inheriting it + // from one call site: the by-name chmod below is only safe over a + // parent that cannot be repointed by another user. + verify_trusted_parent(parent_fd, display, euid)?; + + let cname = std::ffi::CString::new(name.as_bytes()).map_err(|_| { + io_err( + std::io::ErrorKind::InvalidInput, + "path component contains an interior NUL byte".to_string(), + ) + })?; + let open_flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC; + + // Fast path: it already exists. Adopt and verify as-is (never pin). + // SAFETY: openat resolves `name` relative to the verified parent. + let existing = unsafe { libc::openat(parent_fd, cname.as_ptr(), open_flags) }; + if existing >= 0 { + // SAFETY: a descriptor we just received and own exactly once. + return Ok((Pinned(unsafe { OwnedFd::from_raw_fd(existing) }), false)); + } + let open_err = std::io::Error::last_os_error(); + if open_err.raw_os_error() != Some(libc::ENOENT) { + return Err(open_err); + } + + #[cfg(test)] + if let Some(mode) = RACE_CREATE_MODE.with(|c| c.take()) { + // SAFETY: mkdirat creates `name` relative to the verified parent. + unsafe { libc::mkdirat(parent_fd, cname.as_ptr(), mode as libc::mode_t) }; + } + + let mut created = true; + // SAFETY: mkdirat creates `name` relative to the verified parent + // descriptor. 0700 is the requested mode; the umask masks it, which the + // pin below repairs. + if unsafe { libc::mkdirat(parent_fd, cname.as_ptr(), 0o700) } != 0 { + let mk_err = std::io::Error::last_os_error(); + if mk_err.kind() != std::io::ErrorKind::AlreadyExists { + return Err(mk_err); + } + // Lost the race. The winner is verified, never pinned. + created = false; + } + + if created { + let skip = { + #[cfg(test)] + { + SKIP_MODE_PIN.with(|c| c.get()) + } + #[cfg(not(test))] + { + false + } + }; + if !skip { + // SAFETY: fchmodat with flags 0 on a name below the verified + // parent; the object was created by this process a moment ago + // and the parent cannot be repointed by another user. + if unsafe { libc::fchmodat(parent_fd, cname.as_ptr(), 0o700, 0) } != 0 { + return Err(std::io::Error::last_os_error()); + } + } + } + + // SAFETY: openat as above, re-resolving the object that actually landed. + let fd = unsafe { libc::openat(parent_fd, cname.as_ptr(), open_flags) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: a descriptor we just received and own exactly once. + let owned = unsafe { OwnedFd::from_raw_fd(fd) }; + + if created { + verify_exact_mode(owned.as_raw_fd(), 0o700, true, display, euid)?; + } + Ok((Pinned(owned), created)) + } + + /// Pin a file this process just created to `want` on its held descriptor + /// and verify the achieved mode. + pub(crate) fn pin_created_file( + file: std::fs::File, + want: libc::mode_t, + display: &Path, + euid: u32, + ) -> std::io::Result> { + let skip = { + #[cfg(test)] + { + SKIP_MODE_PIN.with(|c| c.get()) + } + #[cfg(not(test))] + { + false + } + }; + if !skip { + // SAFETY: fchmod on a descriptor this process owns. + if unsafe { libc::fchmod(file.as_raw_fd(), want) } != 0 { + return Err(std::io::Error::last_os_error()); + } + } + verify_exact_mode(file.as_raw_fd(), want, false, display, euid)?; + Ok(Pinned(file)) + } + + /// The ancestor predicate, applied to the parent this helper is about to + /// chmod a child of. + fn verify_trusted_parent( + fd: std::os::fd::RawFd, + display: &Path, + euid: u32, + ) -> std::io::Result<()> { + let mut st = std::mem::MaybeUninit::::uninit(); + // SAFETY: fstat writes the struct on success; checked before read. + if unsafe { libc::fstat(fd, st.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: fstat returned 0. + let st = unsafe { st.assume_init() }; + if (st.st_mode & libc::S_IFMT) != libc::S_IFDIR { + return Err(io_err( + std::io::ErrorKind::InvalidInput, + format!("{}'s parent is not a directory", display.display()), + )); + } + if st.st_uid != euid && st.st_uid != 0 { + return Err(io_err( + std::io::ErrorKind::PermissionDenied, + format!( + "{}'s parent is owned by uid {} rather than this node (uid {}) or root", + display.display(), + st.st_uid, + euid + ), + )); + } + let perms = st.st_mode & 0o777; + let sticky = st.st_mode & 0o1000 != 0; + if perms & 0o022 != 0 && !sticky { + return Err(io_err( + std::io::ErrorKind::PermissionDenied, + format!( + "{}'s parent has mode {:04o} and is writable beyond its owner", + display.display(), + perms + ), + )); + } + Ok(()) + } +} + /// Descriptor-anchored ancestor walk from a trusted anchor to the key /// directory's parent. /// @@ -367,8 +664,8 @@ fn foreign_ownership_error(what: &str, path: &Path, owner_uid: u32, euid: u32) - /// `ensure_key_dir` opens it no-follow, checks its ownership, and tightens it /// to 0700 afterwards. #[cfg(unix)] -fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { - use std::os::unix::ffi::OsStrExt; +fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result { + use std::os::fd::{AsRawFd, FromRawFd}; /// Refuse an opened component unless it is a real directory owned by /// `euid` or root with no write bits beyond the owner unless sticky. @@ -420,29 +717,16 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { Ok(()) } - fn child_name(name: &std::ffi::OsStr) -> std::io::Result { - std::ffi::CString::new(name.as_bytes()).map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "path component contains an interior NUL byte", - ) - }) - } - - /// Owning wrapper for a raw directory descriptor, so the walk's descriptor - /// chain is closed on every exit path (success, error, or early return). - struct OwnedFd(i32); - impl Drop for OwnedFd { - fn drop(&mut self) { - // SAFETY: close(2) on a descriptor this struct owns. - unsafe { libc::close(self.0) }; - } - } - // The components strictly above the key directory, below the anchor. let Some(parent) = dir.parent() else { - // `dir` is the filesystem root; there is no chain to walk. - return Ok(()); + // `dir` is the filesystem root, so there is no parent descriptor to + // hand back. `key_parent_is_filesystem_root` already refuses this + // configuration lexically; this arm is the backstop and bails rather + // than opening `/` for a path the validator rejects. + anyhow::bail!( + "p2p key directory {} is the filesystem root; the key needs a dedicated directory", + dir.display() + ); }; let parent = if parent.as_os_str().is_empty() { Path::new(".") @@ -475,7 +759,11 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { format!("failed to open the filesystem root for {}", dir.display()) }); } - (OwnedFd(fd), PathBuf::from("/")) + // SAFETY: a descriptor we just received and own exactly once. + ( + unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }, + PathBuf::from("/"), + ) } else { // SAFETY: open(2) on "." returns a descriptor for the process cwd // itself; O_NOFOLLOW and O_DIRECTORY pin the object type. The display @@ -501,116 +789,51 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result<()> { } }) .unwrap_or_else(|_| PathBuf::from(".")); - (OwnedFd(fd), display) + // SAFETY: a descriptor we just received and own exactly once. + (unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }, display) }; - verify_component(anchor.0, dir, &anchor_display, euid)?; + verify_component(anchor.as_raw_fd(), dir, &anchor_display, euid)?; let mut acc = anchor_display; let mut cur = anchor; for name in &components { - let cname = child_name(name)?; acc.push(name); - // SAFETY: openat resolves `name` relative to the previously verified - // parent descriptor; O_NOFOLLOW refuses a symlink at this position. - let fd = unsafe { - libc::openat( - cur.0, - cname.as_ptr(), - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, - ) - }; - if fd >= 0 { - // SAFETY: `fd` is a descriptor we own from openat; the OwnedFd - // below takes ownership and closes it when the chain advances. - verify_component(fd, dir, &acc, euid)?; - cur = OwnedFd(fd); - continue; - } + // One call covers both cases: an existing component is opened + // no-follow and adopted as-is, a missing one is created at 0700, pinned + // on the object this process just made, reopened, and verified at its + // ACHIEVED mode. Before this, the create arm pinned only AFTER the + // reopen, so under a mask that strips owner bits the reopen failed + // EACCES and the pin was unreachable in exactly the case it existed + // for. + let (next, _created) = pin::create_dir_pinned_at(cur.as_raw_fd(), name, &acc, euid) + .map_err(|e| walk_component_error(e, dir, &acc))?; + + // The R3 predicate still judges every component, created or adopted. + verify_component(next.get().as_raw_fd(), dir, &acc, euid)?; + cur = next.into_inner(); + } + Ok(cur) +} - match std::io::Error::last_os_error().raw_os_error() { - Some(code) if code == libc::ELOOP || code == libc::ENOTDIR => { - anyhow::bail!( - "p2p key directory {} sits under {}, which is a symlink or another object \ - type; the node refuses anything but a real directory on the key storage path", - dir.display(), - acc.display() - ); - } - Some(code) if code == libc::ENOENT => { - // Create relative to the verified parent at 0700, then reopen - // no-follow and verify whatever actually landed. `AlreadyExists` - // is a race: the winner gets the same full verification below. - // SAFETY: mkdirat creates `name` relative to the verified parent - // descriptor; mode 0700 is the requested mode, but the umask can - // still mask it down (mkdirat applies the process umask), so the - // created directory is fchmod'd to exactly 0700 after it is - // reopened below. - let mut created = false; - if unsafe { libc::mkdirat(cur.0, cname.as_ptr(), 0o700) } != 0 { - let mk_err = std::io::Error::last_os_error(); - if mk_err.kind() != std::io::ErrorKind::AlreadyExists { - return Err(mk_err).with_context(|| { - format!( - "failed to create key directory component {} below {}", - name.to_string_lossy(), - acc.parent() - .map(|p| p.display().to_string()) - .unwrap_or_default() - ) - }); - } - } else { - created = true; - } - // SAFETY: openat as above, re-checking the winner. - let fd = unsafe { - libc::openat( - cur.0, - cname.as_ptr(), - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC, - ) - }; - if fd < 0 { - return Err(std::io::Error::last_os_error()).with_context(|| { - format!( - "failed to reopen created key directory component {}", - acc.display() - ) - }); - } - // We created this directory ourselves, so pin its mode to - // exactly 0700 (the requested mode, unmasked). A race winner - // (`created == false`) is not fchmod'd: it is verified as-is. - if created { - // SAFETY: `fd` is the descriptor of the directory this - // process just created and owns. - if unsafe { libc::fchmod(fd, 0o700) } != 0 { - let ch_err = std::io::Error::last_os_error(); - // SAFETY: `fd` is a descriptor we own from openat. - unsafe { libc::close(fd) }; - return Err(ch_err).with_context(|| { - format!( - "failed to pin mode 0700 on created key directory component {}", - acc.display() - ) - }); - } - } - // SAFETY: `fd` is a descriptor we own from openat; the OwnedFd - // below takes ownership and closes it when the chain advances. - verify_component(fd, dir, &acc, euid)?; - cur = OwnedFd(fd); - } - _ => { - return Err(std::io::Error::last_os_error()).with_context(|| { - format!("failed to open key directory component {}", acc.display()) - }); - } - } +/// Map an io error from the pinned-creation helper onto the walk's own +/// vocabulary, preserving the symlink and wrong-object-type messages the +/// storage-contract tests assert on. +#[cfg(unix)] +fn walk_component_error(e: std::io::Error, key_dir: &Path, component: &Path) -> anyhow::Error { + match e.raw_os_error() { + Some(code) if code == libc::ELOOP || code == libc::ENOTDIR => anyhow::anyhow!( + "p2p key directory {} sits under {}, which is a symlink or another object type; \ + the node refuses anything but a real directory on the key storage path", + key_dir.display(), + component.display() + ), + _ => anyhow::Error::new(e).context(format!( + "failed to open or create key directory component {}", + component.display() + )), } - Ok(()) } /// Whether the configured path names a directory rather than a key file. @@ -800,9 +1023,27 @@ struct KeyDirHandle { #[cfg(unix)] impl KeyDirHandle { + /// Build a handle from a descriptor that has already been pinned and + /// verified by [`pin::create_dir_pinned_at`]. + /// + /// This is the only unix constructor on the production path, and taking a + /// `Pinned` rather than a bare descriptor is what makes the mode rule + /// compile-enforced: a directory that never went through the pin helper + /// cannot be turned into a `KeyDirHandle`, so it cannot reach key IO. + fn from_pinned_fd(pinned: pin::Pinned, dir_path: &Path) -> KeyDirHandle { + KeyDirHandle { + dir: std::fs::File::from(pinned.into_inner()), + path: dir_path.to_path_buf(), + } + } + /// Open `dir_path` refusing symlinks and non-directories at the open /// itself, so rejection happens before any chmod or key IO rather than /// after a separate stat that something else could invalidate. + /// + /// Test-only on unix: the production path builds its handle from the + /// pinned descriptor the walk hands back, never by re-resolving a pathname. + #[cfg(test)] fn open(dir_path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; @@ -869,12 +1110,22 @@ impl KeyDirHandle { /// Create a scratch file with 0600 applied at creation. `O_EXCL` keeps a /// collision an error rather than an adoption. - fn create_scratch(&self, name: &std::ffi::OsStr) -> std::io::Result { - self.open_child( + fn create_scratch( + &self, + name: &std::ffi::OsStr, + ) -> std::io::Result> { + let file = self.open_child( name, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, 0o600 as libc::mode_t, - ) + )?; + // 0600 was the REQUESTED mode and the umask has already edited it, so + // pin it on the descriptor we hold and verify what actually landed. + // Without this the key is published at whatever the mask allowed, the + // creating boot still succeeds on this open descriptor, and the NEXT + // boot cannot read its own key. `O_EXCL` guarantees this process + // created the inode, so there is no race winner to leave alone. + pin::pin_created_file(file, 0o600, &self.path.join(name), effective_uid()) } /// Atomically publish `from` at `to` via `linkat` on the held descriptor. @@ -960,7 +1211,10 @@ impl KeyDirHandle { std::fs::OpenOptions::new().read(true).open(path) } - fn create_scratch(&self, name: &std::ffi::OsStr) -> std::io::Result { + fn create_scratch( + &self, + name: &std::ffi::OsStr, + ) -> std::io::Result> { std::fs::OpenOptions::new() .write(true) .create_new(true) @@ -1004,6 +1258,16 @@ fn describe_unusable_key_dir(dir: &Path, e: std::io::Error) -> anyhow::Error { dir.display() ); } + Some(code) if code == libc::EACCES => { + return anyhow::anyhow!( + "GITLAWB_P2P_KEY's directory {} cannot be opened by this node; run \ + `chmod 700 {}` if it should own it. Check the directory's current mode \ + first: two node processes starting at once can produce this transiently, \ + in which case the directory is already 0700 and the next start succeeds.", + dir.display(), + dir.display() + ); + } _ => {} } } @@ -1040,74 +1304,45 @@ fn describe_unusable_key_dir(dir: &Path, e: std::io::Error) -> anyhow::Error { /// `~/.gitlawb/identity.pem` lives in this directory too, so this covers both /// keys. Issue #231 owns the sibling gap in `main.rs`'s own creation path for /// that file; nothing here touches it. +#[cfg(unix)] fn ensure_key_dir(dir: &Path) -> Result { - #[cfg(unix)] - { - let euid = effective_uid(); - verify_and_create_ancestor_chain(dir, euid)?; - } - - // Non-unix builds keep the platform-neutral ancestor creation: the - // descriptor-anchored walk above is unix-only, so without this a non-unix - // first boot would have no way to create missing ancestors at all. - #[cfg(not(unix))] - if let Some(parent) = dir.parent() { - if !parent.as_os_str().is_empty() && parent != dir { - std::fs::create_dir_all(parent).with_context(|| { - format!( - "failed to create parent directories for key directory {}", - dir.display() - ) - })?; - } - } - - let handle = match KeyDirHandle::open(dir) { - Ok(handle) => handle, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let mut builder = std::fs::DirBuilder::new(); - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - builder.mode(0o700); - } - match builder.create(dir) { - Ok(()) => {} - // Rule 5 at the directory layer: two first boots can both see - // the leaf absent, and only one `mkdir` wins. Losing is a - // successful outcome of the same state transition, not an - // error — what matters is what actually occupies the path now, - // and the re-open below judges that object itself (a symlink - // or non-directory raced into place fails there, before any - // chmod or key IO). The winner's owner and mode are then - // verified through the handle like any pre-existing directory. - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => { - return Err(e).with_context(|| { - format!("failed to create key directory {}", dir.display()) - }); - } - } - KeyDirHandle::open(dir).map_err(|e| describe_unusable_key_dir(dir, e))? - } - Err(e) => return Err(describe_unusable_key_dir(dir, e)), - }; + use std::os::fd::AsRawFd; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let euid = effective_uid(); + + // The walk hands back the key directory's PARENT descriptor, so the leaf is + // created and judged relative to a verified parent rather than by resolving + // its pathname a second time. + let parent_fd = verify_and_create_ancestor_chain(dir, euid)?; + let leaf_name = dir.file_name().ok_or_else(|| { + anyhow::anyhow!( + "p2p key directory {} names no final component", + dir.display() + ) + })?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - use std::os::unix::fs::PermissionsExt; + let (pinned, created) = pin::create_dir_pinned_at(parent_fd.as_raw_fd(), leaf_name, dir, euid) + .map_err(|e| describe_unusable_key_dir(dir, e))?; + let handle = KeyDirHandle::from_pinned_fd(pinned, dir); + // A directory this process just created is already verified at exactly 0700 + // by the helper. Only an adopted one (pre-existing, or the winner of a + // creation race) is judged here, and it is never widened beyond 0700. + if !created { let md = handle .metadata() .with_context(|| format!("failed to stat key directory {}", dir.display()))?; - let euid = effective_uid(); if let Some(err) = foreign_ownership_error("key directory", dir, md.uid(), euid) { anyhow::bail!(err); } - let mode = md.permissions().mode() & 0o777; + // Read the full permission word, not `& 0o777`: an inherited setgid bit + // makes a 2700 directory compare unequal to 0700, and judging the two + // on different bit widths would skip the repair and then fail the + // verify. + let mode = md.permissions().mode() & 0o7777; if mode & 0o077 != 0 { warn!( dir = %dir.display(), @@ -1126,12 +1361,78 @@ fn ensure_key_dir(dir: &Path) -> Result { dir.display() ) })?; + let after = handle + .metadata() + .with_context(|| format!("failed to re-stat key directory {}", dir.display()))? + .permissions() + .mode() + & 0o7777; + if after != 0o700 { + anyhow::bail!( + "key directory {} achieved mode {:04o}, requested 0700, after tightening", + dir.display(), + after + ); + } + } else if mode != 0o700 { + // Over-closed, and deliberately NOT widened. Granting owner-write + // back to a directory an operator froze would override their + // intent, and it would fire the "a loose key directory was + // tightened, treat the key as exposed" advice for a case where + // nothing was ever exposed. Refuse with the remedy instead, which + // is how an over-closed key FILE is already handled. + anyhow::bail!( + "p2p key directory {} has mode {:04o}, which this node cannot use; it is not \ + widened automatically because a directory closed on purpose is an operator \ + decision. Run `chmod 700 {}` if the node should own it.", + dir.display(), + mode, + dir.display() + ); } } Ok(handle) } +/// Non-unix builds have no descriptor-anchored walk and no mode enforcement, +/// so they keep the platform-neutral create-then-open flow unchanged. +#[cfg(not(unix))] +fn ensure_key_dir(dir: &Path) -> Result { + if let Some(parent) = dir.parent() { + if !parent.as_os_str().is_empty() && parent != dir { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create parent directories for key directory {}", + dir.display() + ) + })?; + } + } + + let handle = match KeyDirHandle::open(dir) { + Ok(handle) => handle, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + match std::fs::DirBuilder::new().create(dir) { + Ok(()) => {} + // Losing a creation race is a successful outcome of the same + // state transition; the re-open below judges whatever occupies + // the path now. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(e).with_context(|| { + format!("failed to create key directory {}", dir.display()) + }); + } + } + KeyDirHandle::open(dir).map_err(|e| describe_unusable_key_dir(dir, e))? + } + Err(e) => return Err(describe_unusable_key_dir(dir, e)), + }; + + Ok(handle) +} + /// Write the key to a scratch file in the same directory, then publish it to /// `key_path` in one atomic step, so no reader ever sees a partial key and a /// crash mid-write cannot leave a truncated file at the final path. @@ -1165,7 +1466,7 @@ fn write_key_atomically( /// with a leftover or a sibling thread impossible rather than merely unlikely. fn create_scratch_key_file( dir: &KeyDirHandle, -) -> std::io::Result<(std::ffi::OsString, std::fs::File)> { +) -> std::io::Result<(std::ffi::OsString, pin::Pinned)> { let pid = std::process::id(); for attempt in 0..64u32 { let scratch_name = std::ffi::OsString::from(format!(".p2p.key.{pid}.{attempt}.tmp")); @@ -1182,7 +1483,7 @@ fn create_scratch_key_file( } fn fill_and_publish( - file: &mut std::fs::File, + file: &mut pin::Pinned, bytes: &[u8], dir: &KeyDirHandle, scratch_name: &std::ffi::OsStr, @@ -1192,14 +1493,14 @@ fn fill_and_publish( #[cfg(test)] if FAIL_KEY_WRITE.with(|f| f.get()) { - file.write_all(&bytes[..bytes.len() / 2])?; + file.get_mut().write_all(&bytes[..bytes.len() / 2])?; return Err(std::io::Error::other("injected key-write failure")); } - file.write_all(bytes)?; + file.get_mut().write_all(bytes)?; // The bytes must be durable before the name that points at them appears, // otherwise a crash can leave the entry pointing at an empty file. - file.sync_all()?; + file.get_mut().sync_all()?; dir.publish(scratch_name, key_name)?; // The new directory entry must reach disk too, and the sync goes to the @@ -1289,6 +1590,19 @@ fn open_existing_key( match dir.open_key_for_read(key_name) { Ok(file) => Ok(Some(file)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + // An unreadable key is its own diagnosis and must not be reported as a + // refused symlink: a key published under a mask that stripped the owner + // bits lands here, and naming the wrong cause sends the operator after + // a link that does not exist. The key is NOT chmodded back; a key file + // closed on purpose is an operator decision. + Err(e) if e.raw_os_error() == Some(libc::EACCES) => { + Err(anyhow::Error::new(e).context(format!( + "failed to open p2p key at {}: this node cannot read it. Run `chmod 600 {}` if \ + the key should be readable, or delete it to generate a fresh identity.", + key_path.display(), + key_path.display() + ))) + } Err(e) => Err(anyhow::Error::new(e).context(format!( "failed to open p2p key at {} (a symlink here is refused rather than followed)", key_path.display() @@ -1959,6 +2273,656 @@ mod tests { ); } + // ---- U1: umask x layout x phase lifecycle matrix ---------------------- + // + // The committed umask fixtures above both use `umask(0o000)`, the + // PERMISSIVE direction, which proves only that an ambient mask cannot + // WIDEN a requested mode. Neither can go red on the round-5 defect, which + // needs a mask that REMOVES owner access: under `umask 0777` a requested + // 0700 lands 0000, the no-follow reopen fails EACCES before the repairing + // fchmod is reached, and a requested 0600 key is published unreadable, so + // the boot that created it succeeds on its already-open descriptor and the + // NEXT boot silently loses p2p. + // + // `umask` is process-global, so the matrix cannot live in the shared test + // process; every row is a child, double-gated like the fixtures above. + + /// Requested modes, named once so the failure text can print achieved + /// against requested rather than a bare boolean. + #[cfg(unix)] + const WANT_DIR_MODE: u32 = 0o700; + #[cfg(unix)] + const WANT_KEY_MODE: u32 = 0o600; + /// Mode the fixture builds PRE-EXISTING ancestors at. Not group-writable, + /// so the ancestor predicate accepts it, and R6 says it must survive. + #[cfg(unix)] + const PREEXISTING_ANCESTOR_MODE: u32 = 0o755; + + #[cfg(unix)] + fn mode_of(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + std::fs::symlink_metadata(path) + .unwrap_or_else(|e| panic!("stat {}: {e}", path.display())) + .permissions() + .mode() + & 0o7777 + } + + /// Every object on the key path with its achieved mode against what the + /// contract requested. This is what makes a RED attributable to the mode + /// rather than to "something failed" (INV-21(g)); red-check matches on it. + #[cfg(unix)] + fn describe_key_tree(base: &Path, key: &Path) -> String { + let mut out = String::new(); + let mut cur = key.parent(); + let mut dirs = Vec::new(); + while let Some(d) = cur { + dirs.push(d.to_path_buf()); + if d == base { + break; + } + cur = d.parent(); + } + dirs.reverse(); + for d in dirs { + match std::fs::symlink_metadata(&d) { + Ok(_) => out.push_str(&format!( + " dir {} achieved mode {:04o}, requested {:04o}\n", + d.display(), + mode_of(&d), + WANT_DIR_MODE + )), + Err(e) => out.push_str(&format!(" dir {} absent ({e})\n", d.display())), + } + } + match std::fs::symlink_metadata(key) { + Ok(_) => out.push_str(&format!( + " key {} achieved mode {:04o}, requested {:04o}\n", + key.display(), + mode_of(key), + WANT_KEY_MODE + )), + Err(e) => out.push_str(&format!(" key {} absent ({e})\n", key.display())), + } + out + } + + /// Fail with the achieved-vs-requested tree rather than the bare error, so + /// the reason a row went red is in the output. + #[cfg(unix)] + fn expect_boot(base: &Path, key: &Path, phase: &str) -> identity::Keypair { + match load_or_create_p2p_keypair(key) { + Ok(kp) => kp, + // `{e:#}`, not `{e}`: the mode refusal is raised deep in the pin + // helper and every layer above it adds context, so the default + // format shows only "failed to write p2p key" and hides the + // achieved-versus-requested text that says WHY. A failure whose + // reason is not in the output cannot be attributed to the mode. + Err(e) => panic!( + "{phase} must boot, got: {e:#}\nkey storage at failure:\n{}", + describe_key_tree(base, key) + ), + } + } + + #[cfg(unix)] + fn assert_contract_modes(base: &Path, key: &Path, layout: &str) { + let keys_dir = key.parent().unwrap(); + let created_dirs: Vec = match layout { + // `a` and `b` were created by the node, `keys` too. + "all-missing" => vec![ + base.join("a"), + base.join("a").join("b"), + keys_dir.to_path_buf(), + ], + // `a` and `b` pre-existed; only `keys` was created. + "ancestors-present" => vec![keys_dir.to_path_buf()], + // nothing was created. + "leaf-present" => vec![], + other => panic!("unknown layout {other}"), + }; + for d in created_dirs { + assert_eq!( + mode_of(&d), + WANT_DIR_MODE, + "created directory {} achieved mode {:04o}, requested {:04o}\n{}", + d.display(), + mode_of(&d), + WANT_DIR_MODE, + describe_key_tree(base, key) + ); + } + if layout != "all-missing" { + for d in [base.join("a"), base.join("a").join("b")] { + assert_eq!( + mode_of(&d), + PREEXISTING_ANCESTOR_MODE, + "pre-existing ancestor {} must keep its mode (R6): achieved {:04o}, \ + expected {:04o}", + d.display(), + mode_of(&d), + PREEXISTING_ANCESTOR_MODE + ); + } + } + assert_eq!( + mode_of(key), + WANT_KEY_MODE, + "key {} achieved mode {:04o}, requested {:04o}\n{}", + key.display(), + mode_of(key), + WANT_KEY_MODE, + describe_key_tree(base, key) + ); + let entries: Vec = std::fs::read_dir(keys_dir) + .expect("read key directory") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + entries, + vec![key.file_name().unwrap().to_string_lossy().into_owned()], + "the key directory must hold only the published key, no scratch residue: {entries:?}" + ); + } + + /// Build the pre-existing part of the layout. Runs AFTER the umask is set, + /// with explicit chmods, so a pre-existing directory has the mode the row + /// names regardless of the mask. + #[cfg(unix)] + fn build_layout(base: &Path, layout: &str) { + use std::os::unix::fs::PermissionsExt; + let ab = base.join("a").join("b"); + match layout { + "all-missing" => {} + "ancestors-present" | "leaf-present" => { + // One level at a time, chmodding each before descending. + // `create_dir_all` would build every level under the row's + // umask first, so under 0777 the outermost lands 0000 and the + // next `mkdir` inside it fails: the fixture that proves umask + // independence has to be umask-independent itself. + for d in [base.join("a"), ab.clone()] { + std::fs::create_dir(&d).expect("create pre-existing ancestor"); + std::fs::set_permissions( + &d, + std::fs::Permissions::from_mode(PREEXISTING_ANCESTOR_MODE), + ) + .expect("chmod pre-existing ancestor"); + } + if layout == "leaf-present" { + let keys = ab.join("keys"); + std::fs::create_dir(&keys).expect("create pre-existing leaf"); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)) + .expect("chmod pre-existing leaf"); + } + } + other => panic!("unknown layout {other}"), + } + } + + /// Fixture: one (umask, layout, phase) row of the lifecycle matrix. + /// + /// Double-gated exactly like the fixtures above: `#[ignore]` keeps it out + /// of a normal run and the env check keeps it inert under a bare + /// `--ignored` sweep, which would otherwise set a process-global umask + /// inside the shared test process. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=p2p-key-umask"] + fn fixture_p2p_key_umask_lifecycle() { + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("p2p-key-umask") { + return; + } + + let base = std::path::PathBuf::from( + std::env::var("GITLAWB_TEST_BASE").expect("GITLAWB_TEST_BASE"), + ); + let umask_str = std::env::var("GITLAWB_TEST_UMASK").expect("GITLAWB_TEST_UMASK"); + let umask_val = u32::from_str_radix(&umask_str, 8).expect("octal umask"); + let layout = std::env::var("GITLAWB_TEST_LAYOUT").expect("GITLAWB_TEST_LAYOUT"); + let phase = std::env::var("GITLAWB_TEST_PHASE").expect("GITLAWB_TEST_PHASE"); + + // The EACCES rows are meaningless as root, which bypasses mode checks. + // Fail loudly rather than skipping: a silent skip here would make the + // whole matrix vacuous in a root container. + // SAFETY: `geteuid` only reads the calling process's effective uid. + assert_ne!( + unsafe { libc::geteuid() }, + 0, + "the lifecycle matrix must not run as root: mode refusals do not apply to uid 0" + ); + + // SAFETY: `umask` only reads and replaces the process-wide value, and + // this process exists solely for this row. No restore: the value dies + // with the child. + unsafe { libc::umask(umask_val as libc::mode_t) }; + + let key = base.join("a").join("b").join("keys").join("p2p.key"); + + let peer_id = match phase.as_str() { + "create" => { + build_layout(&base, &layout); + let kp = expect_boot(&base, &key, "first boot"); + assert_contract_modes(&base, &key, &layout); + PeerId::from(kp.public()) + } + "reload" => { + // The tree was left by a previous `create` child under this + // same base; this process only reads it. + assert!( + key.exists(), + "reload row requires the create row to have published a key at {}", + key.display() + ); + let kp = expect_boot(&base, &key, "reload in a fresh process"); + assert_contract_modes(&base, &key, &layout); + PeerId::from(kp.public()) + } + "create-concurrent" => { + build_layout(&base, &layout); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let mut handles = Vec::new(); + for _ in 0..2 { + let b = std::sync::Arc::clone(&barrier); + let k = key.clone(); + handles.push(std::thread::spawn(move || { + b.wait(); + load_or_create_p2p_keypair(&k).map(|kp| PeerId::from(kp.public())) + })); + } + let results: Vec> = + handles.into_iter().map(|h| h.join().unwrap()).collect(); + let winners: Vec = results + .iter() + .filter_map(|r| r.as_ref().ok()) + .copied() + .collect(); + // Requiring at least one success is what keeps this row able to + // go RED. "every Ok agrees" alone passes when zero creators + // succeed, which is exactly the regression a widened + // create-to-pin window would produce. + assert!( + !winners.is_empty(), + "at least one concurrent creator must succeed; all failed:\n{:?}\n{}", + results + .iter() + .map(|r| r.as_ref().err().map(|e| e.to_string())) + .collect::>(), + describe_key_tree(&base, &key) + ); + assert!( + winners.iter().all(|p| *p == winners[0]), + "concurrent creators disagreed on the PeerId: {winners:?}" + ); + assert_contract_modes(&base, &key, &layout); + // A fresh reload after both finish must agree with the winners. + let reloaded = PeerId::from(expect_boot(&base, &key, "post-race reload").public()); + assert_eq!( + reloaded, winners[0], + "the published key must reload to the winning PeerId" + ); + reloaded + } + "create-interrupted" => { + build_layout(&base, &layout); + FAIL_KEY_WRITE.with(|f| f.set(true)); + let interrupted = load_or_create_p2p_keypair(&key); + FAIL_KEY_WRITE.with(|f| f.set(false)); + assert!( + interrupted.is_err(), + "an injected write failure must not report success" + ); + assert!( + !key.exists(), + "an interrupted publish must leave nothing at the final key path" + ); + let keys_dir = key.parent().unwrap(); + if keys_dir.exists() { + let residue: Vec = std::fs::read_dir(keys_dir) + .expect("read key directory") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + residue.is_empty(), + "an interrupted publish must leave no scratch residue: {residue:?}" + ); + } + let kp = expect_boot(&base, &key, "boot after an interrupted publish"); + assert_contract_modes(&base, &key, &layout); + PeerId::from(kp.public()) + } + other => panic!("unknown phase {other}"), + }; + + // Printed only after every assertion for this row, and carrying the + // PeerId so the parent can compare create against reload. "1 passed" + // does not prove the row asserted anything: the env gate above returns + // early, and an early return is itself a passing test. + println!("p2p-key-umask: asserted peer_id={peer_id}"); + } + + // ---- U2: the pin and the exact-mode verify are load-bearing ----------- + + /// Fixture: disable the pin and confirm the exact-mode VERIFY is what + /// refuses, naming the achieved mode. + /// + /// Without this the verify could be inert: with the pin doing the work, a + /// weakened or deleted verify would never be observed. Deleting the added + /// term alone and watching it go red is the INV-21(i) precondition. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=p2p-key-skip-pin"] + fn fixture_p2p_key_skip_pin_is_caught_by_the_verify() { + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("p2p-key-skip-pin") { + return; + } + let base = std::path::PathBuf::from( + std::env::var("GITLAWB_TEST_BASE").expect("GITLAWB_TEST_BASE"), + ); + let target = std::env::var("GITLAWB_TEST_TARGET").expect("GITLAWB_TEST_TARGET"); + let umask_val = + u32::from_str_radix(&std::env::var("GITLAWB_TEST_UMASK").unwrap(), 8).unwrap(); + + // SAFETY: `umask` only reads and replaces the process-wide value, and + // this process exists solely for this probe. + unsafe { libc::umask(umask_val as libc::mode_t) }; + pin::SKIP_MODE_PIN.with(|c| c.set(true)); + + let key = base.join("keys").join("p2p.key"); + let err = load_or_create_p2p_keypair(&key) + .expect_err("with the pin disabled the exact-mode verify must refuse"); + let text = format!("{err:#}"); + + // The refusal must name the ACHIEVED mode. A generic failure would let + // an unrelated RED (an EACCES on the reopen, say) pass for this one. + let (want_achieved, want_requested) = match target.as_str() { + "dir" => ("achieved mode 0500", "requested 0700"), + "key" => ("achieved mode 0200", "requested 0600"), + other => panic!("unknown target {other}"), + }; + assert!( + text.contains(want_achieved) && text.contains(want_requested), + "the refusal must name the achieved mode against the requested one, got: {text}" + ); + println!("p2p-key-skip-pin: asserted target={target}"); + } + + #[cfg(unix)] + #[test] + fn p2p_key_mode_pin_is_load_bearing() { + use std::os::unix::fs::PermissionsExt; + + // dir: umask 0277 leaves a created directory at 0500, which is still + // openable, so the reopen succeeds and only the verify can catch it. + // key: umask 0477 leaves a created file at 0200. + for (target, umask) in [("dir", "0277"), ("key", "0477")] { + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + if target == "key" { + // Pre-create the key directory so the run reaches the key. + let keys = base.path().join("keys"); + std::fs::create_dir(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let output = fixture_command_with_env( + "p2p::tests::fixture_p2p_key_skip_pin_is_caught_by_the_verify", + "p2p-key-skip-pin", + ) + .env("GITLAWB_TEST_BASE", base.path()) + .env("GITLAWB_TEST_TARGET", target) + .env("GITLAWB_TEST_UMASK", umask) + .output() + .expect("spawn the skip-pin fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "target={target}: the skip-pin fixture must pass\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("1 passed"), + "target={target}: filter must select one passing test\n{stdout}" + ); + assert!( + stdout.contains(&format!("p2p-key-skip-pin: asserted target={target}")), + "target={target}: fixture must print its sentinel\n{stdout}" + ); + } + } + + /// A directory this process did NOT create is verified as-is and never + /// chmodded, which is what keeps a concurrent first boot from rewriting the + /// winner's directory. + #[cfg(unix)] + #[test] + fn ancestor_race_winner_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + + let base = key_base_0700(); + let key = base.path().join("a").join("keys").join("p2p.key"); + + // Arm the race: `a` is created by "another process" at 0750 in the + // window between the ENOENT and our mkdirat. + pin::RACE_CREATE_MODE.with(|c| c.set(Some(0o750))); + load_or_create_p2p_keypair(&key).expect("a race-won ancestor at 0750 is acceptable"); + + let mode = std::fs::metadata(base.path().join("a")) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, 0o750, + "race winner must keep its mode: achieved {mode:04o}, expected 0750" + ); + } + + /// The leaf rule in both directions: a directory that is too OPEN is + /// tightened, one that is too CLOSED is refused rather than widened. + #[cfg(unix)] + #[test] + fn key_directory_is_tightened_when_loose_and_refused_when_over_closed() { + use std::os::unix::fs::PermissionsExt; + + // Too open: tightened to exactly 0700. + for loose in [0o750u32, 0o755, 0o770] { + let base = key_base_0700(); + let keys = base.path().join("keys"); + std::fs::create_dir(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(loose)).unwrap(); + load_or_create_p2p_keypair(&keys.join("p2p.key")).unwrap_or_else(|e| { + panic!("a loose key directory ({loose:04o}) is tightened: {e}") + }); + let after = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777; + assert_eq!(after, 0o700, "loose {loose:04o} must be tightened to 0700"); + } + + // An inherited setgid bit must be repaired, not refused: 2700 compares + // unequal to 0700 on the full word, and judging the predicate on 0o777 + // while verifying on 0o7777 would skip the repair and then fail. + { + let base = key_base_0700(); + let keys = base.path().join("keys"); + std::fs::create_dir(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o2750)).unwrap(); + load_or_create_p2p_keypair(&keys.join("p2p.key")) + .expect("a setgid key directory is repaired rather than refused"); + let after = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777; + assert_eq!(after, 0o700, "setgid 2750 must be tightened to 0700"); + } + + // Too closed: refused, named, and left exactly as the operator set it. + for closed in [0o500u32, 0o100, 0o600] { + let base = key_base_0700(); + let keys = base.path().join("keys"); + std::fs::create_dir(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(closed)).unwrap(); + let err = load_or_create_p2p_keypair(&keys.join("p2p.key")) + .expect_err("an over-closed key directory must be refused, not widened"); + let text = format!("{err:#}"); + assert!( + text.contains("chmod 700"), + "the refusal must name the remedy, got: {text}" + ); + let after = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777; + assert_eq!( + after, closed, + "an over-closed directory ({closed:04o}) must be left untouched, found {after:04o}" + ); + assert!( + !keys.join("p2p.key").exists(), + "a refusal must not create a key" + ); + } + } + + /// An unreadable existing key names its own cause and its own remedy, and + /// is never chmodded back. + #[cfg(unix)] + #[test] + fn unreadable_existing_key_is_refused_with_the_chmod_600_remedy() { + use std::os::unix::fs::PermissionsExt; + + let base = key_base_0700(); + let keys = base.path().join("keys"); + std::fs::create_dir(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)).unwrap(); + let key = keys.join("p2p.key"); + + let created = load_or_create_p2p_keypair(&key).expect("first boot"); + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let err = load_or_create_p2p_keypair(&key).expect_err("an unreadable key must be refused"); + let text = format!("{err:#}"); + assert!( + text.contains("chmod 600"), + "the refusal must name the remedy, got: {text}" + ); + // The old message blamed a symlink for every open failure, which sent + // the operator after a link that does not exist. + assert!( + !text.contains("symlink here is refused"), + "an unreadable key must not be reported as a refused symlink, got: {text}" + ); + assert_eq!( + std::fs::metadata(&key).unwrap().permissions().mode() & 0o7777, + 0o000, + "a refusal must not chmod the key back" + ); + + // 0400 is readable and owner-only, so it still loads. + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o400)).unwrap(); + let reloaded = load_or_create_p2p_keypair(&key).expect("a 0400 key still loads"); + assert_eq!( + PeerId::from(created.public()), + PeerId::from(reloaded.public()), + "the reloaded key must be the same identity" + ); + } + + /// Parent for the lifecycle matrix: drives every row as a child process, + /// inspects the resulting tree itself, and requires create and reload to + /// agree on the PeerId. + #[cfg(unix)] + #[test] + fn p2p_key_lifecycle_matrix_is_umask_independent() { + use std::os::unix::fs::PermissionsExt; + + const SENTINEL: &str = "p2p-key-umask: asserted peer_id="; + + // Run one row and return the PeerId it printed. + fn run_row(base: &Path, umask: &str, layout: &str, phase: &str) -> String { + let output = fixture_command_with_env( + "p2p::tests::fixture_p2p_key_umask_lifecycle", + "p2p-key-umask", + ) + .env("GITLAWB_TEST_BASE", base) + .env("GITLAWB_TEST_UMASK", umask) + .env("GITLAWB_TEST_LAYOUT", layout) + .env("GITLAWB_TEST_PHASE", phase) + .output() + .expect("spawn the lifecycle fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let row = format!("umask={umask} layout={layout} phase={phase}"); + + assert!( + output.status.success(), + "row {row} must pass in its child process\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + // A filter matching nothing runs zero tests and exits 0. + assert!( + stdout.contains("1 passed"), + "row {row}: the fixture filter must select exactly one test that passed\ + \n--- stdout ---\n{stdout}" + ); + // And "1 passed" does not prove it asserted: the env gate's early + // return is itself a passing test. + let line = stdout + .lines() + .find(|l| l.starts_with(SENTINEL)) + .unwrap_or_else(|| { + panic!( + "row {row}: the fixture must print its sentinel after asserting\ + \n--- stdout ---\n{stdout}" + ) + }); + line[SENTINEL.len()..].trim().to_string() + } + + // A base the PARENT owns, at 0700 so the ancestor predicate accepts it, + // and created here (not in the child) so it survives across the create + // and reload processes of the same row. + fn fresh_base() -> tempfile::TempDir { + let d = tempfile::tempdir().unwrap(); + std::fs::set_permissions(d.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + d + } + + let umasks = ["0000", "0022", "0777"]; + let layouts = ["all-missing", "ancestors-present", "leaf-present"]; + + // 9 create/reload pairs: every umask against every layout. + for umask in umasks { + for layout in layouts { + let base = fresh_base(); + let created = run_row(base.path(), umask, layout, "create"); + let reloaded = run_row(base.path(), umask, layout, "reload"); + assert_eq!( + created, reloaded, + "umask={umask} layout={layout}: a fresh process must reload the same PeerId" + ); + } + } + + // 12 concurrent/interrupted rows on the two layouts where creation + // actually happens. + for umask in umasks { + for layout in ["all-missing", "leaf-present"] { + for phase in ["create-concurrent", "create-interrupted"] { + let base = fresh_base(); + let created = run_row(base.path(), umask, layout, phase); + let reloaded = run_row(base.path(), umask, layout, "reload"); + assert_eq!( + created, reloaded, + "umask={umask} layout={layout} phase={phase}: reload must agree" + ); + } + } + } + + // A tree created under an ordinary mask must reload under a hostile + // one: reload creates nothing, so the mask must not matter there. + let base = fresh_base(); + let created = run_row(base.path(), "0022", "all-missing", "create"); + let reloaded = run_row(base.path(), "0777", "all-missing", "reload"); + assert_eq!( + created, reloaded, + "a key created under umask 0022 must reload unchanged under umask 0777" + ); + } + /// Fixture: a relative key path under a WORLD-WRITABLE non-sticky cwd must /// be refused before anything is created. #[cfg(unix)] From 221f9018cd7ab501b4c8202f82cf9458abc2267c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:45:45 -0500 Subject: [PATCH 24/36] fix(node): create and publish the node identity key through the pinned path The node identity PEM had its own storage flow: create_dir_all with no mode, then write, then set_permissions. That is the exists-then-write-then- chmod sequence INV-23 prohibits, and it sits in the same ~/.gitlawb the p2p key uses, so the umask defect the previous commit fixed for one key was still live for the other. Measured on the current code, as a non-root uid, all three RED: umask 0000 directory landed 0777, world-writable, holding the key umask 0022 directory landed 0755, world-traversable umask 0777 directory landed 0000 and the write failed with EACCES The third is the one that mattered most: load_or_create_keypair runs before the listener binds, so the node exited there and no p2p code was reached at all. The umask-independence guarantee could not be demonstrated on the shipped default while this stood. The directory is now created through the same pin helper, at a verified 0700, and the PEM is published through the same scratch-then-link path at a verified 0600. An existing directory that grants access beyond the owner is tightened, which closes the other half of the same gap: a 0600 key inside a 0755 directory is still replaceable by anyone who can write that directory. An existing key is loaded untouched and never chmodded. Deliberately not the full ensure_key_dir. That carries the ancestor trust walk, and importing its refusals onto a path that never had them would turn an unsafe but currently booting deployment into a boot failure on upgrade. Only the immediate parent goes through the pin helper, which verifies the grandparent it is about to chmod a child of; ancestors above that keep the existing create_dir_all behavior. The one new refusal is a group or world writable non-sticky grandparent, which is the case where another local user can replace the node's identity outright. --- crates/gitlawb-node/src/main.rs | 218 +++++++++++++++++++++++++++-- crates/gitlawb-node/src/p2p/mod.rs | 81 +++++++++++ 2 files changed, 288 insertions(+), 11 deletions(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 769a5732..8dea8c14 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1374,10 +1374,14 @@ async fn ping_peer_readiness_with_timeout( } fn load_or_create_keypair(config: &Config) -> Result { - let key_path = config.resolved_key_path(); + load_or_create_keypair_at(&config.resolved_key_path()) +} +/// The node identity key's load-or-create, taken by path so the storage +/// contract can be tested without building a whole `Config`. +fn load_or_create_keypair_at(key_path: &std::path::Path) -> Result { if key_path.exists() { - let pem = std::fs::read_to_string(&key_path) + 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}"))?; info!(path = %key_path.display(), "loaded existing identity"); @@ -1388,18 +1392,23 @@ 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)?; - } - + // The directory is created pinned to 0700 and the PEM is published + // through the same scratch-then-link path the p2p key uses, at a + // verified 0600. The previous flow (create_dir_all with no mode, then + // write, then set_permissions) is the sequence INV-23 prohibits: it + // left the directory world-writable under a permissive umask, and + // under a restrictive one it could not be opened at all, which failed + // the whole node here, before the listener binds. #[cfg(unix)] + p2p::create_pinned_dir_and_publish(key_path, pem.as_bytes())?; + + #[cfg(not(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))?; + if let Some(parent) = key_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(key_path, pem.as_bytes())?; } - #[cfg(not(unix))] - std::fs::write(&key_path, pem.as_bytes())?; info!(path = %key_path.display(), did = %kp.did(), "generated new node identity"); Ok(kp) @@ -1842,3 +1851,190 @@ mod gossip_ssrf_tests { assert!(!ok, "a connection error must count as an unready peer"); } } + +#[cfg(test)] +mod identity_key_storage_tests { + use super::*; + + /// Fixture: create the node identity key under a hostile umask. + /// + /// `~/.gitlawb` holds BOTH keys, and `load_or_create_keypair` runs before + /// the listener binds, so a mask that strips owner bits here takes the + /// whole node down before any p2p code is reached. Double-gated like the + /// p2p fixtures: `#[ignore]` keeps it out of a normal run and the env check + /// keeps it inert under a bare `--ignored` sweep, which would otherwise set + /// a process-global umask inside the shared test process. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=identity-key-umask"] + fn fixture_identity_key_under_hostile_umask() { + use std::os::unix::fs::PermissionsExt; + + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("identity-key-umask") { + return; + } + let base = std::path::PathBuf::from( + std::env::var("GITLAWB_TEST_BASE").expect("GITLAWB_TEST_BASE"), + ); + let umask_val = + u32::from_str_radix(&std::env::var("GITLAWB_TEST_UMASK").unwrap(), 8).unwrap(); + + // SAFETY: `umask` only reads and replaces the process-wide value, and + // this process exists solely for this probe. No restore: the value dies + // with the child. + unsafe { libc::umask(umask_val as libc::mode_t) }; + + let key = base.join(".gitlawb").join("identity.pem"); + let kp = load_or_create_keypair_at(&key).unwrap_or_else(|e| { + let dir_mode = std::fs::symlink_metadata(key.parent().unwrap()) + .map(|m| format!("{:04o}", m.permissions().mode() & 0o7777)) + .unwrap_or_else(|_| "absent".into()); + panic!( + "first boot must create the node identity, got: {e:#}\n \ + dir {} achieved mode {}, requested 0700", + key.parent().unwrap().display(), + dir_mode + ) + }); + + let dir_mode = std::fs::symlink_metadata(key.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + dir_mode, 0o700, + "identity key directory achieved mode {dir_mode:04o}, requested 0700" + ); + let key_mode = std::fs::symlink_metadata(&key) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + key_mode, 0o600, + "identity key achieved mode {key_mode:04o}, requested 0600" + ); + + // The identity must survive a reload, same as the p2p key. + let reloaded = load_or_create_keypair_at(&key).expect("reload the identity"); + assert_eq!(kp.did(), reloaded.did(), "the identity must be stable"); + + println!("identity-key-umask: asserted did={}", kp.did()); + } + + /// A pre-existing directory that grants access beyond the owner is + /// tightened on the next start, which is the INV-23(a) half issue #231 + /// names: a 0600 PEM inside a 0755 directory is still replaceable by + /// anyone who can write that directory. + #[cfg(unix)] + #[test] + fn existing_identity_key_directory_is_tightened() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let dir = base.path().join(".gitlawb"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let key = dir.join("identity.pem"); + load_or_create_keypair_at(&key).expect("first boot into a loose directory"); + + let mode = std::fs::symlink_metadata(&dir) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, 0o700, + "a loose identity key directory must be tightened" + ); + assert_eq!( + std::fs::symlink_metadata(&key) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o600, + "the identity key must be owner-only" + ); + } + + /// An existing key is loaded, never rewritten and never chmodded: the + /// creation path is the only thing this change touches. + #[cfg(unix)] + #[test] + fn existing_identity_key_is_loaded_unchanged() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let key = base.path().join(".gitlawb").join("identity.pem"); + + let created = load_or_create_keypair_at(&key).expect("first boot"); + let before = std::fs::read(&key).unwrap(); + + // A deliberately odd but readable mode must survive: an existing key is + // the operator's, and this path does not repair it. + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o400)).unwrap(); + let reloaded = load_or_create_keypair_at(&key).expect("reload"); + + assert_eq!(created.did(), reloaded.did(), "the identity must be stable"); + assert_eq!( + std::fs::read(&key).unwrap(), + before, + "the key must not be rewritten" + ); + assert_eq!( + std::fs::symlink_metadata(&key) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o400, + "an existing key's mode must not be changed" + ); + } + + #[cfg(unix)] + #[test] + fn identity_key_storage_is_umask_independent() { + use std::os::unix::fs::PermissionsExt; + + for umask in ["0000", "0022", "0777"] { + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + let mut cmd = std::process::Command::new(std::env::current_exe().expect("current_exe")); + cmd.args([ + "identity_key_storage_tests::fixture_identity_key_under_hostile_umask", + "--exact", + "--ignored", + "--nocapture", + ]) + .env("GITLAWB_TEST_FIXTURE", "identity-key-umask") + .env("GITLAWB_TEST_BASE", base.path()) + .env("GITLAWB_TEST_UMASK", umask); + let output = cmd.output().expect("spawn the identity-key fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "umask={umask}: the identity-key fixture must pass\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + // A filter matching nothing exits 0, and the fixture's env gate + // returns early as a passing test, so neither alone is proof. + assert!( + stdout.contains("1 passed"), + "umask={umask}: filter must select one passing test\n{stdout}" + ); + assert!( + stdout.contains("identity-key-umask: asserted did="), + "umask={umask}: fixture must print its sentinel\n{stdout}" + ); + } + } +} diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 59260004..1e5002a6 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -1433,6 +1433,87 @@ fn ensure_key_dir(dir: &Path) -> Result { Ok(handle) } +/// Create `key_path`'s directory pinned to 0700 and publish `bytes` into it at +/// a verified 0600, using the same scratch-then-link path the p2p key uses. +/// +/// Exposed for the node identity PEM in `main.rs`, which lives in the same +/// `~/.gitlawb` directory and had its own creation flow: `create_dir_all` with +/// no mode, then `write`, then `set_permissions`. That is the sequence INV-23 +/// prohibits, and it left the directory world-writable under a permissive +/// umask and unopenable under a restrictive one, which took the node down +/// before any p2p code ran. +/// +/// Deliberately NOT the full `ensure_key_dir`: that carries the ancestor +/// trust walk, and importing its refusals onto a path that never had them +/// would turn an unsafe-but-working deployment into a boot failure on +/// upgrade. Only the immediate parent is created through the pin helper, which +/// verifies the grandparent it is about to chmod a child of; ancestors above +/// that keep today's `create_dir_all` behavior. +#[cfg(unix)] +pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Result<()> { + use std::os::fd::AsRawFd; + + let dir = key_path + .parent() + .ok_or_else(|| anyhow::anyhow!("{} names no directory to hold it", key_path.display()))?; + let file_name = key_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("{} names no key file", key_path.display()))?; + let dir_name = dir + .file_name() + .ok_or_else(|| anyhow::anyhow!("{} names no final directory component", dir.display()))?; + + // Ancestors above the key directory keep the existing behavior; only the + // directory that actually holds the secret is pinned. + if let Some(grandparent) = dir.parent() { + if !grandparent.as_os_str().is_empty() { + std::fs::create_dir_all(grandparent).with_context(|| { + format!("failed to create parent directories for {}", dir.display()) + })?; + } + } + let grandparent = dir.parent().filter(|g| !g.as_os_str().is_empty()); + let gp_path = grandparent.unwrap_or_else(|| Path::new(".")); + let gp = std::fs::File::open(gp_path) + .with_context(|| format!("failed to open {}", gp_path.display()))?; + + let euid = effective_uid(); + let (pinned, created) = pin::create_dir_pinned_at(gp.as_raw_fd(), dir_name, dir, euid) + .map_err(|e| { + anyhow::Error::new(e) + .context(format!("failed to create key directory {}", dir.display())) + })?; + let handle = KeyDirHandle::from_pinned_fd(pinned, dir); + + // An adopted directory is tightened when it grants access beyond the owner, + // the same rule the p2p key directory follows, so an existing 0755 + // `~/.gitlawb` stops being world-traversable on the next start. + if !created { + use std::os::unix::fs::PermissionsExt; + let mode = handle + .metadata() + .with_context(|| format!("failed to stat key directory {}", dir.display()))? + .permissions() + .mode() + & 0o7777; + if mode & 0o077 != 0 { + warn!( + dir = %dir.display(), + mode = format!("{mode:04o}"), + "identity key directory grants access beyond its owner; tightening it to 0700" + ); + handle + .tighten_to_0700() + .with_context(|| format!("failed to tighten key directory {}", dir.display()))?; + } + } + + write_key_atomically(&handle, file_name, bytes) + .with_context(|| format!("failed to write identity key to {}", key_path.display()))?; + let _ = gp; + Ok(()) +} + /// Write the key to a scratch file in the same directory, then publish it to /// `key_path` in one atomic step, so no reader ever sees a partial key and a /// crash mid-write cannot leave a truncated file at the final path. From 082200e96ebbbee609cfcf5caf97050c30535a03 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:08:38 -0500 Subject: [PATCH 25/36] fix(node): split p2p key config validation from live storage validation The key-path validator answered two different questions and its callers disagreed about what to do with the answer. Bare names, `..`, a trailing separator and the filesystem root are properties of the configured value, and Config::validate refuses them before the listener binds. The same function also stat'd the key path and its parent, so a symlinked parent, a non-directory parent or an unreadable parent exited the node as invalid configuration, while the identical class of fault found one layer later in load_or_create_p2p_keypair only logged and left HTTP serving. That split was invisible from the outside and the docs described only one half of it. Measured against the binary before this change: a symlinked parent, a regular-file parent and an unreadable parent all exited 1 before bind, which is the opposite of what README and .env.example promise. Validation is now lexical only, behind a P2pKeyConfigError so the two domains cannot be confused at a call site. Every live storage fact is left to the load path, which already re-establishes each one on a descriptor it opened rather than a pathname it stat'd: O_NOFOLLOW on the key open, a regular-file check by fstat, and ELOOP or ENOTDIR at the leaf. The verdicts are unchanged; what changes is that one policy now decides all of them. Two supporting changes. The whole p2p port gate moves ahead of the database connect, both arms together, because connect_db_with_retry retries forever and left the disabled arm unreachable without a database; only p2p::start still needs the pool. And a failed key load is now logged at error with a stable event name and mirrored into a gauge, because the policy this commit settles on is to keep serving HTTP with a green health check while the node is off the p2p network, and that is only defensible if the outage is visible to something other than a human reading startup logs. A new integration test drives the real binary with no database and proves both domains at the process boundary: nine lexical spellings exit before binding, ten storage faults serve HTTP with the failure logged, and GITLAWB_P2P_PORT=0 reaches its disabled log without touching the key tree. Every row asserts a before/after snapshot, because a refusal that mutates storage on its way out is its own defect and a returned error cannot show it. Three of those rows were RED before this change. A symlinked key directory also reports as a symlink again. Linux returns ENOTDIR rather than ELOOP when O_NOFOLLOW meets O_DIRECTORY, so the errno alone cannot separate a symlink from a regular file and the message had regressed to naming the wrong cause. --- crates/gitlawb-node/src/config.rs | 107 ++++- crates/gitlawb-node/src/main.rs | 107 +++-- crates/gitlawb-node/src/metrics.rs | 24 + crates/gitlawb-node/src/p2p/mod.rs | 170 ++++--- .../tests/p2p_key_startup_policy.rs | 426 ++++++++++++++++++ 5 files changed, 719 insertions(+), 115 deletions(-) create mode 100644 crates/gitlawb-node/tests/p2p_key_startup_policy.rs diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 2d5f8f0e..bf44756d 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -813,7 +813,12 @@ impl Config { self.p2p_key_path )); } - crate::p2p::validate_p2p_key_path(&p2p_key_path, Some(&self.p2p_key_path))?; + // Lexical only: this runs before the listener binds and its + // failure stops the node, so it must not decide anything about + // live filesystem objects. Storage faults belong to the load path, + // which degrades instead of exiting. + crate::p2p::validate_p2p_key_config(&p2p_key_path, Some(&self.p2p_key_path)) + .map_err(|e| e.to_string())?; } Ok(()) @@ -1712,6 +1717,106 @@ mod tests { ); } + /// The other half of the scoping pair: p2p ENABLED, hostile resource on + /// disk, and `validate` still passes. + /// + /// This is the finding-2 boundary. A symlinked parent, a regular file + /// where the key directory should be, and an unreadable parent are live + /// storage facts, not properties of the configured value, so they belong + /// to the load path, which degrades. Deciding them here made the node exit + /// before binding for exactly the cases README and `.env.example` promise + /// leave HTTP up. Each row also proves validate touched nothing: an + /// inspection is still an observation, and the port-zero test above is the + /// disabled half of the same pair. + #[cfg(unix)] + #[test] + fn enabled_p2p_does_not_treat_live_storage_faults_as_configuration_errors() { + use std::os::unix::fs::PermissionsExt; + + fn snapshot(root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + if let Ok(entries) = std::fs::read_dir(root) { + for e in entries.flatten() { + let md = std::fs::symlink_metadata(e.path()).unwrap(); + out.push(format!( + "{} dir={} link={} mode={:04o}", + e.path().display(), + md.is_dir(), + md.is_symlink(), + md.permissions().mode() & 0o7777 + )); + } + } + out.sort(); + out + } + + /// Builds a trap under `base` and returns the key path inside it. + type BuildTrap = Box std::path::PathBuf>; + + // (label, build the trap, the key path inside it) + let cases: Vec<(&str, BuildTrap)> = vec![ + ( + "symlinked parent", + Box::new(|base: &std::path::Path| { + let target = base.join("real"); + std::fs::create_dir(&target).unwrap(); + let link = base.join("keys"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + link.join("p2p.key") + }), + ), + ( + "regular file as the key directory", + Box::new(|base: &std::path::Path| { + let file = base.join("keys"); + std::fs::write(&file, b"not a directory").unwrap(); + file.join("p2p.key") + }), + ), + ( + "unreadable parent", + Box::new(|base: &std::path::Path| { + let locked = base.join("locked"); + std::fs::create_dir(&locked).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)) + .unwrap(); + locked.join("keys").join("p2p.key") + }), + ), + ]; + + for (label, build) in cases { + let base = tempfile::tempdir().unwrap(); + let key_path = build(base.path()); + let before = snapshot(base.path()); + + let config = Config::parse_from([ + "gitlawb-node", + "--p2p-port", + "7546", + "--p2p-key-path", + key_path.to_str().unwrap(), + ]); + let verdict = config.validate(); + assert!( + verdict.is_ok(), + "{label}: a live storage fault is not a configuration error, got: {verdict:?}" + ); + assert_eq!( + snapshot(base.path()), + before, + "{label}: validation must not mutate the key tree" + ); + + // Leave the tempdir removable. + let locked = base.path().join("locked"); + if locked.exists() { + let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)); + } + } + } + /// Rule 2's positive direction: the shipped `~/` default resolves to a /// path beneath the selected home, and suffixes that would escape are /// left unexpanded (and then rejected by `validate`, as the matrix above diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 8dea8c14..1c652c8c 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -36,7 +36,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::net::TcpListener; use tokio::sync::watch; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use gitlawb_core::http_sig::sign_request; use gitlawb_core::identity::Keypair; @@ -187,6 +187,43 @@ async fn main() -> Result<()> { shutdown_tx.subscribe(), )); + // Resolve the p2p identity BEFORE the database connect, both arms of the + // port gate together. + // + // The key load is pure filesystem work with no database dependency, and + // its failure is deliberately non-fatal. Leaving it behind the DB connect + // meant the "HTTP up, p2p off" outcome and the port-zero no-IO guarantee + // were both unobservable whenever the database was unreachable, because + // `connect_db_with_retry` retries indefinitely and never falls through. + // Moving only the load would have left the disabled arm stranded, so the + // whole gate moves and just `p2p::start` stays behind the database. + let p2p_local_key = if config.p2p_port > 0 { + match p2p::load_or_create_p2p_keypair(&config.resolved_p2p_key_path()) { + Ok(local_key) => { + metrics::set_p2p_key_load_failed(false); + Some(local_key) + } + // Non-fatal by policy, and the cost is named rather than hidden: + // the node keeps serving HTTP with a green /health while it is off + // the p2p network entirely. Logged at error with a stable event + // name and mirrored into a metric, so the outage is alertable + // without reading startup logs by hand. + Err(e) => { + error!( + err = %format!("{e:#}"), + event = "p2p_identity_key_load_failed", + "failed to load p2p identity key, continuing without p2p" + ); + metrics::set_p2p_key_load_failed(true); + None + } + } + } else { + info!("p2p disabled (p2p_port = 0)"); + metrics::set_p2p_key_load_failed(false); + None + }; + // Connect to PostgreSQL database. A transient outage or bad secret should // not crash-loop the process and hammer the database provider; permanent // misconfiguration surfaces through error-level logs and the /ready check. @@ -250,49 +287,37 @@ async fn main() -> Result<()> { // Ensure repos directory exists std::fs::create_dir_all(&config.repos_dir).context("failed to create repos directory")?; - // Start libp2p swarm (if p2p_port > 0) - let p2p_handle = if config.p2p_port > 0 { - let bootstrap_addrs = config - .p2p_bootstrap - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - let shutdown_rx = shutdown_tx.subscribe(); - match p2p::load_or_create_p2p_keypair(&config.resolved_p2p_key_path()) { - Ok(local_key) => { - match p2p::start( - local_key, - config.p2p_port, - bootstrap_addrs, - Arc::clone(&db), - config.auto_sync, - shutdown_rx, - ) - .await - { - Ok(handle) => { - info!(port = config.p2p_port, peer_id = %handle.local_peer_id, "libp2p swarm started"); - Some(Arc::new(handle)) - } - Err(e) => { - tracing::warn!(err = %e, "failed to start libp2p swarm — continuing without p2p"); - None - } + // The identity was resolved before the database connect; this is only the + // swarm start, which genuinely needs the database handle. + let p2p_handle = match p2p_local_key { + Some(local_key) => { + let bootstrap_addrs = config + .p2p_bootstrap + .iter() + .filter_map(|s| s.parse().ok()) + .collect(); + let shutdown_rx = shutdown_tx.subscribe(); + match p2p::start( + local_key, + config.p2p_port, + bootstrap_addrs, + Arc::clone(&db), + config.auto_sync, + shutdown_rx, + ) + .await + { + Ok(handle) => { + info!(port = config.p2p_port, peer_id = %handle.local_peer_id, "libp2p swarm started"); + Some(Arc::new(handle)) + } + Err(e) => { + tracing::warn!(err = %e, "failed to start libp2p swarm — continuing without p2p"); + None } - } - // Deliberately non-fatal, and the cost is worth naming: an - // unreadable key file takes the node off the p2p network for the - // whole run while /health keeps reporting healthy, so the outage is - // visible only to whoever reads the logs. Making it fatal, or - // surfacing it in the health response, is its own change. - Err(e) => { - tracing::warn!(err = %e, "failed to load p2p identity key, continuing without p2p"); - None } } - } else { - info!("p2p disabled (p2p_port = 0)"); - None + None => None, }; // Shared no-redirect HTTP client. See build_http_client for the SSRF rationale. diff --git a/crates/gitlawb-node/src/metrics.rs b/crates/gitlawb-node/src/metrics.rs index c95ef1d1..758405e1 100644 --- a/crates/gitlawb-node/src/metrics.rs +++ b/crates/gitlawb-node/src/metrics.rs @@ -51,6 +51,11 @@ static SYNC_PROCESSED: OnceLock = OnceLock::new(); static WEBHOOK_DELIVERIES: OnceLock = OnceLock::new(); static PACK_SIZE: OnceLock = OnceLock::new(); static PEERS_CONNECTED: OnceLock = OnceLock::new(); +/// 1 when the node started without its p2p identity because the key storage +/// could not be used. The node keeps serving HTTP in that state and /health +/// stays green, so without this the outage is visible only to whoever reads +/// the startup log. +static P2P_KEY_LOAD_FAILED: OnceLock = OnceLock::new(); /// One-time initializer. Builds the registry, registers every metric, /// and sets the constant `gitlawb_info` gauge. Idempotent — calling @@ -202,6 +207,18 @@ fn init_inner(version: &str, node_did: &str) { .set(peers_connected) .expect("set PEERS_CONNECTED once"); + let p2p_key_load_failed = IntGauge::with_opts(Opts::new( + "gitlawb_p2p_identity_key_load_failed", + "1 if the node is running without p2p because its identity key could not be loaded", + )) + .expect("gitlawb_p2p_identity_key_load_failed definition"); + registry + .register(Box::new(p2p_key_load_failed.clone())) + .expect("register gitlawb_p2p_identity_key_load_failed"); + P2P_KEY_LOAD_FAILED + .set(p2p_key_load_failed) + .expect("set P2P_KEY_LOAD_FAILED once"); + REGISTRY .set(registry) .expect("set REGISTRY once (init must be called exactly once)"); @@ -284,6 +301,13 @@ pub fn set_peers_connected(count: i64) { } } +/// Record whether the node is running without its p2p identity. +pub fn set_p2p_key_load_failed(failed: bool) { + if let Some(g) = P2P_KEY_LOAD_FAILED.get() { + g.set(i64::from(failed)); + } +} + /// Encode the registry as the standard Prometheus text exposition format. /// Returns an error if `init` was never called. pub fn encode() -> Result { diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 1e5002a6..f4479b6a 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -284,35 +284,6 @@ pub(crate) fn tilde_suffix_escapes_home(suffix: &str) -> bool { }) } -/// Inspect `dir` without following symlinks. Ok when absent; Err when present -/// but not a real directory. -pub(crate) fn parent_directory_is_safe_to_mutate(dir: &Path) -> Result<(), String> { - match std::fs::symlink_metadata(dir) { - Ok(md) => { - if md.is_symlink() { - return Err(format!( - "GITLAWB_P2P_KEY's directory {} must be a real directory, not a symlink", - dir.display() - )); - } - if !md.is_dir() { - return Err(format!( - "GITLAWB_P2P_KEY's directory {} must be a directory, not another file type", - dir.display() - )); - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return Err(format!( - "failed to inspect key directory {}: {e}", - dir.display() - )); - } - } - Ok(()) -} - /// Mode bits alone do not make something node-owned. A `0700` directory or a /// `0600` file belonging to a different user passes every permission check here /// while that user keeps the ability to replace what is inside it, which means @@ -853,53 +824,60 @@ fn path_denotes_a_directory(key_path: &Path, configured_raw: Option<&str>) -> bo _ => {} } - if let Ok(md) = std::fs::symlink_metadata(key_path) { - return md.is_dir(); - } - + // Deliberately lexical only. Asking the filesystem whether the path is + // currently a directory turns a configuration question into a live storage + // observation, and the load path already refuses a directory at the key + // position by fstat on the descriptor it opened. false } /// Validate the resolved key path before creating or chmodding anything. /// /// `configured_raw` is the operator's `GITLAWB_P2P_KEY` string when available. -pub(crate) fn validate_p2p_key_path( +/// A key path that cannot name a securable key file, whatever is on disk. +/// +/// A distinct type rather than a `String` so the two failure domains cannot be +/// confused at a call site: this one is boot-fatal, and everything the load +/// path discovers about live filesystem objects is not. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub(crate) struct P2pKeyConfigError(String); + +pub(crate) fn validate_p2p_key_config( key_path: &Path, configured_raw: Option<&str>, -) -> Result<(), String> { +) -> Result<(), P2pKeyConfigError> { let display = configured_raw.unwrap_or_else(|| key_path.to_str().unwrap_or("")); if names_no_usable_directory(key_path) { - return Err(format!( + return Err(P2pKeyConfigError(format!( "GITLAWB_P2P_KEY ({display}) must include a directory that does not walk back through \ `..`, such as ./keys/p2p.key or /data/keys/p2p.key: the node will not store its p2p \ identity key in the working directory, where the directory holding it cannot be secured." - )); + ))); } if key_parent_is_filesystem_root(key_path) { - return Err(format!( + return Err(P2pKeyConfigError(format!( "GITLAWB_P2P_KEY ({display}) must not place the key in the filesystem root; use a \ dedicated directory such as /data/keys/p2p.key" - )); + ))); } if path_denotes_a_directory(key_path, configured_raw) { - return Err(format!( + return Err(P2pKeyConfigError(format!( "GITLAWB_P2P_KEY ({display}) must name a key file, not a directory" - )); + ))); } - if let Ok(md) = std::fs::symlink_metadata(key_path) { - if md.is_symlink() { - return Err(format!( - "GITLAWB_P2P_KEY ({display}) must name a regular key file; symlinks are refused" - )); - } - } - - parent_directory_is_safe_to_mutate(key_parent(key_path))?; - + // Nothing below this point may look at the filesystem. A symlink at the key + // path, a directory in its place, a symlinked or non-directory parent, and + // an unreadable parent are all live storage facts, and the load path + // re-establishes every one of them on a descriptor it actually opens: + // `open_key_for_read` adds O_NOFOLLOW, `read_p2p_keypair_from` refuses a + // non-regular file, and the leaf openat gives ELOOP or ENOTDIR through + // `describe_unusable_key_dir`. Deciding them here made the same fault + // fatal or degradable depending only on which layer noticed it first. Ok(()) } @@ -935,7 +913,7 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result // inspective — the checks below re-establish everything it observed on the // actual opened objects, so this exists for early, precise errors rather // than for safety. - validate_p2p_key_path(key_path, None).map_err(|e| anyhow::anyhow!(e))?; + validate_p2p_key_config(key_path, None).map_err(|e| anyhow::anyhow!(e))?; // Validation rejected paths without a final component, so `file_name` is // present from here on; the error is a backstop, not a reachable path for @@ -1253,6 +1231,18 @@ fn describe_unusable_key_dir(dir: &Path, e: std::io::Error) -> anyhow::Error { ); } Some(code) if code == libc::ENOTDIR => { + // Linux returns ENOTDIR, not ELOOP, when O_NOFOLLOW is + // combined with O_DIRECTORY on a symlink, so the errno alone + // cannot separate a symlink from a regular file. The refusal + // already happened; this lstat only picks the wording, and + // naming the symlink is what tells an operator what to look + // for. + if std::fs::symlink_metadata(dir).is_ok_and(|md| md.is_symlink()) { + return anyhow::anyhow!( + "GITLAWB_P2P_KEY's directory {} must be a real directory, not a symlink", + dir.display() + ); + } return anyhow::anyhow!( "GITLAWB_P2P_KEY's directory {} must be a directory, not another file type", dir.display() @@ -3211,24 +3201,51 @@ mod tests { } } + /// The config validator is LEXICAL only, in both directions. + /// + /// It used to also stat the key path and its parent, which meant a + /// symlinked or unreadable parent exited the node as invalid configuration + /// while the very same class of fault found one layer later only logged a + /// warning. The live cases now belong to the storage matrix; what stays + /// here is what can be decided from the configured string alone. #[test] - fn validate_p2p_key_path_rejects_root_parent_and_directory_targets() { - let root_err = - validate_p2p_key_path(Path::new("/p2p.key"), Some("/p2p.key")).expect_err("/p2p.key"); - assert!( - root_err.contains("filesystem root"), - "root-parent paths must be refused before chmod, got: {root_err}" - ); + fn validate_p2p_key_config_decides_only_lexical_properties() { + // Refused: properties of the value itself. + for (raw, needle) in [ + ("/p2p.key", "filesystem root"), + ("keys/", "must include a directory"), + ("p2p.key", "must include a directory"), + ("a/../p2p.key", "must include a directory"), + ] { + let err = validate_p2p_key_config(Path::new(raw), Some(raw)) + .expect_err("a lexically invalid key path must be refused"); + assert!( + err.to_string().contains(needle), + "{raw} must be refused for {needle}, got: {err}" + ); + } + // Accepted, and it must stay accepted no matter what is on disk: an + // existing directory at the key position, a symlinked parent and an + // unreadable parent are storage facts the load path judges on a + // descriptor. Deciding them here is what made the fatal/degraded split + // depend on which layer looked first. let dir = tempfile::tempdir().unwrap(); let key_dir = dir.path().join("keys"); std::fs::create_dir(&key_dir).unwrap(); - let dir_err = validate_p2p_key_path(&key_dir, Some(key_dir.to_str().unwrap())) - .expect_err("an existing directory target"); - assert!( - dir_err.contains("must name a key file"), - "directory targets must be refused before chmod, got: {dir_err}" - ); + validate_p2p_key_config(&key_dir, Some(key_dir.to_str().unwrap())) + .expect("an existing directory on disk is not a configuration error"); + + #[cfg(unix)] + { + let target = dir.path().join("real"); + std::fs::create_dir(&target).unwrap(); + let link = dir.path().join("linked"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let via_link = link.join("p2p.key"); + validate_p2p_key_config(&via_link, Some(via_link.to_str().unwrap())) + .expect("a symlinked parent is not a configuration error"); + } } #[cfg(unix)] @@ -3460,13 +3477,17 @@ mod tests { fn p2p_symlinked_key_parent_is_refused_before_chmod() { use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); + // A 0700 base: a plain tempdir is 0775 under the suite's umask, and + // the pinned-creation helper verifies its own parent, so an unsafe + // base is refused before the symlink under test is ever reached. + let dir = key_base_0700(); let real_parent = dir.path().join("real"); std::fs::create_dir(&real_parent).unwrap(); std::fs::set_permissions(&real_parent, std::fs::Permissions::from_mode(0o755)).unwrap(); let safe = dir.path().join("safe"); std::fs::create_dir(&safe).unwrap(); + std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o700)).unwrap(); let link = safe.join("keys"); std::os::unix::fs::symlink(&real_parent, &link).unwrap(); @@ -3803,8 +3824,7 @@ mod tests { Row { name: "symlink at the key position is refused", setup: |base| { - let dir = base.join("keys"); - std::fs::create_dir(&dir).unwrap(); + let dir = key_dir_0700(base); let real = dir.join("real.key"); valid_key_at(&real); let link = dir.join("p2p.key"); @@ -3816,8 +3836,7 @@ mod tests { Row { name: "dangling symlink at the key position is refused", setup: |base| { - let dir = base.join("keys"); - std::fs::create_dir(&dir).unwrap(); + let dir = key_dir_0700(base); let link = dir.join("p2p.key"); std::os::unix::fs::symlink(dir.join("absent"), &link).unwrap(); link @@ -3827,11 +3846,16 @@ mod tests { Row { name: "directory at the key position is refused", setup: |base| { - let path = base.join("keys").join("p2p.key"); - std::fs::create_dir_all(&path).unwrap(); + let dir = key_dir_0700(base); + let path = dir.join("p2p.key"); + std::fs::create_dir(&path).unwrap(); path }, - expect: Expect::Refused("must name a key file"), + // The refusal moved from the config validator to the load + // path, which judges the object it actually opened rather than + // a pathname it stat'd, so it names the object type instead of + // the setting. Same verdict, better diagnosis. + expect: Expect::Refused("must be a regular file"), }, Row { name: "FIFO at the key position is refused without blocking", diff --git a/crates/gitlawb-node/tests/p2p_key_startup_policy.rs b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs new file mode 100644 index 00000000..12f9484f --- /dev/null +++ b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs @@ -0,0 +1,426 @@ +//! Which p2p key-storage failures stop the node, and which leave HTTP up. +//! +//! The two domains are decided by different code at different phases, so the +//! only honest proof is at the process boundary: spawn the real binary and +//! watch what it does. A helper's returned error says nothing about whether +//! `main` treated it as fatal. +//! +//! Every row also asserts the key tree is byte-identical afterwards. A refusal +//! that mutates storage on its way out is its own defect, and a returned error +//! cannot show that. +//! +//! No database: `DATABASE_URL` points at a closed port on purpose, so a row +//! that reaches the degraded server proves the outcome is observable without +//! Postgres. That is what makes the port-zero no-I/O guarantee testable at all. + +#![cfg(unix)] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Wall clock, never an iteration count: a fixed loop count starves on a slow +/// or loaded machine and turns a real pass into a flake. +const DEADLINE: Duration = Duration::from_secs(15); + +/// Kills the child on every exit path, including a panicking assertion. +/// +/// Without this a failing row leaves a node behind that retries its database +/// connection forever, holding the row's tempdir open. The first run of this +/// file did exactly that, and the leak is invisible until something else in +/// the suite gets slow. +struct ChildGuard(Child); + +impl ChildGuard { + /// The tracing subscriber writes to STDOUT; only anyhow's final error + /// print goes to stderr. A row that watches one stream sees half the + /// story, so both are read. + fn take_stdout(&mut self) -> std::process::ChildStdout { + self.0.stdout.take().expect("stdout piped") + } + + fn take_stderr(&mut self) -> std::process::ChildStderr { + self.0.stderr.take().expect("stderr piped") + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// `(path, is_dir, is_symlink, mode, len)` for everything under `root`, sorted. +/// Taken before and after each row so a refusal that creates, chmods, or +/// deletes anything fails the row. +fn snapshot(root: &Path) -> Vec { + fn walk(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let p = e.path(); + let Ok(md) = std::fs::symlink_metadata(&p) else { + continue; + }; + out.push(format!( + "{} dir={} link={} mode={:04o} len={}", + p.display(), + md.is_dir(), + md.is_symlink(), + md.permissions().mode() & 0o7777, + if md.is_file() { md.len() } else { 0 } + )); + if md.is_dir() && !md.is_symlink() { + walk(&p, out); + } + } + } + let mut out = Vec::new(); + walk(root, &mut out); + out.sort(); + out +} + +struct Row { + /// Sandbox the row owns: HOME, repos, cwd and the key tree all live here. + home: tempfile::TempDir, +} + +impl Row { + /// The only directory the no-mutation assertions watch. + fn tree(&self) -> PathBuf { + self.home.path().join("keytree") + } +} + +impl Row { + fn new() -> Row { + let home = tempfile::tempdir().expect("tempdir"); + std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o700)) + .expect("chmod home"); + // Created here, before any row takes its `before` snapshot, so the + // no-mutation assertions measure what the NODE did and not what this + // harness set up. + std::fs::create_dir_all(home.path().join("repos")).expect("repos dir"); + // The p2p key tree lives in its own subdirectory so the no-mutation + // assertions can watch exactly it. HOME also holds `.gitlawb`, which + // the node creates for its NODE identity on every start; measuring the + // whole home would read that legitimate write as a p2p storage + // mutation. + let tree = home.path().join("keytree"); + std::fs::create_dir(&tree).expect("key tree"); + std::fs::set_permissions(&tree, std::fs::Permissions::from_mode(0o700)) + .expect("chmod key tree"); + Row { home } + } + + fn spawn(&self, p2p_key: &str, p2p_port: &str, cwd: &Path) -> ChildGuard { + let repos = self.home.path().join("repos"); + Command::new(env!("CARGO_BIN_EXE_gitlawb-node")) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + // The subscriber is built from the default env filter, so with a + // cleared environment the node logs nothing at all and every + // assertion here reads as a timeout with empty output. The rows + // are about which log lines appear, so the filter is part of the + // fixture, not incidental setup. + .env("RUST_LOG", "info") + .env("HOME", self.home.path()) + .env("GITLAWB_REPOS_DIR", &repos) + .env("GITLAWB_HOST", "127.0.0.1") + // Ephemeral: the real port is read back from the ready log. + .env("GITLAWB_PORT", "0") + // A closed port, so the node stays on the degraded path for the + // whole row instead of ever reaching a database. + .env("DATABASE_URL", "postgres://127.0.0.1:1/nonexistent") + .env("GITLAWB_P2P_PORT", p2p_port) + .env("GITLAWB_P2P_KEY", p2p_key) + .env("GITLAWB_METRICS_ADDR", "") + .current_dir(cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map(ChildGuard) + .expect("spawn gitlawb-node") + } +} + +/// Read the child's stderr until `needle` appears, the stream ends, or the +/// deadline passes. Returns everything read. +/// +/// The read runs on its own thread feeding a channel, and the deadline is +/// enforced with `recv_timeout`. Checking elapsed time between blocking +/// `read_line` calls does NOT work: a child that goes quiet blocks the read +/// forever and the check never runs, so the deadline looks present and cannot +/// fire. The first version of this file did that and hung for minutes on a row +/// that was supposed to fail in fifteen seconds. +fn read_until(child: &mut ChildGuard, needle: &str) -> (bool, String) { + let stdout = child.take_stdout(); + let (tx, rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + if tx.send(line).is_err() { + break; + } + } + } + } + }); + + let mut acc = String::new(); + let start = Instant::now(); + loop { + if acc.contains(needle) { + return (true, acc); + } + let left = match DEADLINE.checked_sub(start.elapsed()) { + Some(d) if !d.is_zero() => d, + _ => return (false, acc), + }; + match rx.recv_timeout(left) { + Ok(line) => acc.push_str(&line), + // Sender gone: the stream ended. One last check, then give up. + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return (acc.contains(needle), acc) + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return (false, acc), + } + } +} + +fn wait_for_exit(child: &mut ChildGuard) -> (std::process::ExitStatus, String) { + // Both streams: the refusal reason is anyhow's stderr print, while + // "binding HTTP listener" is a tracing line on stdout, and the fatal rows + // assert on one of each. + let mut out = String::new(); + let mut o = child.take_stdout(); + let mut e = child.take_stderr(); + let reader = std::thread::spawn(move || { + let mut buf = String::new(); + let _ = o.read_to_string(&mut buf); + buf + }); + let _ = e.read_to_string(&mut out); + let status = child.0.wait().expect("wait"); + out.push_str(&reader.join().unwrap_or_default()); + (status, out) +} + +/// Strip ANSI escapes: the subscriber colourises even when piped, which would +/// otherwise sit between `addr=` and the value. +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + for c2 in chars.by_ref() { + if c2.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +/// A lexically invalid value must stop the process before the listener binds. +fn assert_fatal_before_bind(row: &Row, key: &str, cwd: &Path) { + let before = snapshot(&row.tree()); + let mut child = row.spawn(key, "7546", cwd); + let (status, err) = wait_for_exit(&mut child); + + assert!( + !status.success(), + "key={key:?} must exit non-zero\n--- stderr ---\n{err}" + ); + assert!( + err.contains("invalid configuration"), + "key={key:?} must fail as invalid configuration\n--- stderr ---\n{err}" + ); + assert!( + !err.contains("binding HTTP listener"), + "key={key:?} must be refused BEFORE the listener binds\n--- stderr ---\n{err}" + ); + assert_eq!( + snapshot(&row.tree()), + before, + "key={key:?}: a configuration refusal must not touch the key tree" + ); +} + +/// A live storage fault must leave HTTP serving with p2p off, and must not +/// mutate the tree on its way to that verdict. +fn assert_degrades_with_http_up(row: &Row, key: &str, cwd: &Path, label: &str) { + let before = snapshot(&row.tree()); + let mut child = row.spawn(key, "7546", cwd); + let (found, log) = read_until(&mut child, "degraded HTTP server ready"); + assert!( + found, + "{label}: the node must bind and serve while p2p is off\n--- stderr ---\n{log}" + ); + assert!( + log.contains("failed to load p2p identity key"), + "{label}: the p2p failure must be logged\n--- stderr ---\n{log}" + ); + + let clean = strip_ansi(&log); + let addr = clean + .lines() + .find(|l| l.contains("degraded HTTP server ready")) + .and_then(|l| l.rsplit("addr=").next()) + .map(|a| { + a.trim() + .trim_start_matches("Some(") + .trim_matches(|c| c == '"' || c == ')') + }) + .and_then(|a| a.parse::().ok()) + .unwrap_or_else(|| panic!("{label}: could not parse the bound address from {clean}")); + + // A served response, any status: the degraded server answers 503, which is + // still proof the port is up and answering. + let mut sock = std::net::TcpStream::connect(addr).expect("connect to the degraded server"); + sock.set_read_timeout(Some(DEADLINE)).unwrap(); + sock.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .expect("send request"); + let mut resp = String::new(); + let _ = sock.read_to_string(&mut resp); + assert!( + resp.starts_with("HTTP/1.1 "), + "{label}: the degraded server must answer, got {resp:?}" + ); + + drop(child); + assert_eq!( + snapshot(&row.tree()), + before, + "{label}: a storage refusal must not mutate the key tree" + ); +} + +#[test] +fn lexically_invalid_key_paths_stop_the_node_before_it_binds() { + for key in [ + "p2p.key", + "./p2p.key", + "a/../p2p.key", + "../p2p.key", + "/p2p.key", + "keys/", + "~/", + "~//etc/p2p.key", + "~/../x/p2p.key", + ] { + let row = Row::new(); + let cwd = row.home.path().to_path_buf(); + assert_fatal_before_bind(&row, key, &cwd); + } +} + +#[test] +fn live_storage_faults_leave_http_up_with_p2p_off() { + // Symlinked final parent. + { + let row = Row::new(); + let real = row.tree().join("real"); + std::fs::create_dir(&real).unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap(); + let link = row.tree().join("keys"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let key = link.join("p2p.key"); + let cwd = row.home.path().to_path_buf(); + assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "symlinked parent"); + } + + // Regular file where the key directory should be. + { + let row = Row::new(); + let file = row.tree().join("keys"); + std::fs::write(&file, b"not a directory").unwrap(); + let key = file.join("p2p.key"); + let cwd = row.home.path().to_path_buf(); + assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "regular-file parent"); + } + + // Unreadable parent: an inspection error, the purest instance of a live + // fault that used to be reported as invalid configuration. + { + let row = Row::new(); + let parent = row.tree().join("locked"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o000)).unwrap(); + let key = parent.join("keys").join("p2p.key"); + let cwd = row.home.path().to_path_buf(); + assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "unreadable parent"); + // Restore so the tempdir can be cleaned up. + let _ = std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)); + } + + // World-writable non-sticky ancestor: already the degrade class today, kept + // so the row set covers both sides of the policy. + { + let row = Row::new(); + let anc = row.tree().join("open"); + std::fs::create_dir(&anc).unwrap(); + std::fs::set_permissions(&anc, std::fs::Permissions::from_mode(0o777)).unwrap(); + let key = anc.join("keys").join("p2p.key"); + let cwd = row.home.path().to_path_buf(); + assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "world-writable ancestor"); + } +} + +#[test] +fn port_zero_does_no_key_storage_io_at_all() { + // A lexically invalid path that would be fatal with p2p enabled. + { + let row = Row::new(); + let before = snapshot(&row.tree()); + let cwd = row.home.path().to_path_buf(); + let mut child = row.spawn("p2p.key", "0", &cwd); + let (found, log) = read_until(&mut child, "p2p disabled"); + assert!( + found, + "port zero must bypass key handling entirely\n--- stderr ---\n{log}" + ); + drop(child); + assert_eq!( + snapshot(&row.tree()), + before, + "port zero must not touch the key tree" + ); + } + + // A hostile tree that would degrade with p2p enabled. + { + let row = Row::new(); + let real = row.tree().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = row.tree().join("keys"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let before = snapshot(&row.tree()); + let cwd = row.home.path().to_path_buf(); + let key: PathBuf = link.join("p2p.key"); + let mut child = row.spawn(key.to_str().unwrap(), "0", &cwd); + let (found, log) = read_until(&mut child, "p2p disabled"); + assert!( + found, + "port zero must bypass a hostile tree too\n--- stderr ---\n{log}" + ); + drop(child); + assert_eq!( + snapshot(&row.tree()), + before, + "port zero must not touch a hostile key tree" + ); + } +} From 90b4cdc28368e407a386e003e9ffcc32aa2c02d3 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:15:33 -0500 Subject: [PATCH 26/36] docs(node): state the two-domain key-path policy and how to see a degrade README and .env.example promised that p2p stays off while HTTP keeps serving when the key cannot be loaded, including for an unsafe ancestor or working directory. Measured against the binary, that was false for a symlinked parent, a non-directory parent and an unreadable parent, each of which exited before binding. The preceding commit made the code match the promise; this makes the promise precise. All three operator surfaces now name the split the same way. A value that cannot name a securable key file is refused before the node binds. Anything wrong with the storage itself degrades, and the sentence says how to notice: the node logs p2p_identity_key_load_failed at error and sets the matching metric, /health stays green in that state, and fixing the storage and restarting restores the same PeerId. A node quietly off the network is the cost this policy accepts, so the docs point at the signal rather than leaving an operator to find it in startup logs. Two corrections while here. The key directory is tightened only when it grants access beyond its owner; one closed too far is refused with a chmod 700 remedy rather than widened, so the sentence no longer implies the node will open up a directory an operator locked down. And the existing advice to delete a key after a tightening warning is now scoped to the loosening case it was written for: an over-closed directory is refused, not tightened, and nothing in it was ever exposed, so following that advice would have meant deleting a safe key and taking a second PeerId rotation that breaks every pinned bootstrap multiaddr. --- .env.example | 30 ++++++++++++++++++++---------- README.md | 2 +- crates/gitlawb-node/src/config.rs | 10 +++++++--- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index eecd4c0a..d3ee2e36 100644 --- a/.env.example +++ b/.env.example @@ -8,16 +8,26 @@ GITLAWB_KEY=/data/keys/identity.pem # Path to the node's persistent libp2p identity key file. Must include a -# directory; the node refuses to start on a bare filename, because it will not -# keep its p2p identity key in the working directory. With a relative path the -# node verifies the working directory (ownership and write permissions) before -# using it; an unsafe cwd or ancestor is refused and p2p stays off while HTTP -# remains up. On Unix it is created 0600 inside a 0700 directory, and a loose -# key directory is tightened to 0700 on start; on other platforms no -# permissions are enforced. If the node logs that it tightened a loose key -# directory, treat the key that was sitting there as possibly exposed: delete -# it so a fresh identity is generated on the next start. Keep it on a -# persistent volume so the PeerId survives redeploys. +# directory, because the node will not keep its p2p identity key in the working +# directory. A value that cannot name a securable key file (a bare filename, +# `..` traversal, a trailing slash, the filesystem root, or a `~/` path that +# escapes home) is refused before the node binds, so the process exits. +# Anything wrong with the storage itself (a symlinked or non-directory parent, +# an unsafe cwd or ancestor, an unreadable or malformed key) leaves p2p off +# while HTTP keeps serving, logged at error as p2p_identity_key_load_failed and +# counted by the gitlawb_p2p_identity_key_load_failed metric. /health still +# reports healthy in that state, so alert on the event or the metric. Fixing +# the storage and restarting restores the same PeerId. +# On Unix it is created 0600 inside a 0700 directory. A key directory that +# grants access beyond its owner is tightened to 0700 on start; one closed too +# far is refused with a chmod 700 remedy rather than widened. On other +# platforms no permissions are enforced. +# If the node logs that it tightened a key directory that was GROUP OR WORLD +# accessible, treat the key that was sitting there as possibly exposed: delete +# it so a fresh identity is generated on the next start. That advice applies +# only to the loosening case. A directory that was merely closed too far is +# refused, not tightened, and no key in it was ever exposed. +# Keep it on a persistent volume so the PeerId survives redeploys. # Default: ~/.gitlawb/p2p.key #GITLAWB_P2P_KEY=/data/keys/p2p.key diff --git a/README.md b/README.md index 75dad411..e033e49b 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Important node settings: | `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. | | `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. | | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | -| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`; bare filenames, directory paths, trailing `/`, and the filesystem root are refused at startup. With a relative path the node verifies the working directory (ownership and write permissions) before using it. On Unix a new key is created `0600` inside a `0700` directory; a loose key directory is tightened to `0700` on start. An existing key copied from backup with group or other bits set is rejected rather than repaired; run `chmod 600` on it before restarting. P2P stays off while HTTP remains up if the key cannot be loaded, including when an unsafe ancestor or working directory is refused. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | +| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | | `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | | `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index bf44756d..11bf8747 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -120,9 +120,13 @@ pub struct Config { #[arg(long, env = "GITLAWB_P2P_PORT", default_value_t = 7546)] pub p2p_port: u16, - /// Path to the persistent libp2p identity key. With a relative path the - /// node verifies the working directory (ownership and write permissions) - /// before using it. + /// Path to the persistent libp2p identity key. A value that cannot name a + /// securable key file (a bare filename, `..` traversal, a trailing + /// separator, the filesystem root, or a `~/` path that escapes home) is + /// refused before the node binds. A problem with the storage itself leaves + /// p2p off while HTTP keeps serving, logged as p2p_identity_key_load_failed. + /// With a relative path the node verifies the working directory (ownership + /// and write permissions) before using it. #[arg(long, env = "GITLAWB_P2P_KEY", default_value = "~/.gitlawb/p2p.key")] pub p2p_key_path: String, From 01e046377c9f3349bea9d7736cf8fcda43afd386 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:13:50 -0500 Subject: [PATCH 27/36] test(node): pin the degrade signal and wait for both startup lines Two gaps in the boundary test, both found by extending the mutation spec to cover this commit's own guards. The degrade rows asserted only the prose message, so renaming the event field or dropping the level would have passed while every alert built on the operator docs broke and the node still looked healthy. The rows now pin the error level and the p2p_identity_key_load_failed name the docs tell operators to watch. The rows also waited on a single log line, which is a race: the degraded server logs "ready" from a spawned task while the p2p gate logs its verdict from the main task, so whichever the reader stopped at first left the other uncaptured. It passed when the test binary ran directly and when the row ran alone, and failed only once other work shifted the scheduling. Reads now wait for every needle before returning. The fix stays on the test side on purpose. Logging the p2p verdict before the degraded server is spawned would also make the order deterministic, but that is production sequencing changed for a test's convenience. --- .../tests/p2p_key_startup_policy.rs | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/gitlawb-node/tests/p2p_key_startup_policy.rs b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs index 12f9484f..4176cf23 100644 --- a/crates/gitlawb-node/tests/p2p_key_startup_policy.rs +++ b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs @@ -159,6 +159,17 @@ impl Row { /// fire. The first version of this file did that and hung for minutes on a row /// that was supposed to fail in fifteen seconds. fn read_until(child: &mut ChildGuard, needle: &str) -> (bool, String) { + read_until_all(child, &[needle]) +} + +/// Read until EVERY needle has appeared, the stream ends, or the deadline +/// passes. +/// +/// Waiting on a single line is a race here: the degraded server logs "ready" +/// from a spawned task while the p2p gate logs its verdict from the main task, +/// so whichever the reader stops at first can leave the other uncaptured. That +/// made this file pass or fail depending on scheduling. +fn read_until_all(child: &mut ChildGuard, needles: &[&str]) -> (bool, String) { let stdout = child.take_stdout(); let (tx, rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { @@ -179,7 +190,7 @@ fn read_until(child: &mut ChildGuard, needle: &str) -> (bool, String) { let mut acc = String::new(); let start = Instant::now(); loop { - if acc.contains(needle) { + if needles.iter().all(|n| acc.contains(n)) { return (true, acc); } let left = match DEADLINE.checked_sub(start.elapsed()) { @@ -190,7 +201,7 @@ fn read_until(child: &mut ChildGuard, needle: &str) -> (bool, String) { Ok(line) => acc.push_str(&line), // Sender gone: the stream ended. One last check, then give up. Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - return (acc.contains(needle), acc) + return (needles.iter().all(|n| acc.contains(n)), acc) } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return (false, acc), } @@ -264,14 +275,33 @@ fn assert_fatal_before_bind(row: &Row, key: &str, cwd: &Path) { fn assert_degrades_with_http_up(row: &Row, key: &str, cwd: &Path, label: &str) { let before = snapshot(&row.tree()); let mut child = row.spawn(key, "7546", cwd); - let (found, log) = read_until(&mut child, "degraded HTTP server ready"); + let (found, log) = read_until_all( + &mut child, + &[ + "degraded HTTP server ready", + "failed to load p2p identity key", + ], + ); assert!( found, "{label}: the node must bind and serve while p2p is off\n--- stderr ---\n{log}" ); + // The operator docs promise a specific signal, so pin it here: the level, + // the stable event name, and the message. A degrade that logs at warn or + // renames the event silently breaks the alert those docs tell operators to + // build, and asserting only the prose would not notice. + let clean_log = strip_ansi(&log); + let signal = clean_log + .lines() + .find(|l| l.contains("failed to load p2p identity key")) + .unwrap_or_else(|| panic!("{label}: the p2p failure must be logged\n{clean_log}")); + assert!( + signal.contains("ERROR"), + "{label}: the p2p failure must be logged at error level, got: {signal}" + ); assert!( - log.contains("failed to load p2p identity key"), - "{label}: the p2p failure must be logged\n--- stderr ---\n{log}" + signal.contains("p2p_identity_key_load_failed"), + "{label}: the p2p failure must carry the documented event name, got: {signal}" ); let clean = strip_ansi(&log); From 32d41e04fb9d7806064afd3502c670185717acbf Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:53:23 -0500 Subject: [PATCH 28/36] fix(node): keep key publication transactional and path-policy split Bare GITLAWB_KEY filenames publish into the working directory without chmodding it. A directory this process created is removed if pin or verify fails. Ancestor walks use search descriptors so a safe 0111 component is traversable. Scratch unlink is part of publish success and is fsynced. Rotation advice is limited to writable or readable exposure, not ordinary 0755 tightening. --- .env.example | 10 +- README.md | 2 +- crates/gitlawb-node/src/config.rs | 4 +- crates/gitlawb-node/src/main.rs | 99 +++++++- crates/gitlawb-node/src/p2p/mod.rs | 377 +++++++++++++++++++++++++---- 5 files changed, 436 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index d3ee2e36..7e5eabc7 100644 --- a/.env.example +++ b/.env.example @@ -23,10 +23,12 @@ GITLAWB_KEY=/data/keys/identity.pem # far is refused with a chmod 700 remedy rather than widened. On other # platforms no permissions are enforced. # If the node logs that it tightened a key directory that was GROUP OR WORLD -# accessible, treat the key that was sitting there as possibly exposed: delete -# it so a fresh identity is generated on the next start. That advice applies -# only to the loosening case. A directory that was merely closed too far is -# refused, not tightened, and no key in it was ever exposed. +# WRITABLE, treat the key that was sitting there as possibly exposed: delete +# it so a fresh identity is generated on the next start. An ordinary 0755 +# directory is tightened too, but others could not read a 0600 key or replace +# its directory entry, so do not rotate for that case. A directory that was +# merely closed too far is refused, not tightened, and no key in it was ever +# exposed. # Keep it on a persistent volume so the PeerId survives redeploys. # Default: ~/.gitlawb/p2p.key #GITLAWB_P2P_KEY=/data/keys/p2p.key diff --git a/README.md b/README.md index e033e49b..304e17e6 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Important node settings: | `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. | | `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. | | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | -| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | +| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | | `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | | `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index 11bf8747..fbdbd169 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -126,7 +126,9 @@ pub struct Config { /// refused before the node binds. A problem with the storage itself leaves /// p2p off while HTTP keeps serving, logged as p2p_identity_key_load_failed. /// With a relative path the node verifies the working directory (ownership - /// and write permissions) before using it. + /// and write permissions) before using it. A 0755 key directory is tightened + /// on start; rotate the key only if the directory was group/world-writable + /// or the key was group/world-readable. #[arg(long, env = "GITLAWB_P2P_KEY", default_value = "~/.gitlawb/p2p.key")] pub p2p_key_path: String, diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 1c652c8c..124691e8 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1950,8 +1950,8 @@ mod identity_key_storage_tests { /// A pre-existing directory that grants access beyond the owner is /// tightened on the next start, which is the INV-23(a) half issue #231 - /// names: a 0600 PEM inside a 0755 directory is still replaceable by - /// anyone who can write that directory. + /// names. Write on the directory is what lets another user replace a + /// 0600 PEM; 0755 is tightened too, but is not replacement authority. #[cfg(unix)] #[test] fn existing_identity_key_directory_is_tightened() { @@ -2062,4 +2062,99 @@ mod identity_key_storage_tests { ); } } + + /// Bare `GITLAWB_KEY=identity.pem` (and `./identity.pem`) must still create + /// the identity in the working directory. The p2p key refuses that form on + /// purpose; the node identity has always allowed it. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=identity-key-bare"] + fn fixture_bare_identity_key_in_cwd() { + use std::os::unix::fs::PermissionsExt; + + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() != Some("identity-key-bare") { + return; + } + let cwd = std::path::PathBuf::from( + std::env::var("GITLAWB_TEST_BASE").expect("GITLAWB_TEST_BASE"), + ); + std::env::set_current_dir(&cwd).expect("chdir into isolated tempdir"); + let cwd_mode_before = std::fs::symlink_metadata(".").unwrap().permissions().mode() & 0o7777; + + let name = std::env::var("GITLAWB_TEST_KEY_NAME").expect("GITLAWB_TEST_KEY_NAME"); + let kp = load_or_create_keypair_at(std::path::Path::new(&name)) + .unwrap_or_else(|e| panic!("bare identity path {name:?} must create, got: {e:#}")); + assert!( + std::path::Path::new(&name).exists() || std::path::Path::new("identity.pem").exists(), + "the key must land in the working directory" + ); + let key = if std::path::Path::new(&name).exists() { + std::path::PathBuf::from(&name) + } else { + std::path::PathBuf::from("identity.pem") + }; + assert_eq!( + std::fs::symlink_metadata(&key) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o600, + "the identity key must be owner-only" + ); + let cwd_mode_after = std::fs::symlink_metadata(".").unwrap().permissions().mode() & 0o7777; + assert_eq!( + cwd_mode_before, cwd_mode_after, + "creating a bare identity key must not chmod the working directory" + ); + let reloaded = load_or_create_keypair_at(std::path::Path::new(&name)).expect("reload"); + assert_eq!(kp.did(), reloaded.did(), "the identity must be stable"); + println!("identity-key-bare: asserted did={}", kp.did()); + } + + #[cfg(unix)] + #[test] + fn bare_identity_key_path_creates_in_cwd_without_chmodding_cwd() { + use std::os::unix::fs::PermissionsExt; + + for name in ["identity.pem", "./identity.pem"] { + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut cmd = std::process::Command::new(std::env::current_exe().expect("current_exe")); + cmd.args([ + "identity_key_storage_tests::fixture_bare_identity_key_in_cwd", + "--exact", + "--ignored", + "--nocapture", + ]) + .env("GITLAWB_TEST_FIXTURE", "identity-key-bare") + .env("GITLAWB_TEST_BASE", base.path()) + .env("GITLAWB_TEST_KEY_NAME", name); + let output = cmd.output().expect("spawn the bare-identity fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "name={name}: bare identity path must create\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("1 passed"), + "name={name}: filter must select one passing test\n{stdout}" + ); + assert!( + stdout.contains("identity-key-bare: asserted did="), + "name={name}: fixture must print its sentinel\n{stdout}" + ); + let cwd_mode = std::fs::symlink_metadata(base.path()) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + cwd_mode, 0o755, + "name={name}: the parent test must also see cwd left at 0755" + ); + } + } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index f4479b6a..45aad825 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -450,6 +450,7 @@ pub(crate) mod pin { name: &std::ffi::OsStr, display: &Path, euid: u32, + open_flags: libc::c_int, ) -> std::io::Result<(Pinned, bool)> { use std::os::unix::ffi::OsStrExt; @@ -464,7 +465,6 @@ pub(crate) mod pin { "path component contains an interior NUL byte".to_string(), ) })?; - let open_flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC; // Fast path: it already exists. Adopt and verify as-is (never pin). // SAFETY: openat resolves `name` relative to the verified parent. @@ -513,7 +513,11 @@ pub(crate) mod pin { // parent; the object was created by this process a moment ago // and the parent cannot be repointed by another user. if unsafe { libc::fchmodat(parent_fd, cname.as_ptr(), 0o700, 0) } != 0 { - return Err(std::io::Error::last_os_error()); + return Err(rollback_created_dir( + parent_fd, + &cname, + std::io::Error::last_os_error(), + )); } } } @@ -521,17 +525,44 @@ pub(crate) mod pin { // SAFETY: openat as above, re-resolving the object that actually landed. let fd = unsafe { libc::openat(parent_fd, cname.as_ptr(), open_flags) }; if fd < 0 { - return Err(std::io::Error::last_os_error()); + let err = std::io::Error::last_os_error(); + return Err(if created { + rollback_created_dir(parent_fd, &cname, err) + } else { + err + }); } // SAFETY: a descriptor we just received and own exactly once. let owned = unsafe { OwnedFd::from_raw_fd(fd) }; if created { - verify_exact_mode(owned.as_raw_fd(), 0o700, true, display, euid)?; + if let Err(err) = verify_exact_mode(owned.as_raw_fd(), 0o700, true, display, euid) { + drop(owned); + return Err(rollback_created_dir(parent_fd, &cname, err)); + } } Ok((Pinned(owned), created)) } + /// Remove a directory `mkdirat` created in this invocation. Never called + /// for an `AlreadyExists` race winner. + fn rollback_created_dir( + parent_fd: std::os::fd::RawFd, + cname: &std::ffi::CString, + primary: std::io::Error, + ) -> std::io::Error { + // SAFETY: `name` was created by this invocation under `parent_fd`. + let rc = unsafe { libc::unlinkat(parent_fd, cname.as_ptr(), libc::AT_REMOVEDIR) }; + if rc == 0 { + return primary; + } + let clean = std::io::Error::last_os_error(); + std::io::Error::new( + primary.kind(), + format!("{primary}; also failed to remove the directory this process created: {clean}"), + ) + } + /// Pin a file this process just created to `want` on its held descriptor /// and verify the achieved mode. pub(crate) fn pin_created_file( @@ -634,6 +665,28 @@ pub(crate) mod pin { /// The key directory itself (the leaf) is deliberately not part of this walk: /// `ensure_key_dir` opens it no-follow, checks its ownership, and tightens it /// to 0700 afterwards. +#[cfg(unix)] +fn walk_dir_open_flags() -> libc::c_int { + // Traversal needs search/execute, not directory-list. `O_RDONLY` on a + // directory additionally requires read permission, so a safe 0111 ancestor + // would fail before the ownership/write-authority predicate ran. + #[cfg(any(target_os = "linux", target_os = "android"))] + { + libc::O_PATH | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC + } +} + +#[cfg(unix)] +fn leaf_dir_open_flags() -> libc::c_int { + // The key directory handle must support fsync and fchmod. Those fail on + // an `O_PATH` descriptor, and a 0700 leaf is owner-readable. + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC +} + #[cfg(unix)] fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result { use std::os::fd::{AsRawFd, FromRawFd}; @@ -719,12 +772,7 @@ fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result Result Result, dir_path: &Path) -> KeyDirHandle { KeyDirHandle { dir: std::fs::File::from(pinned.into_inner()), @@ -1015,6 +1058,24 @@ impl KeyDirHandle { } } + /// Publish into an already-existing directory without creating or chmodding + /// it. The working-directory case for a bare node-identity filename uses + /// this so the p2p key's named-directory contract is not imported onto + /// `GITLAWB_KEY=identity.pem`. + fn from_existing_dir(dir: std::fs::File, dir_path: &Path) -> std::io::Result { + let md = dir.metadata()?; + if !md.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{} is not a directory", dir_path.display()), + )); + } + Ok(KeyDirHandle { + dir, + path: dir_path.to_path_buf(), + }) + } + /// Open `dir_path` refusing symlinks and non-directories at the open /// itself, so rejection happens before any chmod or key IO rather than /// after a separate stat that something else could invalidate. @@ -1103,7 +1164,15 @@ impl KeyDirHandle { // creating boot still succeeds on this open descriptor, and the NEXT // boot cannot read its own key. `O_EXCL` guarantees this process // created the inode, so there is no race winner to leave alone. - pin::pin_created_file(file, 0o600, &self.path.join(name), effective_uid()) + pin::pin_created_file(file, 0o600, &self.path.join(name), effective_uid()).map_err(|e| { + match self.remove_child(name) { + Ok(()) => e, + Err(clean) => std::io::Error::new( + e.kind(), + format!("{e}; also failed to remove the scratch this process created: {clean}"), + ), + } + }) } /// Atomically publish `from` at `to` via `linkat` on the held descriptor. @@ -1135,6 +1204,11 @@ impl KeyDirHandle { fn remove_child(&self, name: &std::ffi::OsStr) -> std::io::Result<()> { use std::os::fd::AsRawFd; + #[cfg(test)] + if FAIL_SCRATCH_UNLINK.with(|f| f.get()) { + return Err(std::io::Error::other("injected scratch unlink failure")); + } + let name = Self::child_name(name)?; // SAFETY: resolves relative to our owned directory descriptor; // `unlinkat` without AT_REMOVEDIR removes only non-directories. @@ -1147,6 +1221,8 @@ impl KeyDirHandle { /// `fsync` the directory itself so a just-published entry survives a crash. fn sync(&self) -> std::io::Result<()> { + #[cfg(test)] + SYNC_COUNT.with(|c| c.set(c.get() + 1)); self.dir.sync_all() } @@ -1272,6 +1348,12 @@ fn describe_unusable_key_dir(dir: &Path, e: std::io::Error) -> anyhow::Error { anyhow::Error::new(e).context(format!("failed to open key directory {}", dir.display())) } +/// Group or world write on a directory is replacement authority over the next +/// entry. Execute or list without write is not. +fn dir_mode_allows_untrusted_replace(mode: u32) -> bool { + mode & 0o022 != 0 +} + /// Create the directory holding the key with owner-only permissions, tighten /// it if it already exists with a looser mode, and hand back the verified /// handle every subsequent operation is anchored to. Write permission on this @@ -1312,8 +1394,14 @@ fn ensure_key_dir(dir: &Path) -> Result { ) })?; - let (pinned, created) = pin::create_dir_pinned_at(parent_fd.as_raw_fd(), leaf_name, dir, euid) - .map_err(|e| describe_unusable_key_dir(dir, e))?; + let (pinned, created) = pin::create_dir_pinned_at( + parent_fd.as_raw_fd(), + leaf_name, + dir, + euid, + leaf_dir_open_flags(), + ) + .map_err(|e| describe_unusable_key_dir(dir, e))?; let handle = KeyDirHandle::from_pinned_fd(pinned, dir); // A directory this process just created is already verified at exactly 0700 @@ -1334,22 +1422,38 @@ fn ensure_key_dir(dir: &Path) -> Result { // verify. let mode = md.permissions().mode() & 0o7777; if mode & 0o077 != 0 { + let writable = dir_mode_allows_untrusted_replace(mode); warn!( dir = %dir.display(), mode = format!("{mode:04o}"), - "key directory grants access beyond its owner; tightening it to 0700" + writable, + "{}", + if writable { + "key directory is writable beyond its owner; tightening it to 0700. Treat a key that was sitting there as possibly exposed" + } else { + "key directory grants access beyond its owner; tightening it to 0700" + } ); // `fchmod` through the handle: the directory whose mode changes is // the object that was just verified, not whatever the pathname // resolves to by now. handle.tighten_to_0700().with_context(|| { - format!( - "key directory {} has mode {:04o}, which lets other users replace \ - the keys it holds, and it could not be tightened; run `chmod 700 {}`", - dir.display(), - mode, - dir.display() - ) + if writable { + format!( + "key directory {} has mode {:04o}, which lets other users replace \ + the keys it holds, and it could not be tightened; run `chmod 700 {}`", + dir.display(), + mode, + dir.display() + ) + } else { + format!( + "key directory {} has mode {:04o} and could not be tightened; run `chmod 700 {}`", + dir.display(), + mode, + dir.display() + ) + } })?; let after = handle .metadata() @@ -1442,13 +1546,30 @@ fn ensure_key_dir(dir: &Path) -> Result { #[cfg(unix)] pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Result<()> { use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt; - let dir = key_path - .parent() - .ok_or_else(|| anyhow::anyhow!("{} names no directory to hold it", key_path.display()))?; let file_name = key_path .file_name() .ok_or_else(|| anyhow::anyhow!("{} names no key file", key_path.display()))?; + + // A bare filename (`identity.pem`) or `./identity.pem` publishes into the + // working directory. That form is legal for GITLAWB_KEY and illegal for + // GITLAWB_P2P_KEY; this helper must not chmod cwd as if it were a nominated + // key directory. + let parent = key_parent(key_path); + if parent == Path::new(".") { + let cwd = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(".") + .with_context(|| "failed to open the working directory for the identity key")?; + let handle = KeyDirHandle::from_existing_dir(cwd, Path::new("."))?; + write_key_atomically(&handle, file_name, bytes) + .with_context(|| format!("failed to write identity key to {}", key_path.display()))?; + return Ok(()); + } + + let dir = parent; let dir_name = dir .file_name() .ok_or_else(|| anyhow::anyhow!("{} names no final directory component", dir.display()))?; @@ -1468,11 +1589,12 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re .with_context(|| format!("failed to open {}", gp_path.display()))?; let euid = effective_uid(); - let (pinned, created) = pin::create_dir_pinned_at(gp.as_raw_fd(), dir_name, dir, euid) - .map_err(|e| { - anyhow::Error::new(e) - .context(format!("failed to create key directory {}", dir.display())) - })?; + let (pinned, created) = + pin::create_dir_pinned_at(gp.as_raw_fd(), dir_name, dir, euid, leaf_dir_open_flags()) + .map_err(|e| { + anyhow::Error::new(e) + .context(format!("failed to create key directory {}", dir.display())) + })?; let handle = KeyDirHandle::from_pinned_fd(pinned, dir); // An adopted directory is tightened when it grants access beyond the owner, @@ -1487,10 +1609,17 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re .mode() & 0o7777; if mode & 0o077 != 0 { + let writable = dir_mode_allows_untrusted_replace(mode); warn!( dir = %dir.display(), mode = format!("{mode:04o}"), - "identity key directory grants access beyond its owner; tightening it to 0700" + writable, + "{}", + if writable { + "identity key directory is writable beyond its owner; tightening it to 0700" + } else { + "identity key directory grants access beyond its owner; tightening it to 0700" + } ); handle .tighten_to_0700() @@ -1515,8 +1644,10 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re /// key between the check and the rename. `hard_link` is atomic and fails with /// `AlreadyExists` if anything already occupies the path (a real file, or a /// symlink, which it does not follow), so the two properties hold together -/// without a check-then-act gap. The scratch file is unlinked either way, so a -/// failed start leaves the key directory as it found it. +/// without a check-then-act gap. On success the scratch name is unlinked and +/// the directory is fsynced again so the leftover name is not the durable +/// state. On failure the scratch is removed too, and a cleanup error is +/// reported alongside the write error. fn write_key_atomically( dir: &KeyDirHandle, key_name: &std::ffi::OsStr, @@ -1525,10 +1656,39 @@ fn write_key_atomically( let (scratch_name, mut file) = create_scratch_key_file(dir)?; let result = fill_and_publish(&mut file, bytes, dir, &scratch_name, key_name); drop(file); - // Unconditional: on success the key is reachable through its own link, and - // on failure nothing may be left behind. - let _ = dir.remove_child(&scratch_name); - result + match result { + Ok(()) => { + dir.remove_child(&scratch_name).map_err(|e| { + std::io::Error::new( + e.kind(), + format!( + "published the key but failed to remove the scratch name {}: {e}", + Path::new(&scratch_name).display() + ), + ) + })?; + dir.sync().map_err(|e| { + std::io::Error::new( + e.kind(), + format!( + "failed to sync key directory {} after removing the scratch name; the \ + identity may not survive a crash until the next successful start: {e}", + dir.path.display() + ), + ) + })?; + Ok(()) + } + Err(e) => { + if let Err(clean) = dir.remove_child(&scratch_name) { + return Err(std::io::Error::new( + e.kind(), + format!("{e}; also failed to remove the scratch this process created: {clean}"), + )); + } + Err(e) + } + } } /// Open a uniquely named scratch file in `dir` with owner-only permissions @@ -1597,6 +1757,12 @@ thread_local! { /// test cannot disturb the others running beside it. static FAIL_KEY_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Test-only fault injection for scratch unlink after publish. + static FAIL_SCRATCH_UNLINK: std::cell::Cell = const { std::cell::Cell::new(false) }; + + /// How many times this test thread fsync'd a key directory. + static SYNC_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Test-only override for the process effective uid. static EUID_OVERRIDE: std::cell::Cell> = const { std::cell::Cell::new(None) }; } @@ -2026,6 +2192,16 @@ pub async fn start( mod tests { use super::*; + #[test] + fn dir_mode_replace_authority_is_the_write_bits() { + assert!(!dir_mode_allows_untrusted_replace(0o755)); + assert!(!dir_mode_allows_untrusted_replace(0o711)); + assert!(!dir_mode_allows_untrusted_replace(0o111)); + assert!(dir_mode_allows_untrusted_replace(0o775)); + assert!(dir_mode_allows_untrusted_replace(0o777)); + assert!(dir_mode_allows_untrusted_replace(0o722)); + } + #[test] fn p2p_identity_not_derivable_from_did_alone() { let dir_a = tempfile::tempdir().unwrap(); @@ -2758,6 +2934,25 @@ mod tests { stdout.contains(&format!("p2p-key-skip-pin: asserted target={target}")), "target={target}: fixture must print its sentinel\n{stdout}" ); + // A pin/verify failure on an object this process just created must + // not leave that object behind. The dir target mkdirat's `keys`; + // the key target O_EXCL-creates a scratch file inside a pre-existing + // `keys`. Either leftover turns a transient pin fault into the next + // boot's adopted-existing path. + if target == "dir" { + assert!( + !base.path().join("keys").exists(), + "target=dir: a failed pin must remove the directory this process created" + ); + } else { + let leftovers: Vec<_> = std::fs::read_dir(base.path().join("keys")) + .map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name()).collect()) + .unwrap_or_default(); + assert!( + leftovers.is_empty(), + "target=key: a failed pin must remove the scratch this process created, found: {leftovers:?}" + ); + } } } @@ -3380,6 +3575,57 @@ mod tests { assert_eq!(PeerId::from(kp.public()), PeerId::from(reloaded.public())); } + #[cfg(unix)] + #[test] + fn successful_publish_fsyncs_after_scratch_unlink() { + SYNC_COUNT.with(|c| c.set(0)); + let dir = key_base_0700(); + let path = dir.path().join("keys").join("p2p.key"); + load_or_create_p2p_keypair(&path).expect("first boot"); + let syncs = SYNC_COUNT.with(|c| c.get()); + assert!( + syncs >= 2, + "publish must fsync after linking and again after unlinking the scratch, got {syncs}" + ); + let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name()) + .collect(); + assert_eq!( + leftovers, + vec![std::ffi::OsString::from("p2p.key")], + "success must leave only the nominated key, found: {leftovers:?}" + ); + } + + #[cfg(unix)] + #[test] + fn scratch_unlink_failure_is_not_reported_as_success() { + let dir = key_base_0700(); + let key_dir = dir.path().join("keys"); + let path = key_dir.join("p2p.key"); + + FAIL_SCRATCH_UNLINK.with(|f| f.set(true)); + let result = load_or_create_p2p_keypair(&path); + FAIL_SCRATCH_UNLINK.with(|f| f.set(false)); + + result.expect_err("an unlink failure after publish must not report success"); + assert!( + path.exists(), + "the nominated key is already linked; unlink failure must not delete it" + ); + let leftovers: Vec<_> = std::fs::read_dir(&key_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + leftovers.iter().any(|n| n.starts_with(".p2p.key.") && n.ends_with(".tmp")), + "the scratch name must still be present so the failure is visible, found: {leftovers:?}" + ); + } + #[cfg(unix)] #[test] fn p2p_key_file_with_loose_permissions_is_rejected() { @@ -4021,6 +4267,41 @@ mod tests { .expect("a 1777 sticky ancestor must boot"); } + /// Owner-execute-only (0111) is enough to traverse. Requiring directory + /// list permission would refuse a path the ownership/write-authority + /// predicate already accepts. The next component must already exist: + /// 0111 has no owner-write, so the walk cannot mkdirat through it. + #[cfg(all(unix, any(target_os = "linux", target_os = "android")))] + #[test] + fn ancestor_walk_accepts_search_only_ancestor() { + use std::os::unix::fs::PermissionsExt; + + let base = key_base_0700(); + let anc = base.path().join("searchonly"); + let keys = anc.join("keys"); + std::fs::create_dir_all(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&anc, std::fs::Permissions::from_mode(0o111)).unwrap(); + + let path = keys.join("p2p.key"); + let loaded = load_or_create_p2p_keypair(&path); + let kp = match loaded { + Ok(kp) => kp, + Err(e) => { + std::fs::set_permissions(&anc, std::fs::Permissions::from_mode(0o700)).unwrap(); + panic!("a search-only ancestor must not require directory-list permission: {e:#}"); + } + }; + let reloaded = load_or_create_p2p_keypair(&path); + std::fs::set_permissions(&anc, std::fs::Permissions::from_mode(0o700)).unwrap(); + let reloaded = reloaded.expect("reload"); + assert_eq!( + PeerId::from(kp.public()), + PeerId::from(reloaded.public()), + "the identity must reload stably through a search-only ancestor" + ); + } + #[cfg(unix)] #[test] fn ancestor_walk_refuses_intermediate_symlink() { From 2a7d2e8dac5e3ebdcaadd1751928d506e592522f Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:10:41 -0500 Subject: [PATCH 29/36] fix(node): refuse a writable cwd for a bare identity key Publishing identity.pem into cwd must not chmod cwd, but a 0600 key is still replaceable if that directory is group or world writable. Apply the same trusted-parent check used for nominated key directories before the handle is returned. --- crates/gitlawb-node/src/main.rs | 92 ++++++++++++++++++++++++++++++ crates/gitlawb-node/src/p2p/mod.rs | 20 +++++-- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 124691e8..bf5d156e 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -2157,4 +2157,96 @@ mod identity_key_storage_tests { ); } } + + /// A 0600 identity file is not protected if cwd is group/world-writable: + /// another local user can unlink or replace the entry. Creating into that + /// cwd must fail, and must not leave a key behind. Do not chmod cwd. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=identity-key-bare-writable"] + fn fixture_bare_identity_key_in_writable_cwd_is_refused() { + use std::os::unix::fs::PermissionsExt; + + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() + != Some("identity-key-bare-writable") + { + return; + } + let cwd = std::path::PathBuf::from( + std::env::var("GITLAWB_TEST_BASE").expect("GITLAWB_TEST_BASE"), + ); + std::env::set_current_dir(&cwd).expect("chdir into isolated tempdir"); + std::fs::set_permissions(&cwd, std::fs::Permissions::from_mode(0o777)).unwrap(); + let cwd_mode_before = std::fs::symlink_metadata(".").unwrap().permissions().mode() & 0o7777; + + let Err(err) = load_or_create_keypair_at(std::path::Path::new("identity.pem")) else { + panic!("a bare identity path under a writable cwd must be refused"); + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("writable beyond its owner") || msg.contains("0777"), + "the refusal must name the writable-parent reason, got: {msg}" + ); + assert!( + !std::path::Path::new("identity.pem").exists(), + "a refused cwd must not have identity.pem created" + ); + let cwd_mode_after = std::fs::symlink_metadata(".").unwrap().permissions().mode() & 0o7777; + assert_eq!( + cwd_mode_before, cwd_mode_after, + "refusing a writable cwd must not chmod it" + ); + println!("identity-key-bare-writable: asserted"); + } + + #[cfg(unix)] + #[test] + fn bare_identity_key_path_refuses_a_writable_cwd() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let mut cmd = std::process::Command::new(std::env::current_exe().expect("current_exe")); + cmd.args([ + "identity_key_storage_tests::fixture_bare_identity_key_in_writable_cwd_is_refused", + "--exact", + "--ignored", + "--nocapture", + ]) + .env("GITLAWB_TEST_FIXTURE", "identity-key-bare-writable") + .env("GITLAWB_TEST_BASE", base.path()); + let output = cmd + .output() + .expect("spawn the writable-cwd identity fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "writable cwd must refuse the bare identity path\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("1 passed"), + "filter must select one passing test\n{stdout}" + ); + assert!( + stdout.contains("identity-key-bare-writable: asserted"), + "fixture must print its sentinel\n{stdout}" + ); + let leftovers: Vec<_> = std::fs::read_dir(base.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name()) + .collect(); + assert!( + leftovers.is_empty(), + "parent must also see no identity.pem, found: {leftovers:?}" + ); + let cwd_mode = std::fs::symlink_metadata(base.path()) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(cwd_mode, 0o777, "cwd must stay 0777"); + } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 45aad825..d635f049 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -593,7 +593,7 @@ pub(crate) mod pin { /// The ancestor predicate, applied to the parent this helper is about to /// chmod a child of. - fn verify_trusted_parent( + pub(crate) fn verify_trusted_parent( fd: std::os::fd::RawFd, display: &Path, euid: u32, @@ -1060,9 +1060,18 @@ impl KeyDirHandle { /// Publish into an already-existing directory without creating or chmodding /// it. The working-directory case for a bare node-identity filename uses - /// this so the p2p key's named-directory contract is not imported onto - /// `GITLAWB_KEY=identity.pem`. - fn from_existing_dir(dir: std::fs::File, dir_path: &Path) -> std::io::Result { + /// this so the p2p key's named-directory contract (pin to 0700) is not + /// imported onto `GITLAWB_KEY=identity.pem`. Write-authority is still + /// checked: a 0600 key is unprotected if this directory is group/world + /// writable, so [`pin::verify_trusted_parent`] runs on the held descriptor + /// before the handle is returned. + fn from_existing_dir( + dir: std::fs::File, + dir_path: &Path, + held_for: &Path, + ) -> std::io::Result { + use std::os::fd::AsRawFd; + let md = dir.metadata()?; if !md.is_dir() { return Err(std::io::Error::new( @@ -1070,6 +1079,7 @@ impl KeyDirHandle { format!("{} is not a directory", dir_path.display()), )); } + pin::verify_trusted_parent(dir.as_raw_fd(), held_for, effective_uid())?; Ok(KeyDirHandle { dir, path: dir_path.to_path_buf(), @@ -1563,7 +1573,7 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC) .open(".") .with_context(|| "failed to open the working directory for the identity key")?; - let handle = KeyDirHandle::from_existing_dir(cwd, Path::new("."))?; + let handle = KeyDirHandle::from_existing_dir(cwd, Path::new("."), key_path)?; write_key_atomically(&handle, file_name, bytes) .with_context(|| format!("failed to write identity key to {}", key_path.display()))?; return Ok(()); From e7e4a16af5bb28f81af9a0defb4c124ce5812788 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:07:59 -0500 Subject: [PATCH 30/36] fix(node): load identity without following pathnames Create already published through a directory handle. Load still used exists-then-read, which followed a symlink at the key and opened the grandparent by pathname. Open both no-follow, treat /identity.pem like a cwd publish so it reaches the filesystem, and open a search-only grandparent with the walk flags. --- crates/gitlawb-node/src/main.rs | 187 +++++++++++++++++++++++++---- crates/gitlawb-node/src/p2p/mod.rs | 113 ++++++++++++++--- 2 files changed, 261 insertions(+), 39 deletions(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index bf5d156e..647e61d1 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1405,39 +1405,47 @@ fn load_or_create_keypair(config: &Config) -> Result { /// The node identity key's load-or-create, taken by path so the storage /// contract can be tested without building a whole `Config`. fn load_or_create_keypair_at(key_path: &std::path::Path) -> Result { + #[cfg(unix)] + if let Some(pem) = p2p::load_identity_pem_if_present(key_path)? { + let kp = Keypair::from_pem(&pem).map_err(|e| anyhow::anyhow!("invalid PEM key: {e}"))?; + info!(path = %key_path.display(), "loaded existing identity"); + return Ok(kp); + } + + #[cfg(not(unix))] if key_path.exists() { 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}"))?; info!(path = %key_path.display(), "loaded existing identity"); - Ok(kp) - } else { - let kp = Keypair::generate(); - let pem = kp - .to_pem() - .map_err(|e| anyhow::anyhow!("failed to serialize key: {e}"))?; - - // The directory is created pinned to 0700 and the PEM is published - // through the same scratch-then-link path the p2p key uses, at a - // verified 0600. The previous flow (create_dir_all with no mode, then - // write, then set_permissions) is the sequence INV-23 prohibits: it - // left the directory world-writable under a permissive umask, and - // under a restrictive one it could not be opened at all, which failed - // the whole node here, before the listener binds. - #[cfg(unix)] - p2p::create_pinned_dir_and_publish(key_path, pem.as_bytes())?; + return Ok(kp); + } - #[cfg(not(unix))] - { - if let Some(parent) = key_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(key_path, pem.as_bytes())?; - } + let kp = Keypair::generate(); + let pem = kp + .to_pem() + .map_err(|e| anyhow::anyhow!("failed to serialize key: {e}"))?; + + // The directory is created pinned to 0700 and the PEM is published + // through the same scratch-then-link path the p2p key uses, at a + // verified 0600. The previous flow (create_dir_all with no mode, then + // write, then set_permissions) is the sequence INV-23 prohibits: it + // left the directory world-writable under a permissive umask, and + // under a restrictive one it could not be opened at all, which failed + // the whole node here, before the listener binds. + #[cfg(unix)] + p2p::create_pinned_dir_and_publish(key_path, pem.as_bytes())?; - info!(path = %key_path.display(), did = %kp.did(), "generated new node identity"); - Ok(kp) + #[cfg(not(unix))] + { + 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) } #[cfg(test)] @@ -2249,4 +2257,133 @@ mod identity_key_storage_tests { & 0o7777; assert_eq!(cwd_mode, 0o777, "cwd must stay 0777"); } + + /// An existing key in a writable directory still loads. Create is refused + /// there; turning that into a boot failure on upgrade would strand nodes + /// that already have a key. + #[cfg(unix)] + #[test] + fn existing_identity_in_a_writable_dir_still_loads() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let key = base.path().join("identity.pem"); + let created = load_or_create_keypair_at(&key).expect("create under 0755"); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let reloaded = load_or_create_keypair_at(&key) + .expect("an existing identity in a writable directory must still load"); + assert_eq!(created.did(), reloaded.did()); + assert_eq!( + std::fs::symlink_metadata(base.path()) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o777, + "load must not chmod the writable directory" + ); + } + + #[cfg(unix)] + #[test] + fn identity_symlink_key_is_refused() { + let base = tempfile::tempdir().unwrap(); + let real = base.path().join("real.pem"); + let created = load_or_create_keypair_at(&real).expect("create the symlink target"); + std::os::unix::fs::symlink(&real, base.path().join("identity.pem")).unwrap(); + let err = match load_or_create_keypair_at(&base.path().join("identity.pem")) { + Ok(kp) => panic!( + "a symlink at the identity path must be refused, not followed; loaded did={}", + kp.did() + ), + Err(e) => format!("{e:#}"), + }; + assert!( + err.contains("symlink") || err.to_lowercase().contains("too many levels"), + "symlink refusal must name the link, got: {err}" + ); + let reread = load_or_create_keypair_at(&real).expect("target still loads by its real path"); + assert_eq!( + created.did(), + reread.did(), + "the symlink target must be unchanged" + ); + } + + #[cfg(unix)] + #[test] + fn identity_symlinked_grandparent_is_refused() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + let real = base.path().join("real"); + std::fs::create_dir(&real).unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap(); + let link = base.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let key = link.join("keys").join("identity.pem"); + let err = match load_or_create_keypair_at(&key) { + Ok(_) => panic!("a symlinked grandparent must be refused, not followed"), + Err(e) => format!("{e:#}"), + }; + assert!( + err.contains("symlink") + || err.to_lowercase().contains("too many levels") + || err.contains("loop"), + "symlinked grandparent refusal must name the link, got: {err}" + ); + assert!( + !real.join("keys").exists(), + "symlink target must be untouched" + ); + } + + /// 0111 grandparent cannot mkdir, so the key directory must already exist. + /// Opening that grandparent must not require directory-list permission. + #[cfg(all(unix, any(target_os = "linux", target_os = "android")))] + #[test] + fn identity_search_only_grandparent_publishes_into_existing_key_dir() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let gp = base.path().join("searchonly"); + let keys = gp.join("keys"); + std::fs::create_dir_all(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&gp, std::fs::Permissions::from_mode(0o111)).unwrap(); + let key = keys.join("identity.pem"); + let created = match load_or_create_keypair_at(&key) { + Ok(kp) => kp, + Err(e) => { + let _ = std::fs::set_permissions(&gp, std::fs::Permissions::from_mode(0o700)); + panic!("search-only grandparent must be enough to publish into an existing 0700 key dir: {e:#}"); + } + }; + let reloaded = load_or_create_keypair_at(&key); + std::fs::set_permissions(&gp, std::fs::Permissions::from_mode(0o700)).unwrap(); + let reloaded = reloaded.expect("reload"); + assert_eq!(created.did(), reloaded.did()); + } + + #[cfg(unix)] + #[test] + fn identity_root_adjacent_path_is_not_the_empty_component_error() { + if std::path::Path::new("/identity.pem").exists() { + return; + } + let err = match load_or_create_keypair_at(std::path::Path::new("/identity.pem")) { + Ok(_) => panic!("creating /identity.pem as a non-root user must not succeed"), + Err(e) => format!("{e:#}"), + }; + assert!( + !err.contains("names no final directory component"), + "root-adjacent identity create must reach the filesystem, got: {err}" + ); + assert!( + !std::path::Path::new("/identity.pem").exists(), + "the probe must not leave /identity.pem behind" + ); + } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index d635f049..6ae17ac9 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -687,6 +687,28 @@ fn leaf_dir_open_flags() -> libc::c_int { libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC } +/// Open `path` as a directory without following a symlink in the final +/// position. `flags` is either the walk set (`O_PATH` on Linux) or the leaf +/// set (`O_RDONLY`). +#[cfg(unix)] +fn open_dir_with_flags(path: &Path, flags: libc::c_int) -> std::io::Result { + use std::os::fd::FromRawFd; + use std::os::unix::ffi::OsStrExt; + + let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{} contains an interior NUL byte", path.display()), + ) + })?; + // SAFETY: `open` returns a new descriptor we own on success. + let fd = unsafe { libc::open(cpath.as_ptr(), flags) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(unsafe { std::fs::File::from_raw_fd(fd) }) +} + #[cfg(unix)] fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result { use std::os::fd::{AsRawFd, FromRawFd}; @@ -1537,6 +1559,68 @@ fn ensure_key_dir(dir: &Path) -> Result { Ok(handle) } +/// Load an existing identity PEM without following a symlink at the key path +/// or at its immediate parent. Missing parent or missing file is `Ok(None)` +/// so the caller can create. A symlink, or any other non-regular object, is +/// an error. Write-authority on the parent is not judged here: an existing +/// key must still load after upgrade even if its directory is one we would +/// refuse to create into. +#[cfg(unix)] +pub(crate) fn load_identity_pem_if_present(key_path: &Path) -> Result> { + use std::io::Read; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let file_name = key_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("{} names no key file", key_path.display()))?; + let parent = key_parent(key_path); + let dir = match open_dir_with_flags(parent, leaf_dir_open_flags()) { + Ok(dir) => dir, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(e).with_context(|| { + format!( + "failed to open {} to read the identity key (a symlink here is refused rather than followed)", + parent.display() + ) + }); + } + }; + let cname = std::ffi::CString::new(file_name.as_bytes()) + .map_err(|_| anyhow::anyhow!("{} contains an interior NUL byte", key_path.display()))?; + let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; + // SAFETY: openat relative to the directory descriptor we hold; O_NOFOLLOW + // refuses a symlink in the final position instead of reading through it. + let fd = unsafe { libc::openat(dir.as_raw_fd(), cname.as_ptr(), flags) }; + if fd < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(err).with_context(|| { + format!( + "failed to open identity key at {} (a symlink here is refused rather than followed)", + key_path.display() + ) + }); + } + let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; + let md = file + .metadata() + .with_context(|| format!("failed to stat identity key at {}", key_path.display()))?; + if !md.is_file() { + anyhow::bail!( + "identity key at {} must be a regular file; directories, FIFOs, and special files are refused", + key_path.display() + ); + } + let mut pem = String::new(); + file.read_to_string(&mut pem) + .with_context(|| format!("failed to read key from {}", key_path.display()))?; + Ok(Some(pem)) +} + /// Create `key_path`'s directory pinned to 0700 and publish `bytes` into it at /// a verified 0600, using the same scratch-then-link path the p2p key uses. /// @@ -1556,24 +1640,21 @@ fn ensure_key_dir(dir: &Path) -> Result { #[cfg(unix)] pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Result<()> { use std::os::fd::AsRawFd; - use std::os::unix::fs::OpenOptionsExt; let file_name = key_path .file_name() .ok_or_else(|| anyhow::anyhow!("{} names no key file", key_path.display()))?; - // A bare filename (`identity.pem`) or `./identity.pem` publishes into the - // working directory. That form is legal for GITLAWB_KEY and illegal for - // GITLAWB_P2P_KEY; this helper must not chmod cwd as if it were a nominated - // key directory. + // A bare filename (`identity.pem` / `./identity.pem`) or a root-adjacent + // path (`/identity.pem`) publishes into an already-nominated directory. + // That form is legal for GITLAWB_KEY and illegal for GITLAWB_P2P_KEY; this + // helper must not chmod cwd or `/` as if they were a nominated key + // directory. let parent = key_parent(key_path); - if parent == Path::new(".") { - let cwd = std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC) - .open(".") - .with_context(|| "failed to open the working directory for the identity key")?; - let handle = KeyDirHandle::from_existing_dir(cwd, Path::new("."), key_path)?; + if parent.file_name().is_none() { + let cwd = open_dir_with_flags(parent, leaf_dir_open_flags()) + .with_context(|| format!("failed to open {} for the identity key", parent.display()))?; + let handle = KeyDirHandle::from_existing_dir(cwd, parent, key_path)?; write_key_atomically(&handle, file_name, bytes) .with_context(|| format!("failed to write identity key to {}", key_path.display()))?; return Ok(()); @@ -1595,8 +1676,12 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re } let grandparent = dir.parent().filter(|g| !g.as_os_str().is_empty()); let gp_path = grandparent.unwrap_or_else(|| Path::new(".")); - let gp = std::fs::File::open(gp_path) - .with_context(|| format!("failed to open {}", gp_path.display()))?; + let gp = open_dir_with_flags(gp_path, walk_dir_open_flags()).map_err(|e| { + anyhow::Error::new(e).context(format!( + "failed to open {} (a symlink here is refused rather than followed)", + gp_path.display() + )) + })?; let euid = effective_uid(); let (pinned, created) = From c57e056192a728f7d6c2946ba45a862049ef3c25 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:01:37 -0500 Subject: [PATCH 31/36] fix(node): parse key paths, leave identity parents, repair special modes A terminal `.` in GITLAWB_P2P_KEY was accepted then retargeted by Path onto the parent directory. Identity creation imported p2p's 0700 pin onto any existing named parent. A setgid-only 2700 key directory skipped repair and disabled P2P. Darwin ancestor walks still required directory-list permission. --- crates/gitlawb-node/src/config.rs | 35 ++- crates/gitlawb-node/src/main.rs | 97 +++++-- crates/gitlawb-node/src/p2p/mod.rs | 265 +++++++++++------- .../tests/p2p_key_startup_policy.rs | 46 +++ 4 files changed, 313 insertions(+), 130 deletions(-) diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index fbdbd169..b02c6ca1 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -823,7 +823,7 @@ impl Config { // failure stops the node, so it must not decide anything about // live filesystem objects. Storage faults belong to the load path, // which degrades instead of exiting. - crate::p2p::validate_p2p_key_config(&p2p_key_path, Some(&self.p2p_key_path)) + let _ = crate::p2p::validate_p2p_key_config(&p2p_key_path, Some(&self.p2p_key_path)) .map_err(|e| e.to_string())?; } @@ -1654,6 +1654,24 @@ mod tests { expect: Expect::Rejected("must name a key file"), needs_home: true, }, + Row { + port: 7546, + path: "/data/keys/.", + expect: Expect::Rejected("must name a key file"), + needs_home: false, + }, + Row { + port: 7546, + path: "/data/keys/./.", + expect: Expect::Rejected("must name a key file"), + needs_home: false, + }, + Row { + port: 7546, + path: "~/.gitlawb/.", + expect: Expect::Rejected("must name a key file"), + needs_home: true, + }, ]; let have_home = dirs_next::home_dir().is_some(); @@ -1935,4 +1953,19 @@ mod tests { "trailing `/` must be rejected before chmod, got: {err}" ); } + + /// A terminal `.` is a directory-valued spelling. Rust's Path drops it, so + /// `/data/keys/.` would otherwise be stored as the file `keys` under `/data`. + #[test] + fn p2p_key_path_terminal_dot_is_rejected() { + for path in ["/data/keys/.", "/data/keys/./.", "/data/keys/.//."] { + let err = config_with_p2p_key(path) + .validate() + .expect_err("a terminal `.` must be rejected as a directory"); + assert!( + err.contains("must name a key file"), + "{path:?} must be refused before Path can retarget it, got: {err}" + ); + } + } } diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 647e61d1..ac90bfcd 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1956,42 +1956,92 @@ mod identity_key_storage_tests { println!("identity-key-umask: asserted did={}", kp.did()); } - /// A pre-existing directory that grants access beyond the owner is - /// tightened on the next start, which is the INV-23(a) half issue #231 - /// names. Write on the directory is what lets another user replace a - /// 0600 PEM; 0755 is tightened too, but is not replacement authority. + /// GITLAWB_KEY names a PEM file, not a dedicated key directory. An existing + /// 0755 parent is usable (no group/world write) and must not be chmodded. #[cfg(unix)] #[test] - fn existing_identity_key_directory_is_tightened() { + fn existing_identity_parent_is_not_chmodded() { + use std::os::unix::fs::PermissionsExt; + + for parent_name in [".gitlawb", "shared"] { + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let dir = base.path().join(parent_name); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let key = dir.join("identity.pem"); + load_or_create_keypair_at(&key).expect("first boot into an existing 0755 parent"); + + let mode = std::fs::symlink_metadata(&dir) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, 0o755, + "{parent_name}: an existing 0755 identity parent must stay 0755, found {mode:04o}" + ); + assert_eq!( + std::fs::symlink_metadata(&key) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o600, + "the identity key must be owner-only" + ); + } + } + + /// A parent this process creates is still pinned to 0700. That is the + /// missing-directory path, not an adopt of an operator-owned tree. + #[cfg(unix)] + #[test] + fn missing_identity_parent_is_created_0700() { use std::os::unix::fs::PermissionsExt; let base = tempfile::tempdir().unwrap(); std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); let dir = base.path().join(".gitlawb"); - std::fs::create_dir(&dir).unwrap(); - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); - let key = dir.join("identity.pem"); - load_or_create_keypair_at(&key).expect("first boot into a loose directory"); - + load_or_create_keypair_at(&key).expect("first boot creates the identity parent"); let mode = std::fs::symlink_metadata(&dir) .unwrap() .permissions() .mode() & 0o7777; - assert_eq!( - mode, 0o700, - "a loose identity key directory must be tightened" - ); - assert_eq!( - std::fs::symlink_metadata(&key) - .unwrap() - .permissions() - .mode() - & 0o7777, - 0o600, - "the identity key must be owner-only" + assert_eq!(mode, 0o700, "a parent this process created must be 0700"); + } + + /// Group/world write on the parent is replacement authority over a 0600 + /// key. Refuse, and leave the directory untouched. + #[cfg(unix)] + #[test] + fn writable_identity_parent_is_refused_unchanged() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let dir = base.path().join("tmp"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap(); + let key = dir.join("identity.pem"); + let Err(err) = load_or_create_keypair_at(&key) else { + panic!("a world-writable identity parent must be refused"); + }; + let text = format!("{err:#}"); + assert!( + text.contains("writable") || text.contains("replace"), + "the refusal must name the write-authority problem, got: {text}" ); + let mode = std::fs::symlink_metadata(&dir) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o777, "a refusal must not chmod the parent"); + assert!(!key.exists(), "a refusal must not publish the identity key"); } /// An existing key is loaded, never rewritten and never chmodded: the @@ -2288,7 +2338,10 @@ mod identity_key_storage_tests { #[cfg(unix)] #[test] fn identity_symlink_key_is_refused() { + use std::os::unix::fs::PermissionsExt; + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); let real = base.path().join("real.pem"); let created = load_or_create_keypair_at(&real).expect("create the symlink target"); std::os::unix::fs::symlink(&real, base.path().join("identity.pem")).unwrap(); diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 6ae17ac9..fc33ee1d 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -670,13 +670,30 @@ fn walk_dir_open_flags() -> libc::c_int { // Traversal needs search/execute, not directory-list. `O_RDONLY` on a // directory additionally requires read permission, so a safe 0111 ancestor // would fail before the ownership/write-authority predicate ran. + let common = libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC; #[cfg(any(target_os = "linux", target_os = "android"))] { - libc::O_PATH | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC - } - #[cfg(not(any(target_os = "linux", target_os = "android")))] + libc::O_PATH | common + } + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + ))] + { + libc::O_SEARCH | common + } + #[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + )))] { - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC + libc::O_RDONLY | common } } @@ -873,13 +890,36 @@ fn walk_component_error(e: std::io::Error, key_dir: &Path, component: &Path) -> } } +/// Whether a configured spelling names a directory rather than a key file. +/// +/// Inspects the stored string, not `Path::file_name`. Rust's `Path` drops a +/// final `.` component, so `/data/keys/.` would otherwise be stored as the +/// file `keys` under `/data`. +fn spelling_denotes_a_directory(spelling: &str) -> bool { + if spelling == "~/" || spelling.ends_with('/') || spelling.ends_with('\\') { + return true; + } + let last = spelling + .rsplit(['/', '\\']) + .next() + .unwrap_or(spelling); + last == "." || last == ".." +} + /// Whether the configured path names a directory rather than a key file. /// -/// Checked lexically (`~/`, a trailing `/`) and against an existing path on -/// disk, before any directory is created or chmodded. +/// Checked lexically (`~/`, a trailing `/`, a final `.` or `..` component) +/// before any directory is created or chmodded. The Path is consulted only +/// as a backstop for callers that pass no raw spelling: the OsStr still +/// carries the operator's `.` even after `components()` has dropped it. fn path_denotes_a_directory(key_path: &Path, configured_raw: Option<&str>) -> bool { if let Some(raw) = configured_raw { - if raw == "~/" || raw.ends_with('/') { + if spelling_denotes_a_directory(raw) { + return true; + } + } + if let Some(stored) = key_path.to_str() { + if spelling_denotes_a_directory(stored) { return true; } } @@ -909,10 +949,19 @@ fn path_denotes_a_directory(key_path: &Path, configured_raw: Option<&str>) -> bo #[error("{0}")] pub(crate) struct P2pKeyConfigError(String); +/// Directory plus leaf name of a validated p2p key path. Later storage must +/// consume this pair rather than asking `Path` again, because `Path` drops a +/// final `.` and would retarget `/data/keys/.` as the file `keys` under `/data`. +#[derive(Debug, Clone)] +pub(crate) struct P2pKeyTarget { + pub dir: PathBuf, + pub leaf: std::ffi::OsString, +} + pub(crate) fn validate_p2p_key_config( key_path: &Path, configured_raw: Option<&str>, -) -> Result<(), P2pKeyConfigError> { +) -> Result { let display = configured_raw.unwrap_or_else(|| key_path.to_str().unwrap_or("")); if names_no_usable_directory(key_path) { @@ -944,7 +993,18 @@ pub(crate) fn validate_p2p_key_config( // non-regular file, and the leaf openat gives ELOOP or ENOTDIR through // `describe_unusable_key_dir`. Deciding them here made the same fault // fatal or degradable depending only on which layer noticed it first. - Ok(()) + let leaf = key_path + .file_name() + .filter(|n| !n.is_empty()) + .ok_or_else(|| { + P2pKeyConfigError(format!( + "GITLAWB_P2P_KEY ({display}) must name a key file, not a directory" + )) + })?; + Ok(P2pKeyTarget { + dir: key_parent(key_path).to_path_buf(), + leaf: leaf.to_os_string(), + }) } /// Load the node's persistent libp2p identity from `key_path`, generating and @@ -979,23 +1039,11 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result // inspective — the checks below re-establish everything it observed on the // actual opened objects, so this exists for early, precise errors rather // than for safety. - validate_p2p_key_config(key_path, None).map_err(|e| anyhow::anyhow!(e))?; - - // Validation rejected paths without a final component, so `file_name` is - // present from here on; the error is a backstop, not a reachable path for - // a validated config. - let key_name = key_path - .file_name() - .ok_or_else(|| anyhow::anyhow!("GITLAWB_P2P_KEY ({}) names no file", key_path.display()))? - .to_os_string(); + let target = validate_p2p_key_config(key_path, None).map_err(|e| anyhow::anyhow!(e))?; - // Runs on both the load and the create path: the directory guards the key - // just as much as the key's own mode does, and an existing directory keeps - // whatever mode it was made with. The returned handle is the anchor every - // later operation goes through. - let dir = ensure_key_dir(key_parent(key_path))?; + let dir = ensure_key_dir(&target.dir)?; - if let Some(file) = open_existing_key(&dir, &key_name, key_path)? { + if let Some(file) = open_existing_key(&dir, &target.leaf, key_path)? { return read_p2p_keypair_from(file, key_path); } @@ -1008,7 +1056,7 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result .map_err(|e| anyhow::anyhow!("failed to serialize p2p key: {e}"))?, ); - match write_key_atomically(&dir, &key_name, &bytes) { + match write_key_atomically(&dir, &target.leaf, &bytes) { Ok(()) => { info!( path = %key_path.display(), @@ -1021,7 +1069,7 @@ pub fn load_or_create_p2p_keypair(key_path: &Path) -> Result // between the open above and the atomic publish. Adopt its key, // through the same handle, so both processes converge on one PeerId. Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - let file = open_existing_key(&dir, &key_name, key_path)?.ok_or_else(|| { + let file = open_existing_key(&dir, &target.leaf, key_path)?.ok_or_else(|| { anyhow::anyhow!( "p2p key at {} vanished after another process published it; \ refusing to guess which identity this node should have", @@ -1081,9 +1129,9 @@ impl KeyDirHandle { } /// Publish into an already-existing directory without creating or chmodding - /// it. The working-directory case for a bare node-identity filename uses - /// this so the p2p key's named-directory contract (pin to 0700) is not - /// imported onto `GITLAWB_KEY=identity.pem`. Write-authority is still + /// it. Identity-key paths use this for cwd, `/`, and any nominated parent + /// that already exists, so the p2p key's dedicated-directory contract (pin + /// to 0700) is not imported onto `GITLAWB_KEY`. Write-authority is still /// checked: a 0600 key is unprotected if this directory is group/world /// writable, so [`pin::verify_trusted_parent`] runs on the held descriptor /// before the handle is returned. @@ -1448,13 +1496,24 @@ fn ensure_key_dir(dir: &Path) -> Result { anyhow::bail!(err); } - // Read the full permission word, not `& 0o777`: an inherited setgid bit - // makes a 2700 directory compare unequal to 0700, and judging the two - // on different bit widths would skip the repair and then fail the - // verify. + // Owner rwx is required to use the directory. Group/world bits and + // special bits (setgid 2700, sticky 1700) are repairable and are + // normalized to 0700. Missing owner bits are over-closed: refuse, + // do not widen. let mode = md.permissions().mode() & 0o7777; - if mode & 0o077 != 0 { + if mode & 0o700 != 0o700 { + anyhow::bail!( + "p2p key directory {} has mode {:04o}, which this node cannot use; it is not \ + widened automatically because a directory closed on purpose is an operator \ + decision. Run `chmod 700 {}` if the node should own it.", + dir.display(), + mode, + dir.display() + ); + } + if mode != 0o700 { let writable = dir_mode_allows_untrusted_replace(mode); + let extra_access = mode & 0o077 != 0; warn!( dir = %dir.display(), mode = format!("{mode:04o}"), @@ -1462,8 +1521,10 @@ fn ensure_key_dir(dir: &Path) -> Result { "{}", if writable { "key directory is writable beyond its owner; tightening it to 0700. Treat a key that was sitting there as possibly exposed" - } else { + } else if extra_access { "key directory grants access beyond its owner; tightening it to 0700" + } else { + "key directory carries special mode bits; normalizing it to 0700" } ); // `fchmod` through the handle: the directory whose mode changes is @@ -1500,21 +1561,6 @@ fn ensure_key_dir(dir: &Path) -> Result { after ); } - } else if mode != 0o700 { - // Over-closed, and deliberately NOT widened. Granting owner-write - // back to a directory an operator froze would override their - // intent, and it would fire the "a loose key directory was - // tightened, treat the key as exposed" advice for a case where - // nothing was ever exposed. Refuse with the remedy instead, which - // is how an over-closed key FILE is already handled. - anyhow::bail!( - "p2p key directory {} has mode {:04o}, which this node cannot use; it is not \ - widened automatically because a directory closed on purpose is an operator \ - decision. Run `chmod 700 {}` if the node should own it.", - dir.display(), - mode, - dir.display() - ); } } @@ -1621,22 +1667,13 @@ pub(crate) fn load_identity_pem_if_present(key_path: &Path) -> Result Result<()> { use std::os::fd::AsRawFd; @@ -1665,8 +1702,30 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re .file_name() .ok_or_else(|| anyhow::anyhow!("{} names no final directory component", dir.display()))?; + // An existing nominated parent is used as-is. GITLAWB_KEY is a file path, + // not a dedicated-directory setting, so this must not chmod `/etc` or a + // shared 0755 volume. Write-authority still refuses a group/world-writable + // parent. A missing parent is created at 0700 below. + match open_dir_with_flags(dir, leaf_dir_open_flags()) { + Ok(existing) => { + let handle = KeyDirHandle::from_existing_dir(existing, dir, key_path)?; + write_key_atomically(&handle, file_name, bytes).with_context(|| { + format!("failed to write identity key to {}", key_path.display()) + })?; + return Ok(()); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(anyhow::Error::new(e).context(format!( + "failed to open {} for the identity key (a symlink here is refused rather than followed)", + dir.display() + ))); + } + } + // Ancestors above the key directory keep the existing behavior; only the - // directory that actually holds the secret is pinned. + // directory that actually holds the secret is pinned, and only when this + // process creates it. if let Some(grandparent) = dir.parent() { if !grandparent.as_os_str().is_empty() { std::fs::create_dir_all(grandparent).with_context(|| { @@ -1690,37 +1749,12 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re anyhow::Error::new(e) .context(format!("failed to create key directory {}", dir.display())) })?; - let handle = KeyDirHandle::from_pinned_fd(pinned, dir); - - // An adopted directory is tightened when it grants access beyond the owner, - // the same rule the p2p key directory follows, so an existing 0755 - // `~/.gitlawb` stops being world-traversable on the next start. - if !created { - use std::os::unix::fs::PermissionsExt; - let mode = handle - .metadata() - .with_context(|| format!("failed to stat key directory {}", dir.display()))? - .permissions() - .mode() - & 0o7777; - if mode & 0o077 != 0 { - let writable = dir_mode_allows_untrusted_replace(mode); - warn!( - dir = %dir.display(), - mode = format!("{mode:04o}"), - writable, - "{}", - if writable { - "identity key directory is writable beyond its owner; tightening it to 0700" - } else { - "identity key directory grants access beyond its owner; tightening it to 0700" - } - ); - handle - .tighten_to_0700() - .with_context(|| format!("failed to tighten key directory {}", dir.display()))?; - } - } + let handle = if created { + KeyDirHandle::from_pinned_fd(pinned, dir) + } else { + // Lost the mkdir race: use the winner without chmodding it. + KeyDirHandle::from_existing_dir(std::fs::File::from(pinned.into_inner()), dir, key_path)? + }; write_key_atomically(&handle, file_name, bytes) .with_context(|| format!("failed to write identity key to {}", key_path.display()))?; @@ -3098,22 +3132,26 @@ mod tests { assert_eq!(after, 0o700, "loose {loose:04o} must be tightened to 0700"); } - // An inherited setgid bit must be repaired, not refused: 2700 compares - // unequal to 0700 on the full word, and judging the predicate on 0o777 - // while verifying on 0o7777 would skip the repair and then fail. - { + // Repairable: owner rwx is present, and group/world or special bits are + // stripped to 0700. 2700 (setgid only) is the control-flow hole the + // 0o077 predicate misses; 2750 still has to keep working. + for repairable in [0o700u32, 0o2700, 0o1700, 0o2750] { let base = key_base_0700(); let keys = base.path().join("keys"); std::fs::create_dir(&keys).unwrap(); - std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o2750)).unwrap(); - load_or_create_p2p_keypair(&keys.join("p2p.key")) - .expect("a setgid key directory is repaired rather than refused"); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(repairable)).unwrap(); + load_or_create_p2p_keypair(&keys.join("p2p.key")).unwrap_or_else(|e| { + panic!("mode {repairable:04o} must boot and land at 0700, got: {e:#}") + }); let after = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777; - assert_eq!(after, 0o700, "setgid 2750 must be tightened to 0700"); + assert_eq!( + after, 0o700, + "{repairable:04o} must be left/normalized to 0700, found {after:04o}" + ); } // Too closed: refused, named, and left exactly as the operator set it. - for closed in [0o500u32, 0o100, 0o600] { + for closed in [0o500u32, 0o100, 0o600, 0o000] { let base = key_base_0700(); let keys = base.path().join("keys"); std::fs::create_dir(&keys).unwrap(); @@ -3506,6 +3544,9 @@ mod tests { ("keys/", "must include a directory"), ("p2p.key", "must include a directory"), ("a/../p2p.key", "must include a directory"), + ("/data/keys/.", "must name a key file"), + ("/data/keys/./.", "must name a key file"), + ("~/.gitlawb/.", "must name a key file"), ] { let err = validate_p2p_key_config(Path::new(raw), Some(raw)) .expect_err("a lexically invalid key path must be refused"); @@ -4366,7 +4407,17 @@ mod tests { /// list permission would refuse a path the ownership/write-authority /// predicate already accepts. The next component must already exist: /// 0111 has no owner-write, so the walk cannot mkdirat through it. - #[cfg(all(unix, any(target_os = "linux", target_os = "android")))] + #[cfg(all( + unix, + any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + ) + ))] #[test] fn ancestor_walk_accepts_search_only_ancestor() { use std::os::unix::fs::PermissionsExt; diff --git a/crates/gitlawb-node/tests/p2p_key_startup_policy.rs b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs index 4176cf23..e3508809 100644 --- a/crates/gitlawb-node/tests/p2p_key_startup_policy.rs +++ b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs @@ -454,3 +454,49 @@ fn port_zero_does_no_key_storage_io_at_all() { ); } } + +/// A terminal `.` is a directory-valued spelling. Path drops it, so the load +/// path would otherwise chmod the parent and publish a file named `keys`. +#[test] +fn terminal_dot_key_path_is_fatal_and_does_not_mutate() { + // Absent leaf: `/data/keys/.` must not create `keys` as a file or chmod `/data`. + { + let row = Row::new(); + let data = row.tree().join("data"); + std::fs::create_dir(&data).unwrap(); + std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap(); + let key = format!("{}/keys/.", data.display()); + let cwd = row.home.path().to_path_buf(); + assert_fatal_before_bind(&row, &key, &cwd); + let mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o755, "rejection must not chmod the parent"); + assert!( + !data.join("keys").exists(), + "rejection must not create a file named keys" + ); + } + + // Existing directory at the would-be leaf: still boot-fatal, directory untouched. + { + let row = Row::new(); + let data = row.tree().join("data"); + let keys = data.join("keys"); + std::fs::create_dir_all(&keys).unwrap(); + std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)).unwrap(); + let key = format!("{}/.", keys.display()); + let cwd = row.home.path().to_path_buf(); + assert_fatal_before_bind(&row, &key, &cwd); + assert!( + keys.is_dir(), + "the existing keys directory must remain a directory" + ); + let data_mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777; + let keys_mode = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777; + assert_eq!(data_mode, 0o755, "rejection must not chmod the parent"); + assert_eq!( + keys_mode, 0o700, + "rejection must not chmod the existing keys dir" + ); + } +} From 2c12ea1fb8ce1230ebb90d9dc40e2445fdc9d614 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:15:04 -0500 Subject: [PATCH 32/36] fix(node): type-check the non-unix p2p scratch writer The shared scratch-then-link path names pin::Pinned on every target, but the pin module was unix-only, so gitlawb-node did not compile off Unix. --- crates/gitlawb-node/src/p2p/mod.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index fc33ee1d..1e50dc3a 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -638,6 +638,25 @@ pub(crate) mod pin { } } +/// Non-unix publication has no mode pin, so `Pinned` is only a wrapper that +/// lets the shared scratch-then-link writer type-check. The unix module above +/// stays helper-only: this constructor is not compiled there. +#[cfg(not(unix))] +pub(crate) mod pin { + #[derive(Debug)] + pub(crate) struct Pinned(T); + + impl Pinned { + pub(crate) fn wrap(inner: T) -> Self { + Self(inner) + } + + pub(crate) fn get_mut(&mut self) -> &mut T { + &mut self.0 + } + } +} + /// Descriptor-anchored ancestor walk from a trusted anchor to the key /// directory's parent. /// @@ -1349,10 +1368,11 @@ impl KeyDirHandle { &self, name: &std::ffi::OsStr, ) -> std::io::Result> { - std::fs::OpenOptions::new() + let file = std::fs::OpenOptions::new() .write(true) .create_new(true) - .open(self.path.join(name)) + .open(self.path.join(name))?; + Ok(pin::Pinned::wrap(file)) } fn publish(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> std::io::Result<()> { From fe3e796763f835189c7a319e8b920f39ee51c421 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:20:34 -0500 Subject: [PATCH 33/36] fix(node): refuse identity path spellings that Path would retarget GITLAWB_KEY had the same terminal-dot hole as GITLAWB_P2P_KEY, and its tilde expansion did not refuse a suffix that escaped home. Bare filenames stay legal. --- .env.example | 5 +- crates/gitlawb-node/src/config.rs | 118 +++++++++++++- crates/gitlawb-node/src/main.rs | 47 ++++++ crates/gitlawb-node/src/p2p/mod.rs | 7 +- .../tests/p2p_key_startup_policy.rs | 147 +++++++++++++++++- 5 files changed, 310 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 7e5eabc7..307a46b5 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,10 @@ # All variables are optional unless marked REQUIRED. # ── Node identity ───────────────────────────────────────────────────────── -# Path to the node's Ed25519 keypair PEM file. +# Path to the node's Ed25519 keypair PEM file. Must name a key file, not a +# directory: a trailing slash, a final `.` or `..`, and `..` traversal are +# refused so the path cannot retarget a parent. A `~/` value must stay under +# home after expansion. Bare `identity.pem` is still legal. # Generate with: gl identity new GITLAWB_KEY=/data/keys/identity.pem diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index b02c6ca1..8aff4dcc 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -724,8 +724,11 @@ impl Config { /// Resolve ~ in key_path pub fn resolved_key_path(&self) -> PathBuf { if self.key_path.starts_with("~/") { - if let Some(home) = dirs_next::home_dir() { - return home.join(&self.key_path[2..]); + let suffix = &self.key_path[2..]; + if !crate::p2p::tilde_suffix_escapes_home(suffix) { + if let Some(home) = dirs_next::home_dir() { + return home.join(suffix); + } } } PathBuf::from(&self.key_path) @@ -772,6 +775,42 @@ impl Config { )); } + // Identity is always loaded, so a directory-valued or escaping spelling + // is boot-fatal here. Bare `identity.pem` stays legal; the p2p "must + // include a dedicated directory" rule is not imported. + if self.key_path.starts_with("~/") { + let suffix = &self.key_path[2..]; + if crate::p2p::tilde_suffix_escapes_home(suffix) { + return Err(format!( + "GITLAWB_KEY ({}) must stay inside the home directory after `~/` \ + expansion; rooted suffixes such as `~//etc/identity.pem`, drive-prefixed \ + suffixes, and `..` traversal are refused", + self.key_path + )); + } + } + let identity_key_path = self.resolved_key_path(); + if self.key_path.starts_with("~/") && identity_key_path == Path::new(&self.key_path) { + return Err(format!( + "GITLAWB_KEY ({}) starts with `~/` but no home directory could be resolved, \ + so it would name a literal `~` directory relative to the working directory. \ + Set an absolute path such as /data/keys/identity.pem.", + self.key_path + )); + } + if crate::p2p::path_denotes_a_directory(&identity_key_path, Some(&self.key_path)) + || identity_key_path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(format!( + "GITLAWB_KEY ({}) must name a key file, not a directory; a trailing separator, \ + a final `.` or `..` component, and `..` traversal are refused so the path \ + cannot retarget a parent", + self.key_path + )); + } + // P2P is optional: an HTTP-only node with GITLAWB_P2P_PORT=0 never loads // or creates this key, so refusing startup on an unused path would be a // silent outage with no security benefit. @@ -1533,6 +1572,10 @@ mod tests { Config::parse_from(["gitlawb-node", "--p2p-key-path", path]) } + fn config_with_key(path: &str) -> Config { + Config::parse_from(["gitlawb-node", "--key-path", path]) + } + fn config_with_p2p_port_and_key(port: u16, path: &str) -> Config { Config::parse_from([ "gitlawb-node", @@ -1968,4 +2011,75 @@ mod tests { ); } } + + /// Identity allows a bare filename; it does not allow a directory spelling + /// that Path would retarget onto a parent. + #[test] + fn identity_key_path_terminal_dot_is_rejected() { + for path in ["/data/keys/.", "/data/keys/./.", "/data/keys/.//."] { + let err = config_with_key(path) + .validate() + .expect_err("a terminal `.` must be rejected as a directory"); + assert!( + err.contains("GITLAWB_KEY") && err.contains("must name a key file"), + "{path:?} must be refused before Path can retarget it, got: {err}" + ); + } + } + + #[test] + fn identity_key_path_trailing_directory_separator_is_rejected() { + let err = config_with_key("/data/keys/") + .validate() + .expect_err("a trailing directory separator must be rejected"); + assert!( + err.contains("must name a key file"), + "trailing `/` must be rejected, got: {err}" + ); + } + + #[test] + fn identity_key_path_tilde_directory_is_rejected() { + let err = config_with_key("~/") + .validate() + .expect_err("`~/` must name a key file, not a directory"); + assert!( + err.contains("must name a key file"), + "`~/` must be rejected, got: {err}" + ); + } + + #[test] + fn identity_key_path_tilde_escape_is_rejected() { + let err = config_with_key("~//etc/identity.pem") + .validate() + .expect_err("a `~/` spelling that escapes home must be rejected"); + assert!( + err.contains("must stay inside the home directory"), + "tilde escape must be refused, got: {err}" + ); + } + + #[test] + fn identity_key_path_parent_dir_component_is_rejected() { + let err = config_with_key("/data/keys/../identity.pem") + .validate() + .expect_err("`..` in an identity path must be rejected"); + assert!( + err.contains("..") || err.contains("must name a key file"), + "`..` must be refused so the parent cannot retarget, got: {err}" + ); + } + + #[test] + fn identity_key_path_bare_filename_is_accepted() { + for path in ["identity.pem", "./identity.pem", "/identity.pem"] { + config_with_key(path) + .validate() + .unwrap_or_else(|e| panic!("{path:?} is a legal identity path, got: {e}")); + } + Config::parse_from(["gitlawb-node"]) + .validate() + .expect("the shipped default identity path must validate"); + } } diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index ac90bfcd..9aa3eaa8 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1405,6 +1405,19 @@ fn load_or_create_keypair(config: &Config) -> Result { /// The node identity key's load-or-create, taken by path so the storage /// contract can be tested without building a whole `Config`. fn load_or_create_keypair_at(key_path: &std::path::Path) -> Result { + if p2p::path_denotes_a_directory(key_path, None) + || key_path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + anyhow::bail!( + "GITLAWB_KEY ({}) must name a key file, not a directory; a trailing separator, \ + a final `.` or `..` component, and `..` traversal are refused so the path \ + cannot retarget a parent", + key_path.display() + ); + } + #[cfg(unix)] if let Some(pem) = p2p::load_identity_pem_if_present(key_path)? { let kp = Keypair::from_pem(&pem).map_err(|e| anyhow::anyhow!("invalid PEM key: {e}"))?; @@ -2439,4 +2452,38 @@ mod identity_key_storage_tests { "the probe must not leave /identity.pem behind" ); } + + /// A terminal `.` is a directory spelling. Path drops it, so this would + /// otherwise publish a 0600 file named `keys` under `data`. + #[cfg(unix)] + #[test] + fn identity_terminal_dot_path_is_refused_without_retargeting() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let data = base.path().join("data"); + std::fs::create_dir(&data).unwrap(); + std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap(); + let spelling = format!("{}/keys/.", data.display()); + let key = std::path::Path::new(&spelling); + let Err(err) = load_or_create_keypair_at(key) else { + panic!("a terminal `.` identity path must be refused"); + }; + let text = format!("{err:#}"); + assert!( + text.contains("must name a key file") || text.contains("directory"), + "the refusal must name the directory spelling, got: {text}" + ); + let mode = std::fs::symlink_metadata(&data) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o755, "rejection must not chmod the parent"); + assert!( + !data.join("keys").exists(), + "rejection must not create a file named keys" + ); + } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 1e50dc3a..2a35bf03 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -918,10 +918,7 @@ fn spelling_denotes_a_directory(spelling: &str) -> bool { if spelling == "~/" || spelling.ends_with('/') || spelling.ends_with('\\') { return true; } - let last = spelling - .rsplit(['/', '\\']) - .next() - .unwrap_or(spelling); + let last = spelling.rsplit(['/', '\\']).next().unwrap_or(spelling); last == "." || last == ".." } @@ -931,7 +928,7 @@ fn spelling_denotes_a_directory(spelling: &str) -> bool { /// before any directory is created or chmodded. The Path is consulted only /// as a backstop for callers that pass no raw spelling: the OsStr still /// carries the operator's `.` even after `components()` has dropped it. -fn path_denotes_a_directory(key_path: &Path, configured_raw: Option<&str>) -> bool { +pub(crate) fn path_denotes_a_directory(key_path: &Path, configured_raw: Option<&str>) -> bool { if let Some(raw) = configured_raw { if spelling_denotes_a_directory(raw) { return true; diff --git a/crates/gitlawb-node/tests/p2p_key_startup_policy.rs b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs index e3508809..782970d3 100644 --- a/crates/gitlawb-node/tests/p2p_key_startup_policy.rs +++ b/crates/gitlawb-node/tests/p2p_key_startup_policy.rs @@ -119,9 +119,19 @@ impl Row { } fn spawn(&self, p2p_key: &str, p2p_port: &str, cwd: &Path) -> ChildGuard { + self.spawn_env(p2p_key, p2p_port, cwd, &[]) + } + + fn spawn_env( + &self, + p2p_key: &str, + p2p_port: &str, + cwd: &Path, + extra: &[(&str, &str)], + ) -> ChildGuard { let repos = self.home.path().join("repos"); - Command::new(env!("CARGO_BIN_EXE_gitlawb-node")) - .env_clear() + let mut cmd = Command::new(env!("CARGO_BIN_EXE_gitlawb-node")); + cmd.env_clear() .env("PATH", std::env::var("PATH").unwrap_or_default()) // The subscriber is built from the default env filter, so with a // cleared environment the node logs nothing at all and every @@ -142,10 +152,11 @@ impl Row { .env("GITLAWB_METRICS_ADDR", "") .current_dir(cwd) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map(ChildGuard) - .expect("spawn gitlawb-node") + .stderr(Stdio::piped()); + for (k, v) in extra { + cmd.env(k, v); + } + cmd.spawn().map(ChildGuard).expect("spawn gitlawb-node") } } @@ -500,3 +511,127 @@ fn terminal_dot_key_path_is_fatal_and_does_not_mutate() { ); } } + +/// GITLAWB_KEY has the same Path-retarget hole as GITLAWB_P2P_KEY: a terminal +/// `.` would publish a file named `keys` under `data`. Fatal before bind, and +/// the parent tree is unchanged. p2p is off so it cannot chmod a shared dir. +#[test] +fn identity_terminal_dot_is_fatal_and_does_not_mutate() { + let row = Row::new(); + let data = row.home.path().join("data"); + std::fs::create_dir(&data).unwrap(); + std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap(); + let key = format!("{}/keys/.", data.display()); + let cwd = row.home.path().to_path_buf(); + let mut child = row.spawn_env("p2p.key", "0", &cwd, &[("GITLAWB_KEY", key.as_str())]); + let (status, err) = wait_for_exit(&mut child); + assert!( + !status.success(), + "GITLAWB_KEY={key:?} must exit non-zero\n--- stderr ---\n{err}" + ); + assert!( + err.contains("invalid configuration"), + "GITLAWB_KEY={key:?} must fail as invalid configuration\n--- stderr ---\n{err}" + ); + assert!( + !err.contains("binding HTTP listener"), + "GITLAWB_KEY={key:?} must be refused BEFORE the listener binds\n--- stderr ---\n{err}" + ); + let mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o755, "rejection must not chmod the parent"); + assert!( + !data.join("keys").exists(), + "rejection must not create a file named keys" + ); +} + +/// An existing 0755 identity parent is usable and must not be chmodded. p2p is +/// off so a default `~/.gitlawb/p2p.key` cannot tighten the same directory. +#[test] +fn identity_existing_0755_parent_is_not_chmodded_on_boot() { + let row = Row::new(); + let iddir = row.home.path().join("idparent"); + std::fs::create_dir(&iddir).unwrap(); + std::fs::set_permissions(&iddir, std::fs::Permissions::from_mode(0o755)).unwrap(); + let key = iddir.join("identity.pem"); + let cwd = row.home.path().to_path_buf(); + let mut child = row.spawn_env( + "p2p.key", + "0", + &cwd, + &[("GITLAWB_KEY", key.to_str().unwrap())], + ); + let (found, log) = read_until(&mut child, "degraded HTTP server ready"); + assert!( + found, + "a 0755 identity parent must still boot\n--- stderr ---\n{log}" + ); + drop(child); + let mode = std::fs::metadata(&iddir).unwrap().permissions().mode() & 0o7777; + assert_eq!( + mode, 0o755, + "boot must not chmod the existing identity parent" + ); + assert_eq!( + std::fs::metadata(&key).unwrap().permissions().mode() & 0o7777, + 0o600, + "the identity key must be owner-only" + ); +} + +/// Creating into a world-writable identity parent is refused before bind, and +/// the directory is left untouched. +#[test] +fn identity_writable_parent_is_fatal_and_unchanged() { + let row = Row::new(); + let iddir = row.home.path().join("idparent"); + std::fs::create_dir(&iddir).unwrap(); + std::fs::set_permissions(&iddir, std::fs::Permissions::from_mode(0o777)).unwrap(); + let key = iddir.join("identity.pem"); + let cwd = row.home.path().to_path_buf(); + let mut child = row.spawn_env( + "p2p.key", + "0", + &cwd, + &[("GITLAWB_KEY", key.to_str().unwrap())], + ); + let (status, err) = wait_for_exit(&mut child); + assert!( + !status.success(), + "a writable identity parent must exit non-zero\n--- stderr ---\n{err}" + ); + assert!( + !err.contains("binding HTTP listener"), + "a writable identity parent must be refused BEFORE the listener binds\n--- stderr ---\n{err}" + ); + let mode = std::fs::metadata(&iddir).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o777, "refusal must not chmod the parent"); + assert!(!key.exists(), "refusal must not publish the identity key"); +} + +/// A 2700 (setgid, owner rwx) p2p key directory is repairable: boot, tighten +/// to 0700, HTTP still comes up. +#[test] +fn p2p_setgid_key_directory_is_tightened_and_http_comes_up() { + let row = Row::new(); + let keys = row.tree().join("keys"); + std::fs::create_dir(&keys).unwrap(); + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o2700)).unwrap(); + let key = keys.join("p2p.key"); + let cwd = row.home.path().to_path_buf(); + let mut child = row.spawn(key.to_str().unwrap(), "7546", &cwd); + let (found, log) = read_until_all( + &mut child, + &["degraded HTTP server ready", "generated new p2p identity"], + ); + assert!( + found, + "a 2700 key directory must boot and generate a key\n--- stderr ---\n{log}" + ); + drop(child); + let mode = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777; + assert_eq!( + mode, 0o700, + "2700 must be normalized to 0700, found {mode:04o}" + ); +} From 050bafb4d3a80953f9e94ea9d506522a73d2f369 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:34:51 -0500 Subject: [PATCH 34/36] fix(node): create every missing identity parent on one pinned walk The identity key path split one creation transition across two policies. `create_dir_all` built every ancestor above the immediate parent at the ambient umask, then `create_dir_pinned_at` ran `verify_trusted_parent` on the last of those and refused it. Under umask 0002 that ancestor lands 0775, so the phase that made it handed it to the phase that rejects it: the node exits before binding and the failed boot leaves the directory behind at 0775, so the next attempt starts from state the first one made. Replace the split with one descriptor-anchored walk. Pass 1 walks up from the key's parent until a component opens; that descriptor is the anchor, and it is the same fd the next step creates into. Pass 2 walks back down calling `create_dir_pinned_at` per component, each one pinned 0700 and verified at its achieved mode before it becomes the parent of the next, so from the anchor down no pathname is resolved twice. Pre-existing ancestors are adopted as-is and never chmodded, since GITLAWB_KEY is a file path rather than a dedicated-directory setting; write-authority is still required on the directory each component is created in. A boot that fails past the walk now removes exactly what it created, deepest first, through unlinkat with AT_REMOVEDIR against the descriptor each entry was made in. A race-adopted directory is never in that list, and AT_REMOVEDIR refuses a non-empty directory, so a leaf another boot has published into survives. The accumulator is an out-parameter so a mid-walk failure and a post-walk failure reach the same single rollback arm. Two consequences worth naming. A symlink at the deepest existing ancestor is refused when the missing suffix reaches it, where `create_dir_all` followed it. Ancestors ABOVE that anchor are still resolved by pathname and their symlinks still followed, unchanged from before this walk, because O_NOFOLLOW binds only the final component of a path-based open; closing that would mean an openat chain from the root and the full ancestor policy that GITLAWB_KEY paths deliberately do not get. The doc comment now says so rather than claiming more than the code does. And a losing concurrent first boot can remove an intermediate another boot adopted but has not yet written into, turning that boot's start into an ENOENT failure; both still fail closed. Also replace the root-adjacent test. It proved a pathname property by running the full mutating path against the real filesystem root and assuming the runner lacks permission. Under euid 0, common in build containers, `/` is accepted (root-owned, 0755, no group or other write bit), the production path returns Ok, the test's panic arm fires before its cleanup assertion, and a real private key is left at /identity.pem, after which the leading exists() check makes every later run a silent skip. The routing is now asserted lexically through a named predicate, with no filesystem access and no dependence on euid. The matrix covers one, two and three missing levels under umasks 0000, 0002, 0022 and 0777, a pre-existing 0755 ancestor that must stay 0755, a relative multi-level path, a symlinked deepest ancestor, refusal without partial mutation, and a populated leaf whose removal must be refused and reported. Created-directory modes are asserted by exact equality, and each success row reloads in a fresh process and requires the same DID. --- crates/gitlawb-node/src/main.rs | 612 ++++++++++++++++++++++++++--- crates/gitlawb-node/src/p2p/mod.rs | 250 ++++++++++-- 2 files changed, 764 insertions(+), 98 deletions(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 9aa3eaa8..4e0c964c 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1902,12 +1902,169 @@ mod gossip_ssrf_tests { mod identity_key_storage_tests { use super::*; - /// Fixture: create the node identity key under a hostile umask. + /// Requested modes for the objects this invocation creates. Named once so + /// a failure prints achieved against requested rather than a bare boolean. + #[cfg(unix)] + const IDENTITY_WANT_DIR_MODE: u32 = 0o700; + #[cfg(unix)] + const IDENTITY_WANT_KEY_MODE: u32 = 0o600; + /// Mode a PRE-EXISTING ancestor is built at. Not group-writable, so the + /// write-authority check accepts it, and nothing this process does may + /// change it: only directories this invocation creates are its business. + #[cfg(unix)] + const IDENTITY_PREEXISTING_MODE: u32 = 0o755; + /// Mode the group-writable rows build. A parent carrying it must be + /// refused before anything is created inside it. + #[cfg(unix)] + const IDENTITY_LOOSE_MODE: u32 = 0o775; + + #[cfg(unix)] + fn identity_mode_of(path: &std::path::Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + std::fs::symlink_metadata(path) + .unwrap_or_else(|e| panic!("stat {}: {e}", path.display())) + .permissions() + .mode() + & 0o7777 + } + + /// Every directory on the key path with its achieved mode against what the + /// contract requested, so a RED is attributable to the mode rather than to + /// "something failed". + #[cfg(unix)] + fn identity_key_tree(base: &std::path::Path, key: &std::path::Path) -> String { + let mut dirs = Vec::new(); + let mut cur = key.parent(); + while let Some(d) = cur { + dirs.push(d.to_path_buf()); + if d == base { + break; + } + cur = d.parent(); + } + dirs.reverse(); + let mut out = String::new(); + for d in dirs { + match std::fs::symlink_metadata(&d) { + Ok(_) => out.push_str(&format!( + " dir {} achieved mode {:04o}, requested {:04o}\n", + d.display(), + identity_mode_of(&d), + IDENTITY_WANT_DIR_MODE + )), + Err(e) => out.push_str(&format!(" dir {} absent ({e})\n", d.display())), + } + } + match std::fs::symlink_metadata(key) { + Ok(_) => out.push_str(&format!( + " key {} achieved mode {:04o}, requested {:04o}\n", + key.display(), + identity_mode_of(key), + IDENTITY_WANT_KEY_MODE + )), + Err(e) => out.push_str(&format!(" key {} absent ({e})\n", key.display())), + } + out + } + + /// An object a refused boot must not have left behind. The message is the + /// fixture's own: the anyhow text never carries the phrase, so a `contains` + /// on the error would be a check that can never pass. + #[cfg(unix)] + fn assert_identity_absent(path: &std::path::Path) { + assert!( + std::fs::symlink_metadata(path).is_err(), + "{} left behind by a refused boot", + path.display() + ); + } + + /// The assertions every success row shares: exact 0700 on each directory + /// this invocation created, 0600 on the key, no scratch residue beside it, + /// and a base this process never touched. + #[cfg(unix)] + fn assert_identity_success( + base: &std::path::Path, + key: &std::path::Path, + created: &[std::path::PathBuf], + ) { + for d in created { + let mode = identity_mode_of(d); + assert_eq!( + mode, + IDENTITY_WANT_DIR_MODE, + "created directory {} achieved mode {:04o}, requested {:04o}\n{}", + d.display(), + mode, + IDENTITY_WANT_DIR_MODE, + identity_key_tree(base, key) + ); + } + let key_mode = identity_mode_of(key); + assert_eq!( + key_mode, + IDENTITY_WANT_KEY_MODE, + "identity key achieved mode {key_mode:04o}, requested 0600\n{}", + identity_key_tree(base, key) + ); + let leaf = key.parent().expect("the key names a parent"); + let entries: Vec = std::fs::read_dir(leaf) + .expect("read the key directory") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + entries, + vec![key.file_name().unwrap().to_string_lossy().into_owned()], + "the key directory must hold only the published key, no scratch residue: {entries:?}" + ); + let base_mode = identity_mode_of(base); + assert_eq!( + base_mode, 0o700, + "the base directory must be left at 0700, achieved {base_mode:04o}" + ); + } + + /// Boot a success row and check its contract. `call_path` is what the code + /// is handed (relative for the relative row); `key` is the same key under + /// `base`, so the mode checks never depend on the child's cwd. + #[cfg(unix)] + fn run_identity_success_row( + base: &std::path::Path, + call_path: &std::path::Path, + key: &std::path::Path, + created: &[std::path::PathBuf], + phase: &str, + ) -> String { + if phase == "reload" { + assert!( + key.exists(), + "the reload phase requires the create phase to have published a key at {}", + key.display() + ); + } + let kp = load_or_create_keypair_at(call_path).unwrap_or_else(|e| { + panic!( + "{} the node identity, got: {e:#}\nkey storage at failure:\n{}", + if phase == "reload" { + "reload in a fresh process must load" + } else { + "first boot must create" + }, + identity_key_tree(base, key) + ) + }); + assert_identity_success(base, key, created); + kp.did().to_string() + } + + /// Fixture: one (layout, umask, phase) row of the identity lifecycle + /// matrix. /// /// `~/.gitlawb` holds BOTH keys, and `load_or_create_keypair` runs before /// the listener binds, so a mask that strips owner bits here takes the - /// whole node down before any p2p code is reached. Double-gated like the - /// p2p fixtures: `#[ignore]` keeps it out of a normal run and the env check + /// whole node down before any p2p code is reached. `umask` is + /// process-global, so every row is a child. Double-gated like the p2p + /// fixtures: `#[ignore]` keeps it out of a normal run and the env check /// keeps it inert under a bare `--ignored` sweep, which would otherwise set /// a process-global umask inside the shared test process. #[cfg(unix)] @@ -1924,49 +2081,259 @@ mod identity_key_storage_tests { ); let umask_val = u32::from_str_radix(&std::env::var("GITLAWB_TEST_UMASK").unwrap(), 8).unwrap(); + let layout = std::env::var("GITLAWB_TEST_LAYOUT").expect("GITLAWB_TEST_LAYOUT"); + let phase = std::env::var("GITLAWB_TEST_PHASE").expect("GITLAWB_TEST_PHASE"); // SAFETY: `umask` only reads and replaces the process-wide value, and // this process exists solely for this probe. No restore: the value dies - // with the child. + // with the child. Set FIRST, before any layout is built, so the + // explicit chmods below are what give a pre-existing directory its + // mode regardless of the mask. unsafe { libc::umask(umask_val as libc::mode_t) }; - let key = base.join(".gitlawb").join("identity.pem"); - let kp = load_or_create_keypair_at(&key).unwrap_or_else(|e| { - let dir_mode = std::fs::symlink_metadata(key.parent().unwrap()) - .map(|m| format!("{:04o}", m.permissions().mode() & 0o7777)) - .unwrap_or_else(|_| "absent".into()); - panic!( - "first boot must create the node identity, got: {e:#}\n \ - dir {} achieved mode {}, requested 0700", - key.parent().unwrap().display(), - dir_mode - ) - }); - - let dir_mode = std::fs::symlink_metadata(key.parent().unwrap()) - .unwrap() - .permissions() - .mode() - & 0o7777; - assert_eq!( - dir_mode, 0o700, - "identity key directory achieved mode {dir_mode:04o}, requested 0700" - ); - let key_mode = std::fs::symlink_metadata(&key) - .unwrap() - .permissions() - .mode() - & 0o7777; - assert_eq!( - key_mode, 0o600, - "identity key achieved mode {key_mode:04o}, requested 0600" - ); - - // The identity must survive a reload, same as the p2p key. - let reloaded = load_or_create_keypair_at(&key).expect("reload the identity"); - assert_eq!(kp.did(), reloaded.did(), "the identity must be stable"); - - println!("identity-key-umask: asserted did={}", kp.did()); + match layout.as_str() { + "one-missing" => { + let dir = base.join(".gitlawb"); + let key = dir.join("identity.pem"); + let did = run_identity_success_row(&base, &key, &key, &[dir], &phase); + println!("identity-key-umask: asserted did={did}"); + } + "multi-missing" => { + let one = base.join("one"); + let two = one.join("two"); + let key = two.join("identity.pem"); + let did = run_identity_success_row(&base, &key, &key, &[one, two], &phase); + println!("identity-key-umask: asserted did={did}"); + } + "three-missing" => { + let one = base.join("one"); + let two = one.join("two"); + let three = two.join("three"); + let key = three.join("identity.pem"); + let did = run_identity_success_row(&base, &key, &key, &[one, two, three], &phase); + println!("identity-key-umask: asserted did={did}"); + } + "under-preexisting" => { + let pre = base.join("pre"); + if phase == "create" { + std::fs::create_dir(&pre).expect("create the pre-existing ancestor"); + std::fs::set_permissions( + &pre, + std::fs::Permissions::from_mode(IDENTITY_PREEXISTING_MODE), + ) + .expect("chmod the pre-existing ancestor"); + } + let one = pre.join("one"); + let two = one.join("two"); + let key = two.join("identity.pem"); + let did = run_identity_success_row(&base, &key, &key, &[one, two], &phase); + let pre_mode = identity_mode_of(&pre); + assert_eq!( + pre_mode, + IDENTITY_PREEXISTING_MODE, + "a pre-existing ancestor is neither chmodded nor judged: {} achieved \ + {pre_mode:04o}, expected {:04o}", + pre.display(), + IDENTITY_PREEXISTING_MODE + ); + println!("identity-key-umask: asserted did={did}"); + } + "relative-multi-missing" => { + std::env::set_current_dir(&base).expect("chdir into the row's base"); + let one = base.join("one"); + let two = one.join("two"); + let key = two.join("identity.pem"); + let call_path = std::path::PathBuf::from("one/two/identity.pem"); + let did = run_identity_success_row(&base, &call_path, &key, &[one, two], &phase); + println!("identity-key-umask: asserted did={did}"); + } + "loose-ancestor" => { + let loose = base.join("loose"); + std::fs::create_dir(&loose).expect("create the group-writable ancestor"); + std::fs::set_permissions( + &loose, + std::fs::Permissions::from_mode(IDENTITY_LOOSE_MODE), + ) + .expect("chmod the group-writable ancestor"); + let one = loose.join("one"); + let two = one.join("two"); + let key = two.join("identity.pem"); + let Err(err) = load_or_create_keypair_at(&key) else { + panic!( + "a group-writable deepest ancestor must be refused\nkey storage:\n{}", + identity_key_tree(&base, &key) + ) + }; + let text = format!("{err:#}"); + // The reason is checked before the leftovers so a refusal for + // the wrong reason is reported as that, not as a stray file. + assert!( + text.contains("writable beyond its owner"), + "a group-writable deepest ancestor must be refused for the write-authority \ + reason, got: {text}" + ); + assert_identity_absent(&one); + assert_identity_absent(&two); + assert_identity_absent(&key); + let loose_mode = identity_mode_of(&loose); + assert_eq!( + loose_mode, + IDENTITY_LOOSE_MODE, + "a refusal must not chmod {}: achieved {loose_mode:04o}", + loose.display() + ); + println!("identity-key-umask: refused"); + } + "write-failure" => { + let one = base.join("one"); + let two = one.join("two"); + let three = two.join("three"); + let key = three.join("identity.pem"); + // Armed on this thread only, and disarmed before the + // assertions so a later boot in this process is unaffected. + p2p::FAIL_KEY_WRITE.with(|f| f.set(true)); + let result = load_or_create_keypair_at(&key); + p2p::FAIL_KEY_WRITE.with(|f| f.set(false)); + let Err(err) = result else { + panic!("an injected key-write failure must not report a created identity") + }; + let text = format!("{err:#}"); + assert!( + text.contains("injected key-write failure"), + "the failure must name the injected key write, got: {text}" + ); + assert_identity_absent(&one); + assert_identity_absent(&two); + assert_identity_absent(&three); + assert_identity_absent(&key); + let residue: Vec = std::fs::read_dir(&base) + .expect("read the base directory") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + residue.is_empty(), + "a failed first boot must leave nothing under the base: {residue:?}" + ); + println!("identity-key-umask: refused"); + } + "race-loose-intermediate" => { + let one = base.join("one"); + let two = one.join("two"); + let key = two.join("identity.pem"); + // A concurrent process wins the mkdir with a group-writable + // mode. The loser adopts nothing it did not create, so its + // rollback must leave the winner's directory alone. + p2p::pin::RACE_CREATE_MODE.with(|c| c.set(Some(IDENTITY_LOOSE_MODE))); + let result = load_or_create_keypair_at(&key); + let Err(err) = result else { + panic!("a race-won group-writable intermediate must be refused") + }; + let text = format!("{err:#}"); + assert!( + text.contains("writable beyond its owner"), + "the refusal must name the write-authority problem, got: {text}" + ); + assert!( + one.exists(), + "a directory this invocation did not create must not be removed: {} is gone", + one.display() + ); + let one_mode = identity_mode_of(&one); + assert_eq!( + one_mode, + IDENTITY_LOOSE_MODE, + "a directory this invocation did not create must not be removed or chmodded: \ + {} achieved {one_mode:04o}", + one.display() + ); + assert_identity_absent(&two); + assert_identity_absent(&key); + println!("identity-key-umask: refused"); + } + "symlinked-deepest-ancestor" => { + let real = base.join("real"); + std::fs::create_dir(&real).expect("create the real ancestor"); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)) + .expect("chmod the real ancestor"); + let link = base.join("link"); + std::os::unix::fs::symlink("real", &link).expect("plant the symlinked ancestor"); + let key = link.join("one").join("two").join("identity.pem"); + let Err(err) = load_or_create_keypair_at(&key) else { + panic!("a symlinked deepest existing ancestor must be refused, not followed") + }; + let text = format!("{err:#}"); + assert!( + text.contains("a symlink here is refused rather than followed"), + "a symlinked deepest existing ancestor must be refused, not followed, and the \ + refusal must say so, got: {text}" + ); + for p in [ + link.join("one"), + link.join("one").join("two"), + key.clone(), + real.join("one"), + real.join("one").join("two"), + real.join("one").join("two").join("identity.pem"), + ] { + assert_identity_absent(&p); + } + println!("identity-key-umask: refused"); + } + "populated-leaf-refuses-rollback" => { + let one = base.join("one"); + let two = one.join("two"); + let key = two.join("identity.pem"); + // The key is published and only the scratch removal fails, so + // the leaf this invocation created is no longer empty when the + // rollback reaches it. `AT_REMOVEDIR` must refuse it. + p2p::FAIL_SCRATCH_UNLINK.with(|f| f.set(true)); + let result = load_or_create_keypair_at(&key); + p2p::FAIL_SCRATCH_UNLINK.with(|f| f.set(false)); + let Err(err) = result else { + panic!("an injected scratch-unlink failure must not report success") + }; + let text = format!("{err:#}"); + assert!( + text.contains("failed to remove the scratch name"), + "the failure must name the scratch it could not remove, got: {text}" + ); + assert!( + text.contains("also failed to remove") + && text.contains("which this process created"), + "the rollback must report the populated leaf it was refused, got: {text}" + ); + let key_mode = identity_mode_of(&key); + assert_eq!( + key_mode, IDENTITY_WANT_KEY_MODE, + "a published key must survive its own boot's rollback: achieved \ + {key_mode:04o}, requested 0600" + ); + for d in [&one, &two] { + let mode = identity_mode_of(d); + assert_eq!( + mode, + IDENTITY_WANT_DIR_MODE, + "created directory {} achieved mode {mode:04o}, requested {:04o}", + d.display(), + IDENTITY_WANT_DIR_MODE + ); + } + let scratch: Vec = std::fs::read_dir(&two) + .expect("read the key directory") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".p2p.key.") && n.ends_with(".tmp")) + .collect(); + assert_eq!( + scratch.len(), + 1, + "the refused unlink must leave exactly one scratch beside the key: {scratch:?}" + ); + let kp = load_or_create_keypair_at(&key) + .expect("a boot after the injected failure must load the published key"); + println!("identity-key-umask: refused did={}", kp.did()); + } + other => panic!("unknown layout {other}"), + } } /// GITLAWB_KEY names a PEM file, not a dedicated key directory. An existing @@ -2093,15 +2460,64 @@ mod identity_key_storage_tests { ); } + /// Parent for the identity lifecycle matrix: drives every row as a child + /// process and requires create and reload to agree on the DID. #[cfg(unix)] #[test] fn identity_key_storage_is_umask_independent() { use std::os::unix::fs::PermissionsExt; - for umask in ["0000", "0022", "0777"] { - let base = tempfile::tempdir().unwrap(); - std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + const SUCCESS_SENTINEL: &str = "identity-key-umask: asserted did="; + const REFUSED_SENTINEL: &str = "identity-key-umask: refused"; + // Fixed order, and part of the contract: 0000 and 0002 are the masks + // that leave a group/world-writable ancestor behind, 0022 is the one + // that boots with the wrong mode, and 0777 strips owner bits. Rows + // abort at the first failing mask, so several load-bearing proofs are + // pinned to which mask that is. + const ALL_UMASKS: &[&str] = &["0000", "0002", "0022", "0777"]; + + // (layout, umasks, succeeds). A success row runs create then reload + // against one base and must return the same DID; a reject row runs + // create alone and must print the refusal sentinel. + let rows: &[(&str, &[&str], bool)] = &[ + ("one-missing", ALL_UMASKS, true), + ("multi-missing", ALL_UMASKS, true), + ("three-missing", ALL_UMASKS, true), + ("under-preexisting", ALL_UMASKS, true), + ("relative-multi-missing", ALL_UMASKS, true), + ("loose-ancestor", ALL_UMASKS, false), + ("write-failure", ALL_UMASKS, false), + ("race-loose-intermediate", &["0002"], false), + ("symlinked-deepest-ancestor", &["0022"], false), + ("populated-leaf-refuses-rollback", &["0022"], false), + ]; + + // Both filters are optional and both panic on a value that names + // nothing: a typo that quietly ran no child would be an empty green, + // which is the failure this matrix exists to rule out. + let layout_only = std::env::var("GITLAWB_TEST_LAYOUT_ONLY").ok(); + let umask_only = std::env::var("GITLAWB_TEST_UMASK_ONLY").ok(); + if let Some(l) = layout_only.as_deref() { + assert!( + rows.iter().any(|r| r.0 == l), + "GITLAWB_TEST_LAYOUT_ONLY={l} names no row in the matrix" + ); + } + if let Some(u) = umask_only.as_deref() { + assert!( + ALL_UMASKS.contains(&u), + "GITLAWB_TEST_UMASK_ONLY={u} names no umask in the matrix" + ); + } + // Run one row in a child and return whatever follows its sentinel. + fn run_row( + base: &std::path::Path, + umask: &str, + layout: &str, + phase: &str, + sentinel: &str, + ) -> String { let mut cmd = std::process::Command::new(std::env::current_exe().expect("current_exe")); cmd.args([ "identity_key_storage_tests::fixture_identity_key_under_hostile_umask", @@ -2110,28 +2526,65 @@ mod identity_key_storage_tests { "--nocapture", ]) .env("GITLAWB_TEST_FIXTURE", "identity-key-umask") - .env("GITLAWB_TEST_BASE", base.path()) - .env("GITLAWB_TEST_UMASK", umask); + .env("GITLAWB_TEST_BASE", base) + .env("GITLAWB_TEST_UMASK", umask) + .env("GITLAWB_TEST_LAYOUT", layout) + .env("GITLAWB_TEST_PHASE", phase); let output = cmd.output().expect("spawn the identity-key fixture"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + let row = format!("layout={layout} umask={umask} phase={phase}"); assert!( output.status.success(), - "umask={umask}: the identity-key fixture must pass\n\ + "row {row}: the identity-key fixture must pass\n\ --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" ); // A filter matching nothing exits 0, and the fixture's env gate // returns early as a passing test, so neither alone is proof. assert!( stdout.contains("1 passed"), - "umask={umask}: filter must select one passing test\n{stdout}" - ); - assert!( - stdout.contains("identity-key-umask: asserted did="), - "umask={umask}: fixture must print its sentinel\n{stdout}" + "row {row}: filter must select one passing test\n{stdout}" ); + let line = stdout + .lines() + .find(|l| l.starts_with(sentinel)) + .unwrap_or_else(|| { + panic!("row {row}: fixture must print its sentinel\n--- stdout ---\n{stdout}") + }); + line[sentinel.len()..].trim().to_string() + } + + let mut ran = 0usize; + for (layout, umasks, succeeds) in rows { + if layout_only.as_deref().is_some_and(|l| l != *layout) { + continue; + } + for umask in *umasks { + if umask_only.as_deref().is_some_and(|u| u != *umask) { + continue; + } + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)) + .unwrap(); + if *succeeds { + let created = run_row(base.path(), umask, layout, "create", SUCCESS_SENTINEL); + let reloaded = run_row(base.path(), umask, layout, "reload", SUCCESS_SENTINEL); + assert_eq!( + created, reloaded, + "layout={layout} umask={umask}: a fresh process must reload the same DID" + ); + } else { + run_row(base.path(), umask, layout, "create", REFUSED_SENTINEL); + } + ran += 1; + } } + assert!( + ran > 0, + "the row and umask filters selected no child; a matrix that runs nothing is not a \ + passing matrix" + ); } /// Bare `GITLAWB_KEY=identity.pem` (and `./identity.pem`) must still create @@ -2433,24 +2886,55 @@ mod identity_key_storage_tests { assert_eq!(created.did(), reloaded.did()); } + /// `/identity.pem` is a pathname contract, not a filesystem probe: its + /// parent is the root, which the operator has already nominated, so + /// `create_pinned_dir_and_publish` must take the already-nominated branch. + /// Asserted lexically because a probe against the real `/` cannot be + /// isolated and, run as root, leaves a real key at `/identity.pem`. #[cfg(unix)] #[test] - fn identity_root_adjacent_path_is_not_the_empty_component_error() { - if std::path::Path::new("/identity.pem").exists() { - return; - } - let err = match load_or_create_keypair_at(std::path::Path::new("/identity.pem")) { - Ok(_) => panic!("creating /identity.pem as a non-root user must not succeed"), - Err(e) => format!("{e:#}"), - }; + fn identity_root_adjacent_path_is_an_already_nominated_parent() { + use std::path::{Component, Path}; + + let root_adjacent = Path::new("/identity.pem"); + assert!( - !err.contains("names no final directory component"), - "root-adjacent identity create must reach the filesystem, got: {err}" + !p2p::path_denotes_a_directory(root_adjacent, None), + "the lexical gate in load_or_create_keypair_at must admit /identity.pem" + ); + assert!( + !root_adjacent + .components() + .any(|c| c == Component::ParentDir), + "/identity.pem walks back out through no `..`" + ); + assert_eq!( + p2p::key_parent(root_adjacent), + Path::new("/"), + "the lexical parent of /identity.pem is the filesystem root" ); assert!( - !std::path::Path::new("/identity.pem").exists(), - "the probe must not leave /identity.pem behind" + p2p::identity_parent_is_already_nominated(root_adjacent), + "root-adjacent identity path must take the already-nominated branch, not the named-parent branch that fails with 'names no final directory component'" ); + + for nominated in ["identity.pem", "./identity.pem"] { + assert!( + p2p::identity_parent_is_already_nominated(Path::new(nominated)), + "{nominated} publishes into the working directory, which is already nominated" + ); + } + + for named in [ + "/data/identity.pem", + "keys/identity.pem", + "/identity/identity.pem", + ] { + assert!( + !p2p::identity_parent_is_already_nominated(Path::new(named)), + "{named} names a parent component this process may create" + ); + } } /// A terminal `.` is a directory spelling. Path drops it, so this would diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 2a35bf03..b1dabefd 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -188,6 +188,19 @@ pub(crate) fn key_parent(key_path: &Path) -> &Path { } } +/// Whether the identity key's lexical parent is a directory the operator has +/// already nominated by shape alone: the working directory (`identity.pem`, +/// `./identity.pem`) or the filesystem root (`/identity.pem`). Those are +/// published into as-is and never created, pinned, or chmodded; any other +/// parent is a named component this process may create. +/// +/// Lexical on purpose so the contract is testable without touching the +/// filesystem: a probe against `/` cannot be isolated and, run as root, leaves +/// a real key at `/identity.pem`. +pub(crate) fn identity_parent_is_already_nominated(key_path: &Path) -> bool { + key_parent(key_path).file_name().is_none() +} + /// Whether `key_path` fails to name a directory the node is willing to manage. /// /// This is the gate `Config::validate` applies, kept next to `key_parent` @@ -1684,17 +1697,183 @@ pub(crate) fn load_identity_pem_if_present(key_path: &Path) -> Result, + /// (index into `chain` of the parent, entry name, display path), in + /// creation order. Only entries `create_dir_pinned_at` reported `created`. + created: Vec<(usize, std::ffi::CString, PathBuf)>, +} + +#[cfg(unix)] +impl CreatedDirs { + /// Remove, deepest first, only what this invocation created, and fold a + /// removal failure into the primary error. + /// + /// A race winner is never in `created`, so an adopted directory is never + /// removed. `AT_REMOVEDIR` refuses a non-empty directory, so a leaf another + /// boot has published into survives this loser's rollback. The first + /// failure stops the walk: a directory that cannot be removed keeps every + /// ancestor above it non-empty, so continuing would only report the same + /// leftover again, and what is left behind is 0700 owner-only. + fn roll_back(self, primary: anyhow::Error) -> anyhow::Error { + use std::os::fd::AsRawFd; + + for (parent_idx, name, display) in self.created.iter().rev() { + let parent_fd = self.chain[*parent_idx].as_raw_fd(); + // SAFETY: `name` was created by this invocation under a descriptor + // this struct still owns, so no pathname is re-resolved and no + // other user can repoint it; `AT_REMOVEDIR` removes only an empty + // directory, never a file and never a populated one. + let rc = unsafe { libc::unlinkat(parent_fd, name.as_ptr(), libc::AT_REMOVEDIR) }; + if rc != 0 { + let clean = std::io::Error::last_os_error(); + return anyhow::anyhow!( + "{primary:#}; also failed to remove {} which this process created: {clean}", + display.display() + ); + } + } + primary + } +} + +/// The lexical parent of `p` for the walk up, with the two spellings of "the +/// working directory" collapsed onto `.` the way [`key_parent`] collapses them. +/// +/// Applied on EVERY step, not only the first: `Path::new("one").parent()` is +/// `Some("")`, `open("")` is `NotFound`, and `Path::new("").parent()` is +/// `None`, so an unnormalized loop would read the empty parent as one more +/// missing component and walk off the top of a relative key path. +#[cfg(unix)] +fn identity_parent_step(p: &Path) -> &Path { + match p.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + +/// Create every missing component from the deepest existing ancestor down to +/// `dir`, and hand back the leaf's descriptor. +/// +/// Pass 1 walks up by name from `dir`'s parent, opening each candidate until +/// one opens; that descriptor is the anchor and the discovery open IS the use, +/// so the anchor pathname is not resolved a second time. Termination is by +/// shape: a candidate with no file name (`""`, `.`, `/`, `..`) is never stepped +/// past, so the helper carries its own precondition rather than inheriting the +/// `..` refusal from `load_or_create_keypair_at` in another file. +/// +/// The `O_NOFOLLOW` in those flags binds the FINAL component only, which is +/// what the kernel gives a path-based open. So a symlink at the anchor itself +/// is refused, and a symlink ABOVE it is followed during path resolution just +/// as it was before this walk existed: the pre-fix code reached the same +/// directory through `create_dir_all` plus a path-based open of the +/// grandparent. Closing that would mean an `openat` chain from the filesystem +/// root, which is what [`verify_and_create_ancestor_chain`] does for +/// `GITLAWB_P2P_KEY`, and it would drag the full ancestor policy onto +/// `GITLAWB_KEY` paths that deliberately do not get it. Ancestors above the +/// anchor are therefore resolved by pathname and neither judged nor mutated, +/// unchanged from the previous behavior. +/// +/// Pass 2 walks back down calling [`pin::create_dir_pinned_at`] per component +/// against the descriptor of the one before it, so from the anchor down no +/// pathname is resolved again. The only judgment an existing directory +/// receives is `verify_trusted_parent` on the descriptor a child is actually +/// created in. +/// +/// `created_dirs` is an out-parameter so a failure on any component leaves the +/// caller holding exactly what was made so far. +#[cfg(unix)] +fn create_missing_identity_parents( + dir: &Path, + dir_name: &std::ffi::OsStr, + euid: u32, + created_dirs: &mut CreatedDirs, +) -> Result<(pin::Pinned, bool)> { + use std::os::fd::AsRawFd; + + // The caller has already observed `dir` as NotFound, so it is the first + // missing component. Deepest first; pass 2 iterates in reverse. + let mut missing: Vec<(std::ffi::OsString, PathBuf)> = + vec![(dir_name.to_os_string(), dir.to_path_buf())]; + let mut cur = identity_parent_step(dir); + let anchor = loop { + match open_dir_with_flags(cur, walk_dir_open_flags()) { + Ok(fd) => break fd, + Err(e) if e.kind() == std::io::ErrorKind::NotFound && cur.file_name().is_some() => { + let name = cur + .file_name() + .expect("the arm guard just observed a file name") + .to_os_string(); + missing.push((name, cur.to_path_buf())); + cur = identity_parent_step(cur); + } + Err(e) => { + return Err(anyhow::Error::new(e).context(format!( + "failed to open {} (a symlink here is refused rather than followed)", + cur.display() + ))); + } + } + }; + created_dirs.chain.push(std::os::fd::OwnedFd::from(anchor)); + + let mut leaf = None; + for (idx, (name, display)) in missing.iter().enumerate().rev() { + let is_leaf = idx == 0; + let flags = if is_leaf { + leaf_dir_open_flags() + } else { + walk_dir_open_flags() + }; + let cname = KeyDirHandle::child_name(name).map_err(|e| { + anyhow::Error::new(e).context(format!("failed to name {}", display.display())) + })?; + let parent_idx = created_dirs.chain.len() - 1; + let parent_fd = created_dirs.chain[parent_idx].as_raw_fd(); + let (pinned, created) = pin::create_dir_pinned_at(parent_fd, name, display, euid, flags) + .map_err(|e| { + anyhow::Error::new(e).context(format!( + "failed to create key directory {}", + display.display() + )) + })?; + if created { + created_dirs + .created + .push((parent_idx, cname, display.clone())); + } + if is_leaf { + leaf = Some((pinned, created)); + } else { + created_dirs.chain.push(pinned.into_inner()); + } + } + Ok(leaf.expect("the leaf is the first entry of a list that always holds it")) +} + /// Publish `bytes` as a 0600 key at `key_path` through the same scratch-then-link /// path the p2p key uses. /// -/// If the immediate parent is missing it is created pinned to 0700. If it -/// already exists it is used without chmod: `GITLAWB_KEY` is a file path, not -/// a dedicated-directory setting. Write-authority on that parent is still -/// required. Deliberately NOT the full `ensure_key_dir` ancestor walk. +/// Every component below the deepest existing ancestor is created pinned to +/// 0700 on one descriptor chain, so a two-deep missing prefix lands at the +/// mode this process chose rather than at the ambient umask. A pre-existing +/// ancestor is adopted as-is and never chmodded: `GITLAWB_KEY` is a file path, +/// not a dedicated-directory setting. Write-authority is still required on the +/// directory each component is created in. Anything this invocation created is +/// removed again if the boot fails past it, except a directory that has since +/// been published into, which `AT_REMOVEDIR` refuses. Deliberately NOT the full +/// `ensure_key_dir` ancestor walk: components above the deepest existing +/// ancestor are neither judged nor mutated. #[cfg(unix)] pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Result<()> { - use std::os::fd::AsRawFd; - let file_name = key_path .file_name() .ok_or_else(|| anyhow::anyhow!("{} names no key file", key_path.display()))?; @@ -1705,7 +1884,7 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re // helper must not chmod cwd or `/` as if they were a nominated key // directory. let parent = key_parent(key_path); - if parent.file_name().is_none() { + if identity_parent_is_already_nominated(key_path) { let cwd = open_dir_with_flags(parent, leaf_dir_open_flags()) .with_context(|| format!("failed to open {} for the identity key", parent.display()))?; let handle = KeyDirHandle::from_existing_dir(cwd, parent, key_path)?; @@ -1740,32 +1919,34 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re } } - // Ancestors above the key directory keep the existing behavior; only the - // directory that actually holds the secret is pinned, and only when this - // process creates it. - if let Some(grandparent) = dir.parent() { - if !grandparent.as_os_str().is_empty() { - std::fs::create_dir_all(grandparent).with_context(|| { - format!("failed to create parent directories for {}", dir.display()) - })?; - } + // Every missing component below the deepest existing ancestor is created on + // one descriptor chain. The accumulator is an out-parameter so a failure + // mid-walk and a failure after it reach the same rollback arm. + let euid = effective_uid(); + let mut created_dirs = CreatedDirs::default(); + let result = create_missing_identity_parents(dir, dir_name, euid, &mut created_dirs).and_then( + |(pinned, created)| publish_into_leaf(pinned, created, dir, key_path, file_name, bytes), + ); + match result { + Ok(()) => Ok(()), + Err(e) => Err(created_dirs.roll_back(e)), } - let grandparent = dir.parent().filter(|g| !g.as_os_str().is_empty()); - let gp_path = grandparent.unwrap_or_else(|| Path::new(".")); - let gp = open_dir_with_flags(gp_path, walk_dir_open_flags()).map_err(|e| { - anyhow::Error::new(e).context(format!( - "failed to open {} (a symlink here is refused rather than followed)", - gp_path.display() - )) - })?; +} - let euid = effective_uid(); - let (pinned, created) = - pin::create_dir_pinned_at(gp.as_raw_fd(), dir_name, dir, euid, leaf_dir_open_flags()) - .map_err(|e| { - anyhow::Error::new(e) - .context(format!("failed to create key directory {}", dir.display())) - })?; +/// Publish the key into the leaf the walk handed back. +/// +/// `created` is the walk's verdict on the leaf: a directory this invocation +/// made arrives pinned and verified, and a race winner is adopted without +/// chmod, exactly as the single-level path did before the walk existed. +#[cfg(unix)] +fn publish_into_leaf( + pinned: pin::Pinned, + created: bool, + dir: &Path, + key_path: &Path, + file_name: &std::ffi::OsStr, + bytes: &[u8], +) -> Result<()> { let handle = if created { KeyDirHandle::from_pinned_fd(pinned, dir) } else { @@ -1775,7 +1956,6 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re write_key_atomically(&handle, file_name, bytes) .with_context(|| format!("failed to write identity key to {}", key_path.display()))?; - let _ = gp; Ok(()) } @@ -1901,10 +2081,12 @@ fn fill_and_publish( thread_local! { /// Test-only fault injection for the key write. Thread-local so an armed /// test cannot disturb the others running beside it. - static FAIL_KEY_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; + pub(crate) static FAIL_KEY_WRITE: std::cell::Cell = + const { std::cell::Cell::new(false) }; /// Test-only fault injection for scratch unlink after publish. - static FAIL_SCRATCH_UNLINK: std::cell::Cell = const { std::cell::Cell::new(false) }; + pub(crate) static FAIL_SCRATCH_UNLINK: std::cell::Cell = + const { std::cell::Cell::new(false) }; /// How many times this test thread fsync'd a key directory. static SYNC_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; From 4efd0d5b9fa756fad568bb79e01e5ca922b340ef Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:26:34 -0500 Subject: [PATCH 35/36] test(node): race real concurrent first boots of the identity key The rollback added with the pinned walk can remove an intermediate that a concurrent boot has already adopted, and until now that was reasoned about rather than executed. Nothing in the suite started two real first boots against the same key path. Add a `concurrent-first-boot` layout to the self-exec fixture and a driver that races several children through `load_or_create_keypair_at` on one missing multi-level path. A two-phase file rendezvous puts every child inside a spin loop before any of them is released, so the spread across the racing call is one loop iteration rather than process-startup jitter. Three arms: unaided boots, one injected write failure against one unaided boot, and four injected failures against one, which is what actually opens the window. The assertions are properties of every legal outcome, never one interleaving, so the test is deterministic even though the race is not. A boot that claims success has a parseable 0600 key on disk, all successes agree on the DID, surviving directories are exactly 0700 with no scratch residue, and if no key was published then nothing claimed success and a retry must still work. Observation counters are printed, not asserted, so a run that never hits the window says so rather than passing as proof. Two things the race showed that reading could not. The documented window is real: a boot was refused with "No such file or directory" on its publish after a loser rolled back the leaf it had adopted. But the more common shape is one hop earlier, the loser removing an adopted intermediate so the ENOENT lands on the next component's creation instead. And under enough concurrent rollbacks every boot can fail, not merely the losers, so several nodes starting against a shared first-boot path can all need a restart. Two-child racing never opened the window at all in 500 iterations. --- crates/gitlawb-node/src/main.rs | 345 ++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 4e0c964c..f7de00de 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -2332,6 +2332,86 @@ mod identity_key_storage_tests { .expect("a boot after the injected failure must load the published key"); println!("identity-key-umask: refused did={}", kp.did()); } + // One process of a real concurrent first boot. Every child of a + // race runs this arm; the driver owns the tree assertions, because + // only it knows the race has finished. + "concurrent-first-boot" => { + let barrier = std::path::PathBuf::from( + std::env::var("GITLAWB_TEST_BARRIER").expect("GITLAWB_TEST_BARRIER"), + ); + let id = std::env::var("GITLAWB_TEST_RACE_ID").expect("GITLAWB_TEST_RACE_ID"); + let role = std::env::var("GITLAWB_TEST_RACE_ROLE").expect("GITLAWB_TEST_RACE_ROLE"); + let key = base.join("one").join("two").join("identity.pem"); + + // Two-phase rendezvous: each child announces itself with + // `ready.` and then spins on `go`, which the driver creates + // only once every ready file is present. Chosen over a shared + // wake-up timestamp because a deadline bounds only the skew it + // cannot observe: a child that is slow to exec still arrives + // late and the race never overlaps. Here every child is already + // inside the spin loop before `go` can appear, so the spread + // across the racing call is one loop iteration rather than the + // tens of milliseconds a process takes to start. The deadline + // exists so a child whose sibling died never hangs the suite. + std::fs::write(barrier.join(format!("ready.{id}")), b"") + .expect("announce readiness at the starting barrier"); + let go = barrier.join("go"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + while !go.exists() { + assert!( + std::time::Instant::now() < deadline, + "the starting barrier never opened" + ); + std::hint::spin_loop(); + } + + // The `fail-write` role is the only injected part of the race, + // and it is what puts a REAL second process into the window + // section 7 of the plan describes: this child creates a + // component, the other adopts it, this one fails before either + // has written, and its rollback reaches the still-empty + // directory the other is holding open. + if role == "fail-write" { + p2p::FAIL_KEY_WRITE.with(|f| f.set(true)); + } + let result = load_or_create_keypair_at(&key); + p2p::FAIL_KEY_WRITE.with(|f| f.set(false)); + + match result { + Ok(kp) => { + // A boot that reports success must have the identity it + // returned on disk, at 0600, and readable as a keypair. + // Checked here rather than in the driver so a success + // that raced with a rollback is attributed to the + // process that claimed it. + let mode = identity_mode_of(&key); + assert_eq!( + mode, + IDENTITY_WANT_KEY_MODE, + "a successful concurrent boot published {} at {mode:04o}, requested \ + 0600", + key.display() + ); + let pem = std::fs::read_to_string(&key) + .unwrap_or_else(|e| panic!("read {}: {e}", key.display())); + let on_disk = Keypair::from_pem(&pem) + .expect("a successful concurrent boot must leave a loadable keypair"); + assert_eq!( + on_disk.did().to_string(), + kp.did().to_string(), + "a successful boot must return the identity that is on disk" + ); + println!("identity-key-race: ok did={}", kp.did()); + } + Err(err) => { + // One line so the driver can classify it; the anyhow + // chain is multi-line under `{:#}` only when a context + // carries a newline, but flattening is free insurance. + let text = format!("{err:#}").replace('\n', " "); + println!("identity-key-race: err {text}"); + } + } + } other => panic!("unknown layout {other}"), } } @@ -2587,6 +2667,271 @@ mod identity_key_storage_tests { ); } + /// Two or more real first boots racing the same multi-level missing key + /// path. + /// + /// The rollback added by this round can remove an intermediate a concurrent + /// boot has already adopted, so the plan's section 7 claims both processes + /// still fail closed. That claim was reasoned, not executed. This drives it + /// with real processes: `umask` and the injection hooks are process-global, + /// so threads inside one process would not be the production path. + /// + /// The race is nondeterministic and the assertions are not. Nothing here + /// asserts a particular interleaving; every check is a property that must + /// hold for all of them, so the test is deterministic in PASS/FAIL: + /// + /// * a boot that reports success has its own identity on disk at 0600 and + /// parseable (asserted in the child, which is the process that claimed + /// it); + /// * every surviving directory this run created is exactly 0700, never + /// group or world writable; + /// * every successful boot agrees on the DID; + /// * if no boot succeeded, nothing partial is left (no key, no scratch) and + /// a retry in this process then succeeds, which is the explicit form of + /// "fail closed and recover on the next boot". + #[cfg(unix)] + #[test] + fn identity_concurrent_first_boots_fail_closed() { + use std::io::Read; + use std::os::unix::fs::PermissionsExt; + + const OK_SENTINEL: &str = "identity-key-race: ok did="; + const ERR_SENTINEL: &str = "identity-key-race: err "; + // 0002 is the mask that produced finding 1, so a directory this run + // creates without pinning it lands 0775 and the exact-mode assertions + // below catch it. + const RACE_UMASK: &str = "0002"; + + // (arm, one role per child). The pure arm is three unaided first boots. + // The other two add losers that fail after the walk and before any key + // is written, so their rollback reaches a directory a sibling may + // already hold open: that is the window the plan describes and the only + // injected part of the race. Two children hit it rarely, because the + // winner reaches its scratch file before a single loser reaches its + // rmdir; four losers against one unaided boot hit it often, so the + // pressure arm is what actually executes the interleaving. The + // assertions do not depend on which arm hits it. + let arms: &[(&str, &[&str])] = &[ + ("pure", &["pure", "pure", "pure"][..]), + ("loser-fails", &["fail-write", "pure"][..]), + ( + "rollback-pressure", + &[ + "fail-write", + "fail-write", + "fail-write", + "fail-write", + "pure", + ][..], + ), + ]; + + // A single run of a nondeterministic test proves very little, so the + // default loops the whole race; raise it through the env for a longer + // shake without editing the test. + let iterations: usize = std::env::var("GITLAWB_TEST_RACE_ITERATIONS") + .ok() + .map(|v| { + v.parse() + .expect("GITLAWB_TEST_RACE_ITERATIONS must be a count") + }) + .unwrap_or(50); + assert!(iterations > 0, "a race run zero times proves nothing"); + + #[derive(Default)] + struct Tally { + all_ok: usize, + some_ok: usize, + none_ok: usize, + /// A boot refused because the directory it was holding had been + /// removed under it: the interleaving section 7 describes. + adopted_dir_unlinked: usize, + } + let mut tallies: Vec<(&str, Tally)> = arms + .iter() + .map(|(name, _)| (*name, Tally::default())) + .collect(); + + for iteration in 0..iterations { + for (arm_idx, (arm, roles)) in arms.iter().enumerate() { + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)) + .unwrap(); + let barrier = tempfile::tempdir().unwrap(); + let one = base.path().join("one"); + let two = one.join("two"); + let key = two.join("identity.pem"); + let label = format!("arm={arm} iteration={iteration}"); + + let mut children = Vec::new(); + for (id, role) in roles.iter().enumerate() { + let mut cmd = + std::process::Command::new(std::env::current_exe().expect("current_exe")); + cmd.args([ + "identity_key_storage_tests::fixture_identity_key_under_hostile_umask", + "--exact", + "--ignored", + "--nocapture", + ]) + .env("GITLAWB_TEST_FIXTURE", "identity-key-umask") + .env("GITLAWB_TEST_BASE", base.path()) + .env("GITLAWB_TEST_UMASK", RACE_UMASK) + .env("GITLAWB_TEST_LAYOUT", "concurrent-first-boot") + .env("GITLAWB_TEST_PHASE", "create") + .env("GITLAWB_TEST_BARRIER", barrier.path()) + .env("GITLAWB_TEST_RACE_ID", id.to_string()) + .env("GITLAWB_TEST_RACE_ROLE", *role) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + children.push(cmd.spawn().expect("spawn a racing identity boot")); + } + + // Open the barrier only once every child has announced itself. + // If one died before announcing, the deadline lets the run + // proceed to the exit-status assertion below rather than hang. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + loop { + let ready = (0..roles.len()) + .filter(|id| barrier.path().join(format!("ready.{id}")).exists()) + .count(); + if ready == roles.len() || std::time::Instant::now() >= deadline { + break; + } + std::thread::yield_now(); + } + std::fs::write(barrier.path().join("go"), b"").expect("open the starting barrier"); + + let mut oks = Vec::new(); + let mut errs = Vec::new(); + for (id, child) in children.into_iter().enumerate() { + let output = child.wait_with_output().expect("await a racing boot"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let who = format!("{label} child={id} role={}", roles[id]); + assert!( + output.status.success(), + "{who}: the racing fixture must pass whichever way the race went\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("1 passed"), + "{who}: filter must select one passing test\n{stdout}" + ); + let line = stdout + .lines() + .find(|l| l.starts_with("identity-key-race: ")) + .unwrap_or_else(|| { + panic!("{who}: the child must report its outcome\n{stdout}") + }); + if let Some(did) = line.strip_prefix(OK_SENTINEL) { + oks.push(did.trim().to_string()); + } else if let Some(text) = line.strip_prefix(ERR_SENTINEL) { + errs.push((roles[id], text.to_string())); + } else { + panic!("{who}: unrecognized outcome line {line:?}"); + } + } + + // Invariant: every surviving directory this run created is + // exactly 0700. A missing one is legal (a loser rolled it + // back); a group or world writable one never is. + for d in [&one, &two] { + if d.exists() { + let mode = identity_mode_of(d); + assert_eq!( + mode, + IDENTITY_WANT_DIR_MODE, + "{label}: surviving directory {} achieved mode {mode:04o}, requested \ + {:04o}", + d.display(), + IDENTITY_WANT_DIR_MODE + ); + let residue: Vec = std::fs::read_dir(d) + .expect("read a surviving directory") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".p2p.key.") && n.ends_with(".tmp")) + .collect(); + assert!( + residue.is_empty(), + "{label}: a finished race must leave no scratch behind in {}: \ + {residue:?}", + d.display() + ); + } + } + + if key.exists() { + // Invariant: the published key is 0600 and loadable, and + // every boot that reported success agrees on it. + let mode = identity_mode_of(&key); + assert_eq!( + mode, IDENTITY_WANT_KEY_MODE, + "{label}: the surviving key achieved mode {mode:04o}, requested 0600" + ); + let mut pem = String::new(); + std::fs::File::open(&key) + .expect("open the surviving key") + .read_to_string(&mut pem) + .expect("read the surviving key"); + let on_disk = Keypair::from_pem(&pem) + .expect("the surviving key must parse as a keypair") + .did() + .to_string(); + for did in &oks { + assert_eq!( + *did, on_disk, + "{label}: a successful boot reported an identity the disk does not \ + hold" + ); + } + } else { + // Invariant: no key means no boot may claim success, and + // the tree must be clean enough that the next boot works. + assert!( + oks.is_empty(), + "{label}: a boot reported success with no key on disk: {oks:?}" + ); + let kp = load_or_create_keypair_at(&key).unwrap_or_else(|e| { + panic!( + "{label}: every boot failed, so a retry must succeed, got: {e:#}\n\ + errors: {errs:?}\nkey storage:\n{}", + identity_key_tree(base.path(), &key) + ) + }); + assert_identity_success(base.path(), &key, &[one.clone(), two.clone()]); + assert!( + !kp.did().to_string().is_empty(), + "{label}: the recovering boot must produce an identity" + ); + } + + let tally = &mut tallies[arm_idx].1; + if oks.len() == roles.len() { + tally.all_ok += 1; + } else if oks.is_empty() { + tally.none_ok += 1; + } else { + tally.some_ok += 1; + } + // The rollback-under-an-adopted-directory window: a boot that + // was refused because the directory it held was unlinked. + if errs.iter().any(|(_, t)| { + t.contains("No such file or directory") || t.contains("(os error 2)") + }) { + tally.adopted_dir_unlinked += 1; + } + } + } + + for (arm, tally) in &tallies { + println!( + "identity-race-summary: arm={arm} iterations={iterations} all_ok={} some_ok={} \ + none_ok={} adopted_dir_unlinked={}", + tally.all_ok, tally.some_ok, tally.none_ok, tally.adopted_dir_unlinked + ); + } + } + /// Bare `GITLAWB_KEY=identity.pem` (and `./identity.pem`) must still create /// the identity in the working directory. The p2p key refuses that form on /// purpose; the node identity has always allowed it. From fe5548b8ff046740d2c82119498f3fcc2e52f5a3 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:06:57 -0500 Subject: [PATCH 36/36] fix(node): resolve identity key path components one at a time `O_NOFOLLOW` binds only the final component of a path-based `open(2)`, which is all the kernel offers for a multi-component pathname. Three sites resolved a whole configured path in one open and so guarded only the last position: the load path, the existing-named-parent fast path, and the walk that creates missing parents. Every interior symlink was followed, and `verify_trusted_parent` then attested the directory that was reached rather than the path taken to it. That is enough to substitute the node's whole identity, not merely misplace its key. Load and create resolve the configured pathname identically, so repointing one interior link redirects both. Point it at a directory holding another PEM and the node boots as that DID while its real key sits untouched on disk and nothing is logged as wrong. Point it at an empty directory and the node mints a fresh identity and reports a first boot. Neither needs the attacker to read anything: ownership and mode checks still pass, because they run on the object at the end of the path. Resolve each component with `openat` and `O_NOFOLLOW` against the descriptor of the one before it, from `/` for an absolute path or the working directory for a relative one, so a symlink is refused at every position. Interior components open search-only and the final component keeps the caller's flags, so a legitimate 0111 ancestor still resolves and the leaf descriptor is still the one that gets fsynced and chmodded. Components are adopted with no ownership or mode judgment. The only new refusal is a symlink on the key path: a shared 0775 volume, a foreign-owned ancestor and `/etc` all keep working, because `GITLAWB_KEY` is a file path rather than a dedicated-directory setting. `verify_trusted_parent` stays exactly where it was, on the descriptor a child is created in. The p2p key path is untouched; it already walked component by component. This is pre-existing rather than a regression: the same probe against the branch point produces the same result. It is fixed here because closing it replaces the walk this branch just added, so splitting it would mean reviewing that code twice. Covered by rows for an interior symlink with the next level present, an absolute target escaping the base, a two-link chain, the fast path, a relative configured path, and both load-path variants asserting on the DID the node ends up presenting rather than on a mode or an error string. The cases that were already refused are pinned too, including a symlink at the immediate parent and a dangling link, neither of which had a test before. --- crates/gitlawb-node/src/main.rs | 543 +++++++++++++++++++++++++++++ crates/gitlawb-node/src/p2p/mod.rs | 153 +++++++- 2 files changed, 680 insertions(+), 16 deletions(-) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index f7de00de..34f13b49 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1979,6 +1979,60 @@ mod identity_key_storage_tests { ); } + /// A symlink refusal must name the symlink. An unrelated EACCES, ENOENT, + /// or write-authority failure satisfies "an error happened" while proving + /// nothing about whether the interior component was followed, so every + /// symlink row asserts the reason rather than the bare failure. + #[cfg(unix)] + fn assert_names_symlink_refusal(text: &str, what: &str) { + let lower = text.to_lowercase(); + assert!( + lower.contains("symlink") || lower.contains("symbolic link"), + "{what} must be refused for the symlink on the path, and the refusal must say so, \ + got: {text}" + ); + } + + /// Every regular file under `root`, following no symlink out of it. Used + /// to prove a refused boot published nothing where a followed interior + /// link would have put it, including a target outside the base. + #[cfg(unix)] + fn identity_collect_files(root: &std::path::Path, found: &mut Vec) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(md) = std::fs::symlink_metadata(&path) else { + continue; + }; + if md.is_dir() { + identity_collect_files(&path, found); + } else if md.is_file() { + found.push(path); + } + } + } + + /// No key anywhere beneath `root`. Stronger than naming one expected path, + /// because a followed link can land the key at a name the test did not + /// predict. + #[cfg(unix)] + fn assert_no_identity_key_under(root: &std::path::Path, what: &str) { + let mut found = Vec::new(); + identity_collect_files(root, &mut found); + let keys: Vec = found + .iter() + .filter(|p| p.file_name().is_some_and(|n| n == "identity.pem")) + .map(|p| p.display().to_string()) + .collect(); + assert!( + keys.is_empty(), + "{what}: a refused boot published an identity under {}: {keys:?}", + root.display() + ); + } + /// The assertions every success row shares: exact 0700 on each directory /// this invocation created, 0600 on the key, no scratch residue beside it, /// and a base this process never touched. @@ -3203,6 +3257,495 @@ mod identity_key_storage_tests { ); } + /// Build `base/evil/one` plus `base/link -> evil`, the layout every + /// interior-symlink row shares. `link` is relative so the layout is + /// self-contained; the absolute row builds its own. + #[cfg(unix)] + fn plant_interior_symlink_layout(base: &std::path::Path) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let evil = base.join("evil"); + std::fs::create_dir(&evil).expect("create the symlink target directory"); + std::fs::set_permissions(&evil, std::fs::Permissions::from_mode(0o700)) + .expect("chmod the symlink target directory"); + let one = evil.join("one"); + std::fs::create_dir(&one).expect("create the already-existing next level"); + std::fs::set_permissions(&one, std::fs::Permissions::from_mode(0o700)) + .expect("chmod the already-existing next level"); + std::os::unix::fs::symlink("evil", base.join("link")).expect("plant the interior symlink"); + evil + } + + /// `O_NOFOLLOW` binds the FINAL component only, so an interior symlink + /// whose next level already exists is resolved in one open and followed. + /// With `link -> evil` and `evil/one` present, the walk-up opens + /// `link/one` by pathname, anchors on `evil/one`, and creates the node + /// identity inside the attacker's tree. + #[cfg(unix)] + #[test] + fn identity_interior_symlink_with_existing_next_level_is_refused_not_followed() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let evil = plant_interior_symlink_layout(base.path()); + + let key = base + .path() + .join("link") + .join("one") + .join("two") + .join("identity.pem"); + match load_or_create_keypair_at(&key) { + Ok(kp) => panic!( + "an interior symlink on the key path was followed, not refused: the identity \ + {} was created through {} into the symlink's target\nkey storage:\n{}", + kp.did(), + key.display(), + identity_key_tree(base.path(), &key) + ), + Err(e) => assert_names_symlink_refusal( + &format!("{e:#}"), + "an interior symlink whose next level already exists", + ), + } + assert_identity_absent(&evil.join("one").join("two")); + assert_no_identity_key_under( + base.path(), + "an interior symlink with an existing next level", + ); + } + + /// The same interior symlink pointed by absolute path at a directory + /// outside the base. Following it publishes the node's identity somewhere + /// the configured path does not name at all, so the assertion is that no + /// key appears outside the base. + #[cfg(unix)] + #[test] + fn identity_interior_symlink_escaping_the_base_must_not_publish_the_key_outside_it() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::set_permissions(outside.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let outside_one = outside.path().join("one"); + std::fs::create_dir(&outside_one).expect("create the escaped next level"); + std::fs::set_permissions(&outside_one, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::os::unix::fs::symlink(outside.path(), base.path().join("link")) + .expect("plant the absolute escaping symlink"); + + let key = base + .path() + .join("link") + .join("one") + .join("two") + .join("identity.pem"); + match load_or_create_keypair_at(&key) { + Ok(kp) => panic!( + "an absolute interior symlink was followed out of the base: the identity {} \ + was created outside {} through {}", + kp.did(), + base.path().display(), + key.display() + ), + Err(e) => assert_names_symlink_refusal( + &format!("{e:#}"), + "an interior symlink whose target is outside the base", + ), + } + assert_no_identity_key_under(outside.path(), "an escaping interior symlink"); + assert_identity_absent(&outside_one.join("two")); + } + + /// A chain, because refusing one link is not the same property as + /// refusing the path. `l1 -> l2 -> evil`, so the resolution that must be + /// refused takes two hops before it reaches the existing next level. + #[cfg(unix)] + #[test] + fn identity_chained_interior_symlinks_are_refused_not_followed() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let evil = base.path().join("evil"); + std::fs::create_dir(&evil).unwrap(); + std::fs::set_permissions(&evil, std::fs::Permissions::from_mode(0o700)).unwrap(); + let one = evil.join("one"); + std::fs::create_dir(&one).unwrap(); + std::fs::set_permissions(&one, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::os::unix::fs::symlink("evil", base.path().join("l2")).expect("plant the second hop"); + std::os::unix::fs::symlink("l2", base.path().join("l1")).expect("plant the first hop"); + + let key = base + .path() + .join("l1") + .join("one") + .join("two") + .join("identity.pem"); + match load_or_create_keypair_at(&key) { + Ok(kp) => panic!( + "a two-link chain on the key path was followed, not refused: the identity {} \ + was created through {}", + kp.did(), + key.display() + ), + Err(e) => { + assert_names_symlink_refusal(&format!("{e:#}"), "a chain of two interior symlinks") + } + } + assert_identity_absent(&one.join("two")); + assert_no_identity_key_under(base.path(), "a chain of two interior symlinks"); + } + + /// The existing-named-parent fast path never enters the walk: it opens the + /// whole configured parent pathname in one call and publishes into + /// whatever that resolves to. `link/one` exists through the symlink, so + /// this row reaches the fast path and not the walk-up. + #[cfg(unix)] + #[test] + fn identity_existing_named_parent_reached_through_a_symlink_is_refused_not_followed() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let evil = plant_interior_symlink_layout(base.path()); + + let key = base.path().join("link").join("one").join("identity.pem"); + match load_or_create_keypair_at(&key) { + Ok(kp) => panic!( + "the existing-named-parent fast path followed an interior symlink: the identity \ + {} was published into {} through {}", + kp.did(), + evil.join("one").display(), + key.display() + ), + Err(e) => assert_names_symlink_refusal( + &format!("{e:#}"), + "an interior symlink on the existing-named-parent fast path", + ), + } + assert_identity_absent(&evil.join("one").join("identity.pem")); + assert_no_identity_key_under(base.path(), "the existing-named-parent fast path"); + } + + /// Fixture: the relative-path row. `cwd` is process-global, so the row + /// that proves a relative configured path is judged the same way runs in + /// its own process. Double-gated like the other fixtures here: `#[ignore]` + /// keeps it out of a normal run and the env check keeps it inert under a + /// bare `--ignored` sweep, which would otherwise chdir the shared test + /// process. + #[cfg(unix)] + #[test] + #[ignore = "self-exec fixture: only runs under GITLAWB_TEST_FIXTURE=identity-symlink-relative"] + fn fixture_identity_relative_path_with_interior_symlink() { + if std::env::var("GITLAWB_TEST_FIXTURE").ok().as_deref() + != Some("identity-symlink-relative") + { + return; + } + let base = std::path::PathBuf::from( + std::env::var("GITLAWB_TEST_BASE").expect("GITLAWB_TEST_BASE"), + ); + let evil = plant_interior_symlink_layout(&base); + std::env::set_current_dir(&base).expect("chdir into the row's base"); + + let call_path = std::path::Path::new("link/one/two/identity.pem"); + match load_or_create_keypair_at(call_path) { + Ok(kp) => panic!( + "an interior symlink on a RELATIVE key path was followed, not refused: the \ + identity {} was created through {}", + kp.did(), + call_path.display() + ), + Err(e) => assert_names_symlink_refusal( + &format!("{e:#}"), + "an interior symlink on a relative configured path", + ), + } + assert_identity_absent(&evil.join("one").join("two")); + assert_no_identity_key_under(&base, "a relative path with an interior symlink"); + println!("identity-symlink-relative: refused"); + } + + /// Driver for the relative row. An absolute path is not the only shape an + /// operator configures, and the walk resolves a relative pathname against + /// the process cwd, so the same interior-symlink refusal has to hold there. + #[cfg(unix)] + #[test] + fn identity_relative_key_path_with_interior_symlink_is_refused_not_followed() { + use std::os::unix::fs::PermissionsExt; + + const REFUSED_SENTINEL: &str = "identity-symlink-relative: refused"; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + let mut cmd = std::process::Command::new(std::env::current_exe().expect("current_exe")); + cmd.args([ + "identity_key_storage_tests::fixture_identity_relative_path_with_interior_symlink", + "--exact", + "--ignored", + "--nocapture", + ]) + .env("GITLAWB_TEST_FIXTURE", "identity-symlink-relative") + .env("GITLAWB_TEST_BASE", base.path()); + let output = cmd + .output() + .expect("spawn the relative-path symlink fixture"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "a relative key path with an interior symlink must be refused, not followed\n\ + --- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + // A filter matching nothing exits 0, and the fixture's env gate returns + // early as a passing test, so neither alone is proof the row ran. + assert!( + stdout.contains("1 passed"), + "the filter must select one passing test\n{stdout}" + ); + assert!( + stdout.contains(REFUSED_SENTINEL), + "the fixture must print its refusal sentinel\n--- stdout ---\n{stdout}" + ); + } + + /// THE LOAD PATH, which is the identity-substitution primitive rather than + /// a permissions bug. The node's real key sits at `a/keys/identity.pem` + /// and the configured path is `link/keys/identity.pem`. An attacker who + /// can only repoint `link` from `a` to `b` makes the load resolve to a PEM + /// they supplied, and the node boots as THEIR DID while the real key is + /// still on disk, untouched, with nothing logged as wrong. + /// + /// The assertion is on the DID, not on a mode or an error string: a mode + /// assertion cannot see a substitution, because the substituted key is + /// a perfectly well-formed 0600 PEM. + #[cfg(unix)] + #[test] + fn identity_load_through_repointed_interior_symlink_must_not_adopt_the_substituted_key() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + let real_key = base.path().join("a").join("keys").join("identity.pem"); + let real_did = load_or_create_keypair_at(&real_key) + .expect("plant the node's real identity") + .did() + .to_string(); + let attacker_key = base.path().join("b").join("keys").join("identity.pem"); + let attacker_did = load_or_create_keypair_at(&attacker_key) + .expect("plant the attacker's identity") + .did() + .to_string(); + assert_ne!( + real_did, attacker_did, + "the row needs two distinct identities to tell substitution from a load" + ); + let real_bytes = std::fs::read(&real_key).expect("read the real key"); + + // The configured path never changes. Only the interior component does, + // which is exactly the authority an attacker with write access to one + // directory has. + let link = base.path().join("link"); + std::os::unix::fs::symlink("a", &link).expect("point the configured path at the real key"); + std::fs::remove_file(&link).expect("the attacker repoints the interior component"); + std::os::unix::fs::symlink("b", &link).expect("repoint the interior component"); + + let configured = link.join("keys").join("identity.pem"); + match load_or_create_keypair_at(&configured) { + Ok(kp) => { + let booted = kp.did().to_string(); + if booted == attacker_did { + panic!( + "identity substitution: repointing the interior symlink {} made the node \ + boot as the attacker's identity {attacker_did} (planted at {}) instead \ + of its own {real_did} at {}", + link.display(), + attacker_key.display(), + real_key.display() + ); + } + panic!( + "the load followed the interior symlink at {} and returned {booted} rather \ + than refusing the path", + link.display() + ); + } + Err(e) => assert_names_symlink_refusal( + &format!("{e:#}"), + "a repointed interior symlink on the load path", + ), + } + + assert_eq!( + std::fs::read(&real_key).expect("read the real key"), + real_bytes, + "the real key must be untouched by the refused boot" + ); + assert_eq!( + load_or_create_keypair_at(&real_key) + .expect("the real key must still load by its real path") + .did() + .to_string(), + real_did, + "the node's own identity must be unchanged" + ); + } + + /// The weaker variant of the same primitive. The attacker repoints the + /// interior component at an EMPTY directory, the load finds no key, and + /// the create path mints a fresh identity and logs it as a first boot. The + /// node comes up with a DID nobody knows and its real key is orphaned in + /// place with nothing reported. + #[cfg(unix)] + #[test] + fn identity_load_through_symlink_to_an_empty_directory_must_not_silently_mint_a_new_identity() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + let real_key = base.path().join("a").join("keys").join("identity.pem"); + let real_did = load_or_create_keypair_at(&real_key) + .expect("plant the node's real identity") + .did() + .to_string(); + let real_bytes = std::fs::read(&real_key).expect("read the real key"); + + let empty_keys = base.path().join("empty").join("keys"); + std::fs::create_dir_all(&empty_keys).expect("create the empty target tree"); + std::fs::set_permissions(&empty_keys, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions( + base.path().join("empty"), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let link = base.path().join("link"); + std::os::unix::fs::symlink("a", &link).expect("point the configured path at the real key"); + std::fs::remove_file(&link).expect("the attacker repoints the interior component"); + std::os::unix::fs::symlink("empty", &link).expect("repoint at an empty directory"); + + let configured = link.join("keys").join("identity.pem"); + match load_or_create_keypair_at(&configured) { + Ok(kp) => { + let booted = kp.did().to_string(); + panic!( + "silent identity loss: repointing the interior symlink {} at an empty \ + directory made the node mint a fresh identity {booted} as if this were a \ + first boot, orphaning its real identity {real_did} at {}", + link.display(), + real_key.display() + ); + } + Err(e) => assert_names_symlink_refusal( + &format!("{e:#}"), + "an interior symlink repointed at an empty directory", + ), + } + + assert_identity_absent(&empty_keys.join("identity.pem")); + assert_eq!( + std::fs::read(&real_key).expect("read the real key"), + real_bytes, + "the real key must be untouched by the refused boot" + ); + assert_eq!( + load_or_create_keypair_at(&real_key) + .expect("the real key must still load by its real path") + .did() + .to_string(), + real_did, + "the node's own identity must be unchanged" + ); + } + + /// Already correct, and here so it stays that way: a symlink at the key's + /// IMMEDIATE parent is the final component of the load's directory open, + /// which is the one position `O_NOFOLLOW` does bind. + #[cfg(unix)] + #[test] + fn identity_symlinked_immediate_parent_is_refused_at_load() { + use std::os::unix::fs::PermissionsExt; + + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let real = base.path().join("real"); + std::fs::create_dir(&real).unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap(); + let planted_did = load_or_create_keypair_at(&real.join("identity.pem")) + .expect("plant a key behind the symlink") + .did() + .to_string(); + std::os::unix::fs::symlink("real", base.path().join("link")).unwrap(); + + let configured = base.path().join("link").join("identity.pem"); + match load_or_create_keypair_at(&configured) { + Ok(kp) => panic!( + "a symlink at the key's immediate parent was followed: loaded {} (planted \ + {planted_did}) through {}", + kp.did(), + configured.display() + ), + Err(e) => assert_names_symlink_refusal( + &format!("{e:#}"), + "a symlink at the key's immediate parent", + ), + } + } + + /// Already correct, and here so it stays that way: a dangling symlink is + /// refused rather than replaced by a real directory of the same name, + /// both when it is the top component of the missing suffix and when it + /// sits under a real directory. + #[cfg(unix)] + #[test] + fn identity_dangling_symlink_on_the_key_path_is_refused_never_replaced() { + use std::os::unix::fs::PermissionsExt; + + for under_real_dir in [false, true] { + let base = tempfile::tempdir().unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let holder = if under_real_dir { + let real = base.path().join("real"); + std::fs::create_dir(&real).unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap(); + real + } else { + base.path().to_path_buf() + }; + let link = holder.join("link"); + std::os::unix::fs::symlink("nowhere", &link).expect("plant the dangling symlink"); + + let key = link.join("one").join("identity.pem"); + let where_ = if under_real_dir { + "a dangling symlink under a real directory" + } else { + "a dangling symlink at the top of the path" + }; + match load_or_create_keypair_at(&key) { + Ok(kp) => panic!( + "{where_} was not refused: the identity {} was created through {}", + kp.did(), + key.display() + ), + Err(e) => assert_names_symlink_refusal(&format!("{e:#}"), where_), + } + assert!( + std::fs::symlink_metadata(&link) + .expect("the dangling symlink must still be there") + .file_type() + .is_symlink(), + "{where_}: the refused boot replaced {} with something else", + link.display() + ); + assert_no_identity_key_under(base.path(), where_); + } + } + /// 0111 grandparent cannot mkdir, so the key directory must already exist. /// Opening that grandparent must not require directory-list permission. #[cfg(all(unix, any(target_os = "linux", target_os = "android")))] diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index b1dabefd..8499550e 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -758,6 +758,123 @@ fn open_dir_with_flags(path: &Path, flags: libc::c_int) -> std::io::Result std::io::Error { + match e.raw_os_error() { + Some(code) if code == libc::ELOOP || code == libc::ENOTDIR => std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "{} is a symlink or another object type rather than a real directory", + component.display() + ), + ), + _ => e, + } +} + +/// Resolve `path` to a directory descriptor one component at a time, opening +/// each component relative to the previously opened one so `O_NOFOLLOW` binds +/// at EVERY position rather than only the last. +/// +/// A path-based `open(2)` applies `O_NOFOLLOW` to the final component alone, +/// which is all the kernel offers for a multi-component pathname, so every +/// interior symlink is followed during resolution. That is enough to substitute +/// the whole identity rather than merely misplace it: the load and the create +/// path resolve the configured pathname identically, so repointing one interior +/// link redirects both and the node boots as whichever key sits behind the new +/// target while its own key stays untouched on disk. +/// +/// Resolution only. No component is created here, and an existing one is +/// adopted with NO ownership or mode judgment: `GITLAWB_KEY` is a file path +/// rather than a dedicated-directory setting, so its ancestors are allowed to +/// be a shared volume or owned by another uid, and the p2p `verify_component` +/// predicate deliberately does not apply. The single refusal this adds is a +/// symlink on the key path. +/// +/// The anchor is the filesystem root for an absolute path and the process cwd +/// (opened as `.`) for a relative one. A symlinked cwd is not a hole: `chdir` +/// resolves to an inode, so `open(".")` is the real directory and its ancestors +/// are out of reach of anyone who can repoint a name. +/// +/// `leaf_flags` applies to the final component, which is the one the caller +/// actually uses (`leaf_dir_open_flags` when it will fsync or fchmod through +/// the handle, the walk set otherwise). Interior components are opened with the +/// walk set, which needs search rather than directory-list permission. +#[cfg(unix)] +fn resolve_dir_nofollow(path: &Path, leaf_flags: libc::c_int) -> std::io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let mut names: Vec = Vec::new(); + for component in path.components() { + match component { + // The anchor covers both, and a `.` inside the path is a no-op. + std::path::Component::RootDir | std::path::Component::CurDir => {} + std::path::Component::Normal(n) => names.push(n.to_os_string()), + // `..` is not a symlink, so stepping through it relative to the + // descriptor we hold is the same object the kernel would reach. + std::path::Component::ParentDir => names.push(std::ffi::OsString::from("..")), + std::path::Component::Prefix(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{} carries an unsupported path prefix", path.display()), + )); + } + } + } + + let absolute = path.is_absolute(); + let (anchor, mut acc) = if absolute { + (Path::new("/"), PathBuf::from("/")) + } else { + (Path::new("."), PathBuf::from(".")) + }; + // With no components of its own the anchor IS the requested directory, so + // it takes the caller's flags. + let anchor_flags = if names.is_empty() { + leaf_flags + } else { + walk_dir_open_flags() + }; + let mut cur = + open_dir_with_flags(anchor, anchor_flags).map_err(|e| nofollow_component_error(e, &acc))?; + + let last = names.len().saturating_sub(1); + for (idx, name) in names.iter().enumerate() { + acc.push(name); + let flags = if idx == last { + leaf_flags + } else { + walk_dir_open_flags() + }; + let cname = std::ffi::CString::new(name.as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{} contains an interior NUL byte", path.display()), + ) + })?; + // SAFETY: openat relative to a directory descriptor this process owns; + // O_NOFOLLOW refuses a symlink in this position instead of resolving + // through it, and the returned descriptor is owned exactly once. + let fd = unsafe { libc::openat(cur.as_raw_fd(), cname.as_ptr(), flags) }; + if fd < 0 { + return Err(nofollow_component_error( + std::io::Error::last_os_error(), + &acc, + )); + } + // SAFETY: a descriptor we just received from openat and own exactly once. + cur = unsafe { std::fs::File::from_raw_fd(fd) }; + } + Ok(cur) +} + #[cfg(unix)] fn verify_and_create_ancestor_chain(dir: &Path, euid: u32) -> Result { use std::os::fd::{AsRawFd, FromRawFd}; @@ -1636,7 +1753,9 @@ fn ensure_key_dir(dir: &Path) -> Result { } /// Load an existing identity PEM without following a symlink at the key path -/// or at its immediate parent. Missing parent or missing file is `Ok(None)` +/// or anywhere above it: the parent is resolved component by component by +/// [`resolve_dir_nofollow`], and the key itself is opened `openat` no-follow +/// against that descriptor. Missing parent or missing file is `Ok(None)` /// so the caller can create. A symlink, or any other non-regular object, is /// an error. Write-authority on the parent is not judged here: an existing /// key must still load after upgrade even if its directory is one we would @@ -1651,7 +1770,7 @@ pub(crate) fn load_identity_pem_if_present(key_path: &Path) -> Result dir, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => { @@ -1770,17 +1889,18 @@ fn identity_parent_step(p: &Path) -> &Path { /// past, so the helper carries its own precondition rather than inheriting the /// `..` refusal from `load_or_create_keypair_at` in another file. /// -/// The `O_NOFOLLOW` in those flags binds the FINAL component only, which is -/// what the kernel gives a path-based open. So a symlink at the anchor itself -/// is refused, and a symlink ABOVE it is followed during path resolution just -/// as it was before this walk existed: the pre-fix code reached the same -/// directory through `create_dir_all` plus a path-based open of the -/// grandparent. Closing that would mean an `openat` chain from the filesystem -/// root, which is what [`verify_and_create_ancestor_chain`] does for -/// `GITLAWB_P2P_KEY`, and it would drag the full ancestor policy onto -/// `GITLAWB_KEY` paths that deliberately do not get it. Ancestors above the -/// anchor are therefore resolved by pathname and neither judged nor mutated, -/// unchanged from the previous behavior. +/// Each candidate is resolved by [`resolve_dir_nofollow`], one component at a +/// time from the filesystem root or the cwd, so `O_NOFOLLOW` binds at every +/// position rather than only the last one a path-based open would cover. A +/// symlink anywhere on the key path is refused instead of followed, which is +/// what stops an attacker who can repoint a single interior component from +/// redirecting the walk into a tree of their choosing. Ancestors above the +/// anchor are still neither judged nor mutated: the resolver applies no +/// ownership or mode predicate, because `GITLAWB_KEY` names a file rather than +/// a dedicated directory and its ancestors are allowed to be a shared volume or +/// owned by another uid. That is the difference from +/// [`verify_and_create_ancestor_chain`], which walks the same way but applies +/// the full `GITLAWB_P2P_KEY` ancestor policy and creates what is missing. /// /// Pass 2 walks back down calling [`pin::create_dir_pinned_at`] per component /// against the descriptor of the one before it, so from the anchor down no @@ -1805,7 +1925,7 @@ fn create_missing_identity_parents( vec![(dir_name.to_os_string(), dir.to_path_buf())]; let mut cur = identity_parent_step(dir); let anchor = loop { - match open_dir_with_flags(cur, walk_dir_open_flags()) { + match resolve_dir_nofollow(cur, walk_dir_open_flags()) { Ok(fd) => break fd, Err(e) if e.kind() == std::io::ErrorKind::NotFound && cur.file_name().is_some() => { let name = cur @@ -1871,7 +1991,8 @@ fn create_missing_identity_parents( /// removed again if the boot fails past it, except a directory that has since /// been published into, which `AT_REMOVEDIR` refuses. Deliberately NOT the full /// `ensure_key_dir` ancestor walk: components above the deepest existing -/// ancestor are neither judged nor mutated. +/// ancestor are neither judged nor mutated, only resolved without following a +/// symlink at any position. #[cfg(unix)] pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Result<()> { let file_name = key_path @@ -1902,7 +2023,7 @@ pub(crate) fn create_pinned_dir_and_publish(key_path: &Path, bytes: &[u8]) -> Re // not a dedicated-directory setting, so this must not chmod `/etc` or a // shared 0755 volume. Write-authority still refuses a group/world-writable // parent. A missing parent is created at 0700 below. - match open_dir_with_flags(dir, leaf_dir_open_flags()) { + match resolve_dir_nofollow(dir, leaf_dir_open_flags()) { Ok(existing) => { let handle = KeyDirHandle::from_existing_dir(existing, dir, key_path)?; write_key_atomically(&handle, file_name, bytes).with_context(|| {