Repository navigation
v1.4.0: SSH signing — receipts that say who signed, verifiable with no secret - #10
Merged
Merged
Conversation
…secret (1.4.0) HMAC receipts carry two caveats: anyone with the shared .titan/key can forge any receipt, and a clone without the key can't check signatures at all. `init --ssh [KEY]` signs each receipt with the committer's own SSH key (ssh-keygen -Y sign, namespace commitproof-receipt) and registers the public key in a committed .titan/allowed_signers. The signer (principal + key fingerprint) is inside the receipt hash, and the verifying key must be the one the receipt names. A probe signature must succeed without a terminal before init writes anything. verify/report/badge check receipt by receipt (chains can move from HMAC to SSH); --public-only checks everything that needs no secret; --allowed-signers pins who may sign. The CI action checks SSH signatures with no secret and takes a pinned allowed_signers input. A hook that can't sign tells the user instead of skipping silently, and never writes an unsigned receipt. core/signing.py is now the one place signatures are made and checked. Suite: 127 tests (was 113), including the action's own run block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by an automated security review: someone with push access but no registered SSH key could append a correctly chained receipt labelled hmac-sha256-v1 with a junk signature, and `verify --public-only` (what CI runs without a secret) skipped it and exited 0. - Once a chain has an SSH receipt, any later HMAC receipt is invalid, checked or not. Order comes from the chain's own prev links, which can't be reordered without breaking the later SSH signatures. - create refuses HMAC signing in a repo with .titan/allowed_signers, and the --hmac init flag (which produced exactly that state) is gone. - With a pinned --allowed-signers, an HMAC receipt that can't be checked exits 2 instead of passing. Tests reproduce the attack (it exited 0 before) and a key holder trying the same with a valid HMAC signature. Suite: 131. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipefail) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps v1.3.1's rule: no file both reads the environment and holds a URL. receipt.py imports signing and badge, holds no https:// string; signing.py reads no environment; test_ssh.py uses tests/_env.clean_env. Vendored tools are now receipt, badge, canonical, chain_state, signing. Version 1.4.0; changelog keeps 1.4.0 above 1.3.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Why
HMAC receipts have two weaknesses. Anyone holding the shared
.titan/keycan forge any receipt, and a clone without the key can't check signatures at all. That limits the claim the product can make ("proves the record wasn't rewritten, under a key the team controls"), and handing the key around was a setup chore.What changes
/receipt-init --ssh [KEY]: each receipt is signed with the committer's own SSH key, usingssh-keygen -Y signin thecommitproof-receiptnamespace. The public key goes into a committed.titan/allowed_signersfile. The key path stays in this clone's git config. There's no secret, and anyone can verify.signer.principaland the key fingerprint are part of the receipt hash, so swapping the signer breaks the chain. Verification also requires that the key which checked out is the one the receipt names.--public-onlychecks everything that needs no secret.--allowed-signers FILEpins who may sign.allowed_signersinput so the repo under audit doesn't decide who's trusted.core/signing.pyis the single place signatures are made and checked, replacing the HMAC loops that were duplicated in verify and report.Evidence
python -m pytest tests/ -qgives 127 passed locally on Windows (was 113). The 14 SSH tests use realssh-keygen: fresh-clone verify, an unlisted key, signer tampering, a teammate joining, an HMAC→SSH chain, a CRLFallowed_signers, a hook signing failure, and the action's own run block (with no secret, and with a pinned list overriding the repo's).--ssh, a commit through the hook, then a freshautocrlfclone, whereverifypassed with no secret.HMAC stays the default, and existing chains are unchanged.
🤖 Generated with Claude Code