Skip to content

identity: author allowed_signers so a run verifies its own commits - #581

Merged
vyskocilm merged 2 commits into
mainfrom
identity/allowed-signers
Sep 17, 2026
Merged

vyskocilm merged 2 commits into
mainfrom
identity/allowed-signers

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

Closes #576. A signed commit read as unsigned INSIDE, successfully — git log --show-signature printed No signature and exited 0, because gpg.ssh.allowedSignersFile was never configured and the error: line went to stderr.

What ships

snug AUTHORS one line from two values it already holds:

<identity.git.email> namespaces="git" <keytype> <base64>

mounted KindData/AccessRO at ~/.config/git/allowed_signers, with gpg.ssh.allowedSignersFile added to the if signing block that already writes gpg.format and commit.gpgsign. No new host read. A run verifies its OWN commits and nobody else's.

Host's ~/.config/git/allowed_signers is never carried — it is the list of people whose signatures you accept, so carrying it hands the sandbox collaborators' addresses and public keys plus a trust decision no grant named.

Three maintainer rulings taken: empty email REFUSED at resolve rather than falling back to the extracted user.email (which can also be absent); namespaces="git" pinned; guest path under git's own .config/git rather than .ssh, which is what the ssh CLIENT reads.

The red-team round found the abuse sentence false. Four findings, all fixed here

F1 — identity.git.email authored the KEY. allowed_signers is unquoted and space is its field separator:

email = "[email protected],[email protected] ssh-ed25519 AAAA…"
->  [email protected],[email protected] ssh-ed25519 AAAA… namespaces="git" ssh-ed25519 <pinned>
    6 fields. Key = attacker's. Pinned key = trailing comment.

Measured with ssh-keygen -Y verify: Good "git" signature for [email protected], Good "file" signature for [email protected] (the namespace pin gone too), and the pinned key No principal matched. CheckText does not reach it — it refuses forging runes and a space is not one.

F2 — " " is not == "". ssh skips leading blanks, so namespaces="git" became the PRINCIPAL and the entry carried no options. Namespace pin evaporates, exit 0, nothing on screen — invariant 5.

F3 — a leading # comments the line out: empty verifier list, verification silently back where the ticket found it.

refuseUnprincipledSigningEmail closes all three at Resolve, SCOPED to signing_key because everywhere else the email is a value gitQuote quotes. allowedSignersLine re-checks at the authoring site.

F4 — snug wrote a file ON THE HOST.

profile: ro = ["{home}/.config"], signing_key pinned
host:    ln -s ~/proj/sub/dotfiles-git ~/.config/git
result:  -r--r--r-- 1 … 0 … allowed_signers   ON THE HOST, snug exit 0

Past rejectGeneratedOntoHost, which decided by LEXICAL containment. Class is #186's; instance is new — .config/git/allowed_signers is the first generated file that is the ONLY one under its granted parent AND whose redirect sits at the directory level, so nothing hits EROFS first. The pre-#576 ~/.ssh equivalent does not fire: config is generated first and dies loudly.

Guard now takes an Environ, walks up to the deepest EXISTING ancestor and resolves it. Predicate is containment, not "lands somewhere writable" — an absolute symlink's landing place is decided by the sandbox's view, not the host's, and a path-translating bind makes them diverge (ro /etc/os-release (from /usr/lib/os-release)). Does not consult Access: the finding's cover is ro, and the doc comment's "a read-only bind fails loudly by itself" was true only while the destination stayed inside the mount. Corrected in place rather than left as a second wrong copy.

Not atomic, and the comment says so: a PREVIOUS run holding rw on the directory can plant the link for the next one.

Verified fixed: exit 77, refusal names the symlink AND its target AND the fix, host directory empty afterwards.

Rejected alternative, measured: refusing any KindData under any bind would refuse EVERY default run on this host — @sys's ro /usr covers the generated /usr/etc/ssh/ssh_config — plus @claude on any repo with a .mcp.json. TestSystemSSHConfigUnderTheUsrBindIsStillAllowed and TestProjectMCPJSONInsideTheTargetIsStillAllowed are the permanent regressions.

Golden diff — TWO lines, and the second one is a bug fix

