Skip to content

gl writes secrets world-readable: a create-then-chmod window on private keys, and ucan.json never chmod'd at all #354

Description

@beardthelion

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    crate:glgl — the contributor CLIkind:securityVulnerability fix or hardeningsev:mediumDegraded but workaround existssubsystem:identityDID/UCAN, http-sig auth, push authorization

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions