From 970e7e30c37cb456850c4cff09f893df61c756eb Mon Sep 17 00:00:00 2001 From: Rehan Masood Date: Tue, 20 Oct 2026 11:19:41 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20SSH=20signing=20=E2=80=94=20receipt?= =?UTF-8?q?s=20that=20say=20who=20signed,=20verifiable=20with=20no=20secre?= =?UTF-8?q?t=20(1.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude-plugin/plugin.json | 4 +- CHANGELOG.md | 35 ++++ PRIVACY.md | 4 + README.md | 65 ++++-- action.yml | 27 ++- commands/receipt-init.md | 31 ++- commands/receipt-report.md | 4 + commands/verify-chain.md | 39 ++-- core/receipt.py | 333 ++++++++++++++++++++++++------ core/signing.py | 160 ++++++++++++++ skills/receipt-integrity/SKILL.md | 32 ++- tests/test_provenance.py | 2 +- tests/test_structure.py | 5 +- 13 files changed, 625 insertions(+), 116 deletions(-) create mode 100644 core/signing.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ce9c8b9..9dc9b23 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "commitproof", "displayName": "CommitProof: AI Audit Trail", - "version": "1.3.0", - "description": "An audit trail for AI-written code. /ai-history answers 'how much of this repo did Claude help write?' in seconds from git history, no setup. /receipt-init then gives every commit a tamper-evident receipt (AI-assisted or not, model, Claude Code version, file hashes): hash-chained, signed, verified offline. /receipt-report turns the chain into change-control evidence for SOC 2, HIPAA or PCI audits.", + "version": "1.4.0", + "description": "An audit trail for AI-written code. /ai-history answers 'how much of this repo did Claude help write?' in seconds from git history, no setup. /receipt-init then gives every commit a tamper-evident receipt (AI-assisted or not, model, Claude Code version, file hashes): hash-chained, signed with your own SSH key (or a shared key), verified offline. /receipt-report turns the chain into change-control evidence for SOC 2, HIPAA or PCI audits.", "author": { "name": "Rehan Masood", "email": "rmasood524@gmail.com", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f74b89..fb36675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 1.4.0 — 2026-10-20 + +**SSH signing: receipts that say who signed, verifiable with no secret.** +HMAC receipts carried two caveats: anyone holding the shared `.titan/key` can +forge any receipt, and a clone without the key can't check signatures. + +- **New: `/receipt-init --ssh [KEY]`.** Each receipt is signed by the + committer's own SSH key (`ssh-keygen -Y sign`, namespace + `commitproof-receipt`, so it can't be replayed as a git commit signature or + the other way round). The key defaults to git's SSH signing key, else + `~/.ssh/id_ed25519`. The public key and git email are added to the + committed `.titan/allowed_signers`; the key path stays in this clone's local + git config. A probe signature must succeed without a terminal before + anything is written, so a passphrase-only key is refused at init with how to + fix it, not discovered on the first commit. Teammates run it in their own + clones; in a repo that already has `allowed_signers`, plain init uses SSH. +- Receipts record `signing_version: ssh-sig-v1` and a `signer` block + (principal, key fingerprint) **inside the hash**: swapping the signer breaks + the chain, and verification requires the key that verified to be the one + the receipt names. +- **Verify, report and badge check receipt by receipt**, so a chain can move + from HMAC to SSH. `verify` names who signed; `--public-only` checks + everything that needs no secret and says HMAC was skipped; + `--allowed-signers FILE` trusts a pinned list instead of the repo's. The + report gains a **Signed by** table. The badge says "chain verified" only when + every signature was checked, "partly verified" when some couldn't be. +- **CI action:** SSH signatures are checked with no secret (`verify + --public-only` when no `key` is given), and a new `allowed_signers` input + pins who may sign, outside the repo under audit. +- **Never silent:** if the Claude Code hook can't sign (key gone, needs a + passphrase), the user gets a message saying the commit has no receipt and + how to fix it. No unsigned receipt is ever written. +- An autocrlf checkout of `allowed_signers` verifies the same as the original. + HMAC chains are unchanged. + ## 1.3.0 — 2026-10-20 Renamed **Titan Receipts → CommitProof** (`commitproof`), with an answer diff --git a/PRIVACY.md b/PRIVACY.md index 33ce17d..2b4b2d9 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -8,5 +8,9 @@ commitproof collects nothing. commit trailers) and are written into your repo, under your control. - The signing key is generated locally and gitignored; this plugin never transmits it anywhere. +- With SSH signing (`/receipt-init --ssh`), your SSH public key and git email + are written to `.titan/allowed_signers` in your repo, for you to commit. Your + private key is used only by your local `ssh-keygen`, by path; the plugin + never reads, copies or sends it. Questions: https://github.com/Rmasood1122/commitproof/issues diff --git a/README.md b/README.md index 38d1dbe..87a9ea7 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,19 @@ what receipts close. Open Claude Code **inside the repository you want receipts for**: ``` -/receipt-init # one-time: signing key (gitignored), vendored verifier, git post-commit hook +/receipt-init --ssh # one-time: sign with your SSH key, vendor the verifier, install a git post-commit hook git commit -m "…" # every commit from now on is receipted, human or AI /verify-chain # walks the chain offline; exit 0 means intact /receipt-report --badge docs/ai-audit-badge.svg ``` +**Sign with your own SSH key (recommended).** `--ssh` uses git's SSH signing +key if you have one, else `~/.ssh/id_ed25519`, and adds your public key to +`.titan/allowed_signers`. Each receipt then names who signed it, and anyone +can verify with no secret: a fresh clone, a reviewer, CI. Teammates run +`/receipt-init --ssh` in their own clones. Without `--ssh`, init creates a +shared HMAC key (`.titan/key`, gitignored) that every verifier needs. + `/receipt-report` writes the evidence document: verification result first, then AI-assisted share by author, month and recorder, and the models and Claude Code versions recorded. **[See a sample report](docs/sample-report.md).** @@ -75,8 +82,10 @@ Each receipt is one small JSON file in `.titan/attestations/`: "models": ["claude-opus-5-5"], "claude_code_version": "2.1.299", "session_id": "7f3a…", "transcript": { "lines": 412, "content_sha256": "2ef9732e…" } }, "prev_receipt_hash": "b9f069af…", + "signing_version": "ssh-sig-v1", + "signer": { "principal": "dev@example.com", "key": "SHA256:9+gzyPF4a6eV…" }, "receipt_hash": "c41d0e7b…", - "signature": "80265ea9…" + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH…" } ``` @@ -101,9 +110,9 @@ was altered, and **unsigned** when it comes from `/ai-history`. | Command | What it does | |---|---| | `/ai-history` | Share of commits and lines added with a Claude trailer, by author and month, from git history. No setup, writes nothing, unsigned. `--since`, `--json`, `--badge`. | -| `/receipt-init` | Generates the signing key (gitignored; init refuses if git tracks it), vendors the verifier into `.titan/tools/`, installs a git `post-commit` hook. `--no-git-hook` to skip the hook. | +| `/receipt-init` | `--ssh [KEY]`: registers your SSH key in `.titan/allowed_signers` and signs with it (no secret). Default: generates a shared HMAC key (gitignored; init refuses if git tracks it). Either way it vendors the verifier into `.titan/tools/` and installs a git `post-commit` hook. `--no-git-hook` to skip the hook. | | `/receipt` | Writes a signed, chained receipt for HEAD by hand. One per commit; re-runs are no-ops. | -| `/verify-chain` | Walks the whole chain offline: structure and every signature. Any tamper exits 1 and names the receipt. `--structure-only` checks without the key and says signatures were not checked. Ends with an `ANCHOR` line: how many receipts are committed **and pushed**. | +| `/verify-chain` | Walks the whole chain offline: structure and every signature, naming who signed each SSH receipt. Any tamper exits 1 and names the receipt. `--public-only` checks everything that needs no secret (structure + SSH) and says HMAC signatures were skipped; `--structure-only` skips signatures; `--allowed-signers FILE` trusts that list instead of the repo's. Ends with an `ANCHOR` line: how many receipts are committed **and pushed**. | | `/receipt-report` | Markdown evidence: verification result, AI-assisted share by recorder / author / month, models and Claude Code versions recorded, and the limits footer. `--out`, `--badge`. | ## Two hooks, full coverage @@ -116,7 +125,7 @@ was altered, and **unsigned** when it comes from `/ai-history`. Neither hook can break a commit: an uninitialized repo is a silent no-op (plus a one-time notice naming `/receipt-init`; `COMMITPROOF_QUIET=1` turns it off), and the git hook always exits 0. Commit `.titan/attestations/` -and `.titan/tools/`, never `.titan/key`. Git hooks aren't cloned, so each +and `.titan/tools/` (and `.titan/allowed_signers`), never `.titan/key`. Git hooks aren't cloned, so each teammate runs `python3 .titan/tools/receipt.py install-hook` once per clone (`python` or `py` on Windows). @@ -131,15 +140,18 @@ and a report built as change-control evidence. The two work side by side. ## Verify in CI ```yaml -- uses: Rmasood1122/commitproof@v1.3.0 +- uses: Rmasood1122/commitproof@v1.4.0 with: - key: ${{ secrets.TITAN_KEY }} # omit for structure-only verification - report: docs/ai-change-receipts.md # optional + report: docs/ai-change-receipts.md # optional + allowed_signers: ${{ vars.COMMITPROOF_SIGNERS }} # optional: who may sign, pinned outside the repo + key: ${{ secrets.TITAN_KEY }} # only for HMAC-signed receipts ``` -Without the key the action verifies chain structure and content hashes and -says plainly that signatures were not checked. The key is written only for -the step and removed after. The action verifies with **its own pinned +SSH-signed receipts are verified with no secret. Without `key`, HMAC-signed +receipts are reported as not checked, never as passed; with it, the key is +written only for the step and removed after. Set `allowed_signers` to a list +you control (a repository or organization variable) so the repo under audit +doesn't get to decide who may sign. The action verifies with **its own pinned verifier**, never the `.titan/tools/receipt.py` in the repo under audit: the party who could alter the receipts also controls that copy. Pin a ref you trust. `allow_repo_verifier: true` runs the repo's copy for the rare @@ -149,14 +161,19 @@ compatibility case and warns that the result is self-reported. **Proves:** the recorded content (file hashes, diff hash, author, provenance claims) is unchanged since signing, and the receipts form one unbroken ordered -chain, under a key your team controls. +chain. With SSH signing it also proves **which key signed each receipt**: the +one `.titan/allowed_signers` lists for the named signer, checkable by anyone.
Does not prove: read before writing a compliance claim -- **Who held the key.** HMAC is a shared secret; anyone with `.titan/key` can - produce valid signatures. Asymmetric signing and external timestamping live - in the upstream [titan-gate](https://github.com/Rmasood1122/titan-gate) engine. +- **Who held the key, for HMAC receipts.** HMAC is a shared secret; anyone + with `.titan/key` can produce valid signatures. Use `--ssh` to close this. +- **Who controls an SSH key.** An SSH receipt identifies the key, and the + person only as far as key custody goes. The key-to-person mapping is only as + trustworthy as `allowed_signers`' git history (adding a key is a visible + commit), or pin the list in CI. External timestamping lives in the upstream + [titan-gate](https://github.com/Rmasood1122/titan-gate) engine. - **That anyone reviewed the change**, or that it was good. - **That provenance is true.** `ai_assisted`, model, version, session and transcript hash are recorded claims: trailers from the commit message and @@ -188,17 +205,25 @@ lines, because a receipt system that overclaims is worse than none. transcript named in the hook payload to record model names, the Claude Code version, its line count and sha256. Transcript content is never copied into the receipt. Writes one JSON file under `.titan/attestations/`. -- **`/receipt-init`:** creates `.titan/key` and adds it to `.gitignore`, - copies `receipt.py`, `canonical.py` and `chain_state.py` into - `.titan/tools/`, and installs or appends to `.git/hooks/post-commit`. - Nothing else in your repo changes. +- **`/receipt-init`:** creates `.titan/key` and adds it to `.gitignore` + (or, with `--ssh`, adds your SSH public key and git email to + `.titan/allowed_signers` and records the key's path in this clone's local + git config as `commitproof.signingkey`), copies `receipt.py`, + `canonical.py`, `chain_state.py` and `signing.py` into `.titan/tools/`, and + installs or appends to `.git/hooks/post-commit`. Nothing else in your repo + changes. +- **SSH signing:** runs your local `ssh-keygen` (`-Y sign` for each receipt, + `-Y verify` when checking). Your private key is only ever passed to + `ssh-keygen` by path, and never read, copied or sent anywhere. - **`/ai-history`, `/verify-chain`, `/receipt-report`:** read-only, apart from the `--out` or `--badge` file you name. - **Network:** none. No telemetry, no package installs, no service. ## Requirements and privacy -Claude Code, git, and Python 3.10+ on PATH as `python3`, `python` or `py`. +Claude Code, git, and Python 3.10+ on PATH as `python3`, `python` or `py`; +for SSH signing, OpenSSH 8.2+ `ssh-keygen` (ships with Git for Windows, +macOS and Linux). Stdlib only: no packages, no network, no service, nothing phones home ([PRIVACY.md](PRIVACY.md)). CI runs the full suite, every tamper path included, on Ubuntu and Windows. diff --git a/action.yml b/action.yml index 185ba13..2243808 100644 --- a/action.yml +++ b/action.yml @@ -1,17 +1,26 @@ name: "CommitProof — verify receipt chain" -description: "Verify a repository's .titan/attestations/ receipt chain offline with THIS action's pinned verifier (not the repo's). Without a key: chain structure + content hashes (and says signatures were not checked). With the key secret: full signature verification." +description: "Verify a repository's .titan/attestations/ receipt chain offline with THIS action's pinned verifier (not the repo's). SSH-signed receipts are checked with no secret; HMAC receipts need the key secret, and without it the action says they were not checked." branding: icon: shield color: gray-dark inputs: key: - description: "Hex signing key (pass a secret). Omit for structure-only verification." + description: "Hex HMAC key (pass a secret). Only HMAC-signed receipts need it; omit it and SSH signatures are still checked." required: false default: "" report: description: "Path to write the markdown compliance report to (optional)." required: false default: "" + allowed_signers: + description: >- + Trusted allowed_signers content (a secret or variable you control). When + set, SSH signatures are checked against these keys instead of the audited + repo's .titan/allowed_signers: the repo under audit should not decide who + may sign. Omit it to trust the repo's file (changes to it are visible in + git history). + required: false + default: "" allow_repo_verifier: description: >- DANGER — weakens the guarantee. When "true", run the verifier vendored in @@ -33,6 +42,7 @@ runs: TITAN_KEY: ${{ inputs.key }} TITAN_REPORT: ${{ inputs.report }} TITAN_ALLOW_REPO_VERIFIER: ${{ inputs.allow_repo_verifier }} + TITAN_ALLOWED_SIGNERS: ${{ inputs.allowed_signers }} run: | set -euo pipefail # Trust model: the verifier MUST be this action's own pinned copy, not @@ -50,16 +60,23 @@ runs: echo "::error title=CommitProof::verifier not found at $TOOL" >&2 exit 1 fi + # Trusted signers, when given, replace the audited repo's own list. + AS="" + if [ -n "$TITAN_ALLOWED_SIGNERS" ]; then + AS="${RUNNER_TEMP:-/tmp}/commitproof_allowed_signers" + printf '%s\n' "$TITAN_ALLOWED_SIGNERS" > "$AS" + fi rc=0 if [ -n "$TITAN_KEY" ]; then mkdir -p .titan && printf '%s\n' "$TITAN_KEY" > .titan/key && chmod 600 .titan/key - python3 "$TOOL" verify || rc=$? + python3 "$TOOL" verify ${AS:+--allowed-signers "$AS"} || rc=$? else - python3 "$TOOL" verify --structure-only || rc=$? + # no secret: SSH signatures are still checked; HMAC ones are reported unchecked + python3 "$TOOL" verify --public-only ${AS:+--allowed-signers "$AS"} || rc=$? fi # The report is wanted MOST when verification fails — it records BROKEN. if [ -n "$TITAN_REPORT" ]; then - python3 "$TOOL" report --out "$TITAN_REPORT" || true + python3 "$TOOL" report --out "$TITAN_REPORT" ${AS:+--allowed-signers "$AS"} || true fi if [ -n "$TITAN_KEY" ]; then rm -f .titan/key; fi exit $rc diff --git a/commands/receipt-init.md b/commands/receipt-init.md index 9702ed3..424cddf 100644 --- a/commands/receipt-init.md +++ b/commands/receipt-init.md @@ -1,6 +1,6 @@ --- -description: Start here — one-time setup that turns on receipts for this repo so every commit (AI-assisted or not) gets a tamper-evident record. Creates a gitignored signing key, a vendored verifier, and a git post-commit hook -argument-hint: "[--no-git-hook] [--force]" +description: Start here — one-time setup that turns on receipts for this repo so every commit (AI-assisted or not) gets a tamper-evident record. --ssh signs with your own SSH key (no shared secret, anyone can verify); default is a gitignored shared HMAC key. Also vendors the verifier and installs a git post-commit hook +argument-hint: "[--ssh [KEY]] [--hmac] [--no-git-hook] [--force]" --- Initialize receipts in the user's current repository: @@ -9,7 +9,28 @@ Initialize receipts in the user's current repository: sh "${CLAUDE_PLUGIN_ROOT}/core/py.sh" "${CLAUDE_PLUGIN_ROOT}/core/receipt.py" init $ARGUMENTS ``` -After it succeeds, explain the three facts the user must not learn later: +**Before running without `--ssh`,** check whether the user has an SSH key +(`git config gpg.format` is `ssh`, or `~/.ssh/id_ed25519.pub` exists). If they +do, recommend `--ssh`: each receipt is then signed by their own key, there's +no secret to share, and fresh clones and CI can verify signatures. Ask; don't +choose for them. In a repo that already has `.titan/allowed_signers`, plain +init uses SSH automatically. + +After it succeeds, explain the three facts the user must not learn later. + +**With `--ssh`:** + +1. Their SSH public key and git email were added to `.titan/allowed_signers`. + Commit that file with the receipts: it says who may sign, and a change to + it shows in git history. The private key never leaves their machine; the + key path is in this clone's local git config only. +2. Receipts land in `.titan/attestations/` and the verifier is vendored into + `.titan/tools/`. Both SHOULD be committed. +3. Each teammate runs `/receipt-init --ssh` in their own clone; it adds their + key beside the others. A key with a passphrase must be loaded in + ssh-agent (`ssh-add`), because hooks can't prompt. + +**Without `--ssh` (HMAC):** 1. `.titan/key` is the shared signing key. It is gitignored and must NEVER be committed — receipts are only as trustworthy as this key is private. @@ -24,6 +45,10 @@ After it succeeds, explain the three facts the user must not learn later: `python3 .titan/tools/receipt.py install-hook` once per clone. Commits made without either hook have no receipt. +If `--ssh` refuses with "SSH key not usable", nothing was written. Relay the +reason (no key found, or it needs a passphrase): `ssh-add` the key, or pass +another with `--ssh PATH`. Don't fall back to HMAC unless the user asks. + If init refuses because the key is tracked by git, that is fail-closed working as designed: help them run `git rm --cached .titan/key`, then retry. A key that has ever been pushed is burned — rotate it (`init --force`) and diff --git a/commands/receipt-report.md b/commands/receipt-report.md index c726568..98d2942 100644 --- a/commands/receipt-report.md +++ b/commands/receipt-report.md @@ -15,6 +15,10 @@ How to present it: signature is invalid, lead with that — do not summarize the counts as if they were trustworthy, and never offer to "clean up" receipts so the report looks better. The failure is the finding. +- With SSH-signed receipts the report has a **Signed by** table: each signer's + principal, key fingerprint and receipt count. Present it as "signed by the + key registered to …", never as proof a person reviewed or approved the + change. HMAC receipts appear as "shared repo key" and identify no one. - The AI-assisted share counts only receipted commits. Commits made before `/receipt-init`, or outside both hooks, have no receipt. Say so if the user is about to quote a percentage to an auditor. diff --git a/commands/verify-chain.md b/commands/verify-chain.md index 3faddfc..b760040 100644 --- a/commands/verify-chain.md +++ b/commands/verify-chain.md @@ -1,6 +1,6 @@ --- -description: Verify the full receipt chain offline — structure (single unbroken chain from GENESIS, content hashes) and every HMAC signature; --structure-only when no key is present -argument-hint: "[--structure-only]" +description: Verify the full receipt chain offline — structure (single unbroken chain from GENESIS, content hashes) and every signature (SSH against .titan/allowed_signers, HMAC under .titan/key); --public-only checks what needs no secret, --structure-only skips signatures +argument-hint: "[--public-only] [--structure-only] [--allowed-signers FILE]" --- Verify everything: @@ -13,23 +13,36 @@ Report the result exactly as it is: - **PASS** means: every receipt's content is unchanged since signing, the chain is a single unbroken line from GENESIS, and every signature checks - out under the repo's key. Include the tool's own caveat when you relay - the result: PASS does not identify WHO signed — HMAC is a shared secret. + out. Relay the tool's own caveats with it: + - **SSH signatures** name who signed ("signed by alice@… (12)"): each was + made by the key `.titan/allowed_signers` lists for that person. That + identifies the key, not who controls it, and the list is only as + trustworthy as its git history. + - **HMAC signatures** don't identify anyone: HMAC is a shared secret. +- **PASS (public signatures only)** (`--public-only`) means SSH signatures + were checked and HMAC ones were deliberately skipped. Say how many HMAC + receipts went unchecked. - **PASS (structure only)** means the chain and content hashes are - consistent but signatures were NOT checked because no key was available. - Relay that limitation verbatim; it is a weaker claim. + consistent but no signature was checked. Relay that limitation verbatim; + it is a weaker claim. - **CHAIN FAIL / SIG FAIL** names the exact receipt. Do not delete or edit it to get to green; the failure IS the finding. Help the user diff the - offending receipt against git history to see what changed and when. -- **SIGNATURES NOT CHECKED (exit 2)** means this checkout has no - `.titan/key` — typically a fresh clone. The structural walk still ran and - its result is real; say so, then say signatures could not be verified and - the key must be obtained out-of-band. Never call this a PASS. + offending receipt against git history to see what changed and when. An + SSH SIG FAIL can also mean the signer's key isn't in `allowed_signers` (a + teammate who never ran `/receipt-init --ssh`); check that before calling it + tampering, and never add a key just to make it pass without the user + confirming it's that person's key. +- **SIGNATURES NOT CHECKED (exit 2)** means some HMAC receipts and no + `.titan/key` here (typically a fresh clone), or SSH receipts with no + `ssh-keygen` / `allowed_signers`. Everything else still ran and its result + is real; say so, then say what was not checked. Never call this a PASS. - **ANCHOR** (always printed last) says how many receipts are committed and pushed. Relay it every time, even on PASS: a chain that verifies but exists only on this machine is not yet evidence to anyone else — the key-holder could rewrite it. If anything is untracked/uncommitted/unpushed, give the exact command: `git add .titan/attestations && git commit && git push`. -This runs fully offline — no network, no service, just the files, the key, -and local git metadata. +`--allowed-signers FILE` trusts that list instead of the repo's own: use it +when the person verifying shouldn't take the audited repo's word for who may +sign. This runs fully offline — no network, no service, just the files, +`ssh-keygen`, the key if there is one, and local git metadata. diff --git a/core/receipt.py b/core/receipt.py index b62471c..615fbdf 100644 --- a/core/receipt.py +++ b/core/receipt.py @@ -4,7 +4,10 @@ Profile: schema_version "change-attestation/v1" - Canonicalization: TRS-1 sorted-keys (vendored canonical.py, byte- compatible with titan-gate's), exclusions {signature, receipt_hash, ...}. - - Signing: hmac-sha256-v1 over sha256(canonical_bytes). + - Signing: hmac-sha256-v1 (shared .titan/key) or ssh-sig-v1 (the committer's + SSH key, checked against .titan/allowed_signers), over the receipt + hash; see signing.py. Chains may mix them; each receipt is checked by + its own signing_version. - Chain: vendored chain_state rules — single GENESIS, forks/dangling prevs/tampered hashes are hard errors, never guesses. - Tree: .titan/attestations/ (its OWN tree; never mixed with a judge- @@ -81,17 +84,20 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) +import signing # noqa: E402 from canonical import canonical_bytes # noqa: E402 from chain_state import ( # noqa: E402 GENESIS, ChainStateError, latest_receipt_hash, ) SCHEMA = "change-attestation/v1" -SIGNING = "hmac-sha256-v1" -PLUGIN_VERSION = "1.3.0" # kept equal to .claude-plugin/plugin.json (tested) +PLUGIN_VERSION = "1.4.0" # kept equal to .claude-plugin/plugin.json (tested) KEY_PATH = Path(".titan/key") +ALLOWED_SIGNERS = Path(".titan/allowed_signers") # committed: who may SSH-sign TREE = Path(".titan/attestations") TOOLS_DIR = Path(".titan/tools") +# per-clone (local git config, never committed): this developer's SSH key +CFG_SSH_KEY, CFG_PRINCIPAL = "commitproof.signingkey", "commitproof.principal" HOOK_MARK = "# commitproof post-commit hook" LEGACY_HOOK_MARKS = ("# titan-receipts post-commit hook",) # v1.0–1.2, still ours AI_TRAILER_RE = re.compile(r"claude|anthropic", re.I) @@ -129,6 +135,25 @@ def git(*args: str) -> str: return r.stdout +def git_config(key: str) -> str | None: + try: + return git("config", "--get", key).strip() or None + except RuntimeError: + return None # unset + + +def local_signer() -> tuple[str, Path | None, str | None] | None: + """How THIS clone signs: ("ssh", key, principal), ("hmac", None, None), + or None when it can't sign. SSH wins when both are set up: a repo that + moved to SSH keeps its HMAC key only so earlier receipts still verify.""" + key = git_config(CFG_SSH_KEY) + if key: + return "ssh", Path(key), git_config(CFG_PRINCIPAL) or git_config("user.email") + if KEY_PATH.exists(): + return "hmac", None, None + return None + + def repo_root() -> Path | None: try: return Path(git("rev-parse", "--show-toplevel").strip()) @@ -216,6 +241,10 @@ def cmd_init(args: argparse.Namespace) -> int: if root is None: return die("not inside a git repository") os.chdir(root) + use_ssh = getattr(args, "ssh", None) is not None or ( + ALLOWED_SIGNERS.exists() and not getattr(args, "hmac", False)) + if use_ssh: + return _init_ssh(args, Path(root)) if KEY_PATH.exists() and not args.force: print(f"key exists: {KEY_PATH} (use --force to rotate — old receipts " f"then verify only with the OLD key; keep it somewhere safe)") @@ -230,13 +259,7 @@ def cmd_init(args: argparse.Namespace) -> int: KEY_PATH.chmod(0o600) except OSError: pass # windows - gi = Path(".gitignore") - text = gi.read_text() if gi.exists() else "" - for line in (".titan/key", ".titan/tools/__pycache__/"): - if line not in text.splitlines(): - text = (text.rstrip("\n") + "\n" if text else "") + line + "\n" - gi.write_text(text) - print(f"gitignore: added {line}") + _ensure_gitignore() # fail-closed: a tracked key is a published key tracked = subprocess.run(["git", "ls-files", "--error-unmatch", str(KEY_PATH)], capture_output=True) @@ -253,6 +276,71 @@ def cmd_init(args: argparse.Namespace) -> int: return 0 +def _resolve_ssh_key(explicit: str) -> Path: + """--ssh KEY, else git's own SSH signing key (gpg.format=ssh), else the + usual ~/.ssh keys. Literal `key::` signing keys aren't paths: pass one.""" + if explicit: + return Path(explicit).expanduser().resolve() + if git_config("gpg.format") == "ssh": + sk = git_config("user.signingkey") + if sk and not sk.startswith(("key::", "ssh-", "ecdsa-", "sk-")): + return Path(sk).expanduser() + for name in ("id_ed25519", "id_ecdsa", "id_rsa"): + for p in (Path.home() / ".ssh" / name, Path.home() / ".ssh" / f"{name}.pub"): + if p.is_file(): + return p + raise signing.SigningError("no SSH key found — pass one: /receipt-init --ssh PATH") + + +def _init_ssh(args: argparse.Namespace, root: Path) -> int: + """Register this developer's SSH key in .titan/allowed_signers and sign + from now on with it. Nothing secret is created, and nothing is written + until a probe signature succeeds without a terminal — the same way the + hooks will sign.""" + principal = git_config("user.email") + if not principal: + return die("set git user.email first — it names you in .titan/allowed_signers") + try: + key = _resolve_ssh_key(getattr(args, "ssh", None) or "") + pub = signing.public_key(key) + fp = signing.fingerprint(pub) + signing.ssh_sign(key, b"commitproof init probe") + except signing.SigningError as exc: + return die(f"SSH key not usable for receipts: {exc}. A key with a passphrase " + f"must be loaded in ssh-agent (ssh-add), because hooks can't prompt.") + entry = f'{principal} namespaces="{signing.NAMESPACE}" {pub}' + lines = ALLOWED_SIGNERS.read_text(encoding="utf-8").splitlines() if ALLOWED_SIGNERS.exists() else [] + if any(ln.split()[:1] == [principal] and pub in ln for ln in lines): + print(f"allowed_signers: already lists {principal} ({fp})") + else: + ALLOWED_SIGNERS.parent.mkdir(parents=True, exist_ok=True) + ALLOWED_SIGNERS.write_text("\n".join([*lines, entry]) + "\n", encoding="utf-8", newline="\n") + print(f"allowed_signers: added {principal} ({fp})") + git("config", "--local", CFG_SSH_KEY, str(key)) + git("config", "--local", CFG_PRINCIPAL, principal) + print(f"signing key: {key} (this clone's git config, never committed)") + _ensure_gitignore() + if KEY_PATH.exists(): + print(f"{KEY_PATH} kept: earlier HMAC receipts still verify with it") + if not getattr(args, "no_git_hook", False): + for w in vendor_tools(root): + print(f"vendored: {w}") + print(install_git_hook(root)) + print(f"Commit {ALLOWED_SIGNERS.as_posix()} with the receipts. There is no secret to share: " + f"anyone can verify, and each teammate runs /receipt-init --ssh in their own clone.") + return 0 + + +def _ensure_gitignore() -> None: + gi = Path(".gitignore") + text = gi.read_text() if gi.exists() else "" + for line in (".titan/key", ".titan/tools/__pycache__/"): + if line not in text.splitlines(): + text = (text.rstrip("\n") + "\n" if text else "") + line + "\n" + gi.write_text(text) + print(f"gitignore: added {line}") + + # ---------------------------------------------------------- git hook POST_COMMIT = f"""#!/bin/sh {HOOK_MARK} — receipt EVERY commit, human or AI. Inside a Claude Code @@ -280,7 +368,7 @@ def vendor_tools(root: Path) -> list[str]: src_dir = Path(__file__).resolve().parent (root / TOOLS_DIR).mkdir(parents=True, exist_ok=True) written = [] - for name in ("receipt.py", "canonical.py", "chain_state.py"): + for name in ("receipt.py", "canonical.py", "chain_state.py", "signing.py"): src = src_dir / name if not src.exists(): continue @@ -473,14 +561,17 @@ def cmd_create(args: argparse.Namespace) -> int: if root is None: return 0 if args.auto else die("not inside a git repository") os.chdir(root) - if not KEY_PATH.exists(): + signer = local_signer() + if signer is None: if args.auto: return 0 # hook mode: repo not initialized — silently do nothing - return die("no .titan/key — run receipt.py init first") - key = load_key() - if key is None: - return 1 # a corrupt key is a loud failure even in --auto (|| true - # protects the commit; silence would hide a broken setup) + return die("no signing set up in this clone — run receipt.py init first (or init --ssh)") + key = None + if signer[0] == "hmac": + key = load_key() + if key is None: + return 1 # a corrupt key is a loud failure even in --auto (|| true + # protects the commit; silence would hide a broken setup) try: sha = git("rev-parse", "HEAD").strip() @@ -523,7 +614,7 @@ def cmd_create(args: argparse.Namespace) -> int: now = datetime.now(timezone.utc) receipt: dict = { "schema_version": SCHEMA, - "signing_version": SIGNING, + "signing_version": signing.SSH_V1 if signer[0] == "ssh" else signing.HMAC_V1, "receipt_id": str(uuid.uuid4()), "evaluated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "root_date": now.strftime("%Y-%m-%d"), @@ -538,9 +629,17 @@ def cmd_create(args: argparse.Namespace) -> int: getattr(args, "hook_payload", None)), "prev_receipt_hash": prev, } - receipt["receipt_hash"] = hashlib.sha256(canonical_bytes(receipt)).hexdigest() - receipt["signature"] = hmac.new( - key, receipt["receipt_hash"].encode(), hashlib.sha256).hexdigest() + try: + if signer[0] == "ssh": + # who signed is part of the hashed content: swapping it breaks the chain + receipt["signer"] = {"principal": signer[2] or "", + "key": signing.fingerprint(signing.public_key(signer[1]))} + receipt["receipt_hash"] = hashlib.sha256(canonical_bytes(receipt)).hexdigest() + message = receipt["receipt_hash"].encode() + receipt["signature"] = (signing.ssh_sign(signer[1], message) if signer[0] == "ssh" + else signing.hmac_sign(key, message)) + except signing.SigningError as exc: + return _signing_failed(args, sha, str(exc)) out = TREE / receipt["root_date"] / f"{receipt['receipt_id']}.json" out.parent.mkdir(parents=True, exist_ok=True) @@ -551,6 +650,22 @@ def cmd_create(args: argparse.Namespace) -> int: return 0 +def _signing_failed(args: argparse.Namespace, sha: str, why: str) -> int: + """No receipt is better than an unsigned one, but a missing receipt must + never be silent. In the Claude Code hook, stderr on exit 0 reaches nobody, + so tell the user with a systemMessage; elsewhere, fail loudly.""" + text = (f"CommitProof: couldn't sign the receipt for commit {sha[:12]}, so it has " + f"no receipt. {why}. If your SSH key has a passphrase, load it with " + f"`ssh-add`; /receipt-init --ssh re-checks the setup.") + if getattr(args, "hook_payload", None) is not None: + print(json.dumps({"systemMessage": text, "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": text + " Tell the user once; don't retry or work " + "around it — signing is their key's decision."}})) + return 0 + return die(text, 1) + + # ---------------------------------------------------------------- hook # A `git commit` invocation inside ONE shell segment (split on ; && || |): # `git log | grep commit` and `git status; echo commit` must not match. @@ -580,7 +695,7 @@ def cmd_hook(args: argparse.Namespace) -> int: if not _commit_succeeded(payload): return 0 root = repo_root() - if root is not None and not (root / KEY_PATH).exists(): + if root is not None and not (root / KEY_PATH).exists() and not git_config(CFG_SSH_KEY): _first_run_notice(root) # uninitialized: tell the user once, then stay silent return 0 args.auto = True @@ -686,40 +801,87 @@ def cmd_verify(args: argparse.Namespace) -> int: _report_anchor() return 0 - if not KEY_PATH.exists(): + public_only = getattr(args, "public_only", False) + if KEY_PATH.exists() and not public_only and load_key() is None: + return 2 # corrupt key: load_key printed why + rows = check_signatures([(p, json.loads(p.read_text())) for p in sorted(TREE.rglob("*.json"))], + _allowed_signers(args), use_hmac=not public_only) + bad = [row for row in rows if row[2] == "invalid"] + for p, _r, _s, detail in bad: + print(f"SIG FAIL: {p} ({detail})", file=sys.stderr) + if bad: + print(f"VERDICT: FAIL — {len(bad)}/{n} bad signatures", file=sys.stderr) + return 1 + s = summarize(rows) + if s["ssh_ok"]: + print(f"SSH signatures: {sum(s['ssh_ok'].values())} valid against " + f"{_allowed_signers(args).as_posix()} — signed by " + + ", ".join(f"{who} ({c})" for who, c in sorted(s["ssh_ok"].items()))) + if s["hmac_ok"]: + print(f"HMAC signatures: {s['hmac_ok']} valid under {KEY_PATH.as_posix()} " + f"(a shared secret: these don't identify who signed)") + if s["ssh_unchecked"]: + print(f"SSH SIGNATURES NOT CHECKED: {len(s['ssh_unchecked'])} receipt(s) — " + f"{s['ssh_unchecked'][0][3]}", file=sys.stderr) + if s["hmac_unchecked"] and public_only: + print(f"NOT CHECKED: {len(s['hmac_unchecked'])} HMAC signature(s) — they need the " + f"shared {KEY_PATH.as_posix()}, which --public-only doesn't use") + elif s["hmac_unchecked"]: # F-T3: no key, and structure-only was not requested (e.g. a fresh # clone) — say exactly what was and wasn't checked, then fail closed. # Structure alone is useful information, not a PASS. print(f"chain structure OK: {n} receipt(s), one unbroken line from " f"GENESIS, every stored receipt_hash matches its content, head {head[:12]}…") - print("SIGNATURES NOT CHECKED: no .titan/key in this checkout (it is " - "gitignored by design). Structure alone proves the files are " - "internally consistent, not that they were signed under the " - "team's key. Obtain the key out-of-band to verify signatures.", + print(f"SIGNATURES NOT CHECKED: {len(s['hmac_unchecked'])} HMAC receipt(s) and no " + f".titan/key in this checkout (it is gitignored by design). Structure alone " + f"proves the files are internally consistent, not that they were signed under " + f"the team's key. Obtain the key out-of-band to verify signatures.", file=sys.stderr) + if s["ssh_unchecked"] or (s["hmac_unchecked"] and not public_only): _report_anchor() return 2 - key = load_key() - if key is None: - return 2 - - bad = 0 - for p in sorted(TREE.rglob("*.json")): - r = json.loads(p.read_text()) - want = hmac.new(key, r["receipt_hash"].encode(), hashlib.sha256).hexdigest() - if not hmac.compare_digest(want, r.get("signature", "")): - print(f"SIG FAIL: {p}", file=sys.stderr) - bad += 1 - if bad: - print(f"VERDICT: FAIL — {bad}/{n} bad signatures", file=sys.stderr) - return 1 - print(f"chain OK: {n} receipt(s), head {head[:12]}…") - print("PASS means: contents unchanged since signing, under this shared " - "key. It does not identify the signer.") + print(f"chain OK{' (public signatures only)' if s['hmac_unchecked'] else ''}: " + f"{n} receipt(s), head {head[:12]}…") + if s["ssh_ok"]: + print(f"PASS means: contents unchanged since signing, and each SSH signature " + f"was made by the key {ALLOWED_SIGNERS.as_posix()} lists for its signer.") + if s["hmac_ok"]: + print("PASS means: contents unchanged since signing, under this shared " + "key. It does not identify the signer.") _report_anchor() return 0 +def _allowed_signers(args: argparse.Namespace) -> Path: + """--allowed-signers lets a verifier (CI) pin who may sign instead of + trusting the file in the repo under audit.""" + given = getattr(args, "allowed_signers", None) + return Path(given) if given else ALLOWED_SIGNERS + + +def check_signatures(rows: list[tuple[Path, dict]], allowed: Path, + use_hmac: bool = True) -> list[tuple[Path, dict, str, str]]: + """One verdict per receipt: (path, receipt, valid|invalid|unchecked, detail).""" + hmac_key = load_key() if use_hmac and KEY_PATH.exists() else None + needs_ssh = any(r.get("signing_version") == signing.SSH_V1 for _, r in rows) + with signing.SshVerifier(allowed if needs_ssh else None) as ssh: + return [(p, r, *signing.check(r, hmac_key, ssh)) for p, r in rows] + + +def summarize(rows: list[tuple[Path, dict, str, str]]) -> dict: + def is_ssh(r: dict) -> bool: + return r.get("signing_version") == signing.SSH_V1 + ssh_ok: dict[str, int] = {} + for _, r, st, who in rows: + if st == "valid" and is_ssh(r): + ssh_ok[who] = ssh_ok.get(who, 0) + 1 + return {"ssh_ok": ssh_ok, + "hmac_ok": sum(1 for _, r, st, _ in rows if st == "valid" and not is_ssh(r)), + "ssh_unchecked": [x for x in rows if x[2] == "unchecked" and is_ssh(x[1])], + "hmac_unchecked": [x for x in rows if x[2] == "unchecked" and not is_ssh(x[1])], + "invalid": [x for x in rows if x[2] == "invalid"]} + + # ---------------------------------------------------------------- report def _load_tree() -> list[dict]: out = [] @@ -744,19 +906,18 @@ def cmd_report(args: argparse.Namespace) -> int: # verification result is part of the report — a report on an unverified # chain would be exactly the kind of document this tool exists to prevent - sig_checked = KEY_PATH.exists() and load_key() is not None try: latest_receipt_hash(TREE) chain = "intact" except ChainStateError as exc: chain = f"BROKEN — {exc}" - bad_sig = 0 - if sig_checked: - key = load_key() - for r in rs: - want = hmac.new(key, str(r.get("receipt_hash", "")).encode(), hashlib.sha256).hexdigest() - if not hmac.compare_digest(want, str(r.get("signature", ""))): - bad_sig += 1 + allowed = _allowed_signers(args) + rows = check_signatures([(Path(str(r.get("receipt_id", "?"))), r) for r in rs], allowed) + sigs = summarize(rows) + bad_sig = len(sigs["invalid"]) + ssh_rs = [r for r in rs if r.get("signing_version") == signing.SSH_V1] + n_ssh, n_hmac = len(ssh_rs), len(rs) - len(ssh_rs) + all_checked = not sigs["ssh_unchecked"] and not sigs["hmac_unchecked"] def prov(r: dict) -> dict: return r.get("provenance") or {} @@ -804,10 +965,36 @@ def pct(n: int, d: int) -> str: L.append("## Verification") L.append("") L.append(f"- Chain structure (single unbroken line from GENESIS, content hashes recomputed): **{chain}**") - if sig_checked: - L.append(f"- Signatures (HMAC-SHA256 under the repo key): **{'all valid' if not bad_sig else f'{bad_sig}/{total} INVALID'}**") - else: - L.append("- Signatures: **not checked** (no key available where this report was generated)") + def sig_state(want_ssh: bool, unchecked: list, n_kind: int) -> str: + bad = sum(1 for _, r, st, _ in rows + if st == "invalid" and (r.get("signing_version") == signing.SSH_V1) == want_ssh) + if bad: + return f"**{bad}/{n_kind} INVALID**" + if unchecked: + return f"**not checked** ({unchecked[0][3]})" + return "**all valid**" + + if n_ssh: + L.append(f"- Signatures (SSH, each against its signer's key in `{allowed.as_posix()}`): " + + sig_state(True, sigs["ssh_unchecked"], n_ssh)) + if n_hmac: + if sigs["hmac_unchecked"]: + L.append("- Signatures (HMAC-SHA256 under the repo key): **not checked** " + "(no key available where this report was generated)") + else: + L.append("- Signatures (HMAC-SHA256 under the repo key): " + + sig_state(False, [], n_hmac)) + if n_ssh: + signed: dict[tuple[str, str], int] = {} + for r in ssh_rs: + who = r.get("signer") or {} + k = (str(who.get("principal", "?")), str(who.get("key", "?"))) + signed[k] = signed.get(k, 0) + 1 + L += ["", "## Signed by", "", "| signer | SSH key | receipts |", "|---|---|---|"] + for (who, fp), c in sorted(signed.items(), key=lambda kv: -kv[1]): + L.append(f"| {who} | `{fp}` | {c} |") + if n_hmac: + L.append(f"| shared repo key (HMAC: doesn't identify a signer) | – | {n_hmac} |") L.append("") L.append("## Summary") L.append("") @@ -846,12 +1033,21 @@ def pct(n: int, d: int) -> str: L.append("") L.append("## What this report proves — and does not") L.append("") + who_signed = [] + if n_ssh: + who_signed.append( + f"SSH-signed receipts were each signed by the key `{ALLOWED_SIGNERS.as_posix()}` " + "lists for the named signer: that identifies the key, not who controls it, and " + "the key-to-person mapping is only as trustworthy as that file's history.") + if n_hmac: + who_signed.append("For HMAC-signed receipts it does **not** prove who held " + "the key (HMAC is a shared secret).") L.append("Each receipt records a commit's changed-file hashes, diff hash, author, " - "and provenance claims, hash-chained to the previous receipt and HMAC-signed. " + "and provenance claims, hash-chained to the previous receipt and signed. " "An intact chain with valid signatures means the recorded history has not been " - "altered since signing, under a key the team controls. It does **not** prove who " - "held the key (HMAC is a shared secret), that any change was reviewed, or that the " - "AI-assistance claims are true — `ai_assisted` comes from commit trailers and from " + "altered since signing. " + " ".join(who_signed) + " It does **not** prove that " + "any change was reviewed, or that the AI-assistance claims are true — " + "`ai_assisted` comes from commit trailers and from " "the Claude Code hook that wrote the receipt, and model/version/transcript fields " "are what that tool observed locally. Commits made outside both the Claude Code " "hook and the git post-commit hook have no receipt and are not counted.") @@ -867,8 +1063,10 @@ def pct(n: int, d: int) -> str: today = datetime.now(timezone.utc).strftime("%Y-%m-%d") if chain != "intact" or bad_sig: value, color = "verification FAILED", BADGE_RED - elif sig_checked: + elif all_checked: value, color = f"{pct(len(ai), total)} · chain verified", BADGE_BLUE + elif sigs["ssh_ok"] or sigs["hmac_ok"]: + value, color = f"{pct(len(ai), total)} · partly verified", BADGE_GREY else: value, color = f"{pct(len(ai), total)} · structure only", BADGE_GREY snippet = write_badge(args.badge, value, color, @@ -959,7 +1157,7 @@ def cmd_history(args: argparse.Namespace) -> int: n_ai = sum(1 for r in rows if r[2]) lines = sum(r[3] for r in rows) lines_ai = sum(r[3] for r in rows if r[2]) - initialized = KEY_PATH.exists() + initialized = KEY_PATH.exists() or ALLOWED_SIGNERS.exists() or bool(git_config(CFG_SSH_KEY)) if getattr(args, "json", False): print(json.dumps({"repo": Path(root).name, "commits": n, "ai_commits": n_ai, "lines_added": lines, "ai_lines_added": lines_ai, @@ -1105,6 +1303,11 @@ def main() -> int: formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("init"); p.add_argument("--force", action="store_true") + p.add_argument("--ssh", nargs="?", const="", default=None, metavar="KEY", + help="sign with your SSH key (default: git's SSH signing key, " + "else ~/.ssh/id_ed25519) and register it in .titan/allowed_signers") + p.add_argument("--hmac", action="store_true", + help="use a shared HMAC key even in a repo that already uses SSH") p.add_argument("--no-git-hook", action="store_true", help="do not vendor tools / install the git post-commit hook") p.set_defaults(fn=cmd_init) @@ -1116,9 +1319,15 @@ def main() -> int: p = sub.add_parser("hook"); p.set_defaults(fn=cmd_hook, auto=True, recorder="claude-code-hook", hook_payload=None) p = sub.add_parser("verify"); p.add_argument("--structure-only", action="store_true") + p.add_argument("--public-only", action="store_true", + help="check SSH signatures, skip HMAC ones (verification with no secret)") + p.add_argument("--allowed-signers", metavar="FILE", + help="trust this allowed_signers file instead of the repo's") p.set_defaults(fn=cmd_verify) p = sub.add_parser("report"); p.add_argument("--out") p.add_argument("--badge", help="also write a README badge SVG here") + p.add_argument("--allowed-signers", metavar="FILE", + help="trust this allowed_signers file instead of the repo's") p.set_defaults(fn=cmd_report) p = sub.add_parser("history"); p.add_argument("--since", help="e.g. 2026-01-01 or '6 months ago'") p.add_argument("--json", action="store_true", help="machine-readable summary") diff --git a/core/signing.py b/core/signing.py new file mode 100644 index 0000000..7104637 --- /dev/null +++ b/core/signing.py @@ -0,0 +1,160 @@ +"""Receipt signatures — the ONE place they are made and checked. + + hmac-sha256-v1 HMAC-SHA256 over receipt_hash under the shared .titan/key. + Anyone holding the key can make one; a clone without the + key can't check one. + ssh-sig-v1 `ssh-keygen -Y sign` over receipt_hash in the + commitproof-receipt namespace, by the committer's own SSH + key; checked with `ssh-keygen -Y verify` against the public + keys in .titan/allowed_signers. It identifies the key that + signed, and needs no secret to check. + +The namespace keeps a receipt signature from being replayed as a git commit +signature (namespace "git") or the other way round. An unknown +signing_version is invalid, never skipped. +""" +from __future__ import annotations + +import hashlib +import hmac +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +HMAC_V1 = "hmac-sha256-v1" +SSH_V1 = "ssh-sig-v1" +NAMESPACE = "commitproof-receipt" +TIMEOUT_S = 20 # a passphrase prompt with no terminal must fail, not hang a hook + + +class SigningError(Exception): + """A receipt could not be signed, or a key could not be read.""" + + +def keygen() -> str | None: + return shutil.which("ssh-keygen") + + +def _keygen(args: list[str], data: bytes | None = None) -> subprocess.CompletedProcess: + kg = keygen() + if kg is None: + raise SigningError("ssh-keygen not found on PATH (OpenSSH 8.2+ needed)") + try: + return subprocess.run([kg, *args], input=data, capture_output=True, timeout=TIMEOUT_S) + except subprocess.TimeoutExpired: + raise SigningError("ssh-keygen timed out — is it waiting for a passphrase? " + "Load the key with ssh-add") from None + + +def _last_line(raw: bytes) -> str: + lines = raw.decode(errors="replace").strip().splitlines() + return lines[-1] if lines else "no output" + + +def public_key(key: Path) -> str: + """'TYPE BASE64' for a private key path (its .pub beside it, or derived) + or a .pub path.""" + key = Path(key) + pub = key if key.suffix == ".pub" else Path(f"{key}.pub") + if pub.is_file(): + parts = pub.read_text(encoding="utf-8").split() + elif key.is_file(): + r = _keygen(["-y", "-f", str(key)]) + if r.returncode != 0: + raise SigningError(f"can't read the public half of {key}: {_last_line(r.stderr)}") + parts = r.stdout.decode().split() + else: + raise SigningError(f"no SSH key at {key}") + if len(parts) < 2 or not re.match(r"(ssh|ecdsa|sk)-", parts[0]): + raise SigningError(f"{pub} is not an SSH public key") + return f"{parts[0]} {parts[1]}" + + +def fingerprint(pub: str) -> str: + with tempfile.TemporaryDirectory() as d: + f = Path(d) / "key.pub" + f.write_text(pub + "\n", encoding="utf-8") + r = _keygen(["-l", "-E", "sha256", "-f", str(f)]) + if r.returncode != 0: + raise SigningError(f"can't fingerprint key: {_last_line(r.stderr)}") + return r.stdout.decode().split()[1] + + +def ssh_sign(key: Path, message: bytes) -> str: + """Armored SSH signature over `message`. `key` is a private key, or a .pub + whose private half is beside it or loaded in ssh-agent.""" + key = Path(key) + if not key.is_file(): + raise SigningError(f"no SSH key at {key}") + r = _keygen(["-Y", "sign", "-n", NAMESPACE, "-f", str(key)], message) + sig = r.stdout.decode("ascii", "replace").replace("\r\n", "\n") + if r.returncode != 0 or "-----BEGIN SSH SIGNATURE-----" not in sig: + raise SigningError(f"ssh-keygen could not sign with {key}: {_last_line(r.stderr)}") + return sig + + +def hmac_sign(key: bytes, message: bytes) -> str: + return hmac.new(key, message, hashlib.sha256).hexdigest() + + +class SshVerifier: + """Checks ssh-sig-v1 signatures against one allowed_signers file. Works + on an LF-normalized copy: an autocrlf checkout of the committed file + must verify the same as the original.""" + + def __init__(self, allowed: Path | None): + self._dir = tempfile.TemporaryDirectory() + self.allowed = allowed + self.reason = "" + if keygen() is None: + self.reason = "ssh-keygen not found on PATH" + elif allowed is None or not Path(allowed).is_file(): + self.reason = f"no allowed_signers file at {allowed}" + else: + text = Path(allowed).read_bytes().replace(b"\r\n", b"\n") + self._allowed = Path(self._dir.name) / "allowed_signers" + self._allowed.write_bytes(text) + + @property + def available(self) -> bool: + return not self.reason + + def verify(self, principal: str, key_fp: str, message: bytes, signature: str) -> bool: + if not principal or not signature: + return False + sig = Path(self._dir.name) / "sig" + sig.write_text(signature, encoding="ascii", errors="replace", newline="\n") + r = _keygen(["-Y", "verify", "-f", str(self._allowed), "-I", principal, + "-n", NAMESPACE, "-s", str(sig)], message) + out = (r.stdout + r.stderr).decode(errors="replace") + # the key that actually verified must be the one the receipt names + return r.returncode == 0 and f"key {key_fp}" in out + + def __enter__(self) -> "SshVerifier": + return self + + def __exit__(self, *exc) -> None: + self._dir.cleanup() + + +def check(receipt: dict, hmac_key: bytes | None, ssh: SshVerifier | None) -> tuple[str, str]: + """('valid' | 'invalid' | 'unchecked', detail) for one receipt's signature. + detail: the SSH principal, 'hmac', or why it could not be checked.""" + version = receipt.get("signing_version", HMAC_V1) + message = str(receipt.get("receipt_hash", "")).encode() + signature = str(receipt.get("signature", "")) + if version == HMAC_V1: + if hmac_key is None: + return "unchecked", "no .titan/key" + ok = hmac.compare_digest(hmac_sign(hmac_key, message), signature) + return ("valid" if ok else "invalid"), "hmac" + if version == SSH_V1: + if ssh is None or not ssh.available: + return "unchecked", ssh.reason if ssh else "SSH not checked" + signer = receipt.get("signer") or {} + principal = str(signer.get("principal", "")) + ok = ssh.verify(principal, str(signer.get("key", "")), message, signature) + return ("valid" if ok else "invalid"), principal + return "invalid", f"unknown signing_version {version!r}" diff --git a/skills/receipt-integrity/SKILL.md b/skills/receipt-integrity/SKILL.md index c2661c1..e312b93 100644 --- a/skills/receipt-integrity/SKILL.md +++ b/skills/receipt-integrity/SKILL.md @@ -34,18 +34,29 @@ unreceipted; never backfill them as if they had been recorded at the time — removing a middle receipt breaks the walk. Chain rules are fail-closed: forks, double-genesis, dangling predecessors, and hash mismatches are hard errors, never guesses. -- **Under a shared key:** the HMAC signature proves the signer held - `.titan/key` — nothing more. +- **Under a shared key (HMAC receipts):** the signature proves the signer + held `.titan/key` — nothing more. +- **By a named key (SSH receipts, `/receipt-init --ssh`):** the signature + proves it was made by the SSH key `.titan/allowed_signers` lists for the + receipt's `signer.principal`, and anyone can check it with no secret. ## What it does NOT prove — never let a claim drift past these -1. **Not identity.** HMAC is a symmetric secret: anyone holding the key can - produce valid signatures, including re-signing altered history wholesale. - Third-party non-repudiation requires asymmetric signing (Ed25519) or an - external timestamp anchor — features of the upstream titan-gate engine, - not this plugin. If a user's compliance draft says "proves who made the - change," correct it to "proves the change is unaltered since signing, +1. **Not identity, for HMAC receipts.** HMAC is a symmetric secret: anyone + holding the key can produce valid signatures, including re-signing altered + history wholesale. If a compliance draft says an HMAC chain "proves who made + the change," correct it to "proves the change is unaltered since signing, under a key the team controls." +1b. **SSH receipts identify a key, not a person.** They prove which key + signed, under the principal `allowed_signers` maps it to. That's a person + only as far as key custody goes (a shared or stolen key signs as its owner), + and the mapping is only as trustworthy as `allowed_signers`' git history: + someone with push access could add a key, visibly. Safe wording: "signed by + the SSH key registered to alice@example.com". Not "alice approved it", and + not "non-repudiable" without the user's auditor signing off. CI can pin a + trusted list (`--allowed-signers` / the action's `allowed_signers` input) + rather than trusting the repo under audit. The receipt's `signer` (who and + which key) is inside the hash, so swapping it breaks the chain. 2. **Not review, not quality.** A receipt attests WHAT changed, not that anyone approved it or that it was good. 3. **Not truthful provenance.** `provenance.ai_assisted`, `basis`, `models`, @@ -82,7 +93,10 @@ Safe sentence template: "Every commit made through our tooling is recorded in a hash-chained, HMAC-signed receipt committed alongside the code, carrying the commit's content hashes and a recorded provenance claim (AI-assisted or not, model and tool version where observed); any post-hoc alteration of -recorded history is detectable by offline verification." `/receipt-report` +recorded history is detectable by offline verification." For SSH-signed +chains, replace "HMAC-signed" with "signed by the committing developer's +registered SSH key" and add "verifiable by anyone with the repository, with no +shared secret." `/receipt-report` produces the evidence document with its verification result and this limits footer attached — do not strip the footer. Do not add "non-repudiable," "cryptographically proves authorship," or named framework compliance (SOC2/EU AI Act) without the diff --git a/tests/test_provenance.py b/tests/test_provenance.py index 0e030ec..74cc332 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -53,7 +53,7 @@ def repo(tmp_path): def test_init_vendors_tools_and_installs_git_hook(repo): r = run(["init"], repo) assert r.returncode == 0, r.stderr - for f in ("receipt.py", "canonical.py", "chain_state.py"): + for f in ("receipt.py", "canonical.py", "chain_state.py", "signing.py"): assert (repo / ".titan/tools" / f).read_bytes() == (PLUGIN / "core" / f).read_bytes() hook = repo / ".git/hooks/post-commit" assert hook.exists() and os.access(hook, os.X_OK) diff --git a/tests/test_structure.py b/tests/test_structure.py index 0decb57..bebf193 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -97,7 +97,10 @@ def test_action_yml_is_composite_and_uses_pinned_verifier_by_default(): a = yaml.safe_load((ROOT / "action.yml").read_text()) assert a["runs"]["using"] == "composite" step = a["runs"]["steps"][0]["run"] - assert "verify --structure-only" in step and 'python3 "$TOOL" verify' in step + # no secret: still check SSH signatures (public), never silently skip them + assert "verify --public-only" in step and 'python3 "$TOOL" verify' in step + # CI can pin who may sign instead of trusting the audited repo + assert "allowed_signers" in a["inputs"] and "--allowed-signers" in step assert "rm -f .titan/key" in step and "exit $rc" in step # key never outlives the step; report written even on failure assert "key" in a["inputs"] and a["inputs"]["key"]["required"] is False From 5b46cc540b4dfb5fdb3ea16ba471dc435dc2d5b5 Mon Sep 17 00:00:00 2001 From: Rehan Masood Date: Tue, 20 Oct 2026 11:19:55 -0400 Subject: [PATCH 2/4] test: SSH signing suite (14 tests), missed from the previous commit Co-Authored-By: Claude Opus 5.5 --- tests/test_ssh.py | 254 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 tests/test_ssh.py diff --git a/tests/test_ssh.py b/tests/test_ssh.py new file mode 100644 index 0000000..4ca0884 --- /dev/null +++ b/tests/test_ssh.py @@ -0,0 +1,254 @@ +"""v1.4: SSH signing — receipts that say WHO signed, verifiable with no secret. + +HMAC receipts carry the caveat that anyone holding the shared .titan/key can +forge any receipt, and a fresh clone can't check signatures at all. With +`init --ssh`, each receipt is signed by the committer's own SSH key +(`ssh-keygen -Y sign`, namespace commitproof-receipt) and verified against +the public keys in the committed .titan/allowed_signers. Every "must" below +is a test: a signature only verifies for the key listed for its signer, the +signer is bound by the receipt hash, a failure to sign is never silent, and +HMAC chains keep working unchanged.""" +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from _shell import shell_argv, posix + +PLUGIN = Path(__file__).resolve().parents[1] +RECEIPT = PLUGIN / "core" / "receipt.py" +KEYGEN = shutil.which("ssh-keygen") + +pytestmark = pytest.mark.skipif(KEYGEN is None, reason="needs ssh-keygen (OpenSSH 8.2+)") + + +def env(**extra): + e = {k: v for k, v in os.environ.items() + if k != "CLAUDECODE" and not k.startswith(("CLAUDE_", "TITAN_RECEIPTS_", "COMMITPROOF_"))} + e.update(extra) + return e + + +def sh(cmd, cwd): + r = subprocess.run(shell_argv(cmd), cwd=cwd, capture_output=True, text=True, env=env()) + assert r.returncode == 0, r.stderr + return r.stdout + + +def run(cwd, *args, e=None, stdin=None): + return subprocess.run([sys.executable, str(RECEIPT), *args], cwd=cwd, capture_output=True, + text=True, encoding="utf-8", env=e or env(), input=stdin) + + +def keypair(d: Path, name: str) -> Path: + key = d / name + subprocess.run([KEYGEN, "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", str(key)], + check=True, capture_output=True) + return key + + +def make_repo(path: Path, email: str) -> Path: + path.mkdir(parents=True) + sh(f"git init -q -b main && git config user.email {email} && git config user.name Dev", path) + (path / "seed.txt").write_text("seed\n") + sh("git add -A && git commit -qm seed", path) + return path + + +def commit(repo: Path, name: str, msg: str = "feat: change"): + (repo / name).write_text(name + "\n") + sh(f"git add {name} && git commit -qm '{msg}'", repo) + + +def receipts(repo: Path) -> list[Path]: + return sorted((repo / ".titan" / "attestations").rglob("*.json"), key=lambda p: p.stat().st_mtime) + + +@pytest.fixture() +def keys(tmp_path): + d = tmp_path / "keys" + d.mkdir() + return {"alice": keypair(d, "alice"), "bob": keypair(d, "bob")} + + +@pytest.fixture() +def alice_repo(tmp_path, keys): + repo = make_repo(tmp_path / "repo", "alice@example.com") + r = run(repo, "init", "--ssh", str(keys["alice"]), "--no-git-hook") + assert r.returncode == 0, r.stdout + r.stderr + commit(repo, "a.py") + assert run(repo, "create").returncode == 0 + return repo + + +def test_init_ssh_registers_signer_without_a_shared_secret(alice_repo, keys): + allowed = (alice_repo / ".titan" / "allowed_signers").read_text() + pub = Path(str(keys["alice"]) + ".pub").read_text().split()[1] + assert allowed.startswith("alice@example.com ") and pub in allowed + assert 'namespaces="commitproof-receipt"' in allowed + assert not (alice_repo / ".titan" / "key").exists() # nothing to hand around + r = json.loads(receipts(alice_repo)[0].read_text()) + assert r["signing_version"] == "ssh-sig-v1" + assert r["signer"]["principal"] == "alice@example.com" + assert r["signer"]["key"].startswith("SHA256:") + assert r["signature"].startswith("-----BEGIN SSH SIGNATURE-----") + + +def test_ssh_receipts_verify_in_a_fresh_clone_with_no_secret(alice_repo, tmp_path): + sh("git add .titan && git commit -qm 'chore: receipts'", alice_repo) + clone = tmp_path / "clone" + sh(f"git clone -q {posix(alice_repo)} {posix(clone)}", tmp_path) + v = run(clone, "verify") + assert v.returncode == 0, v.stdout + v.stderr + assert "chain OK: 1" in v.stdout and "alice@example.com" in v.stdout + + +def test_receipt_signed_by_an_unlisted_key_fails(alice_repo, keys): + """Bob's key claiming to be alice: the signature is real, but not by the + key allowed_signers lists for alice.""" + sh(f"git config commitproof.signingkey {posix(keys['bob'])}", alice_repo) + commit(alice_repo, "b.py") + assert run(alice_repo, "create").returncode == 0 + v = run(alice_repo, "verify") + assert v.returncode == 1 and "SIG FAIL" in v.stderr + + +def test_signer_is_bound_by_the_receipt_hash(alice_repo): + p = receipts(alice_repo)[0] + r = json.loads(p.read_text()) + r["signer"]["principal"] = "bob@example.com" + p.write_text(json.dumps(r, indent=2) + "\n") + v = run(alice_repo, "verify") + assert v.returncode == 1 and "CHAIN FAIL" in v.stderr + + +def test_second_developer_joins_from_their_own_clone(alice_repo, keys, tmp_path): + sh("git add .titan && git commit -qm 'chore: receipts'", alice_repo) + clone = tmp_path / "bob-clone" + sh(f"git clone -q {posix(alice_repo)} {posix(clone)}", tmp_path) + sh("git config user.email bob@example.com && git config user.name Bob", clone) + r = run(clone, "init", "--ssh", str(keys["bob"]), "--no-git-hook") + assert r.returncode == 0, r.stdout + r.stderr + allowed = (clone / ".titan" / "allowed_signers").read_text() + assert "alice@example.com " in allowed and "bob@example.com " in allowed + commit(clone, "bob.py") + assert run(clone, "create").returncode == 0 + v = run(clone, "verify") + assert v.returncode == 0, v.stdout + v.stderr + assert "alice@example.com" in v.stdout and "bob@example.com" in v.stdout + + +def test_hmac_chain_switching_to_ssh_verifies_receipt_by_receipt(tmp_path, keys): + repo = make_repo(tmp_path / "mixed", "alice@example.com") + assert run(repo, "init", "--no-git-hook").returncode == 0 # HMAC first + commit(repo, "h.py") + assert run(repo, "create").returncode == 0 + assert run(repo, "init", "--ssh", str(keys["alice"]), "--no-git-hook").returncode == 0 + commit(repo, "s.py") + assert run(repo, "create").returncode == 0 + kinds = [json.loads(p.read_text())["signing_version"] for p in receipts(repo)] + assert kinds == ["hmac-sha256-v1", "ssh-sig-v1"] + v = run(repo, "verify") + assert v.returncode == 0, v.stdout + v.stderr + (repo / ".titan" / "key").unlink() + v = run(repo, "verify") + assert v.returncode == 2 # HMAC receipt unchecked + assert "SIGNATURES NOT CHECKED" in v.stderr and "alice@example.com" in v.stdout + v = run(repo, "verify", "--public-only") + assert v.returncode == 0, v.stdout + v.stderr # what CI can check without a secret + assert "1 HMAC" in v.stdout and "not checked" in v.stdout.lower() + + +def test_report_names_signers_and_badge_verifies_with_no_secret(alice_repo, tmp_path): + badge = tmp_path / "b.svg" + r = run(alice_repo, "report", "--badge", str(badge)) + assert r.returncode == 0, r.stderr + assert "## Signed by" in r.stdout and "alice@example.com" in r.stdout + assert "SSH" in r.stdout and "all valid" in r.stdout + assert "chain verified" in badge.read_text(encoding="utf-8") + + +def test_unusable_key_is_refused_at_init_and_nothing_is_written(tmp_path): + repo = make_repo(tmp_path / "r", "alice@example.com") + r = run(repo, "init", "--ssh", str(tmp_path / "no-such-key"), "--no-git-hook") + assert r.returncode == 2 and "ssh key not usable" in r.stderr.lower() + assert not (repo / ".titan").exists() + assert subprocess.run(["git", "config", "--get", "commitproof.signingkey"], cwd=repo, + capture_output=True).returncode != 0 + + +def test_signing_failure_in_the_claude_hook_tells_the_user(alice_repo, keys): + keys["alice"].unlink() # the key is gone: signing must fail + Path(str(keys["alice"]) + ".pub").unlink() + commit(alice_repo, "c.py") + payload = {"tool_name": "Bash", "tool_input": {"command": "git commit -m x"}, + "tool_response": {"stdout": "[main 1a2b3c4] x"}} + r = run(alice_repo, "hook", e=env(CLAUDECODE="1"), stdin=json.dumps(payload)) + assert r.returncode == 0, r.stderr + msg = json.loads(r.stdout)["systemMessage"] + assert "couldn't sign" in msg.lower() and "c.py" not in msg + assert len(receipts(alice_repo)) == 1 # no unsigned receipt was written + + +def test_allowed_signers_override_is_what_gets_trusted(alice_repo, keys, tmp_path): + """CI can pin who may sign instead of trusting the audited repo's file.""" + other = tmp_path / "trusted_signers" + other.write_text("bob@example.com " + Path(str(keys["bob"]) + ".pub").read_text()) + v = run(alice_repo, "verify", "--allowed-signers", str(other)) + assert v.returncode == 1 and "SIG FAIL" in v.stderr + + +def test_crlf_allowed_signers_from_an_autocrlf_checkout_still_verifies(alice_repo): + p = alice_repo / ".titan" / "allowed_signers" + p.write_bytes(p.read_bytes().replace(b"\n", b"\r\n")) + v = run(alice_repo, "verify") + assert v.returncode == 0, v.stdout + v.stderr + + +def test_init_in_an_ssh_repo_uses_git_signing_key_by_default(alice_repo, keys, tmp_path): + """A teammate who already signs commits with SSH (gpg.format=ssh) runs + plain /receipt-init and gets SSH receipts, not a stray HMAC key.""" + sh("git add .titan && git commit -qm 'chore: receipts'", alice_repo) + clone = tmp_path / "carol" + sh(f"git clone -q {posix(alice_repo)} {posix(clone)}", tmp_path) + sh("git config user.email carol@example.com && git config gpg.format ssh && " + f"git config user.signingkey {posix(Path(str(keys['bob']) + '.pub'))}", clone) + r = run(clone, "init", "--no-git-hook") + assert r.returncode == 0, r.stdout + r.stderr + assert not (clone / ".titan" / "key").exists() + assert "carol@example.com " in (clone / ".titan" / "allowed_signers").read_text() + + +def _action_step() -> str: + import yaml + a = yaml.safe_load((PLUGIN / "action.yml").read_text(encoding="utf-8")) + return a["runs"]["steps"][0]["run"] + + +def run_action(repo: Path, **inputs): + from _shell import POSIX_SHELL + if POSIX_SHELL is None: + pytest.skip("needs a POSIX shell") + e = env(GITHUB_ACTION_PATH=PLUGIN.as_posix(), TITAN_KEY=inputs.get("key", ""), + TITAN_REPORT=inputs.get("report", ""), TITAN_ALLOW_REPO_VERIFIER="false", + TITAN_ALLOWED_SIGNERS=inputs.get("allowed_signers", ""), + RUNNER_TEMP=str(repo.parent)) + return subprocess.run([POSIX_SHELL, "-c", _action_step()], cwd=repo, capture_output=True, + text=True, encoding="utf-8", env=e) + + +def test_ci_action_checks_ssh_signatures_with_no_secret(alice_repo): + r = run_action(alice_repo) + assert r.returncode == 0, r.stdout + r.stderr + assert "SSH signatures: 1 valid" in r.stdout and "alice@example.com" in r.stdout + + +def test_ci_action_pinned_signers_override_the_repo(alice_repo, keys): + bob_only = "bob@example.com " + Path(str(keys["bob"]) + ".pub").read_text().strip() + r = run_action(alice_repo, allowed_signers=bob_only) + assert r.returncode == 1 and "SIG FAIL" in r.stderr From 023f24fd8149186a537db41e2d8fc5b6a46f5f4c Mon Sep 17 00:00:00 2001 From: Rehan Masood Date: Tue, 20 Oct 2026 11:29:29 -0400 Subject: [PATCH 3/4] fix: no signature downgrade from SSH back to HMAC 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 --- CHANGELOG.md | 7 +++++ commands/receipt-init.md | 7 +++-- commands/verify-chain.md | 5 ++- core/receipt.py | 52 ++++++++++++++++++++++++++++--- tests/test_ssh.py | 67 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb36675..e982470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ forge any receipt, and a clone without the key can't check signatures. - **CI action:** SSH signatures are checked with no secret (`verify --public-only` when no `key` is given), and a new `allowed_signers` input pins who may sign, outside the repo under audit. +- **No signature downgrade.** Once a chain has an SSH receipt, any later HMAC + receipt is invalid, checked or not (order from the chain's own prev links), + and HMAC signing is refused in a repo with `allowed_signers`. Otherwise + someone with push access but no registered key could append an HMAC-labelled + receipt and pass `--public-only`, which skips HMAC. With a pinned + `--allowed-signers`, HMAC receipts that can't be checked fail instead of + passing. Found by an automated security review before release. - **Never silent:** if the Claude Code hook can't sign (key gone, needs a passphrase), the user gets a message saying the commit has no receipt and how to fix it. No unsigned receipt is ever written. diff --git a/commands/receipt-init.md b/commands/receipt-init.md index 424cddf..f24630e 100644 --- a/commands/receipt-init.md +++ b/commands/receipt-init.md @@ -1,6 +1,6 @@ --- description: Start here — one-time setup that turns on receipts for this repo so every commit (AI-assisted or not) gets a tamper-evident record. --ssh signs with your own SSH key (no shared secret, anyone can verify); default is a gitignored shared HMAC key. Also vendors the verifier and installs a git post-commit hook -argument-hint: "[--ssh [KEY]] [--hmac] [--no-git-hook] [--force]" +argument-hint: "[--ssh [KEY]] [--no-git-hook] [--force]" --- Initialize receipts in the user's current repository: @@ -47,7 +47,10 @@ After it succeeds, explain the three facts the user must not learn later. If `--ssh` refuses with "SSH key not usable", nothing was written. Relay the reason (no key found, or it needs a passphrase): `ssh-add` the key, or pass -another with `--ssh PATH`. Don't fall back to HMAC unless the user asks. +another with `--ssh PATH`. A repo that has moved to SSH has no HMAC +fallback: verify treats an HMAC receipt after the first SSH one as a +signature downgrade, so commits are refused a receipt until this clone +runs `/receipt-init --ssh`. If init refuses because the key is tracked by git, that is fail-closed working as designed: help them run `git rm --cached .titan/key`, then retry. diff --git a/commands/verify-chain.md b/commands/verify-chain.md index b760040..639283e 100644 --- a/commands/verify-chain.md +++ b/commands/verify-chain.md @@ -31,7 +31,10 @@ Report the result exactly as it is: SSH SIG FAIL can also mean the signer's key isn't in `allowed_signers` (a teammate who never ran `/receipt-init --ssh`); check that before calling it tampering, and never add a key just to make it pass without the user - confirming it's that person's key. + confirming it's that person's key. A SIG FAIL saying **signature + downgrade** is an HMAC receipt after the chain moved to SSH: treat it as a + finding (someone wrote a receipt without a registered key), not a setup + glitch. - **SIGNATURES NOT CHECKED (exit 2)** means some HMAC receipts and no `.titan/key` here (typically a fresh clone), or SSH receipts with no `ssh-keygen` / `allowed_signers`. Everything else still ran and its result diff --git a/core/receipt.py b/core/receipt.py index 615fbdf..2d30fff 100644 --- a/core/receipt.py +++ b/core/receipt.py @@ -241,8 +241,7 @@ def cmd_init(args: argparse.Namespace) -> int: if root is None: return die("not inside a git repository") os.chdir(root) - use_ssh = getattr(args, "ssh", None) is not None or ( - ALLOWED_SIGNERS.exists() and not getattr(args, "hmac", False)) + use_ssh = getattr(args, "ssh", None) is not None or ALLOWED_SIGNERS.exists() if use_ssh: return _init_ssh(args, Path(root)) if KEY_PATH.exists() and not args.force: @@ -567,6 +566,16 @@ def cmd_create(args: argparse.Namespace) -> int: return 0 # hook mode: repo not initialized — silently do nothing return die("no signing set up in this clone — run receipt.py init first (or init --ssh)") key = None + if signer[0] == "hmac" and ALLOWED_SIGNERS.exists(): + # verify treats HMAC after the move to SSH as a downgrade; don't write one + try: + head = git("rev-parse", "HEAD").strip() + except RuntimeError: + head = "(none yet)" + return _signing_failed(args, head, + f"this repo signs receipts with SSH ({ALLOWED_SIGNERS.as_posix()}) " + f"and this clone has only the old HMAC key; run " + f"/receipt-init --ssh here") if signer[0] == "hmac": key = load_key() if key is None: @@ -823,6 +832,14 @@ def cmd_verify(args: argparse.Namespace) -> int: if s["ssh_unchecked"]: print(f"SSH SIGNATURES NOT CHECKED: {len(s['ssh_unchecked'])} receipt(s) — " f"{s['ssh_unchecked'][0][3]}", file=sys.stderr) + pinned = bool(getattr(args, "allowed_signers", None)) + if s["hmac_unchecked"] and public_only and pinned: + # a pinned signer list is a policy: every receipt must be checkable + print(f"NOT CHECKED: {len(s['hmac_unchecked'])} HMAC signature(s), and with a pinned " + f"--allowed-signers a receipt that can't be checked fails. Provide the HMAC " + f"key, or verify without pinning.", file=sys.stderr) + _report_anchor() + return 2 if s["hmac_unchecked"] and public_only: print(f"NOT CHECKED: {len(s['hmac_unchecked'])} HMAC signature(s) — they need the " f"shared {KEY_PATH.as_posix()}, which --public-only doesn't use") @@ -865,7 +882,34 @@ def check_signatures(rows: list[tuple[Path, dict]], allowed: Path, hmac_key = load_key() if use_hmac and KEY_PATH.exists() else None needs_ssh = any(r.get("signing_version") == signing.SSH_V1 for _, r in rows) with signing.SshVerifier(allowed if needs_ssh else None) as ssh: - return [(p, r, *signing.check(r, hmac_key, ssh)) for p, r in rows] + out = [(p, r, *signing.check(r, hmac_key, ssh)) for p, r in rows] + # No downgrade: once the chain moved to SSH, a later HMAC receipt is how + # someone without a registered key would slip past --public-only (HMAC is + # skipped there). Order comes from the chain's own prev links, which a + # forger can't reorder without breaking later SSH signatures. + seen_ssh = False + rank = {id(r): i for i, r in enumerate(chain_order([r for _, r in rows]))} + for i in sorted(range(len(out)), key=lambda i: rank.get(id(out[i][1]), len(out))): + p, r, st, detail = out[i] + if r.get("signing_version") == signing.SSH_V1: + seen_ssh = True + elif seen_ssh: + out[i] = (p, r, "invalid", "HMAC receipt after the chain moved to SSH: " + "signature downgrade") + return out + + +def chain_order(rs: list[dict]) -> list[dict]: + """Receipts from GENESIS forward by their prev links. A broken chain (it + already fails structure) yields what is reachable, then the rest.""" + by_prev = {r.get("prev_receipt_hash"): r for r in rs} + ordered, cur, seen = [], GENESIS, set() + while cur in by_prev and cur not in seen: + seen.add(cur) + nxt = by_prev[cur] + ordered.append(nxt) + cur = nxt.get("receipt_hash") + return ordered + [r for r in rs if all(r is not o for o in ordered)] def summarize(rows: list[tuple[Path, dict, str, str]]) -> dict: @@ -1306,8 +1350,6 @@ def main() -> int: p.add_argument("--ssh", nargs="?", const="", default=None, metavar="KEY", help="sign with your SSH key (default: git's SSH signing key, " "else ~/.ssh/id_ed25519) and register it in .titan/allowed_signers") - p.add_argument("--hmac", action="store_true", - help="use a shared HMAC key even in a repo that already uses SSH") p.add_argument("--no-git-hook", action="store_true", help="do not vendor tools / install the git post-commit hook") p.set_defaults(fn=cmd_init) diff --git a/tests/test_ssh.py b/tests/test_ssh.py index 4ca0884..ca0b6c7 100644 --- a/tests/test_ssh.py +++ b/tests/test_ssh.py @@ -252,3 +252,70 @@ def test_ci_action_pinned_signers_override_the_repo(alice_repo, keys): bob_only = "bob@example.com " + Path(str(keys["bob"]) + ".pub").read_text().strip() r = run_action(alice_repo, allowed_signers=bob_only) assert r.returncode == 1 and "SIG FAIL" in r.stderr + + +# ------------------------------------------------- signature downgrade +def _forge_hmac_receipt(repo: Path, signature: str = "0" * 64) -> Path: + """What someone with push access but no registered SSH key could write: + a correctly chained receipt that claims the weaker HMAC scheme.""" + sys.path.insert(0, str(PLUGIN / "core")) + import hashlib + from canonical import canonical_bytes + last = json.loads(receipts(repo)[-1].read_text()) + forged = {k: v for k, v in last.items() if k not in ("signer", "signature", "receipt_hash")} + forged.update(signing_version="hmac-sha256-v1", receipt_id="f0f0f0f0-forged", + prev_receipt_hash=last["receipt_hash"]) + forged["commit"] = dict(last["commit"], sha="f" * 40, subject="forged") + forged["receipt_hash"] = hashlib.sha256(canonical_bytes(forged)).hexdigest() + forged["signature"] = signature + p = receipts(repo)[-1].parent / "forged.json" + p.write_text(json.dumps(forged, indent=2) + "\n") + return p + + +def test_hmac_receipt_after_the_move_to_ssh_is_a_downgrade(alice_repo): + _forge_hmac_receipt(alice_repo) + for args in (["verify", "--public-only"], ["verify"]): + v = run(alice_repo, *args) + assert v.returncode == 1, (args, v.stdout + v.stderr) + assert "SIG FAIL" in v.stderr and "downgrade" in v.stderr + + +def test_downgrade_fails_even_with_a_valid_hmac_signature(alice_repo): + """A key holder can't slip HMAC receipts back in after SSH either.""" + sys.path.insert(0, str(PLUGIN / "core")) + import signing + key = "11" * 32 + (alice_repo / ".titan" / "key").write_text(key + "\n") + p = _forge_hmac_receipt(alice_repo) + r = json.loads(p.read_text()) + r["signature"] = signing.hmac_sign(bytes.fromhex(key), r["receipt_hash"].encode()) + p.write_text(json.dumps(r, indent=2) + "\n") + v = run(alice_repo, "verify") + assert v.returncode == 1 and "downgrade" in v.stderr + + +def test_hmac_signing_is_refused_in_an_ssh_repo(alice_repo): + sh("git config --unset commitproof.signingkey", alice_repo) + (alice_repo / ".titan" / "key").write_text("22" * 32 + "\n") + commit(alice_repo, "d.py") + r = run(alice_repo, "create") + assert r.returncode == 1 and "--ssh" in r.stderr + assert len(receipts(alice_repo)) == 1 + + +def test_pinned_signers_mean_unchecked_hmac_cannot_pass(tmp_path, keys): + repo = make_repo(tmp_path / "legacy", "alice@example.com") + assert run(repo, "init", "--no-git-hook").returncode == 0 # HMAC era + commit(repo, "h.py") + assert run(repo, "create").returncode == 0 + assert run(repo, "init", "--ssh", str(keys["alice"]), "--no-git-hook").returncode == 0 + commit(repo, "s.py") + assert run(repo, "create").returncode == 0 + (repo / ".titan" / "key").unlink() + pinned = tmp_path / "pinned_signers" + pinned.write_bytes((repo / ".titan" / "allowed_signers").read_bytes()) + v = run(repo, "verify", "--public-only") + assert v.returncode == 0 # no policy given: legacy HMAC is reported, not failed + v = run(repo, "verify", "--public-only", "--allowed-signers", str(pinned)) + assert v.returncode == 2 and "pinned" in v.stderr From bb523b71205f293639c864e172518052d14d4ac5 Mon Sep 17 00:00:00 2001 From: Rehan Masood Date: Tue, 20 Oct 2026 11:33:47 -0400 Subject: [PATCH 4/4] test: run the action's step with bash, as GitHub does (dash rejects pipefail) Co-Authored-By: Claude Opus 5.5 --- tests/test_ssh.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_ssh.py b/tests/test_ssh.py index ca0b6c7..c47eaef 100644 --- a/tests/test_ssh.py +++ b/tests/test_ssh.py @@ -227,18 +227,23 @@ def test_init_in_an_ssh_repo_uses_git_signing_key_by_default(alice_repo, keys, t def _action_step() -> str: import yaml a = yaml.safe_load((PLUGIN / "action.yml").read_text(encoding="utf-8")) - return a["runs"]["steps"][0]["run"] + step = a["runs"]["steps"][0] + assert step["shell"] == "bash" # the run block uses bash-only `set -o pipefail` + return step["run"] def run_action(repo: Path, **inputs): + # Run it the way GitHub does: with bash. On Ubuntu `sh` is dash, which + # rejects pipefail; on Windows use Git's bash, never System32's WSL one. from _shell import POSIX_SHELL - if POSIX_SHELL is None: - pytest.skip("needs a POSIX shell") + bash = POSIX_SHELL if sys.platform == "win32" else shutil.which("bash") + if bash is None: + pytest.skip("needs bash") e = env(GITHUB_ACTION_PATH=PLUGIN.as_posix(), TITAN_KEY=inputs.get("key", ""), TITAN_REPORT=inputs.get("report", ""), TITAN_ALLOW_REPO_VERIFIER="false", TITAN_ALLOWED_SIGNERS=inputs.get("allowed_signers", ""), RUNNER_TEMP=str(repo.parent)) - return subprocess.run([POSIX_SHELL, "-c", _action_step()], cwd=repo, capture_output=True, + return subprocess.run([bash, "-c", _action_step()], cwd=repo, capture_output=True, text=True, encoding="utf-8", env=e)