+--tmpfs /home/u/.config/git
+--ro-bind-data 19 /home/u/.config/git/allowed_signers

TestGoldenIdentityArgv now runs the second InstallAnchors and Validate that main.go runs after staging, and had not been. allowed_signers is the first post-Resolve identity mount introducing a new intermediate directory, so the golden had silently stopped being the argv under review. The anchor is #553's class and load-bearing here: with it mv ~/.config/git … is EBUSY, so a payload cannot author its own allowed_signers under a renamed directory.

Also

--dry-run grows a SIGNING block naming the principal — the mount row names a path, not what the file DOES, and the principal was on no screen at all (showIdentity is reached only from snug profile show). VERIFY.md's signing walk rewritten; its step that grepped --dry-run for an ssh key row is corrected, since --dry-run renders none and it had been matching zero lines while passing. GIT-CONFIG.md §4's signing bullet rewritten — it still said signing was unbuilt.

Filed, not fixed: #580, a generated file under a read-only bind with nothing at the destination dies on bwrap's message naming no fix. The one-line stat that fixes it breaks eight tests by requiring every injected fake Environ to model the host filesystem — a decision about how much host state Validate may consult.

make gate exit 0. Narrow integration green including TestSymlinkedGeneratedParentCreatesNoHostFile; full suite running.

🤖 Generated with Claude Code

…loses #576)

Before this, a signed commit read as unsigned INSIDE, successfully: `git log
--show-signature` printed `No signature` and exited 0, because
`gpg.ssh.allowedSignersFile` was never configured and the `error:` line went to
stderr. Anything inside that checks signatures got a false negative it could not
distinguish from an unsigned commit.

snug now authors the file from the two values it already holds — the pinned
`identity.git.email` as the principal, the staged signing `.pub` as the key — and
adds `gpg.ssh.allowedSignersFile` to the `if signing` block that already writes
`gpg.format` and `commit.gpgsign`. One line, `namespaces="git"` pinned. No new
host read. The host's own `~/.config/git/allowed_signers` is never carried: it is
the list of people whose signatures you accept, so carrying it would hand the
sandbox collaborators' addresses and public keys plus a trust decision no grant
named.

Guest path is `.config/git/allowed_signers`, git's own directory. `.ssh` holds
what the ssh CLIENT reads.

ABUSE: a hostile process inside can read one public key and one email address it
was already given, and can make `git verify-commit` succeed on commits signed by
the key the profile pinned. It gains no ability to produce a signature it could
not already produce — the proxy already holds the key — and it learns nothing
about anyone else's keys.

THE RED-TEAM ROUND FOUND THAT SENTENCE FALSE THREE WAYS, all fixed here.

`identity.git.email` authored the KEY, not just the principal. allowed_signers
has no quoting and SPACE IS ITS FIELD SEPARATOR, so an address of
`[email protected],[email protected] ssh-ed25519 AAAA...` produced a SIX-field line
whose key was the attacker's and whose `namespaces="git" <pinned key>` tail was
swallowed as a trailing comment. Measured with ssh-keygen -Y verify: `Good "git"
signature for [email protected]`, `Good "file" signature for [email protected]`,
and the pinned key `No principal matched`. CheckText does not reach it — it
refuses forging runes and a space is not one.

`email = " "` is not `== ""`. ssh skips leading blanks, so `namespaces="git"`
became the PRINCIPAL and the entry carried no options at all — the namespace pin
evaporating with exit 0 and nothing on screen, invariant 5.

`email = "#[email protected]"` commented the whole line out: empty verifier list,
verification silently back where this ticket found it.

refuseUnprincipledSigningEmail closes all three at Resolve and is SCOPED TO
signing_key, because everywhere else the email is a value gitQuote quotes.
allowedSignersLine re-checks at the authoring site: the refusal is a decision
about a config, this is the last gate before bytes whose field count decides
which key the sandbox believes.

A FOURTH FINDING WROTE A FILE ONTO THE HOST, and it is the reason validate.go
moves. `ro = ["{home}/.config"]` plus a host symlink `~/.config/git ->
~/proj/sub/dotfiles-git` created a 0-byte `-r--r--r--` allowed_signers outside
the sandbox, surviving teardown, snug exit 0 — past rejectGeneratedOntoHost,
which decided by LEXICAL guest-path containment. The class is #186's; the
instance is new, because `.config/git/allowed_signers` is the first generated
file that is the ONLY one under its granted parent and whose redirect sits at the
directory level, so nothing fails with EROFS first. The pre-existing `~/.ssh`
equivalent does not fire: `config` is generated first and dies loudly.

