identity: signing_key, a second pin for git commit signing (closes #453) - #573
Merged
Merged
Conversation
…igns with it (closes #453) git signs with a different key than it authenticates with. `[identity]` had one key field and the stack below it was singular, so a profile with `ssh_mode = "agent-proxy"` could push and could not sign. No spelling got both. A signing key is typically NOT an authorized key, so granting signing does not grant push and granting push does not grant signing. One field cannot say that; two can. - `policy.Identity.SigningKey` (`signing_key`), through `CheckText`, `expandVars` and the under-target symlink refusal `ssh_key` already gets - both key paths now share one loop rather than two copied blocks. - `sshproxy` pins a SET. `New([]PinnedKey, ...)`, `Proxy.pinned []pin`, `REQUEST_IDENTITIES` advertises both, `handleSign` matches with `containsBlob` - every element compared, results OR'd, no early exit. - The generated `~/.gitconfig` AUTHORS `user.signingkey`, `gpg.format = ssh` and `commit.gpgsign = true` from the resolved identity. `GitKeyWhitelist` stays three keys: a carried `user.signingkey` names a host path that does not exist inside, and a carried `commit.gpgsign = true` then fails every commit with `error: No private key found for public key "..."` / `fatal: failed to write commit object` (measured, git 2.55.0). - `sshproxy.New` PROBES the host agent with `REQUEST_IDENTITIES` before binding the listener and refuses when a pinned key is not held. This is what makes `commit.gpgsign = true` safe to author: without it an unheld key fails EVERY commit inside, with an error naming no cause, and the sandbox cannot see the host agent to diagnose it. - `signing_key` with `ssh_mode = "none"` is refused at resolve time. BEHAVIOUR CHANGE, existing single-key profiles included: a run whose agent does not hold the pinned key now refuses at startup instead of failing at first push. The probe covers both keys in one exchange; the old asymmetry was the absence of any check, not a decision. Two residuals, stated rather than closed. The agent protocol carries no purpose field, so with both keys pinned either key can be used for either job - two keys are two identities' worth of blast radius, not two compartments. And the probe is liveness at startup, not a guarantee: a key removed or an agent locked mid-run fails at sign time as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…at it lists the key Red-team finding against 61b76b3, sev:medium, confirmed by running it. The probe sent REQUEST_IDENTITIES and checked membership. A key can be LISTED and UNSIGNABLE, with no state change at all, and `GitConfigFrom` authors `commit.gpgsign = true` — so two ordinary agent configurations passed the probe and then failed EVERY commit inside: ssh-add -c <key> confirm-before-use, no askpass ssh-add -h github.com <key> destination-constrained Measured, OpenSSH 10.5p1: both are answered by REQUEST_IDENTITIES (reply type 12) and refuse every SIGN_REQUEST (type 5, in 57 ms with no askpass). Inside, that is `Couldn't sign message (signer): agent refused operation?` and `fatal: failed to write commit object`, naming no cause. The -c case is the setup #453 itself describes: a signing key is frequently the one key in the agent authorised to push nowhere. The -h case can NEVER work through snug — lifting the constraint needs session-bind@openssh.com, which `handle` refuses wholesale so `ssh -A` from inside cannot chain the agent onward — and the refusal says so. Fix: a second probe phase on the SAME connection (so a caller counting accepted connections still counts one). For each pin the CALLER marks MustSign, one SIGN_REQUEST over 49 bytes snug generates itself. - `PinnedKey.MustSign`, set in internal/cli for identity.signing_key only. The bit is a statement about what the caller AUTHORED, so sshproxy still holds nothing but blobs and #454 does not have to unpick a field name here. - The auth key is deliberately not probed for signing: its failure is `Permission denied (publickey)` against a generated ~/.ssh/config naming one key, which is attributable, and probing it would put a confirmation dialog in front of every snug start including runs that never touch the network. - Payload is `snug-agent-probe\0` + 32 bytes of crypto/rand. It cannot be replayed: SSHSIG blobs begin with the literal "SSHSIG" (measured, git's own signing blob is 95 bytes starting 53 53 48 53 49 47) and this begins 's'; an SSH userauth signature's first four bytes are a session-id length, and here they read 1936618855 against a 49-byte payload. The reply is inspected and dropped. - Flags are what git measurably sends, read from the blob's own algorithm name: 0x0 for ed25519, 0x4 (RSA_SHA2_512) for ssh-rsa. - `signProbeTimeout` is 2 minutes, NOT the 5s list deadline. Measured: an agent whose askpass took 8s blocked 8.059s and then SUCCEEDED, so probeTimeout would have aborted exactly the case this probe exists to serve. Two more findings from the same run, both fixed here: - `handleSign` audited a success and stayed silent on an upstream REFUSAL. The human saw only git's `agent refused operation?`; snug had the answer in reply[0] and did not say it. - `showIdentity` was the only member of `showCapabilities` not using `capRows`, so the grant whose base.toml ABUSE line reads "SIGN COMMITS AND TAGS AS YOU" got four words in brackets while `listen_names` gets "THIS IS A SANDBOX ESCAPE". Both key rows now carry a consequence sentence. `identitiesAnswer` is one agent message: `New` now refuses a pin set whose exact rendered size exceeds maxMessage, computed from the real blobs rather than asserted against a bound, because three maximal pins fit (196625) and four do not (262165) and #454 makes a third key field foreseeable. Four comments were FALSE as written and are repaired rather than annotated: the package doc's "the sandbox sees exactly one key", New's "asked ONCE whether it holds", probeUpstream's WHAT IT DOES NOT PROMISE, and both prose blocks claiming the probe is why gpgsign is safe — including the one rendered into every sandbox's ~/.gitconfig. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 11, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #453.
git signs with a different key than it authenticates with.
[identity]had one key field and the stack below it was singular —policy.Identity.SSHKey,sshproxy.Proxy.pinned []byte, oneIdentityFile— so a profile withssh_mode = "agent-proxy"could push and could not sign. No spelling got both.A signing key is typically NOT an authorized key. Granting signing does not grant push; granting push does not grant signing. One field cannot say that; two can.
What lands
policy.Identity.SigningKey(signing_key), throughCheckText,expandVarsand the under-target symlink refusalssh_keyalready gets. Both key paths now share one loop, not two copied blocks.sshproxypins a SET.New([]PinnedKey, …),Proxy.pinned []pin,REQUEST_IDENTITIESadvertises both,handleSignmatches withcontainsBlob— every element compared, results OR'd, no early exit.~/.gitconfigAUTHORSuser.signingkey,gpg.format = ssh,commit.gpgsign = true.GitKeyWhiteliststays three keys: a carrieduser.signingkeynames a host path absent inside, and a carriedcommit.gpgsign = truethen fails every commit witherror: No private key found for public key "…"/fatal: failed to write commit object(measured, git 2.55.0).signing_keywithssh_mode = "none"refused at resolve time.~/.ssh/id_snug_signing.pub,--ro-bind-data, second constant besidePubKeyGuest.The startup probe, and why it is two phases
sshproxy.Newasks the host agent, on ONE connection before the listener binds, whether it holds each pinned key — and foridentity.signing_key, whether it will actually SIGN.Phase 2 exists because the red team broke phase 1. Membership was not enough: a key can be LISTED and UNSIGNABLE with no state change at all. Measured, OpenSSH 10.5p1:
ssh-add -c <key>, no askpassssh-add -h github.com <key>Both then failed EVERY commit inside, because snug authors
commit.gpgsign = true. The-ccase is the setup #453 itself describes — a signing key is frequently the one key in the agent authorised to push nowhere. The-hcase can never work through snug at all: lifting the constraint needssession-bind@openssh.com, whichhandlerefuses wholesale sossh -Afrom inside cannot chain the agent onward. The refusal says that, rather than suggesting a retry.Probe payload is
snug-agent-probe\0+ 32 bytescrypto/rand, 49 bytes, fresh per key per run. Not replayable: SSHSIG blobs begin with the literalSSHSIG(git's own signing blob measured at 95 bytes starting53 53 48 53 49 47) and this beginss; an SSH userauth signature's first four bytes are a session-id length, here reading 1936618855 against a 49-byte payload. Reply inspected and dropped — never stored, audited, written or relayed.Flags are what git measurably sends, read from the blob's algorithm name:
0x0ed25519,0x4(RSA_SHA2_512) forssh-rsa. A probe succeeding with flags git will not use proves the wrong thing in both directions.signProbeTimeoutis 2 minutes, deliberately not the 5 s list deadline: an agent whose askpass took 8 s blocked 8.059 s and then SUCCEEDED, so the short deadline would abort exactly the case the probe exists to serve. Atime.AfterFuncininternal/clisays why snug is waiting, after 2 s, on stderr only.The auth key is NOT sign-probed. Its failure is
Permission denied (publickey)against a generated~/.ssh/confignaming one key — attributable. Probing it would put a confirmation dialog in front of every snug start, including runs that never touch the network, which trains people to click through the dialog that is the whole point of-c. It is one line ininternal/cliif that is wrong.BEHAVIOUR CHANGE
Existing single-key profiles included: a run whose agent does not hold the pinned key now refuses at startup instead of failing at first push. The probe covers both keys in one exchange; the old asymmetry was the absence of any check, not a decision.
Residuals, stated not closed
Newreturns fails at sign time as before.Red team
Run before merge. One sev:medium (above) and two sev:low, all fixed in
41299c0, none ticketed:handleSignaudited the success and stayed silent on an upstream REFUSAL — the human saw only git'sagent refused operation?, whichgitextract.go's own comment describes as reading like a host keyring fault. snug had the answer inreply[0].showIdentitywas the only member ofshowCapabilitiesnot usingcapRows, so the grant whosebase.tomlABUSE line reads "SIGN COMMITS AND TAGS AS YOU" got four words in brackets whilelisten_namesgets "THIS IS A SANDBOX ESCAPE". Both key rows now carry a consequence sentence.Attacked and held: membership is the whole filter (one upstream contact per run, verified on the wire with a logging relay); a third key held by the agent never leaked into
REQUEST_IDENTITIES, any error, or any timing;SSH_AUTH_SOCKpointed at a FIFO, a regular file and a directory all failed atconnect(2)immediately — a FIFO does not hang the probe becauseconnectis notopen; 32767 claimed identities and a 300 KiB reply refused cleanly with no preallocation; forging runes insigning_keyrefused at resolve time; probe refusals left zero runtime dirs and no socket.Four comments were FALSE as written after the fix and are repaired rather than annotated — including the one rendered into every sandbox's
~/.gitconfig.Tests
make gategreen. New: the finding itself (TestListedButUnsignableKeyRefusesTheRun, which also assertsos.Lstat(socketPath)isErrNotExist— the ordering claim made observable from outside rather than asserted about line order),TestSignProbeCoversOnlyMustSignKeys,TestSignProbePayloadCannotBeReplayed,TestSignProbeSendsTheFlagsGitSends,TestSignProbeSignatureNeverLeavesNew,TestSignProbeDoesNotUseTheListDeadline,TestDryRunNeverAsksTheAgentToSign(with the positive control, without which it passes on astartIdentitythat does nothing),TestCheckTextCoversEveryIdentityField(reflective — makes #454's walk a refactor rather than a security change), plus the pin-conflict, symlink, forging-rune andssh_modenegatives.Tests verified by mutation: disabling the phase-2 loop fails five of them; setting
MustSign: falsefails the dry-run control.Two new goldens — no committed golden rendered an identity before this. The argv golden asserts the
--ro-bind-datarow for the staged signing key and that no argv line binds a host~/.sshpath.VERIFY.md§13a-2 is the by-hand equivalent, negatives included. IntegrationTestGeneratedGitconfigSignsACommitis written and unrun here — no sandbox guarantee in this environment.Not in scope
Signature VERIFICATION.
gpg.ssh.allowedSignersFilenames a file of other people's keys — a different grant with a different abuse sentence (#453 point 5). GPG: nogpg-agentproxy, no single-blob-pin property.🤖 Generated with Claude Code