chore(#68): rotate signing keys, key-rotation re-attestation mode, harden show-seed-b64
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 35s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Failing after 6s

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).
This commit is contained in:
BCC Agent
2026-07-13 12:22:22 -04:00
parent cd2ac2f6f8
commit 4836c6cb48
7 changed files with 292 additions and 32 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
# below to the new key's base64 form, as its own reviewed change. # below to the new key's base64 form, as its own reviewed change.
- name: Verify data/catalog.json.sig - name: Verify data/catalog.json.sig
env: env:
EXPECTED_CATALOG_PUBKEY_B64: "082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4=" EXPECTED_CATALOG_PUBKEY_B64: "0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k="
run: | run: |
python - <<'PY' python - <<'PY'
import base64, os, pathlib, sys import base64, os, pathlib, sys
+19 -2
View File
@@ -110,12 +110,29 @@ refuses to run without `--release` specifically so the catalog seed can't
be exported by habit or muscle memory. be exported by habit or muscle memory.
**Release signing public key** (Ed25519, base64, raw 32 bytes) — this is **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:
``` ```
<PLACEHOLDER — AJ: paste the release public key from `catalog_console.py keygen --release` here> 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 ## Run from source
```bash ```bash
+1 -1
View File
@@ -2193,7 +2193,7 @@ CATALOG_DENIED_ENV_KEYS = frozenset(
# still trust an older key: verify_catalog_signature() accepts a match # still trust an older key: verify_catalog_signature() accepts a match
# against ANY key in this list. # against ANY key in this list.
CATALOG_PUBKEYS: list[bytes] = [ CATALOG_PUBKEYS: list[bytes] = [
base64.b64decode("082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="), base64.b64decode("0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k="),
] ]
# Domain-separation prefix for the signed message. The signature covers # Domain-separation prefix for the signed message. The signature covers
+133 -21
View File
@@ -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) 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( def commit_and_push_signed_catalog(
repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main" repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main"
) -> str: ) -> str:
@@ -709,6 +733,22 @@ class ReviewWindow(QMainWindow):
top.addLayout(side) top.addLayout(side)
root.addLayout(top) 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 = QScrollArea()
self.scroll.setWidgetResizable(True) self.scroll.setWidgetResizable(True)
self.card_container = QWidget() self.card_container = QWidget()
@@ -737,6 +777,7 @@ class ReviewWindow(QMainWindow):
def _on_load(self): def _on_load(self):
row = self.source_list.currentRow() row = self.source_list.currentRow()
reattest = False
try: try:
if row <= 0: if row <= 0:
# source = main: diff against the last catalog a maintainer # source = main: diff against the last catalog a maintainer
@@ -750,16 +791,32 @@ class ReviewWindow(QMainWindow):
commit = fetch_ref(self.repo_dir, loaded_ref) commit = fetch_ref(self.repo_dir, loaded_ref)
new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit) new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit)
new_catalog = core.load_catalog(new_raw) new_catalog = core.load_catalog(new_raw)
old_raw = last_signed_catalog_raw(self.repo_dir, commit)
old_catalog = ( # KEY-ROTATION RE-ATTESTATION (issue #68 finding 5
core.load_catalog(old_raw) # follow-up): if the committed .sig does not verify under
if old_raw is not None # the CURRENTLY-trusted bcc_core.CATALOG_PUBKEYS, the
else { # trusted key changed since this catalog was last signed.
"schema": 1, # The new key has never vouched for ANY of this catalog's
"version": 0, # content, so there is nothing meaningful to diff against
"servers": [], # -- 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: else:
pr = self._prs[row - 1] pr = self._prs[row - 1]
loaded_ref = pr.head_ref loaded_ref = pr.head_ref
@@ -781,8 +838,21 @@ class ReviewWindow(QMainWindow):
# from the SAME ref that was reviewed, instead of a hardcoded # from the SAME ref that was reviewed, instead of a hardcoded
# "main" -- see review.sign_precondition and issue #68 finding 1. # "main" -- see review.sign_precondition and issue #68 finding 1.
self.session = review.start_review( 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() self._render_cards()
def _render_cards(self): def _render_cards(self):
@@ -817,9 +887,15 @@ class ReviewWindow(QMainWindow):
all_ack = review.all_entries_acknowledged(self.session) all_ack = review.all_entries_acknowledged(self.session)
self.sign_btn.setEnabled(all_ack) self.sign_btn.setEnabled(all_ack)
pending = len(self.session.changes) - len(self.session.acknowledged) pending = len(self.session.changes) - len(self.session.acknowledged)
self.status_label.setText( if self.session.reattest:
f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged." 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): def _on_sign(self):
assert self.session is not None assert self.session is not None
@@ -920,8 +996,12 @@ def cmd_keygen(args: argparse.Namespace) -> int:
) )
print() print()
print( print(
"Public key (base64) -- hand this to whoever maintains bcc_core.py so " "PUBLIC key (base64, safe to commit/share) -- hand this to whoever "
"they can add it to CATALOG_PUBKEYS (this tool does not edit that file):" "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}") print(f" {pubkey_b64}")
else: else:
@@ -932,14 +1012,23 @@ def cmd_keygen(args: argparse.Namespace) -> int:
"contain it)." "contain it)."
) )
print() 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(f" {pubkey_b64}")
print() print()
print( print(
"2. Private key -- add it as the Gitea repo secret RELEASE_SIGNING_KEY " "2. PRIVATE key (secret -- NEVER commit, NEVER paste into chat/email) -- "
"(base64 of the 32-byte private seed). Get that value with:" "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 return 0
@@ -959,12 +1048,35 @@ def cmd_show_seed_b64(args: argparse.Namespace) -> int:
file=sys.stderr, file=sys.stderr,
) )
return 1 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: try:
seed = unlock_signing_key(passphrase, kind="release") seed = unlock_signing_key(passphrase, kind="release")
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
return 1 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")) print(base64.b64encode(seed).decode("ascii"))
return 0 return 0
+36 -1
View File
@@ -442,18 +442,43 @@ class ReviewSession:
from the reviewed ref on every PR review (the bug that made the PR path 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 unable to sign at all, and forced everyone onto the vacuous
main-vs-itself path instead). 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 pinned_blob_sha: str
old_catalog: dict old_catalog: dict
new_catalog: dict new_catalog: dict
loaded_ref: str = "main" loaded_ref: str = "main"
reattest: bool = False
changes: list[EntryChange] = field(default_factory=list) changes: list[EntryChange] = field(default_factory=list)
acknowledged: set[str] = field(default_factory=set) acknowledged: set[str] = field(default_factory=set)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.changes: 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( def start_review(
@@ -461,12 +486,14 @@ def start_review(
old_catalog: dict, old_catalog: dict,
new_catalog: dict, new_catalog: dict,
loaded_ref: str = "main", loaded_ref: str = "main",
reattest: bool = False,
) -> ReviewSession: ) -> ReviewSession:
return ReviewSession( return ReviewSession(
pinned_blob_sha=pinned_blob_sha, pinned_blob_sha=pinned_blob_sha,
old_catalog=old_catalog, old_catalog=old_catalog,
new_catalog=new_catalog, new_catalog=new_catalog,
loaded_ref=loaded_ref, 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; 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 this is what let commit b08cf21 sign all 19 entries with zero of them
ever reviewed.) 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, 1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing,
from the ref that was actually reviewed -- see sign_precondition()) from the ref that was actually reviewed -- see sign_precondition())
must match the blob SHA pinned when review began. If the bytes on the must match the blob SHA pinned when review began. If the bytes on the
+10 -6
View File
@@ -53,13 +53,17 @@ DOMAIN_PREFIX = b"bcc-release-v1|"
# the signature on every past release: verification accepts a match against # the signature on every past release: verification accepts a match against
# ANY key here. # ANY key here.
# #
# Empty until the maintainer generates the release keypair (separately from # Populated by the maintainer via:
# the catalog keypair) and pastes the public half in:
# python catalog_console.py keygen --release # python catalog_console.py keygen --release
# This is intentionally NOT pre-populated with a placeholder that looks # Rotated 2026-07 (issue #68 finding 5 / #68 CI-exposure incident): the
# like a real key -- release.yml's signing-smoke-test fails closed (loudly) # original key was shared with the catalog key and had been exposed to CI,
# on an empty list rather than silently verifying against nothing. # so both keypairs were regenerated as separate, disjoint keys. This list
RELEASE_PUBKEYS: list[bytes] = [] # 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 CHUNK_SIZE = 1024 * 1024
+92
View File
@@ -491,6 +491,98 @@ def test_sign_precondition_defaults_to_main_when_loaded_ref_unset():
assert decision.ok is True 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 # find_last_signed_catalog_raw: what source=main diffs against
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #