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
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+120
-8
@@ -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,6 +791,22 @@ 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)
|
||||||
|
|
||||||
|
# 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_raw = last_signed_catalog_raw(self.repo_dir, commit)
|
||||||
old_catalog = (
|
old_catalog = (
|
||||||
core.load_catalog(old_raw)
|
core.load_catalog(old_raw)
|
||||||
@@ -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,6 +887,12 @@ 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)
|
||||||
|
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(
|
self.status_label.setText(
|
||||||
f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged."
|
f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged."
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -442,17 +442,42 @@ 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:
|
||||||
|
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)
|
self.changes = diff_catalogs(self.old_catalog, self.new_catalog)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user