rejectGeneratedOntoHost now takes an Environ and walks up from the destination's
parent to the deepest EXISTING ancestor, resolving it with EvalSymlinks. The
predicate is CONTAINMENT, not "does it land somewhere writable": where an
absolute symlink lands is decided by the sandbox's view rather than the host's,
and a path-translating bind makes the two diverge — snug emits those, `ro
/etc/os-release (from /usr/lib/os-release)`. It does not consult Access, because
the finding's cover is `ro`; the doc comment's claim that a read-only bind "fails
loudly and by itself" was true only while the destination stayed inside the
mount, and is corrected here rather than left as a second wrong copy of the rule.
Not atomic, and the comment says so: a PREVIOUS run holding rw on the directory
can plant the link for the next one.

The rejected alternative was to refuse any KindData under any bind. Measured, it
would refuse EVERY default run on this host, on `@sys`'s own ro /usr covering the
generated /usr/etc/ssh/ssh_config, and `@claude` on any repo with a .mcp.json.
TestSystemSSHConfigUnderTheUsrBindIsStillAllowed and
TestProjectMCPJSONInsideTheTargetIsStillAllowed are the permanent regressions for
that.

The golden gains TWO lines, not one. `--tmpfs /home/u/.config/git` appears
because TestGoldenIdentityArgv now runs the second InstallAnchors and Validate
that main.go runs after staging, and had not been: allowed_signers is the first
post-Resolve identity mount to introduce a new intermediate directory, so the
golden had silently stopped being the argv under review. The anchor is #553's
class and it is load-bearing here — with it, `mv ~/.config/git ...` is EBUSY, so
a payload cannot author its own allowed_signers under a renamed directory.

`--dry-run` grows a SIGNING block naming the principal, because the mount row
names a path and not what the file DOES. The principal was on no screen at all:
showIdentity is reached only from `snug profile show`.

VERIFY.md's signing walk is rewritten for verification that now works, and its
step that grepped `--dry-run` for an `ssh key` row is corrected — `--dry-run`
renders none, so that step matched zero lines while passing.

Not fixed here, and filed with the measurement: a generated file under a
read-only bind with nothing at the destination still dies on bwrap's own message
naming no fix (#580). The one-line stat that would fix it breaks eight tests by
requiring every injected fake Environ to model the host filesystem, which is a
decision about how much host state Validate may consult.

closes #576

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@vyskocilm

Copy link
Copy Markdown
Contributor Author

Local make integration could not be completed on this host and CI is the evidence instead.

CI: 5/5 green, including real sandbox behaviour and real engine behaviour (Tumbleweed, podman 6.x).

Local, honestly: first full run was red on TestHostLoopbackIsUnreachable ("the sandbox has no default route") and TestEgressWorks (exit 6). Both pass 2/2 narrowly straight after, on a branch that touches no networking code — same family as the flake seen on #579's first run. Two subsequent full runs were then KILLED by the low-memory watchdog (~557 MB free of 30.9 GB, desktop browsers holding the rest), leaving truncated logs with zero failures in them.

Narrow integration runs that DID complete on this branch, all PASS: TestSymlinkedGeneratedParentCreatesNoHostFile (the red-team F4 regression — asserts the host file does not exist afterwards), TestSnugRefusesToWriteItsGeneratedFilesOntoTheHost, TestGeneratedGitconfigSignsACommit, TestGitExecutesRepoLocalConfigWithGeneratedIdentity, TestPinnedIdentityIgnoresAPayloadAuthoredXDGGitConfig, plus the rest of the identity subset.

@vyskocilm
vyskocilm merged commit 83f0da2 into main Sep 17, 2026
5 checks passed
@vyskocilm
vyskocilm deleted the identity/allowed-signers branch September 17, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[identity] a signed commit reads as "No signature" inside the sandbox: gpg.ssh.allowedSignersFile is never authored

1 participant