Skip to content

v1.4.0: SSH signing — receipts that say who signed, verifiable with no secret - #10

Merged
Rmasood1122 merged 5 commits into
mainfrom
feat/ssh-signing
Oct 20, 2026
Merged

Rmasood1122 merged 5 commits into
mainfrom
feat/ssh-signing

Conversation

@Rmasood1122

Copy link
Copy Markdown
Owner

Why

HMAC receipts have two weaknesses. Anyone holding the shared .titan/key can 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, using ssh-keygen -Y sign in the commitproof-receipt namespace. The public key goes into a committed .titan/allowed_signers file. The key path stays in this clone's git config. There's no secret, and anyone can verify.
  • The signer is inside the hash. signer.principal and 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.
  • Fails closed and never silently.
    • At init, a probe signature must work without a terminal before anything is written.
    • In the Claude Code hook, a signing failure shows the user a message.
    • An unsigned receipt is never written.
  • Mixed chains verify receipt by receipt. Flags:
    • --public-only checks everything that needs no secret.
    • --allowed-signers FILE pins who may sign.
    • The report gets a "Signed by" table; the badge says "chain verified" only when every signature was checked.
  • CI action: checks SSH signatures with no secret, and takes a new allowed_signers input so the repo under audit doesn't decide who's trusted.
  • core/signing.py is the single place signatures are made and checked, replacing the HMAC loops that were duplicated in verify and report.

Evidence

  • python -m pytest tests/ -q gives 127 passed locally on Windows (was 113). The 14 SSH tests use real ssh-keygen: fresh-clone verify, an unlisted key, signer tampering, a teammate joining, an HMAC→SSH chain, a CRLF allowed_signers, a hook signing failure, and the action's own run block (with no secret, and with a pinned list overriding the repo's).
  • The spike confirmed that both Git for Windows' and Windows' built-in OpenSSH sign and verify the same way.
  • End-to-end demo on this machine: init with --ssh, a commit through the hook, then a fresh autocrlf clone, where verify passed with no secret.

HMAC stays the default, and existing chains are unchanged.

🤖 Generated with Claude Code

Rmasood1122 and others added 5 commits October 20, 2026 11:19
…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>
@Rmasood1122
Rmasood1122 merged commit 8f5f8f1 into main Oct 20, 2026
4 checks passed
Rmasood1122 added a commit that referenced this pull request Oct 20, 2026
This reverts commit 8f5f8f1, reversing changes made to 2a9e526.
main returns to v1.3.1.

Co-Authored-By: Claude Opus 5.5 <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.

1 participant