Summary
The node identity key file is 0600, but the directory that holds it (and all the marker/claim/quarantine files the crash-recovery protocol writes there) is created at the process umask with no mode pinning and no verification. On a directory that is group- or world-writable, every defense in the identity-key recovery path degrades, because those defenses assume a node-owned directory and nothing establishes that assumption. openssh pins ~/.ssh to 0700 for exactly this reason.
Both parts below are hardening gaps on crates/gitlawb-node/src/lib.rs, not live exploits on a default single-user host. They predate the round-seven recovery work (#194); that work is what made the directory contents (markers, claims) security-relevant, which is why they surface now.
Part 1: pin the key directory to 0700
std::fs::create_dir_all(parent) in the generate arm (lib.rs ~3120) creates the key directory with no mode. Observed after real provisioning: nested=0775 keys=0775 key=0600 (umask 0002; under 022 the dirs are 0755). No code path creates the dir with a mode, chmods it, or verifies its mode on load, so a pre-existing group/other-writable directory is silently accepted too.
Blast radius by directory mode:
- Non-writable to the attacker (0755/0775): name disclosure only. Another local user can list
.publishing. / .recovering. / .quarantined. / .superseded. names, leaking pids and crash/recovery timing. The 0600 final stays unreadable. Low severity.
- Writable by the attacker (shared-group, or umask 0000, or a pre-existing 0777 dir): directory-write permits delete/rename of the 0600 final regardless of its file mode. An attacker can delete the key, or replace it with garbage plus a planted
.publishing. marker so the next boot sees the crash signature (content-class failure + marker), quarantines, and silently regenerates a new node identity; or plant perpetually-refreshed .recovering. claims to wedge every start. The content-class and no-marker gates were built against crash residue, not a hostile directory writer.
Fix: create the directory with DirBuilder::mode(0o700) on Unix, and on the load path verify (and tighten, or fail closed) the directory mode the same way load_existing_key already does for the key file.
Part 2: create marker-class files 0600
The publish marker is opened write(true).create_new(true) with no mode (lib.rs ~2137), so it lands at umask (observed mode=0664), unlike the temp file and fallback final which both pin 0o600. Claims are renames of markers and inherit that umask mode; quarantine and superseded files are renames of the final and correctly inherit 0600 (verified: rename preserves source mode both directions).
No key material leaks today (markers and claims are empty or, since the heartbeat's set_modified fallback appends a byte, hold . bytes; every consumer matches by name only). Two modest exposures remain: a future change that writes anything sensitive into a marker or claim would ship it at umask mode and rename would propagate it silently; and under a shared-group umask (0664) a group member has write access to a live claim without directory-write, enough to keep refreshing its mtime and defeat the age-gated sweep, stalling demoted publishers and boot recovery. Under the common 022 umask (0644) only the future-content risk remains.
Fix: add OpenOptionsExt::mode(0o600) to the marker create site, matching the temp/final pattern already used a few lines away.
Verification
Confirmed by execution against head 32903c4 (probes reverted): directory modes stat'd after real provisioning; the live marker mode stat'd mid-publish via the hard-link-fallback path; rename mode-preservation confirmed both directions; the claim non-empty-after-append behavior confirmed with a failing primary stamp. The hostile-dir-writer attack chains are reasoned from directory semantics plus the verified gate code, not built as a multi-user repro.
This extends the atomic-secret-file-creation hardening from #194: that covered the key file's creation mode, but not the containing directory's mode, nor the non-key files written beside it.
Summary
The node identity key file is 0600, but the directory that holds it (and all the marker/claim/quarantine files the crash-recovery protocol writes there) is created at the process umask with no mode pinning and no verification. On a directory that is group- or world-writable, every defense in the identity-key recovery path degrades, because those defenses assume a node-owned directory and nothing establishes that assumption. openssh pins
~/.sshto 0700 for exactly this reason.Both parts below are hardening gaps on
crates/gitlawb-node/src/lib.rs, not live exploits on a default single-user host. They predate the round-seven recovery work (#194); that work is what made the directory contents (markers, claims) security-relevant, which is why they surface now.Part 1: pin the key directory to 0700
std::fs::create_dir_all(parent)in the generate arm (lib.rs~3120) creates the key directory with no mode. Observed after real provisioning:nested=0775 keys=0775 key=0600(umask 0002; under 022 the dirs are 0755). No code path creates the dir with a mode, chmods it, or verifies its mode on load, so a pre-existing group/other-writable directory is silently accepted too.Blast radius by directory mode:
.publishing./.recovering./.quarantined./.superseded.names, leaking pids and crash/recovery timing. The 0600 final stays unreadable. Low severity..publishing.marker so the next boot sees the crash signature (content-class failure + marker), quarantines, and silently regenerates a new node identity; or plant perpetually-refreshed.recovering.claims to wedge every start. The content-class and no-marker gates were built against crash residue, not a hostile directory writer.Fix: create the directory with
DirBuilder::mode(0o700)on Unix, and on the load path verify (and tighten, or fail closed) the directory mode the same wayload_existing_keyalready does for the key file.Part 2: create marker-class files 0600
The publish marker is opened
write(true).create_new(true)with nomode(lib.rs~2137), so it lands at umask (observedmode=0664), unlike the temp file and fallback final which both pin 0o600. Claims are renames of markers and inherit that umask mode; quarantine and superseded files are renames of the final and correctly inherit 0600 (verified: rename preserves source mode both directions).No key material leaks today (markers and claims are empty or, since the heartbeat's
set_modifiedfallback appends a byte, hold.bytes; every consumer matches by name only). Two modest exposures remain: a future change that writes anything sensitive into a marker or claim would ship it at umask mode and rename would propagate it silently; and under a shared-group umask (0664) a group member has write access to a live claim without directory-write, enough to keep refreshing its mtime and defeat the age-gated sweep, stalling demoted publishers and boot recovery. Under the common 022 umask (0644) only the future-content risk remains.Fix: add
OpenOptionsExt::mode(0o600)to the marker create site, matching the temp/final pattern already used a few lines away.Verification
Confirmed by execution against head 32903c4 (probes reverted): directory modes stat'd after real provisioning; the live marker mode stat'd mid-publish via the hard-link-fallback path; rename mode-preservation confirmed both directions; the claim non-empty-after-append behavior confirmed with a failing primary stamp. The hostile-dir-writer attack chains are reasoned from directory semantics plus the verified gate code, not built as a multi-user repro.
This extends the atomic-secret-file-creation hardening from #194: that covered the key file's creation mode, but not the containing directory's mode, nor the non-key files written beside it.