Skip to content

identity: signing_key, a second pin for git commit signing (closes #453) - #573

Merged
vyskocilm merged 2 commits into
mainfrom
ssh-signing-key
Sep 11, 2026
Merged

vyskocilm merged 2 commits into
mainfrom
ssh-signing-key

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

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, one IdentityFile — 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. 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), through CheckText, expandVars and the under-target symlink refusal ssh_key already gets. Both key paths now share one loop, not 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.
  • Generated ~/.gitconfig AUTHORS user.signingkey, gpg.format = ssh, commit.gpgsign = true. GitKeyWhitelist stays three keys: a carried user.signingkey names a host path absent 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).
  • signing_key with ssh_mode = "none" refused at resolve time.
  • Staged at ~/.ssh/id_snug_signing.pub, --ro-bind-data, second constant beside PubKeyGuest.

The startup probe, and why it is two phases

sshproxy.New asks the host agent, on ONE connection before the listener binds, whether it holds each pinned key — and for identity.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:

setup REQUEST_IDENTITIES SIGN_REQUEST
ssh-add -c <key>, no askpass listed (type 12) refused (type 5), 57 ms
ssh-add -h github.com <key> listed (type 12) refused, always

Both then failed EVERY commit inside, because snug authors commit.gpgsign = true. 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 at all: lifting the constraint needs session-bind@openssh.com, which handle refuses wholesale so ssh -A from inside cannot chain the agent onward. The refusal says that, rather than suggesting a retry.

Probe payload is snug-agent-probe\0 + 32 bytes crypto/rand, 49 bytes, fresh per key per run. Not replayable: SSHSIG blobs begin with the literal SSHSIG (git's own signing blob measured at 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, 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: 0x0 ed25519, 0x4 (RSA_SHA2_512) for ssh-rsa. A probe succeeding with flags git will not use proves the wrong thing in both directions.

signProbeTimeout is 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. A time.AfterFunc in internal/cli says 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/config naming 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 in internal/cli if 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

  • The agent protocol carries no purpose field. With both keys pinned, either key can be used for either job by anything inside. Two keys are two identities' worth of blast radius, not two compartments.
  • The probe is startup state, not a guarantee. A key removed, an agent locked, or a confirmation declined after New returns fails at sign time as before.
  • The signature is not verified cryptographically. The measured failure is an agent that REFUSES; an agent lying to its own user is not in the threat model.

Red team

Run before merge. One sev:medium (above) and two sev:low, all fixed in 41299c0, none ticketed:

  • handleSign audited the success and stayed silent on an upstream REFUSAL — the human saw only git's agent refused operation?, which gitextract.go's own comment describes as reading like a host keyring fault. snug had the answer in reply[0].
  • 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.

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_SOCK pointed at a FIFO, a regular file and a directory all failed at connect(2) immediately — a FIFO does not hang the probe because connect is not open; 32767 claimed identities and a 300 KiB reply refused cleanly with no preallocation; forging runes in signing_key refused 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 gate green. New: the finding itself (TestListedButUnsignableKeyRefusesTheRun, which also asserts os.Lstat(socketPath) is ErrNotExist — 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 a startIdentity that does nothing), TestCheckTextCoversEveryIdentityField (reflective — makes #454's walk a refactor rather than a security change), plus the pin-conflict, symlink, forging-rune and ssh_mode negatives.

Tests verified by mutation: disabling the phase-2 loop fails five of them; setting MustSign: false fails the dry-run control.

Two new goldens — no committed golden rendered an identity before this. The argv golden asserts the --ro-bind-data row for the staged signing key and that no argv line binds a host ~/.ssh path.

VERIFY.md §13a-2 is the by-hand equivalent, negatives included. Integration TestGeneratedGitconfigSignsACommit is written and unrun here — no sandbox guarantee in this environment.

Not in scope

Signature VERIFICATION. gpg.ssh.allowedSignersFile names a file of other people's keys — a different grant with a different abuse sentence (#453 point 5). GPG: no gpg-agent proxy, no single-blob-pin property.

🤖 Generated with Claude Code

vyskocilm and others added 2 commits September 11, 2026 10:30
…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>
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] ssh_key cannot express a signing key: git signs with a different key than it authenticates with

1 participant