From 4836c6cb48815eaa4d35eeb7825277ae27529320 Mon Sep 17 00:00:00 2001 From: BCC Agent Date: Mon, 13 Jul 2026 12:22:22 -0400 Subject: [PATCH] chore(#68): rotate signing keys, key-rotation re-attestation mode, harden show-seed-b64 Wires the maintainer's freshly-rotated signing keys into BCC (issue #68 finding 5: the old key was shared between catalog+release and had been exposed to CI), adds a key-rotation re-attestation mode to the Catalog Console so the Console can actually re-sign under the new key, and hardens the show-seed-b64 CLI prompt that led to a private key being pasted into a chat. ## New keys (#68 finding 5) - bcc_core.CATALOG_PUBKEYS -> new CATALOG pubkey (0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=). Signs data/catalog.json, Console-only, offline. - .github/workflows/ci.yml EXPECTED_CATALOG_PUBKEY_B64 -> same new catalog pubkey (the CI trust anchor added for #68 finding 4). - scripts/sign_checksums.py RELEASE_PUBKEYS -> new RELEASE pubkey (6BnPgJEHJFyVltFoLTCNadIsehjy00iiW8IRlC1TfhA=). Signs SHA256SUMS only, CI-resident. - README.md 'Verifying your download' / 'Signing keys' sections filled in with both pubkeys, explicit about which key is which. The old key 082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4= is retired (it was shared and exposed to CI) and is deliberately NOT retained in either trust list -- keeping a burned key in CATALOG_PUBKEYS would defeat the point of rotating it. ## Key-rotation re-attestation mode (Catalog Console) Problem: after the key swap, the existing data/catalog.json.sig (signed with the OLD key) no longer verifies under the NEW CATALOG_PUBKEYS, but catalog content is unchanged, so diff_catalogs(last_signed, current) is empty -- and can_sign()'s empty-diff guard (load-bearing, #68 finding 1) correctly refuses to sign an empty changeset. Without a rotation-aware path, the Console could never re-sign and CI would stay red forever. Fix: treat rotation as a full re-attestation, not a diff. - catalog_review.py: ReviewSession/start_review gain reattest: bool = False. When set, changes is built via diff_catalogs(None, new_catalog) -- every entry presented as if newly added, requiring a fresh acknowledgement -- instead of diffing against old_catalog. can_sign() is UNCHANGED: it still refuses a genuinely-empty changeset and still enforces the TOCTOU blob-SHA pin and the blocking-risk check, because reattest sessions simply never produce an empty changeset (unless the catalog itself is empty). - catalog_console.py: adds catalog_signature_valid_at(repo_dir, commit, raw), which calls bcc_core.verify_catalog_signature directly (never reimplemented) to detect whether the committed .sig verifies under the CURRENT CATALOG_PUBKEYS. ReviewWindow._on_load's source=main path uses this to decide reattest=True/False, and shows a loud, explicit red banner ("KEY ROTATION IN PROGRESS...") whenever reattest mode is entered -- never silent. Status text and Sign-button gating flow through the same can_sign()/all_entries_acknowledged() path as normal review. - tests/test_catalog_review.py: 6 new tests covering re-attest mode (one change-entry per server, gating until all acknowledged, then permits), confirming normal mode still refuses an empty diff (rotation path is not a general bypass), and confirming reattest mode still enforces the blocking-risk check and the TOCTOU pin. ## show-seed-b64 hardening (Task 3) The maintainer ran show-seed-b64 --release, saw an ambiguous prompt, and pasted the printed PRIVATE seed into a chat believing it was public. - cmd_show_seed_b64: passphrase prompt is now explicit ("Passphrase for the release signing key (the one YOU chose when generating it)"). A loud three-line warning banner prints to STDERR immediately before the seed ("!!! PRIVATE KEY BELOW..."); the seed itself stays alone on STDOUT so piping into pbcopy or a CI secret field still works cleanly. - cmd_keygen: labels for both key kinds now say PUBLIC/PRIVATE explicitly and state safety properties inline (safe to commit vs. never commit), so the printed output can't be mistaken for the other key's. ## Verification - ruff check . / ruff format --check .: clean. - pytest: full suite green (360 passed, 1 skipped). - Delete-the-check-and-watch-it-fail: each of can_sign()'s four gates (empty-diff, TOCTOU pin, blocking-risk, acknowledge-all) and the reattest branch itself were individually removed and confirmed to turn a test red, then restored -- see PR description for the exact failures. Not touched: data/catalog.json (content-signed, maintainer's job via the Console). No private key generated, requested, or committed. bcc.py untouched (open PR #67). --- .github/workflows/ci.yml | 2 +- README.md | 21 ++++- bcc_core.py | 2 +- catalog_console.py | 154 ++++++++++++++++++++++++++++++----- catalog_review.py | 37 ++++++++- scripts/sign_checksums.py | 16 ++-- tests/test_catalog_review.py | 92 +++++++++++++++++++++ 7 files changed, 292 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2cd6c4..646fe2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: # below to the new key's base64 form, as its own reviewed change. - name: Verify data/catalog.json.sig env: - EXPECTED_CATALOG_PUBKEY_B64: "082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4=" + EXPECTED_CATALOG_PUBKEY_B64: "0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=" run: | python - <<'PY' import base64, os, pathlib, sys diff --git a/README.md b/README.md index c04b894..9e108ae 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,29 @@ refuses to run without `--release` specifically so the catalog seed can't be exported by habit or muscle memory. **Release signing public key** (Ed25519, base64, raw 32 bytes) — this is -the RELEASE key, not the catalog key: +the RELEASE key, which signs `SHA256SUMS` (release checksums). It does +**not** sign `data/catalog.json` and is not the key `bcc_core.CATALOG_PUBKEYS` +trusts: ``` - +6BnPgJEHJFyVltFoLTCNadIsehjy00iiW8IRlC1TfhA= ``` +The catalog public key (Ed25519, base64, raw 32 bytes) — this is the key +that signs `data/catalog.json` and is trusted via `bcc_core.CATALOG_PUBKEYS` +and the CI trust anchor in `.github/workflows/ci.yml`. It is listed here +for completeness, not because you need it to verify a download — use the +*release* key above for that: + +``` +0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k= +``` + +Both keys above were rotated 2026-07 — see [issue #68](../../issues/68) +finding 5. The prior (shared) key is retired and is deliberately **not** +kept in either trust list; retaining a burned key would defeat the point +of rotating it. + ## Run from source ```bash diff --git a/bcc_core.py b/bcc_core.py index c81d5ea..a39f824 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -2193,7 +2193,7 @@ CATALOG_DENIED_ENV_KEYS = frozenset( # still trust an older key: verify_catalog_signature() accepts a match # against ANY key in this list. CATALOG_PUBKEYS: list[bytes] = [ - base64.b64decode("082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="), + base64.b64decode("0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k="), ] # Domain-separation prefix for the signed message. The signature covers diff --git a/catalog_console.py b/catalog_console.py index 736d67d..95603d4 100644 --- a/catalog_console.py +++ b/catalog_console.py @@ -283,6 +283,30 @@ def last_signed_catalog_raw(repo_dir: Path, commit: str) -> bytes | None: return review.find_last_signed_catalog_raw(candidates, sig_bytes, core.CATALOG_PUBKEYS) +def catalog_signature_valid_at(repo_dir: Path, commit: str, raw: bytes) -> bool: + """Whether data/catalog.json.sig as of `commit` verifies `raw` against + the CURRENTLY-TRUSTED bcc_core.CATALOG_PUBKEYS. + + False whenever there is no .sig file, or the .sig exists but was + produced by a key that is not (or no longer) in CATALOG_PUBKEYS -- most + notably right after a catalog signing-key rotation (issue #68 finding 5 + follow-up), when the committed .sig was produced by the now-retired old + key. This is the rotation-detection primitive ReviewWindow._on_load + uses to decide whether to enter re-attestation mode + (review.start_review(..., reattest=True)) instead of the ordinary + diff-against-last-signed path. + + Delegates to bcc_core.verify_catalog_signature -- never reimplemented, + per this module's docstring ("one source of truth"). + """ + try: + sig_sha = blob_sha_at(repo_dir, commit, SIG_PATH) + except GitError: + return False + sig_bytes = blob_bytes(repo_dir, sig_sha) + return core.verify_catalog_signature(raw, sig_bytes, core.CATALOG_PUBKEYS) + + def commit_and_push_signed_catalog( repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main" ) -> str: @@ -709,6 +733,22 @@ class ReviewWindow(QMainWindow): top.addLayout(side) root.addLayout(top) + # KEY-ROTATION RE-ATTESTATION BANNER (issue #68 finding 5 follow-up). + # Hidden until _on_load() detects that the loaded catalog's + # signature does NOT verify under the currently-trusted + # bcc_core.CATALOG_PUBKEYS (catalog_signature_valid_at()) -- most + # commonly, right after the maintainer rotates the catalog signing + # key. This mode must NEVER be entered silently: this banner is the + # only thing standing between "every entry needs re-review" and a + # maintainer wondering why the diff view suddenly shows 19 + # "added" entries with no explanation. + self.reattest_banner = plain_label("") + self.reattest_banner.setStyleSheet( + "background-color: #c62828; color: white; font-weight: bold; padding: 8px;" + ) + self.reattest_banner.setVisible(False) + root.addWidget(self.reattest_banner) + self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.card_container = QWidget() @@ -737,6 +777,7 @@ class ReviewWindow(QMainWindow): def _on_load(self): row = self.source_list.currentRow() + reattest = False try: if row <= 0: # source = main: diff against the last catalog a maintainer @@ -750,16 +791,32 @@ class ReviewWindow(QMainWindow): commit = fetch_ref(self.repo_dir, loaded_ref) new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit) new_catalog = core.load_catalog(new_raw) - old_raw = last_signed_catalog_raw(self.repo_dir, commit) - old_catalog = ( - core.load_catalog(old_raw) - if old_raw is not None - else { - "schema": 1, - "version": 0, - "servers": [], - } - ) + + # KEY-ROTATION RE-ATTESTATION (issue #68 finding 5 + # follow-up): if the committed .sig does not verify under + # the CURRENTLY-trusted bcc_core.CATALOG_PUBKEYS, the + # trusted key changed since this catalog was last signed. + # The new key has never vouched for ANY of this catalog's + # content, so there is nothing meaningful to diff against + # -- every entry needs a fresh acknowledgement under the + # new key. Do NOT fall through to last_signed_catalog_raw() + # in this case: it walks history looking for a match under + # the (now-untrusted) old key's signature, which is exactly + # backwards after a rotation. + if not catalog_signature_valid_at(self.repo_dir, commit, new_raw): + reattest = True + old_catalog = {"schema": 1, "version": 0, "servers": []} + else: + old_raw = last_signed_catalog_raw(self.repo_dir, commit) + old_catalog = ( + core.load_catalog(old_raw) + if old_raw is not None + else { + "schema": 1, + "version": 0, + "servers": [], + } + ) else: pr = self._prs[row - 1] loaded_ref = pr.head_ref @@ -781,8 +838,21 @@ class ReviewWindow(QMainWindow): # from the SAME ref that was reviewed, instead of a hardcoded # "main" -- see review.sign_precondition and issue #68 finding 1. self.session = review.start_review( - new_blob_sha, old_catalog, new_catalog, loaded_ref=loaded_ref + new_blob_sha, old_catalog, new_catalog, loaded_ref=loaded_ref, reattest=reattest ) + if reattest: + self.reattest_banner.setText( + "KEY ROTATION IN PROGRESS -- the trusted catalog signing key changed. " + "The existing data/catalog.json.sig does NOT verify under the current " + "bcc_core.CATALOG_PUBKEYS, so it is NOT trusted. Every one of the " + f"{len(self.session.changes)} entries below must be re-reviewed and " + "acknowledged before the new key can re-sign this catalog -- this is " + "the intended cost of rotating the key, not a bug." + ) + self.reattest_banner.setVisible(True) + else: + self.reattest_banner.setVisible(False) + self.reattest_banner.setText("") self._render_cards() def _render_cards(self): @@ -817,9 +887,15 @@ class ReviewWindow(QMainWindow): all_ack = review.all_entries_acknowledged(self.session) self.sign_btn.setEnabled(all_ack) pending = len(self.session.changes) - len(self.session.acknowledged) - self.status_label.setText( - f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged." - ) + if self.session.reattest: + self.status_label.setText( + f"RE-ATTESTATION under the new key: {len(self.session.changes)} entries, " + f"{pending} not yet acknowledged." + ) + else: + self.status_label.setText( + f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged." + ) def _on_sign(self): assert self.session is not None @@ -920,8 +996,12 @@ def cmd_keygen(args: argparse.Namespace) -> int: ) print() print( - "Public key (base64) -- hand this to whoever maintains bcc_core.py so " - "they can add it to CATALOG_PUBKEYS (this tool does not edit that file):" + "PUBLIC key (base64, safe to commit/share) -- hand this to whoever " + "maintains bcc_core.py so they can add it to CATALOG_PUBKEYS (this tool " + "does not edit that file). There is no PRIVATE-key output for the catalog " + "key: it is never meant to leave this machine, and this tool has no " + "command that exports it (show-seed-b64 refuses without --release, and " + "even then only exports the release key)." ) print(f" {pubkey_b64}") else: @@ -932,14 +1012,23 @@ def cmd_keygen(args: argparse.Namespace) -> int: "contain it)." ) print() - print("1. Public key (base64) -- paste into scripts/sign_checksums.py RELEASE_PUBKEYS:") + print( + "1. PUBLIC key (base64, safe to commit/share) -- paste into " + "scripts/sign_checksums.py RELEASE_PUBKEYS:" + ) print(f" {pubkey_b64}") print() print( - "2. Private key -- add it as the Gitea repo secret RELEASE_SIGNING_KEY " - "(base64 of the 32-byte private seed). Get that value with:" + "2. PRIVATE key (secret -- NEVER commit, NEVER paste into chat/email) -- " + "add it as the Gitea repo secret RELEASE_SIGNING_KEY (base64 of the " + "32-byte private seed). This command does not print it; fetch it " + "separately, when you're ready to paste it straight into the Gitea " + "secret field, with:" + ) + print( + " python catalog_console.py show-seed-b64 --release " + "# prints the PRIVATE key -- read the warning banner it shows" ) - print(" python catalog_console.py show-seed-b64 --release # prints the raw key") return 0 @@ -959,12 +1048,35 @@ def cmd_show_seed_b64(args: argparse.Namespace) -> int: file=sys.stderr, ) return 1 - passphrase = getpass.getpass("Release signing key passphrase: ") + # The prior wording ("Release signing key passphrase:") was ambiguous + # enough that a maintainer who ran this command, saw that prompt, and + # then saw a base64 blob printed with zero surrounding context, pasted + # the output into a chat believing it was the PUBLIC key. It is not -- + # it is the raw private seed. Every string this command prints from here + # down exists to make that mistake structurally harder to make again. + passphrase = getpass.getpass( + "Passphrase for the release signing key (the one YOU chose when generating it): " + ) try: seed = unlock_signing_key(passphrase, kind="release") except (FileNotFoundError, ValueError) as e: print(f"error: {e}", file=sys.stderr) return 1 + + # Loud banner on STDERR, seed alone on STDOUT -- so `show-seed-b64 + # --release | pbcopy` (or piping into the Gitea secret field) still + # gets ONLY the seed, while anyone watching the terminal still sees the + # warning. Never merge these into one stream. + print("!!! PRIVATE KEY BELOW -- this is the RELEASE_SIGNING_KEY secret value.", file=sys.stderr) + print( + "!!! Paste it ONLY into the Gitea secret field. Never into chat, email, " + "a file, or a commit.", + file=sys.stderr, + ) + print( + "!!! Anyone holding this value can forge release checksum signatures.", + file=sys.stderr, + ) print(base64.b64encode(seed).decode("ascii")) return 0 diff --git a/catalog_review.py b/catalog_review.py index c3c5669..5a97834 100644 --- a/catalog_review.py +++ b/catalog_review.py @@ -442,18 +442,43 @@ class ReviewSession: from the reviewed ref on every PR review (the bug that made the PR path unable to sign at all, and forced everyone onto the vacuous main-vs-itself path instead). + + `reattest` marks a KEY-ROTATION re-attestation pass (issue #68 finding 5 + follow-up): the currently-trusted `bcc_core.CATALOG_PUBKEYS` key changed + and the existing `data/catalog.json.sig` no longer verifies under it. + Content-wise nothing may have changed -- `diff_catalogs(old, new)` can be + genuinely empty -- but the NEW key has never vouched for any of this + catalog before, so every entry needs a first-time attestation under the + new key, not a diff against the old one. When `reattest` is set, + `changes` is built as "every entry in `new_catalog`, presented as if + newly added" (via `diff_catalogs(None, new_catalog)`) instead of a + diff against `old_catalog`, so the acknowledge-gate in `can_sign()` + requires re-reviewing everything the new key will sign -- which is the + intended cost of a key rotation, not a bypass of the empty-diff guard. """ pinned_blob_sha: str old_catalog: dict new_catalog: dict loaded_ref: str = "main" + reattest: bool = False changes: list[EntryChange] = field(default_factory=list) acknowledged: set[str] = field(default_factory=set) def __post_init__(self) -> None: if not self.changes: - self.changes = diff_catalogs(self.old_catalog, self.new_catalog) + if self.reattest: + # Every entry in the new catalog is treated as though it + # were newly added -- because, under the NEW signing key, + # it is: nothing this key signs was ever attested by it + # before. Reusing diff_catalogs(None, new) (rather than a + # bespoke code path) means the same "added" risk predicate + # (risk_new_entry) and the same EntryChange shape the rest + # of this module and the GUI already know how to render + # apply here unmodified. + self.changes = diff_catalogs(None, self.new_catalog) + else: + self.changes = diff_catalogs(self.old_catalog, self.new_catalog) def start_review( @@ -461,12 +486,14 @@ def start_review( old_catalog: dict, new_catalog: dict, loaded_ref: str = "main", + reattest: bool = False, ) -> ReviewSession: return ReviewSession( pinned_blob_sha=pinned_blob_sha, old_catalog=old_catalog, new_catalog=new_catalog, loaded_ref=loaded_ref, + reattest=reattest, ) @@ -531,6 +558,14 @@ def can_sign(session: ReviewSession, current_blob_sha: str) -> SignDecision: mean "nothing to sign", never "sign unlocked". (Issue #68 finding 1; this is what let commit b08cf21 sign all 19 entries with zero of them ever reviewed.) + + This gate is unaffected by `session.reattest`: a key-rotation + re-attestation session's `changes` is built from + `diff_catalogs(None, new_catalog)` (see ReviewSession), which is + empty ONLY if the catalog itself has zero entries -- a genuinely + empty catalog either way. Rotation never manufactures a non-empty + changeset out of an empty one; it just changes *what* "non-empty" + is computed against. 1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing, from the ref that was actually reviewed -- see sign_precondition()) must match the blob SHA pinned when review began. If the bytes on the diff --git a/scripts/sign_checksums.py b/scripts/sign_checksums.py index d4a5f88..8c44215 100755 --- a/scripts/sign_checksums.py +++ b/scripts/sign_checksums.py @@ -53,13 +53,17 @@ DOMAIN_PREFIX = b"bcc-release-v1|" # the signature on every past release: verification accepts a match against # ANY key here. # -# Empty until the maintainer generates the release keypair (separately from -# the catalog keypair) and pastes the public half in: +# Populated by the maintainer via: # python catalog_console.py keygen --release -# This is intentionally NOT pre-populated with a placeholder that looks -# like a real key -- release.yml's signing-smoke-test fails closed (loudly) -# on an empty list rather than silently verifying against nothing. -RELEASE_PUBKEYS: list[bytes] = [] +# Rotated 2026-07 (issue #68 finding 5 / #68 CI-exposure incident): the +# original key was shared with the catalog key and had been exposed to CI, +# so both keypairs were regenerated as separate, disjoint keys. This list +# holds only the current release key -- if release.yml's signing-smoke-test +# ever sees this list empty, it fails closed (loudly) rather than silently +# verifying against nothing. +RELEASE_PUBKEYS: list[bytes] = [ + base64.b64decode("6BnPgJEHJFyVltFoLTCNadIsehjy00iiW8IRlC1TfhA="), +] CHUNK_SIZE = 1024 * 1024 diff --git a/tests/test_catalog_review.py b/tests/test_catalog_review.py index 8249d7c..2fbe463 100644 --- a/tests/test_catalog_review.py +++ b/tests/test_catalog_review.py @@ -491,6 +491,98 @@ def test_sign_precondition_defaults_to_main_when_loaded_ref_unset(): assert decision.ok is True +# --------------------------------------------------------------------------- # +# ReviewSession(reattest=True): key-rotation re-attestation (issue #68 +# finding 5 follow-up). After rotating bcc_core.CATALOG_PUBKEYS, the +# existing data/catalog.json.sig no longer verifies under the new key even +# though catalog CONTENT is unchanged -- diff_catalogs(old, new) would be +# empty, and an empty changeset must never unlock Sign (can_sign gate 0). +# Re-attestation mode sidesteps that correctly: instead of diffing against +# the (now-untrustworthy) last-signed content, it treats every entry as +# requiring a fresh acknowledgement, exactly like a brand-new catalog. +# --------------------------------------------------------------------------- # +def test_reattest_mode_with_unchanged_content_yields_one_change_per_entry(): + same = _catalog(_entry(id="a"), _entry(id="b"), _entry(id="c")) + session = r.start_review("sha1", same, same, reattest=True) + assert len(session.changes) == 3 + assert {c_.entry_id for c_ in session.changes} == {"a", "b", "c"} + # Presented "as if newly added" -- old_catalog plays no role here. + assert all(c_.status == "added" for c_ in session.changes) + assert all(c_.old is None for c_ in session.changes) + + +def test_reattest_mode_can_sign_refuses_until_all_acknowledged_then_permits(): + same = _catalog(_entry(id="a"), _entry(id="b"), _entry(id="c")) + session = r.start_review("sha1", same, same, reattest=True) + + decision = r.can_sign(session, "sha1") + assert decision.ok is False + assert "acknowledged" in decision.reason.lower() + + r.acknowledge_entry(session, "a") + r.acknowledge_entry(session, "b") + decision = r.can_sign(session, "sha1") + assert decision.ok is False # "c" still outstanding + assert "acknowledged" in decision.reason.lower() + + r.acknowledge_entry(session, "c") + decision = r.can_sign(session, "sha1") + assert decision.ok is True + assert decision.reason is None + + +def test_normal_mode_with_unchanged_content_still_refuses_empty_diff(): + """The rotation path must NOT become a general bypass of the empty-diff + guard: reattest=False (the default) against identical old/new catalogs + must behave exactly as before -- can_sign refuses with 'nothing to + sign', full stop.""" + same = _catalog(_entry(id="a"), _entry(id="b")) + session = r.start_review("sha1", same, same) # reattest defaults False + assert session.changes == [] + decision = r.can_sign(session, "sha1") + assert decision.ok is False + assert "nothing to sign" in decision.reason.lower() + + +def test_reattest_mode_still_enforces_blocking_risk_check(): + """Re-attestation must not relax the blocking-risk gate: a + disallowed-command entry blocks Sign even with every entry + acknowledged.""" + cat = _catalog( + _entry(id="a"), + _entry(id="evil", config={"command": "bash", "args": ["-c", "rm -rf /"]}), + ) + session = r.start_review("sha1", cat, cat, reattest=True) + assert len(session.changes) == 2 + r.acknowledge_entry(session, "a") + r.acknowledge_entry(session, "evil") + assert r.all_entries_acknowledged(session) is True + decision = r.can_sign(session, "sha1") + assert decision.ok is False + assert "blocking" in decision.reason.lower() + + +def test_reattest_mode_still_enforces_toctou_pin(): + """Re-attestation must not relax the TOCTOU blob-SHA pin: acknowledging + everything is not enough if the bytes moved underneath the review.""" + same = _catalog(_entry(id="a")) + session = r.start_review("sha1", same, same, reattest=True) + r.acknowledge_entry(session, "a") + decision = r.can_sign(session, "sha1") + assert decision.ok is True # sanity: matches when blob is unchanged + + decision = r.can_sign(session, "sha2-a-new-commit-landed") + assert decision.ok is False + assert "mismatch" in decision.reason.lower() or "changed" in decision.reason.lower() + + +def test_reattest_defaults_to_false(): + """start_review()'s reattest parameter defaults to False -- normal + (diff-based) review is the default behaviour, never silently entered.""" + session = r.start_review("sha1", _catalog(), _catalog(_entry())) + assert session.reattest is False + + # --------------------------------------------------------------------------- # # find_last_signed_catalog_raw: what source=main diffs against # --------------------------------------------------------------------------- #