Two defect classes across six call sites in crates/gl, sharing one root cause: nothing pins a mode
at creation time, and the containing directory is left at the umask default.
Class 1: the private key is 0666-minus-umask between two syscalls
crates/gl/src/identity.rs:143-145:
fs::write(&path, pem.as_bytes())?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
strace of gl identity new confirms the window rather than inferring it:
openat(AT_FDCWD, ".../identity.pem", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 9
fchmodat(AT_FDCWD, ".../identity.pem", 0600) = 0
The Ed25519 private key is written between those two calls. grep -rn "mode(0o600)" crates/gl
returns only set_permissions sites; no OpenOptions::mode anywhere in the crate.
Five sites: identity.rs:143 (generate), identity.rs:208 (export), identity.rs:271 (import),
init.rs:240, quickstart.rs:239. crates/gitlawb-node/src/main.rs:1289 does it too.
Scope limit, verified by execution: the window exists only on a fresh create. O_CREAT|O_TRUNC
against an existing 0600 file leaves it 0600, because the mode argument is ignored when no file is
created. So --force over an existing key is fine; first-run generation is not.
Class 2: ucan.json is never chmod'd
register.rs:80, init.rs:108-118, and quickstart.rs:103-110 each write the token with a plain
std::fs::write and no follow-up. Observed after real runs:
775 <dir>
600 <dir>/identity.pem
664 <dir>/ucan.json # 0664 under umask 002, 0644 under 022
Note the third site. An audit pass that patches only register.rs and init.rs leaves
gl quickstart still producing a world-readable token.
What the token is and is not
Being accurate here, because the obvious framing overstates it. ucan.json holds a bootstrap UCAN
minted as Ucan::bootstrap(&state.node_keypair, agent_did) with iss = node DID and aud = agent
DID. Presenting it requires X-Ucan, and auth/mod.rs:284-296 rejects it unless
ucan.payload.iss == signer_did, where the signer is the RFC 9421 identity. A delegated wrapper does
not help either, since verify_chain requires proof.payload.aud == self.payload.iss
(gitlawb-core/src/ucan.rs:269), so any chain rooted in this token must be issued by the agent's own
DID, which needs the 0600 key.
So a stolen ucan.json is not a usable credential against the node as it stands today. What leaks is
registration metadata: node URL, capability set, expiry, registration time. Worth fixing because the
mode is wrong and the fix is small, not because the token is presently bearer.
One caveat on relying on that: PR #331 is actively reworking UCAN authorization, so the
iss == signer binding that defuses this is not a property to bank on permanently.
Reachability
Another local user on a shared host. The directory lands 0775, so it is traversable and listable.
Class 2 needs no timing at all. For class 1 an attacker does not have to poll: an inotify watch on
~/.gitlawb for IN_CREATE fires on the openat and can read before the fchmodat. I did not build
that watcher, so treat the event-driven variant as reasoned rather than demonstrated; the two-syscall
window itself is trace-confirmed.
Relationship to #231
#231 does not cover any of this. It scopes itself explicitly to
crates/gitlawb-node/src/lib.rs ("Both parts below are hardening gaps on
crates/gitlawb-node/src/lib.rs"), covering the node key directory and the publish-marker create
site, and it defers the key-file creation mode as already handled by #194.
Two gaps follow. Nothing covers crates/gl at all, and the premise that the key file's creation mode
is solved does not hold on main today, since gitlawb-node/src/main.rs:1289 still uses the same
create-then-chmod shape.
Fix direction
OpenOptions::new().write(true).create_new(true).mode(0o600) at each of the six sites, and pin the
directory with DirBuilder::mode(0o700) where it is created. One line per site.
Adjacent and worth the same pass: fs::write follows symlinks and there is no O_EXCL, so on a host
where users share a primary group (umask 002) a group member can pre-plant identity.pem as a symlink
and the CLI writes the key through it. Same root cause, same fix shape.
Two defect classes across six call sites in
crates/gl, sharing one root cause: nothing pins a modeat creation time, and the containing directory is left at the umask default.
Class 1: the private key is 0666-minus-umask between two syscalls
crates/gl/src/identity.rs:143-145:straceofgl identity newconfirms the window rather than inferring it:The Ed25519 private key is written between those two calls.
grep -rn "mode(0o600)" crates/glreturns only
set_permissionssites; noOpenOptions::modeanywhere in the crate.Five sites:
identity.rs:143(generate),identity.rs:208(export),identity.rs:271(import),init.rs:240,quickstart.rs:239.crates/gitlawb-node/src/main.rs:1289does it too.Scope limit, verified by execution: the window exists only on a fresh create.
O_CREAT|O_TRUNCagainst an existing 0600 file leaves it 0600, because the mode argument is ignored when no file is
created. So
--forceover an existing key is fine; first-run generation is not.Class 2: ucan.json is never chmod'd
register.rs:80,init.rs:108-118, andquickstart.rs:103-110each write the token with a plainstd::fs::writeand no follow-up. Observed after real runs:Note the third site. An audit pass that patches only
register.rsandinit.rsleavesgl quickstartstill producing a world-readable token.What the token is and is not
Being accurate here, because the obvious framing overstates it.
ucan.jsonholds a bootstrap UCANminted as
Ucan::bootstrap(&state.node_keypair, agent_did)withiss= node DID andaud= agentDID. Presenting it requires
X-Ucan, andauth/mod.rs:284-296rejects it unlessucan.payload.iss == signer_did, where the signer is the RFC 9421 identity. A delegated wrapper doesnot help either, since
verify_chainrequiresproof.payload.aud == self.payload.iss(
gitlawb-core/src/ucan.rs:269), so any chain rooted in this token must be issued by the agent's ownDID, which needs the 0600 key.
So a stolen
ucan.jsonis not a usable credential against the node as it stands today. What leaks isregistration metadata: node URL, capability set, expiry, registration time. Worth fixing because the
mode is wrong and the fix is small, not because the token is presently bearer.
One caveat on relying on that: PR #331 is actively reworking UCAN authorization, so the
iss == signerbinding that defuses this is not a property to bank on permanently.Reachability
Another local user on a shared host. The directory lands 0775, so it is traversable and listable.
Class 2 needs no timing at all. For class 1 an attacker does not have to poll: an inotify watch on
~/.gitlawbforIN_CREATEfires on theopenatand can read before thefchmodat. I did not buildthat watcher, so treat the event-driven variant as reasoned rather than demonstrated; the two-syscall
window itself is trace-confirmed.
Relationship to #231
#231 does not cover any of this. It scopes itself explicitly to
crates/gitlawb-node/src/lib.rs("Both parts below are hardening gaps oncrates/gitlawb-node/src/lib.rs"), covering the node key directory and the publish-marker createsite, and it defers the key-file creation mode as already handled by #194.
Two gaps follow. Nothing covers
crates/glat all, and the premise that the key file's creation modeis solved does not hold on main today, since
gitlawb-node/src/main.rs:1289still uses the samecreate-then-chmod shape.
Fix direction
OpenOptions::new().write(true).create_new(true).mode(0o600)at each of the six sites, and pin thedirectory with
DirBuilder::mode(0o700)where it is created. One line per site.Adjacent and worth the same pass:
fs::writefollows symlinks and there is noO_EXCL, so on a hostwhere users share a primary group (umask 002) a group member can pre-plant
identity.pemas a symlinkand the CLI writes the key through it. Same root cause, same fix shape.