Compare commits
46
Commits
86139100eb
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73b49f17c | ||
|
|
451e04cd96 | ||
|
|
675490e3c3 | ||
|
|
3768113938 | ||
|
|
583e4e4af0 | ||
|
|
38e9cf2b26 | ||
|
|
d4ce2647ae | ||
|
|
ece8c99f79 | ||
|
|
66b0101dea | ||
|
|
c5a6bdd1d1 | ||
|
|
ded1eef2dd | ||
|
|
cdda60da1b | ||
|
|
0920846c2c | ||
|
|
ce82f7b5c3 | ||
|
|
d2c126a60c | ||
|
|
603d24566d | ||
|
|
0b2827e6b8 | ||
|
|
2e5d0351b4 | ||
|
|
2cd8e0fb3b | ||
|
|
8c51c25211 | ||
|
|
e087107710 | ||
|
|
436524bf00 | ||
|
|
e542ff6e8f | ||
|
|
dc9e035781 | ||
|
|
7368dcdbff | ||
|
|
694439b6f3 | ||
|
|
4743c4a995 | ||
|
|
8fdbe90b37 | ||
|
|
072a5cdc08 | ||
|
|
0191a93eb9 | ||
|
|
5add9b0ce0 | ||
|
|
57fd3cb6e3 | ||
|
|
4a95b370b9 | ||
|
|
f168079755 | ||
|
|
a73f2e3883 | ||
|
|
7ff4f6e5c0 | ||
|
|
7517e16b15 | ||
|
|
9a0433225e | ||
|
|
fa82d30087 | ||
|
|
05b00a40c0 | ||
|
|
3068e74e5c | ||
|
|
febd617c56 | ||
|
|
da20eb2fdb | ||
|
|
cd2ac2f6f8 | ||
|
|
26c66b7db1 | ||
|
|
82483e693d |
@@ -3,13 +3,16 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ["**/*.md"]
|
||||
pull_request:
|
||||
paths-ignore: ["**/*.md"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
name: Lint (ruff)
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -31,6 +34,11 @@ jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
|
||||
# Guards against a job that hangs mid-run (e.g. a wedged test). Note: this
|
||||
# counts from when a runner PICKS UP the job, so it does not rescue a job
|
||||
# stuck "Waiting to run" because the self-hosted Windows runner is offline —
|
||||
# for that, bring the runner back or skip via paths-ignore (docs).
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -85,6 +93,7 @@ jobs:
|
||||
catalog-signature:
|
||||
name: Catalog signature
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -101,9 +101,20 @@ jobs:
|
||||
# signing step — which means a wrong/missing RELEASE_SIGNING_KEY secret
|
||||
# would only be discovered at the worst possible moment: during a real
|
||||
# release. This job signs a throwaway manifest with the secret and verifies
|
||||
# the result against the PUBLIC key already compiled into bcc_core.
|
||||
# the result against scripts/sign_checksums.RELEASE_PUBKEYS.
|
||||
#
|
||||
# It proves the two halves of the keypair actually match, without
|
||||
# IMPORTANT (issue #68 finding 5): this must verify against the RELEASE
|
||||
# public key, never bcc_core.CATALOG_PUBKEYS. The catalog key is the
|
||||
# offline, maintainer-held root of trust for what BCC executes; it must
|
||||
# NEVER be compared against a value that lives in a CI secret, because
|
||||
# that comparison is itself a way to smuggle a catalog-trusted key through
|
||||
# CI review ("does this repo secret match the catalog key" is a question
|
||||
# this workflow must never even ask). The release key is a SEPARATE
|
||||
# keypair, generated via `catalog_console.py keygen --release`, that only
|
||||
# ever signs release SHA256SUMS manifests -- a CI/secret compromise burns
|
||||
# this key, not the catalog key.
|
||||
#
|
||||
# It proves the two halves of the RELEASE keypair actually match, without
|
||||
# publishing anything. Run it from the Actions tab after setting or
|
||||
# rotating the secret.
|
||||
signing-smoke-test:
|
||||
@@ -120,42 +131,54 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install cryptography
|
||||
|
||||
- name: Sign a throwaway manifest and verify against the shipped pubkey
|
||||
- name: Sign a throwaway manifest and verify against the RELEASE pubkey
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_SIGNING_KEY" ]; then
|
||||
echo "FAIL: RELEASE_SIGNING_KEY secret is not set."
|
||||
echo "Generate it with: python catalog_console.py show-seed-b64"
|
||||
echo "then add it under Settings -> Actions -> Secrets."
|
||||
echo "Generate the RELEASE key (NOT the catalog key) with:"
|
||||
echo " python catalog_console.py keygen --release"
|
||||
echo "then add its seed under Settings -> Actions -> Secrets, via:"
|
||||
echo " python catalog_console.py show-seed-b64 --release"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p smoke && echo "smoke test payload" > smoke/hello.txt
|
||||
python3 scripts/sign_checksums.py generate smoke --out smoke/SHA256SUMS
|
||||
python3 scripts/sign_checksums.py sign --sums smoke/SHA256SUMS --out smoke/SHA256SUMS.sig
|
||||
python - <<'PY'
|
||||
import base64, pathlib, sys
|
||||
import bcc_core as c
|
||||
from scripts.sign_checksums import verify_checksums
|
||||
import pathlib, sys
|
||||
from scripts.sign_checksums import RELEASE_PUBKEYS, verify_checksums_against_any
|
||||
|
||||
# The public half that ships inside the binary. If the secret is a
|
||||
# DIFFERENT key than the one users' copies trust, this fails here --
|
||||
# which is the entire point of the job.
|
||||
pub_b64 = base64.b64encode(c.CATALOG_PUBKEYS[0]).decode()
|
||||
# Deliberately does NOT import bcc_core / CATALOG_PUBKEYS at all --
|
||||
# this smoke test must never be able to compare the CI secret
|
||||
# against the catalog's root of trust (issue #68 finding 5). Only
|
||||
# RELEASE_PUBKEYS (scripts/sign_checksums.py) is a legitimate
|
||||
# target for a CI-resident key.
|
||||
if not RELEASE_PUBKEYS:
|
||||
sys.exit(
|
||||
"FAIL: scripts/sign_checksums.RELEASE_PUBKEYS is empty.\n"
|
||||
"\n"
|
||||
"Generate the release keypair with:\n"
|
||||
" python catalog_console.py keygen --release\n"
|
||||
"then paste the printed public key into RELEASE_PUBKEYS in\n"
|
||||
"scripts/sign_checksums.py and commit that change."
|
||||
)
|
||||
|
||||
sums = pathlib.Path("smoke/SHA256SUMS").read_text()
|
||||
sig = pathlib.Path("smoke/SHA256SUMS.sig").read_bytes()
|
||||
|
||||
if not verify_checksums(pub_b64, sums, sig):
|
||||
if not verify_checksums_against_any(RELEASE_PUBKEYS, sums, sig):
|
||||
sys.exit(
|
||||
"FAIL: the signature produced by RELEASE_SIGNING_KEY does NOT verify\n"
|
||||
"against the public key in bcc_core.CATALOG_PUBKEYS.\n"
|
||||
"against any key in scripts/sign_checksums.RELEASE_PUBKEYS.\n"
|
||||
"\n"
|
||||
"The secret and the shipped public key are different keypairs. Users\n"
|
||||
"would reject every signature this CI produces. Re-copy the seed from\n"
|
||||
"`catalog_console.py show-seed-b64`, or update CATALOG_PUBKEYS."
|
||||
"The secret and the shipped release public key are different keypairs.\n"
|
||||
"Downloaders would reject every signature this CI produces. Re-copy the\n"
|
||||
"seed from `catalog_console.py show-seed-b64 --release`, or update\n"
|
||||
"RELEASE_PUBKEYS with the matching public key."
|
||||
)
|
||||
print("OK: RELEASE_SIGNING_KEY matches the public key shipped in bcc_core.")
|
||||
print("OK: RELEASE_SIGNING_KEY matches a key in RELEASE_PUBKEYS.")
|
||||
PY
|
||||
|
||||
# ── Create GitHub Release with all three artifacts ──────────────────────
|
||||
@@ -206,9 +229,13 @@ jobs:
|
||||
# checks. It does NOT remove Gatekeeper/SmartScreen warnings.
|
||||
#
|
||||
# The private key is a repo secret (RELEASE_SIGNING_KEY, base64 raw
|
||||
# Ed25519 seed) generated via the Catalog Console (#62). If it's not
|
||||
# set, we still publish the release — just without a .sig — rather
|
||||
# than fail the release outright.
|
||||
# Ed25519 seed) for the RELEASE key -- a SEPARATE keypair from the
|
||||
# catalog key, generated via `python catalog_console.py keygen
|
||||
# --release` (issue #68 finding 5; #62). This key is intentionally
|
||||
# CI-resident and signs ONLY this checksum manifest; it is never
|
||||
# trusted to sign data/catalog.json. If it's not set, we still
|
||||
# publish the release — just without a .sig — rather than fail the
|
||||
# release outright.
|
||||
- name: Check for signing key
|
||||
id: signing
|
||||
run: |
|
||||
@@ -234,7 +261,7 @@ jobs:
|
||||
- name: Warn — release will be unsigned
|
||||
if: steps.signing.outputs.has_key != 'true'
|
||||
run: |
|
||||
echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Add the secret (base64 raw Ed25519 seed, generated via the Catalog Console, #62) before the next tag."
|
||||
echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Generate the RELEASE key (python catalog_console.py keygen --release) and add its seed (python catalog_console.py show-seed-b64 --release) as this secret before the next tag."
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **BetterClaudeConfig** are recorded here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/), and this project
|
||||
follows [Semantic Versioning](https://semver.org/): breaking changes bump the
|
||||
major, new features bump the minor, fixes bump the patch.
|
||||
|
||||
**When you open a PR, add a line under `[Unreleased]`.** At release time, that
|
||||
section is renamed to the new version + date and a fresh `[Unreleased]` is
|
||||
started.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0] — 2026-08-13
|
||||
|
||||
> ⚠️ **Using the `ssh-mcp` server?** Upstream `ssh-mcp` shipped a **breaking
|
||||
> v2**: it removed the `--password`, `--sudoPassword`, `--suPassword` and
|
||||
> `--disableSudo` command-line flags, and it now reads a `config.toml` sidecar
|
||||
> file that overrides your command-line arguments. A config written for v1
|
||||
> crashes on v2's startup. This release adds tools to detect and fix that.
|
||||
> **BetterClaudeConfig itself has no breaking changes** — your existing configs
|
||||
> keep working; nothing is changed without your click.
|
||||
|
||||
### Added
|
||||
- **ssh-mcp v2 flag migration.** Detects the credential flags v2 removed and
|
||||
one-click-moves each into the environment variable ssh-mcp now reads
|
||||
(`--password` → `SSH_MCP_PASSWORD`; `--sudoPassword` / `--suPassword` →
|
||||
`SSH_MCP_SUDO_PASSWORD`). Also flags `--maxChars=none`, whose meaning changed
|
||||
between versions.
|
||||
- **Sidecar-config awareness.** When a server reads a separate config file
|
||||
(ssh-mcp's `config.toml`), BCC shows where that file actually lives on your
|
||||
platform and warns when your command-line args are **inert** because the file
|
||||
takes precedence — including when a file sits at the wrong, documented-but-
|
||||
unused path.
|
||||
- **In-app sidecar editor.** Edit that external config from inside BCC —
|
||||
pick-lists for known fields, a raw-text fallback — written atomically with a
|
||||
timestamped backup and locked to `0600`. No dropping to a terminal.
|
||||
- **Live hot-reload.** External changes to a sidecar (or to your Claude config)
|
||||
now surface without restarting BCC.
|
||||
- **Version pinning for `npx` servers.** Spots servers launched with `-y` /
|
||||
`@latest` that resolve a fresh version every run, shows the version currently
|
||||
resolved, and offers a one-click **Pin to this version** — with a drift note
|
||||
when a pin has fallen behind.
|
||||
- **Permission pre-flight.** Warns when a credential-bearing config is readable
|
||||
by other users on the machine and offers a one-click fix to `0600`/`0700`.
|
||||
- **Light theme + system-following** (the dark theme is preserved exactly).
|
||||
- **"Move to environment variable."** Convert a plaintext secret in a config
|
||||
into a `${VAR}` reference in place — offered only on clients that actually
|
||||
expand references, so it can't silently break a Claude Desktop config.
|
||||
- **Cross-client foundation.** Claude Desktop and Claude Code now flow through a
|
||||
single adapter — groundwork for supporting more clients.
|
||||
|
||||
### Changed
|
||||
- The update checker is now **visible** — a persistent banner plus a Help-menu
|
||||
item — instead of being buried in the About dialog.
|
||||
- Config writes share one atomic-write path; the temp file is created `0600`, so
|
||||
a secret is never briefly world-readable mid-write.
|
||||
|
||||
### Fixed
|
||||
- Loading a config with a non-object server value no longer crashes, and named
|
||||
server sets survive an external-change merge.
|
||||
- Project profiles that share a directory basename are disambiguated, so you
|
||||
can't accidentally edit the wrong `.mcp.json`.
|
||||
|
||||
### Internal / maintainer
|
||||
- Signed-catalog core and a maintainer-only **Catalog Console** (review + sign),
|
||||
with hardening of the review gate and a split of the signing keys. No
|
||||
user-facing catalog browser ships yet.
|
||||
|
||||
## [1.3.0] — 2026-07-12
|
||||
Named server sets, Claude Code project `.mcp.json` discovery, structural schema
|
||||
lint, and UX polish (Ctrl+S to save, enable-all / disable-all).
|
||||
|
||||
## [1.2.1] — 2026-07-12
|
||||
First shipped binaries: app-icon fix, a batch of audit fixes, and Windows
|
||||
process-tree cleanup on spawn-tests.
|
||||
|
||||
## [1.2.0] — 2026-07-08
|
||||
Server log viewer, duplicate-name conflict handling, a Restart-Claude button,
|
||||
stale-file protection, an About dialog, and a notify-only update checker.
|
||||
|
||||
## [1.1.0] — 2026-07-04
|
||||
Backup / restore UI and secret masking.
|
||||
|
||||
## [1.0.1] — 2026-06-29
|
||||
## [1.0.0] — 2026-06-29
|
||||
Initial releases: the core `mcpServers` editor with the lenient paste/repair
|
||||
pipeline that tolerates malformed JSON.
|
||||
|
||||
<!-- Backfill for 1.0.0–1.3.0 is summarised from release notes; the Unreleased
|
||||
section onward is maintained per-PR. -->
|
||||
@@ -31,6 +31,8 @@ The codebase is split into two layers:
|
||||
|
||||
**`bcc_core.py`** — All logic with no GUI imports. Contains:
|
||||
- `Profile` / `ServerEntry` dataclasses (the data model)
|
||||
- `ClientSpec` (issue #5, cross-client) — one adapter object per MCP host capturing everything client-specific: the top-level `servers_key` (Claude uses `mcpServers`; VS Code will use `servers`), the parking `disabled_key`, config `config_filename`, the capability flags (`expands_env_refs`, `supports_restart`), and a per-server `entry_to_internal`/`entry_from_internal` translation pair (identity for Claude; the seam a differently-shaped client overrides). `CLAUDE_DESKTOP` and `CLAUDE_CODE` are the two shipped specs; `resolve_client(path)` picks one by filename, and each `Profile` carries its resolved `client`. The read/write/diff functions take an optional `spec` and default to Claude's layout, so a call with no spec is unchanged.
|
||||
|
||||
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config…
|
||||
- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
|
||||
- `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
|
||||
@@ -43,7 +45,7 @@ The codebase is split into two layers:
|
||||
- `KeyValueTable` — reusable widget for env vars and headers
|
||||
- `ConnTester(QThread)` — background thread for remote reachability tests
|
||||
|
||||
**The cardinal rule**: `apply_servers()` only ever writes to `mcpServers` and `_disabledMcpServers`. All other keys in the user's config are preserved verbatim and in their original order.
|
||||
**The cardinal rule**: `apply_servers()` only ever writes the two keys the target client's servers live under — by default `mcpServers` and `_disabledMcpServers`, or whatever the profile's `ClientSpec` declares (`servers_key` + `disabled_key`). All other keys in the user's config are preserved verbatim and in their original order. The rule generalises across clients precisely because it is parameterised by the spec rather than hard-coded.
|
||||
|
||||
Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition.
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# PR #87 — "Move to environment variable" (#83): manual test checklist
|
||||
|
||||
The logic is covered by 15 unit tests in CI; what CI **can't** exercise is the GUI (no PySide6). This checklist is only the parts a human needs to click. Should take ~10 minutes.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd ~/Documents/Claude/Projects/BetterClaudeConfig/better-claude-config
|
||||
git fetch origin
|
||||
git checkout feat/83-move-to-env-var
|
||||
git pull # ensure you're on 8fdbe90 or later
|
||||
source .venv/bin/activate # or recreate: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
|
||||
python bcc.py
|
||||
```
|
||||
|
||||
Pick a **Claude Code** profile (e.g. `~/.claude.json`) that has, or add, a server with an env value that looks like a secret — e.g. `env: { "API_KEY": "ghp_test123" }`. (You can use a throwaway value; nothing is sent anywhere.)
|
||||
|
||||
## The checklist
|
||||
|
||||
### Gating — where the action appears
|
||||
- [ ] Right-click the **value cell** of a secret env row (`API_KEY`) on a **Claude Code** profile → a **"Move to environment variable…"** item appears.
|
||||
- [ ] Right-click a **non-secret** row (e.g. `REGION` = `us-east-1`) → the item does **not** appear.
|
||||
- [ ] Right-click a row whose value is already a reference (`${API_KEY}`) → the item does **not** appear.
|
||||
- [ ] Switch to a **Claude Desktop** profile (a `claude_desktop_config.json`), right-click the same kind of secret row → the item does **not** appear. (Desktop doesn't expand `${VAR}`, so offering it would break the config — this is the important gate.)
|
||||
|
||||
### The dialog
|
||||
- [ ] Trigger the action → dialog opens with **Variable** pre-filled from the key, sanitized to a legal shell name (e.g. `api-key` → `API_KEY`).
|
||||
- [ ] Edit the variable name → the shown **shell line updates live** and matches your platform (`export VAR='…'` on macOS/Linux, `setx VAR "…"` on Windows), with the other platform shown in parentheses.
|
||||
- [ ] If you type a variable name that **is already set** in your shell environment, the green "already looks set" note appears; if not, it's hidden.
|
||||
- [ ] **Cancel** → nothing changes (value still the raw secret, no dirty state).
|
||||
|
||||
### The conversion
|
||||
- [ ] **Move && copy secret** → the cell now shows the reference `${VAR}` (visible, **not** masked to dots), and the window goes dirty (Save enabled).
|
||||
- [ ] Paste from your clipboard somewhere → it's the **original secret value** (handed back before removal).
|
||||
- [ ] The reference value is **not** flagged as a secret warning anymore (it's the recommended state).
|
||||
|
||||
### Headers + persistence
|
||||
- [ ] Repeat on a **remote server's Headers** table (e.g. an `Authorization` header) → same behavior.
|
||||
- [ ] **Save**, then open the config file on disk in a text editor → the servers block holds `${VAR}`, and the **plaintext secret is gone** from the file.
|
||||
- [ ] Re-open the profile in BCC → the row still shows `${VAR}` (round-trips).
|
||||
|
||||
### Undo (nice-to-have)
|
||||
- [ ] After a conversion, **Ctrl+Z / Cmd+Z** restores the previous value.
|
||||
|
||||
## Known scope (not bugs)
|
||||
- **Args rows** are out of scope for this PR — the core supports them, but the args editor is a free-text widget, so wiring that UI is a deliberate follow-up. Right-clicking args won't offer the action yet.
|
||||
- The "already set" check reads **BCC's** environment, which may differ from the client's — it's advisory, worded that way.
|
||||
|
||||
## If anything's off
|
||||
Tell me which checkbox failed and what you saw; I'll fix on the branch and re-push. If everything passes, approve/merge #87 (or tell me to merge it).
|
||||
@@ -36,11 +36,10 @@ are only suppressed by a paid OS-vendor certificate, which this project
|
||||
doesn't have. Verifying checksums is about detecting tampering in transit or
|
||||
on a mirror, not about vouching for the software.
|
||||
|
||||
**Release signing public key** (Ed25519, base64, raw 32 bytes):
|
||||
|
||||
```
|
||||
<PLACEHOLDER — AJ: paste the public key from the Catalog Console (#62) here>
|
||||
```
|
||||
This manifest is signed with BCC's **release key**, which is a different
|
||||
key from the one that signs the MCP server catalog — see
|
||||
[Signing keys](#signing-keys) below for why, and for the public key value
|
||||
to use with `--pubkey-b64` below.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -59,7 +58,7 @@ To also verify the manifest's signature (optional, requires Python +
|
||||
```bash
|
||||
python3 scripts/sign_checksums.py verify \
|
||||
--sums SHA256SUMS --sig SHA256SUMS.sig \
|
||||
--pubkey-b64 "<the public key above>"
|
||||
--pubkey-b64 "<the release public key from Signing keys, below>"
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
@@ -78,6 +77,45 @@ release is missing the `.sig` file, the checksums themselves are still
|
||||
valid and safe to check against — the release workflow only skips signing,
|
||||
never checksum generation.
|
||||
|
||||
## Signing keys
|
||||
|
||||
BCC uses **two separate Ed25519 keypairs**, deliberately never the same
|
||||
key, because they protect different things and live in different places:
|
||||
|
||||
| | Catalog key | Release key |
|
||||
|---|---|---|
|
||||
| Signs | `data/catalog.json` (the MCP server catalog every user's app trusts) | `SHA256SUMS` (the checksum manifest for release binaries) |
|
||||
| Verified by | `bcc_core.CATALOG_PUBKEYS` | `scripts/sign_checksums.RELEASE_PUBKEYS` |
|
||||
| Lives | Offline, passphrase-encrypted, maintainer's machine only (OS keychain or an encrypted file outside the repo — see the [Catalog Console](#files), issue #62) | A Gitea Actions repo secret, `RELEASE_SIGNING_KEY` — **intentionally CI-resident** |
|
||||
| Generated with | `python catalog_console.py keygen` | `python catalog_console.py keygen --release` |
|
||||
| Exported for CI with | *(never — there is no supported way to export this key)* | `python catalog_console.py show-seed-b64 --release` |
|
||||
|
||||
**Why two keys:** the catalog key is the root of trust for what BCC
|
||||
actually *executes* on a user's machine — every `command`/`args` pair in
|
||||
the shipped catalog is only there because this key signed it. If that key
|
||||
and the release-checksum key were the same (as they briefly were — see
|
||||
[issue #68](../../issues/68)), then anything that can exfiltrate a Gitea
|
||||
Actions secret (a malicious workflow-file PR, a compromised runner, a leaky
|
||||
log) could sign a catalog every user's copy of BCC would trust, not just a
|
||||
checksum manifest. Splitting them means **a CI/secret compromise burns the
|
||||
release key, never the catalog key** — checksums for a future release could
|
||||
be forged, which is bad, but no attacker gains the ability to make BCC run
|
||||
arbitrary commands on installs that trust the catalog. That asymmetry is
|
||||
the entire point of having two keys instead of one.
|
||||
|
||||
The catalog key is **never** meant to leave the maintainer's machine: it's
|
||||
generated, stored, unlocked, and used to sign entirely inside the Catalog
|
||||
Console (`catalog_console.py`), and `catalog_console.py show-seed-b64`
|
||||
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:
|
||||
|
||||
```
|
||||
<PLACEHOLDER — AJ: paste the release public key from `catalog_console.py keygen --release` here>
|
||||
```
|
||||
|
||||
## Run from source
|
||||
|
||||
```bash
|
||||
@@ -152,6 +190,7 @@ file is also listed, marked *legacy*, so you can copy them over.
|
||||
- `bcc.spec` — PyInstaller build spec (cross-platform).
|
||||
- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
|
||||
- `scripts/sign_checksums.py` — generates and Ed25519-signs the release `SHA256SUMS` manifest (see [Verifying your download](#verifying-your-download)).
|
||||
- `catalog_console.py` / `catalog_review.py` — **maintainer-only**, never shipped to users (excluded from `bcc.spec`; see `tests/test_catalog_console_packaging.py`). The Catalog Console: review + sign `data/catalog.json`, and generate/manage both signing keys (`keygen`, `keygen --release`) — see [Signing keys](#signing-keys).
|
||||
|
||||
## Building from source
|
||||
|
||||
|
||||
+2315
-53
File diff suppressed because it is too large
Load Diff
+262
-61
@@ -12,7 +12,12 @@ Flow: Load -> Review -> Sign.
|
||||
1. Load -- pick a source: an open Gitea PR touching data/catalog.json,
|
||||
or the current tip of `main`. The Console fetches the exact
|
||||
git blob (via a local clone's git plumbing) and PINS its
|
||||
blob SHA for the rest of this review pass.
|
||||
blob SHA *and the ref it came from* for the rest of this
|
||||
review pass. For a PR, the diff is against `main`; for
|
||||
`main`, the diff is against the last catalog a maintainer
|
||||
actually SIGNED (the bytes covered by the current
|
||||
data/catalog.json.sig), never against itself -- an empty
|
||||
diff must mean "nothing to sign", never "sign unlocked".
|
||||
2. Review -- a semantic diff (catalog_review.diff_catalogs), one card per
|
||||
changed entry, with risk annotations
|
||||
(catalog_review.entry_risk_findings). A registry lookup for
|
||||
@@ -25,20 +30,34 @@ Flow: Load -> Review -> Sign.
|
||||
changed entry must be individually acknowledged (its
|
||||
checkbox ticked) before Sign unlocks. There is no
|
||||
"acknowledge all" -- see catalog_review.py.
|
||||
3. Sign -- re-fetches the current blob SHA and refuses to sign unless
|
||||
it still matches the pinned SHA from step 1 (TOCTOU fix:
|
||||
catalog_review.can_sign). On success, writes
|
||||
3. Sign -- re-resolves the current blob SHA from the SAME ref that was
|
||||
reviewed (never a hardcoded "main") and refuses to sign
|
||||
unless it still matches the pinned SHA from step 1, the diff
|
||||
is non-empty, and no changed entry has an outstanding
|
||||
blocking risk finding (catalog_review.sign_precondition /
|
||||
can_sign -- the TOCTOU fix). On success, writes
|
||||
data/catalog.json + data/catalog.json.sig and commits BOTH
|
||||
in a single commit, then pushes -- so main is never red
|
||||
between a catalog merge and its signature.
|
||||
between a catalog merge and its signature. Signing uses the
|
||||
CATALOG key ONLY -- see "Two signing keys" below.
|
||||
|
||||
The signature must be the artefact of an actual review, not a step that
|
||||
follows one. Signing IS the approval act.
|
||||
|
||||
Two signing keys (issue #68 finding 5): the CATALOG key (offline,
|
||||
Console-only, `keygen` / `keygen --release` picks which) is the root of
|
||||
trust for what BCC executes and must never touch CI. The RELEASE key is
|
||||
CI-resident and signs ONLY the release SHA256SUMS manifest
|
||||
(scripts/sign_checksums.py) -- `show-seed-b64 --release` is the only
|
||||
supported way to get a seed out of this tool, and it refuses to run without
|
||||
`--release` so the catalog seed can never be exported by habit. See the
|
||||
README's "Signing keys" section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
import getpass
|
||||
import html
|
||||
@@ -69,8 +88,28 @@ SIG_PATH = "data/catalog.json.sig"
|
||||
# maintainer-only tool, so a dotfile under $HOME is an acceptable fallback
|
||||
# when the OS keychain isn't available -- the blob stored there is always
|
||||
# passphrase-encrypted (see catalog_review.encrypt_private_key), never raw.
|
||||
#
|
||||
# Two SEPARATE keys are stored here, never conflated (issue #68 finding 5):
|
||||
# "catalog" -- offline, Console-only. Verified by bcc_core.CATALOG_PUBKEYS.
|
||||
# Roots of trust for every catalog entry BCC ships. Must
|
||||
# NEVER leave this machine, never touch CI, never become an
|
||||
# env var or a repo secret.
|
||||
# "release" -- CI-resident. Verified by scripts.sign_checksums.
|
||||
# RELEASE_PUBKEYS. Signs ONLY the release SHA256SUMS
|
||||
# manifest. Its private seed is deliberately meant to be
|
||||
# pasted into the RELEASE_SIGNING_KEY Gitea Actions secret
|
||||
# (via `show-seed-b64 --release`) -- that is its normal,
|
||||
# intended flow. A CI compromise burns this key, not the
|
||||
# catalog key: that asymmetry is the whole point of having
|
||||
# two keys instead of one.
|
||||
KEY_STORAGE_DIR = Path.home() / ".bcc-catalog-console"
|
||||
KEY_STORAGE_FILE = KEY_STORAGE_DIR / "signing_key.enc"
|
||||
_KEY_KINDS = ("catalog", "release")
|
||||
|
||||
|
||||
def _key_storage_file(kind: str) -> Path:
|
||||
assert kind in _KEY_KINDS, f"unknown key kind {kind!r}, expected one of {_KEY_KINDS}"
|
||||
return KEY_STORAGE_DIR / f"signing_key_{kind}.enc"
|
||||
|
||||
|
||||
HTTP_TIMEOUT = 6.0
|
||||
|
||||
@@ -97,51 +136,64 @@ def _keyring_module():
|
||||
|
||||
|
||||
_KEYRING_SERVICE = "bcc-catalog-console"
|
||||
_KEYRING_USERNAME = "signing-key"
|
||||
|
||||
|
||||
def store_encrypted_key(blob: bytes) -> str:
|
||||
def _keyring_username(kind: str) -> str:
|
||||
assert kind in _KEY_KINDS, f"unknown key kind {kind!r}, expected one of {_KEY_KINDS}"
|
||||
return f"signing-key-{kind}"
|
||||
|
||||
|
||||
def store_encrypted_key(blob: bytes, kind: str = "catalog") -> str:
|
||||
"""Persist an already-encrypted key blob (see
|
||||
catalog_review.encrypt_private_key). Prefers the OS keychain; falls back
|
||||
to a file under KEY_STORAGE_DIR (outside the repo) with restrictive
|
||||
catalog_review.encrypt_private_key) under the given `kind`
|
||||
("catalog" or "release" -- see the KEY_STORAGE_DIR comment above; the
|
||||
two are stored under different keychain entries / filenames so they can
|
||||
never be loaded interchangeably). Prefers the OS keychain; falls back to
|
||||
a file under KEY_STORAGE_DIR (outside the repo) with restrictive
|
||||
permissions. Returns a human-readable description of where it went."""
|
||||
keyring = _keyring_module()
|
||||
if keyring is not None:
|
||||
try:
|
||||
keyring.set_password(_KEYRING_SERVICE, _KEYRING_USERNAME, blob.hex())
|
||||
keyring.set_password(_KEYRING_SERVICE, _keyring_username(kind), blob.hex())
|
||||
return "OS keychain (via the `keyring` package)"
|
||||
except Exception:
|
||||
pass # fall through to the file-based path
|
||||
KEY_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
KEY_STORAGE_FILE.write_bytes(blob)
|
||||
key_file = _key_storage_file(kind)
|
||||
key_file.write_bytes(blob)
|
||||
with contextlib.suppress(OSError): # best-effort on platforms without POSIX perm bits
|
||||
KEY_STORAGE_FILE.chmod(0o600)
|
||||
return f"encrypted file at {KEY_STORAGE_FILE}"
|
||||
key_file.chmod(0o600)
|
||||
return f"encrypted file at {key_file}"
|
||||
|
||||
|
||||
def load_encrypted_key() -> bytes:
|
||||
"""Load the encrypted key blob from wherever store_encrypted_key() put
|
||||
it. Raises FileNotFoundError if no key has been generated yet."""
|
||||
def load_encrypted_key(kind: str = "catalog") -> bytes:
|
||||
"""Load the encrypted key blob of the given `kind` from wherever
|
||||
store_encrypted_key() put it. Raises FileNotFoundError if no key of that
|
||||
kind has been generated yet."""
|
||||
keyring = _keyring_module()
|
||||
if keyring is not None:
|
||||
try:
|
||||
hex_blob = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
|
||||
hex_blob = keyring.get_password(_KEYRING_SERVICE, _keyring_username(kind))
|
||||
if hex_blob:
|
||||
return bytes.fromhex(hex_blob)
|
||||
except Exception:
|
||||
pass
|
||||
if not KEY_STORAGE_FILE.exists():
|
||||
key_file = _key_storage_file(kind)
|
||||
if not key_file.exists():
|
||||
flag = " --release" if kind == "release" else ""
|
||||
raise FileNotFoundError(
|
||||
f"No signing key found (checked the OS keychain and {KEY_STORAGE_FILE}). "
|
||||
"Run `python catalog_console.py keygen` first."
|
||||
f"No {kind} signing key found (checked the OS keychain and {key_file}). "
|
||||
f"Run `python catalog_console.py keygen{flag}` first."
|
||||
)
|
||||
return KEY_STORAGE_FILE.read_bytes()
|
||||
return key_file.read_bytes()
|
||||
|
||||
|
||||
def unlock_signing_key(passphrase: str) -> bytes:
|
||||
"""Load + decrypt the signing key seed. Raises ValueError on a wrong
|
||||
passphrase, FileNotFoundError if no key exists yet."""
|
||||
blob = load_encrypted_key()
|
||||
def unlock_signing_key(passphrase: str, kind: str = "catalog") -> bytes:
|
||||
"""Load + decrypt the signing key seed of the given `kind`. Raises
|
||||
ValueError on a wrong passphrase, FileNotFoundError if no key of that
|
||||
kind exists yet. Defaults to "catalog" because that's the key
|
||||
ReviewWindow._on_sign uses -- the GUI never touches the release key."""
|
||||
blob = load_encrypted_key(kind)
|
||||
return review.decrypt_private_key(blob, passphrase)
|
||||
|
||||
|
||||
@@ -188,6 +240,49 @@ def read_catalog_at_commit(repo_dir: Path, commit: str) -> tuple[bytes, str]:
|
||||
return blob_bytes(repo_dir, sha), sha
|
||||
|
||||
|
||||
def catalog_blob_history(repo_dir: Path, ref: str, limit: int = 200) -> list[str]:
|
||||
"""Blob SHAs of CATALOG_PATH at each commit that touched it, walking
|
||||
back from `ref`, most-recent-first. Used by last_signed_catalog_raw() to
|
||||
find "the version of the catalog the current signature actually covers"
|
||||
without assuming it's the tip commit."""
|
||||
log_out = _git(repo_dir, "log", f"--max-count={limit}", "--format=%H", ref, "--", CATALOG_PATH)
|
||||
commits = [line for line in log_out.splitlines() if line]
|
||||
shas: list[str] = []
|
||||
for commit in commits:
|
||||
try:
|
||||
shas.append(blob_sha_at(repo_dir, commit, CATALOG_PATH))
|
||||
except GitError:
|
||||
continue
|
||||
return shas
|
||||
|
||||
|
||||
def last_signed_catalog_raw(repo_dir: Path, commit: str) -> bytes | None:
|
||||
"""The raw data/catalog.json bytes that verify against
|
||||
data/catalog.json.sig as of `commit` -- i.e. "the last catalog a
|
||||
maintainer actually signed", found by walking the catalog's git history
|
||||
on that ref until a version verifies against the *current* signature.
|
||||
|
||||
This is what source="main (current tip)" diffs against (issue #68
|
||||
finding 1): if main's tip catalog.json already matches its .sig, this
|
||||
returns that same content and the diff is correctly empty (nothing new
|
||||
to sign). If someone merged a catalog change to main without running it
|
||||
through the Console -- the exact bypass that produced commit b08cf21 --
|
||||
the signature still covers the OLDER content, so this returns that older
|
||||
version and the diff surfaces exactly what was never actually reviewed.
|
||||
|
||||
Returns None if there's no signature yet, or none of the recent history
|
||||
verifies against it (caller should treat this as "diff against nothing
|
||||
ever signed", i.e. every current entry shows as newly added).
|
||||
"""
|
||||
try:
|
||||
sig_sha = blob_sha_at(repo_dir, commit, SIG_PATH)
|
||||
except GitError:
|
||||
return None
|
||||
sig_bytes = blob_bytes(repo_dir, sig_sha)
|
||||
candidates = [blob_bytes(repo_dir, sha) for sha in catalog_blob_history(repo_dir, commit)]
|
||||
return review.find_last_signed_catalog_raw(candidates, sig_bytes, core.CATALOG_PUBKEYS)
|
||||
|
||||
|
||||
def commit_and_push_signed_catalog(
|
||||
repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main"
|
||||
) -> str:
|
||||
@@ -644,28 +739,50 @@ class ReviewWindow(QMainWindow):
|
||||
row = self.source_list.currentRow()
|
||||
try:
|
||||
if row <= 0:
|
||||
commit = fetch_ref(self.repo_dir, "main")
|
||||
old_commit = None # main vs itself has no "old" -- nothing to diff without a base
|
||||
# source = main: diff against the last catalog a maintainer
|
||||
# actually SIGNED (the bytes covered by the current
|
||||
# data/catalog.json.sig), never against itself. Diffing
|
||||
# main-vs-main is what made an empty diff -> instantly
|
||||
# "signable" in the first place (issue #68 finding 1) --
|
||||
# this is the only path that reaches sign_catalog_bytes(),
|
||||
# so if it can't be trusted nothing can.
|
||||
loaded_ref = "main"
|
||||
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": [],
|
||||
}
|
||||
)
|
||||
else:
|
||||
pr = self._prs[row - 1]
|
||||
commit = fetch_ref(self.repo_dir, pr.head_ref)
|
||||
loaded_ref = pr.head_ref
|
||||
commit = fetch_ref(self.repo_dir, loaded_ref)
|
||||
old_commit = fetch_ref(self.repo_dir, "main")
|
||||
|
||||
new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit)
|
||||
new_catalog = core.load_catalog(new_raw)
|
||||
|
||||
if old_commit:
|
||||
new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit)
|
||||
new_catalog = core.load_catalog(new_raw)
|
||||
old_raw, _old_sha = read_catalog_at_commit(self.repo_dir, old_commit)
|
||||
old_catalog = core.load_catalog(old_raw)
|
||||
else:
|
||||
old_catalog = new_catalog
|
||||
|
||||
except (GitError, ValueError) as e:
|
||||
QMessageBox.critical(self, "Load failed", html.escape(str(e)))
|
||||
return
|
||||
|
||||
self._new_raw = new_raw
|
||||
self.session = review.start_review(new_blob_sha, old_catalog, new_catalog)
|
||||
# `loaded_ref` is pinned into the session (not just this method's
|
||||
# local variable) so _on_sign can re-resolve the TOCTOU blob SHA
|
||||
# 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
|
||||
)
|
||||
self._render_cards()
|
||||
|
||||
def _render_cards(self):
|
||||
@@ -706,20 +823,43 @@ class ReviewWindow(QMainWindow):
|
||||
|
||||
def _on_sign(self):
|
||||
assert self.session is not None
|
||||
|
||||
def _resolve_blob_sha(ref: str) -> str:
|
||||
# Re-fetch fresh, immediately before signing, from the SAME ref
|
||||
# that was reviewed (session.loaded_ref) -- NEVER hardcode
|
||||
# "main" here. Hardcoding "main" is the bug that made the PR
|
||||
# review path unable to sign at all: _on_load() pins the PR
|
||||
# head's blob SHA, so comparing against main's SHA differs by
|
||||
# definition for any PR that actually changes the catalog, and
|
||||
# this refused every PR review permanently (see issue #68
|
||||
# finding 1 and review.sign_precondition's docstring).
|
||||
return blob_sha_at(self.repo_dir, fetch_ref(self.repo_dir, ref), CATALOG_PATH)
|
||||
|
||||
try:
|
||||
current_sha = blob_sha_at(self.repo_dir, fetch_ref(self.repo_dir, "main"), CATALOG_PATH)
|
||||
decision = review.sign_precondition(self.session, _resolve_blob_sha)
|
||||
except GitError as e:
|
||||
QMessageBox.critical(self, "Sign failed", html.escape(str(e)))
|
||||
return
|
||||
|
||||
decision = review.can_sign(self.session, current_sha)
|
||||
if not decision.ok:
|
||||
QMessageBox.warning(self, "Cannot sign", html.escape(decision.reason or ""))
|
||||
if decision.reason and "changed" in decision.reason.lower():
|
||||
self._on_load() # force a re-review against the new bytes
|
||||
# Only the TOCTOU blob-SHA-mismatch reason should trigger a
|
||||
# reload -- "mismatch" appears ONLY in that reason (deliberately
|
||||
# checked instead of the broader "changed", which also matches
|
||||
# "Not every CHANGED entry has been acknowledged yet" and would
|
||||
# wrongly force a reload -- and with it a fresh review.py
|
||||
# ReviewSession -- every time a reviewer pauses partway through
|
||||
# ticking checkboxes).
|
||||
if decision.reason and "mismatch" in decision.reason.lower():
|
||||
# Force a genuine re-review against the new bytes -- this
|
||||
# must call _on_load() (which re-fetches and re-diffs), NOT
|
||||
# re-invoke the same stale resolver, or this becomes the
|
||||
# infinite loop described in issue #68 finding 1: re-pinning
|
||||
# the same wrong SHA forever instead of ever converging.
|
||||
self._on_load()
|
||||
return
|
||||
|
||||
dialog = PassphraseDialog("Enter signing key passphrase:", self)
|
||||
dialog = PassphraseDialog("Enter CATALOG signing key passphrase:", self)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
try:
|
||||
@@ -744,9 +884,17 @@ class ReviewWindow(QMainWindow):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def cmd_keygen(_args: argparse.Namespace) -> int:
|
||||
def cmd_keygen(args: argparse.Namespace) -> int:
|
||||
# Two SEPARATE keypairs, never conflated (issue #68 finding 5): the
|
||||
# catalog key is the offline root of trust for what BCC executes and
|
||||
# must never touch CI; the release key is CI-resident and signs ONLY
|
||||
# the release SHA256SUMS manifest. Which one this run generates is
|
||||
# explicit via --release, and the printed instructions differ sharply
|
||||
# so it's obvious which key is safe to paste into a CI secret (release)
|
||||
# and which one never is (catalog).
|
||||
kind = "release" if getattr(args, "release", False) else "catalog"
|
||||
seed, pubkey = review.generate_keypair()
|
||||
passphrase = getpass.getpass("Choose a passphrase to encrypt the new signing key: ")
|
||||
passphrase = getpass.getpass(f"Choose a passphrase to encrypt the new {kind} signing key: ")
|
||||
confirm = getpass.getpass("Confirm passphrase: ")
|
||||
if passphrase != confirm:
|
||||
print("error: passphrases did not match", file=sys.stderr)
|
||||
@@ -756,31 +904,67 @@ def cmd_keygen(_args: argparse.Namespace) -> int:
|
||||
return 1
|
||||
|
||||
blob = review.encrypt_private_key(seed, passphrase)
|
||||
where = store_encrypted_key(blob)
|
||||
pubkey_b64 = __import__("base64").b64encode(pubkey).decode("ascii")
|
||||
where = store_encrypted_key(blob, kind=kind)
|
||||
pubkey_b64 = base64.b64encode(pubkey).decode("ascii")
|
||||
|
||||
print(f"Private key encrypted and stored in: {where}")
|
||||
print(f"{kind.capitalize()} private key encrypted and stored in: {where}")
|
||||
print()
|
||||
print("Public key (base64, paste into bcc_core.CATALOG_PUBKEYS):")
|
||||
print(f" {pubkey_b64}")
|
||||
print()
|
||||
print(
|
||||
"Also add it as the Gitea repo secret RELEASE_SIGNING_KEY (base64 of the "
|
||||
"32-byte private seed) used by release.yml -- get that value with:"
|
||||
)
|
||||
print(" python catalog_console.py show-seed-b64 # careful: prints the raw key")
|
||||
if kind == "catalog":
|
||||
print(
|
||||
"This is the CATALOG key. It is the root of trust for every catalog "
|
||||
"entry BCC ships -- it must stay offline and Console-only. NEVER paste "
|
||||
"it, its seed, or `show-seed-b64` output into CI, an env var, or a repo "
|
||||
"secret. (If the key currently in bcc_core.CATALOG_PUBKEYS has ever been "
|
||||
"pasted into a CI secret, treat it as burned for catalog use -- generate "
|
||||
"a fresh one with this command and rotate.)"
|
||||
)
|
||||
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):"
|
||||
)
|
||||
print(f" {pubkey_b64}")
|
||||
else:
|
||||
print(
|
||||
"This is the RELEASE key. It signs ONLY the release SHA256SUMS "
|
||||
"manifest in CI -- it is intentionally CI-resident and is NOT trusted "
|
||||
"to sign the catalog (bcc_core.CATALOG_PUBKEYS does not and must not "
|
||||
"contain it)."
|
||||
)
|
||||
print()
|
||||
print("1. Public key (base64) -- 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:"
|
||||
)
|
||||
print(" python catalog_console.py show-seed-b64 --release # prints the raw key")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show_seed_b64(_args: argparse.Namespace) -> int:
|
||||
passphrase = getpass.getpass("Signing key passphrase: ")
|
||||
def cmd_show_seed_b64(args: argparse.Namespace) -> int:
|
||||
# Deliberately requires --release: this command's whole purpose is to
|
||||
# produce a value that gets pasted into a CI secret, and the catalog key
|
||||
# must NEVER be pasted into CI (issue #68 finding 5 -- that is exactly
|
||||
# how the catalog key ended up burned in the first place). Refusing to
|
||||
# run without --release makes "export the catalog seed for CI" a
|
||||
# structurally different, more deliberate action than a typo away.
|
||||
if not getattr(args, "release", False):
|
||||
print(
|
||||
"error: show-seed-b64 only ever exports the RELEASE key -- it is the "
|
||||
"only key allowed to leave this machine, for the RELEASE_SIGNING_KEY CI "
|
||||
"secret. Re-run as `show-seed-b64 --release`. The catalog key must never "
|
||||
"be exported this way; see issue #68 finding 5.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
passphrase = getpass.getpass("Release signing key passphrase: ")
|
||||
try:
|
||||
seed = unlock_signing_key(passphrase)
|
||||
seed = unlock_signing_key(passphrase, kind="release")
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
import base64
|
||||
|
||||
print(base64.b64encode(seed).decode("ascii"))
|
||||
return 0
|
||||
|
||||
@@ -810,11 +994,28 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_gui.add_argument("--repo", default=".", help="path to a BCC git checkout (default: cwd)")
|
||||
p_gui.set_defaults(func=cmd_gui)
|
||||
|
||||
p_keygen = sub.add_parser("keygen", help="generate a new Ed25519 signing keypair")
|
||||
p_keygen = sub.add_parser(
|
||||
"keygen", help="generate a new Ed25519 signing keypair (catalog key by default)"
|
||||
)
|
||||
p_keygen.add_argument(
|
||||
"--release",
|
||||
action="store_true",
|
||||
help=(
|
||||
"generate the RELEASE key (CI-resident, signs SHA256SUMS only) instead "
|
||||
"of the CATALOG key (offline, Console-only, signs data/catalog.json -- "
|
||||
"see issue #68 finding 5)"
|
||||
),
|
||||
)
|
||||
p_keygen.set_defaults(func=cmd_keygen)
|
||||
|
||||
p_seed = sub.add_parser(
|
||||
"show-seed-b64", help="print the base64 private seed (for the RELEASE_SIGNING_KEY secret)"
|
||||
"show-seed-b64",
|
||||
help="print a base64 private seed for a CI secret -- RELEASE key only",
|
||||
)
|
||||
p_seed.add_argument(
|
||||
"--release",
|
||||
action="store_true",
|
||||
help="required: only the release key may ever be exported this way",
|
||||
)
|
||||
p_seed.set_defaults(func=cmd_show_seed_b64)
|
||||
|
||||
|
||||
+97
-6
@@ -24,6 +24,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
from bcc_core import _CATALOG_SIG_DOMAIN as CATALOG_SIG_DOMAIN
|
||||
from bcc_core import CATALOG_ALLOWED_COMMANDS
|
||||
from bcc_core import verify_catalog_signature as _verify_catalog_signature
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Semantic diff
|
||||
@@ -432,11 +433,21 @@ def has_blocking_risk(change: EntryChange) -> bool:
|
||||
class ReviewSession:
|
||||
"""State for one review pass. `pinned_blob_sha` is the git blob SHA of
|
||||
data/catalog.json as it existed the moment review began -- see
|
||||
can_sign()."""
|
||||
can_sign()/sign_precondition().
|
||||
|
||||
`loaded_ref` is the exact ref this review was loaded from ("main", or a
|
||||
PR's `refs/pull/<n>/head`) -- see issue #68 finding 1. It exists so the
|
||||
Sign path can re-resolve the TOCTOU blob SHA from *the ref that was
|
||||
actually reviewed*, instead of a hardcoded "main" that silently diverges
|
||||
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).
|
||||
"""
|
||||
|
||||
pinned_blob_sha: str
|
||||
old_catalog: dict
|
||||
new_catalog: dict
|
||||
loaded_ref: str = "main"
|
||||
changes: list[EntryChange] = field(default_factory=list)
|
||||
acknowledged: set[str] = field(default_factory=set)
|
||||
|
||||
@@ -445,12 +456,39 @@ class ReviewSession:
|
||||
self.changes = diff_catalogs(self.old_catalog, self.new_catalog)
|
||||
|
||||
|
||||
def start_review(pinned_blob_sha: str, old_catalog: dict, new_catalog: dict) -> ReviewSession:
|
||||
def start_review(
|
||||
pinned_blob_sha: str,
|
||||
old_catalog: dict,
|
||||
new_catalog: dict,
|
||||
loaded_ref: str = "main",
|
||||
) -> ReviewSession:
|
||||
return ReviewSession(
|
||||
pinned_blob_sha=pinned_blob_sha, old_catalog=old_catalog, new_catalog=new_catalog
|
||||
pinned_blob_sha=pinned_blob_sha,
|
||||
old_catalog=old_catalog,
|
||||
new_catalog=new_catalog,
|
||||
loaded_ref=loaded_ref,
|
||||
)
|
||||
|
||||
|
||||
def find_last_signed_catalog_raw(
|
||||
candidates: list[bytes], sig: bytes, pubkeys: list[bytes]
|
||||
) -> bytes | None:
|
||||
"""Given `candidates` (candidate raw catalog.json byte-strings -- e.g.
|
||||
successive historical versions from git log, most-recent-first),
|
||||
return the first one whose signature verifies against `sig`/`pubkeys`,
|
||||
or None if none do.
|
||||
|
||||
This is how source="main" review diffs against "the last catalog a
|
||||
maintainer actually signed" instead of against itself (issue #68
|
||||
finding 1): `catalog_console.last_signed_catalog_raw` walks
|
||||
data/catalog.json's git history on main and hands the candidates here.
|
||||
"""
|
||||
for raw in candidates:
|
||||
if _verify_catalog_signature(raw, sig, pubkeys):
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def acknowledge_entry(session: ReviewSession, entry_id: str) -> None:
|
||||
ids = {c.entry_id for c in session.changes}
|
||||
if entry_id not in ids:
|
||||
@@ -483,22 +521,53 @@ class SignDecision:
|
||||
def can_sign(session: ReviewSession, current_blob_sha: str) -> SignDecision:
|
||||
"""Whether the Sign button may fire right now.
|
||||
|
||||
Two independent gates, both required:
|
||||
1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing)
|
||||
Four independent gates, all required, checked in this order:
|
||||
|
||||
0. The diff must be non-empty. An empty diff historically meant "Sign
|
||||
unlocks instantly" (`set() <= set()` is vacuously True), which is
|
||||
exactly backwards: a vacuously-satisfied gate is worse than no gate
|
||||
at all, because it *manufactures confidence* -- the signature looks
|
||||
identical to one produced by a real review. "Nothing changed" must
|
||||
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.)
|
||||
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
|
||||
remote changed since -- a new commit pushed to the same PR, a
|
||||
force-push, another PR merged in between -- signing is refused and a
|
||||
re-review is forced. This is what makes "signing is the approval act"
|
||||
true rather than aspirational: the signature is bound to the exact
|
||||
reviewed bytes, not to "whatever the file happens to be now".
|
||||
2. Every changed entry in the diff must be individually acknowledged.
|
||||
2. No blocking risk finding may be outstanding on ANY changed entry, full
|
||||
stop -- checked here, not just in the GUI. The GUI additionally
|
||||
disables the acknowledge checkbox for a blocking entry, but that is a
|
||||
UI nicety, not the enforcement point: if this pure gate didn't also
|
||||
check it, a blocking risk would only be stopped by the GUI happening
|
||||
to have wired the checkbox correctly, and nothing would catch a
|
||||
regression in that wiring. The GUI must not be the only thing
|
||||
standing between a blocking risk and a signature.
|
||||
3. Every changed entry in the diff must be individually acknowledged.
|
||||
"""
|
||||
if not session.changes:
|
||||
return SignDecision(
|
||||
False,
|
||||
"Nothing to sign: this review's diff is empty. If you expected "
|
||||
"changes here, you may be diffing the wrong source/ref.",
|
||||
)
|
||||
if current_blob_sha != session.pinned_blob_sha:
|
||||
return SignDecision(
|
||||
False,
|
||||
"The reviewed bytes changed since this review began (blob SHA "
|
||||
"mismatch) -- re-review required before signing.",
|
||||
)
|
||||
blocking_ids = sorted({c.entry_id for c in session.changes if has_blocking_risk(c)})
|
||||
if blocking_ids:
|
||||
return SignDecision(
|
||||
False,
|
||||
"Blocking risk finding(s) outstanding on: "
|
||||
f"{', '.join(blocking_ids)} -- fix the underlying change, do not sign around it.",
|
||||
)
|
||||
if not all_entries_acknowledged(session):
|
||||
pending = sorted({c.entry_id for c in session.changes} - session.acknowledged)
|
||||
return SignDecision(
|
||||
@@ -507,6 +576,28 @@ def can_sign(session: ReviewSession, current_blob_sha: str) -> SignDecision:
|
||||
return SignDecision(True, None)
|
||||
|
||||
|
||||
def sign_precondition(
|
||||
session: ReviewSession, resolve_blob_sha: Callable[[str], str]
|
||||
) -> SignDecision:
|
||||
"""The real Sign-button gate: resolves the current TOCTOU blob SHA from
|
||||
*the ref this session was actually loaded from* (`session.loaded_ref`),
|
||||
never a hardcoded "main", then delegates to can_sign().
|
||||
|
||||
`resolve_blob_sha` is injected so this stays testable without git/Qt --
|
||||
catalog_console.ReviewWindow._on_sign passes a real resolver
|
||||
(fetch_ref + blob_sha_at against self.repo_dir); tests pass a fake
|
||||
dict-backed lookup. This is the fix for issue #68 finding 1's first bug:
|
||||
`_on_sign` used to hardcode `fetch_ref(self.repo_dir, "main")` as the
|
||||
comparison ref, so for any PR review (where `loaded_ref` is the PR's
|
||||
head, not main) the SHAs differed by definition and Sign could never
|
||||
fire -- and the retry path re-called the same hardcoded resolver, so it
|
||||
re-pinned the same wrong value and looped forever instead of forcing a
|
||||
genuine re-review.
|
||||
"""
|
||||
current_blob_sha = resolve_blob_sha(session.loaded_ref)
|
||||
return can_sign(session, current_blob_sha)
|
||||
|
||||
|
||||
def catalog_signing_message(raw_bytes: bytes) -> bytes:
|
||||
"""The exact bytes that get signed: bcc_core's domain-separation prefix
|
||||
(imported, never retyped) + the raw catalog bytes. Using this function
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "better-claude-config"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Runtime (also in requirements.txt)
|
||||
PySide6>=6.6
|
||||
cryptography>=42.0 # catalog signature verification (bcc_core) + release checksum signing
|
||||
|
||||
# Build / packaging
|
||||
pyinstaller>=6.0
|
||||
@@ -8,4 +9,3 @@ pillow>=10.0 # generates icons/app.ico during CI (Windows build)
|
||||
# Test / lint
|
||||
pytest>=8.0
|
||||
ruff>=0.6
|
||||
cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
PySide6>=6.6
|
||||
cryptography>=42.0 # bcc_core imports it at load (catalog signature verification)
|
||||
|
||||
@@ -42,6 +42,25 @@ from pathlib import Path
|
||||
# message signed by the same key.
|
||||
DOMAIN_PREFIX = b"bcc-release-v1|"
|
||||
|
||||
# Public half of the RELEASE signing key(s) -- a SEPARATE keypair from
|
||||
# bcc_core.CATALOG_PUBKEYS (issue #68 finding 5). The catalog key is the
|
||||
# offline, Console-only root of trust for what BCC executes; this key is
|
||||
# CI-resident and signs ONLY the release SHA256SUMS manifest, never the
|
||||
# catalog. Keeping them apart means a CI/repo-secret compromise burns the
|
||||
# release key -- annoying, but it never lets an attacker sign a catalog a
|
||||
# user's binary would trust. A LIST (not a single key), mirroring
|
||||
# CATALOG_PUBKEYS, so the release key can be rotated without invalidating
|
||||
# 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:
|
||||
# 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] = []
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
@@ -135,6 +154,19 @@ def public_key_b64_from_seed(seed_b64: str) -> str:
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def verify_checksums_against_any(pubkeys: list[bytes], sums_text: str, signature: bytes) -> bool:
|
||||
"""Verify `signature` against ANY key in `pubkeys` (each a raw 32-byte
|
||||
Ed25519 public key). Mirrors bcc_core.verify_catalog_signature's
|
||||
rotation-friendly "any currently-trusted key" semantics, applied to
|
||||
RELEASE_PUBKEYS instead of the catalog's key list. Returns False (never
|
||||
raises) for an empty `pubkeys` list -- fails closed rather than
|
||||
vacuously verifying against nothing."""
|
||||
return any(
|
||||
verify_checksums(base64.b64encode(pk).decode("ascii"), sums_text, signature)
|
||||
for pk in pubkeys
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -334,13 +334,29 @@ def test_acknowledge_gating_requires_every_entry():
|
||||
assert r.all_entries_acknowledged(session) is True
|
||||
|
||||
|
||||
def test_no_acknowledge_all_function_exists():
|
||||
"""Deliberate: there must be no shortcut to acknowledge every entry at
|
||||
once. See the comment in catalog_review.py above SignDecision."""
|
||||
def test_no_acknowledge_all_shortcut_and_gate_is_real():
|
||||
"""Two things, both load-bearing (issue #68: the original version of
|
||||
this test asserted ONLY the first half, and passed the entire time the
|
||||
gate below it was vacuously satisfiable -- 'no function named
|
||||
acknowledge_all' is worthless if signing doesn't actually require
|
||||
acknowledgement in practice).
|
||||
|
||||
1. No bulk-acknowledge shortcut exists (see the comment in
|
||||
catalog_review.py above SignDecision -- deliberate friction).
|
||||
2. The gate that friction protects is actually enforced: with entries
|
||||
still unacknowledged, can_sign() must refuse, not just "some GUI
|
||||
checkbox happens to be unticked".
|
||||
"""
|
||||
names = [n for n in dir(r) if "acknowledge" in n.lower()]
|
||||
assert "acknowledge_all" not in names
|
||||
assert "acknowledge_all_entries" not in names
|
||||
|
||||
session = r.start_review("sha1", _catalog(), _catalog(_entry(id="a"), _entry(id="b")))
|
||||
r.acknowledge_entry(session, "a") # only one of two -- not a bulk call
|
||||
decision = r.can_sign(session, "sha1")
|
||||
assert decision.ok is False
|
||||
assert "acknowledged" in decision.reason.lower()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# can_sign: TOCTOU blob pinning + acknowledge gating combined
|
||||
@@ -377,6 +393,130 @@ def test_can_sign_blob_mismatch_takes_priority_message():
|
||||
assert "blob" in decision.reason.lower() or "changed" in decision.reason.lower()
|
||||
|
||||
|
||||
def test_can_sign_false_on_empty_changeset():
|
||||
"""The exact bug behind issue #68 finding 1: 'main' loaded against
|
||||
itself diffs to [], and an empty changeset used to leave can_sign()
|
||||
with nothing to refuse on (set() <= set() is vacuously True). Commit
|
||||
b08cf21 signed 19 entries through precisely this path -- zero of them
|
||||
were ever reviewed. An empty diff must mean 'nothing to sign', never
|
||||
'sign unlocked'."""
|
||||
same_catalog = _catalog(_entry())
|
||||
session = r.start_review("sha1", same_catalog, same_catalog)
|
||||
assert session.changes == [] # diff_catalogs(x, x) -> []
|
||||
assert r.all_entries_acknowledged(session) is True # vacuously -- this is the trap
|
||||
decision = r.can_sign(session, "sha1") # blob matches, "everything" acknowledged
|
||||
assert decision.ok is False
|
||||
assert "nothing to sign" in decision.reason.lower()
|
||||
|
||||
|
||||
def test_can_sign_false_with_outstanding_blocking_risk_even_if_acknowledged():
|
||||
"""can_sign() must itself refuse a blocking risk finding -- today a
|
||||
blocking finding only disables the GUI checkbox, so the pure gate must
|
||||
not simply trust that the caller never acknowledged a blocking entry.
|
||||
Acknowledge it directly here (bypassing any GUI checkbox-disable logic
|
||||
entirely) to prove the gate catches it independently of the GUI."""
|
||||
session = r.start_review(
|
||||
"sha1",
|
||||
_catalog(),
|
||||
_catalog(_entry(config={"command": "bash", "args": ["-c", "evil"]})),
|
||||
)
|
||||
r.acknowledge_entry(session, "filesystem")
|
||||
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()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sign_precondition: the ref-resolution seam that used to hardcode "main"
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_sign_precondition_resolves_against_loaded_ref_not_hardcoded_main():
|
||||
"""The regression test for issue #68 finding 1's first bug:
|
||||
ReviewWindow._on_sign used to hardcode fetch_ref(repo, "main") as the
|
||||
TOCTOU comparison ref. For a PR review, _on_load pins the PR HEAD's
|
||||
blob SHA, so comparing against main's SHA differs by definition and
|
||||
Sign could never fire on the PR path.
|
||||
|
||||
The fake resolver below returns a DIFFERENT (deliberately wrong) SHA for
|
||||
"main" than for the PR ref that was actually loaded. If
|
||||
sign_precondition ever resolves against "main" instead of
|
||||
session.loaded_ref, this test fails -- both via the recorded `calls`
|
||||
list and via decision.ok flipping to False.
|
||||
"""
|
||||
pr_ref = "refs/pull/42/head"
|
||||
session = r.start_review("pr-blob-sha", _catalog(), _catalog(_entry()), loaded_ref=pr_ref)
|
||||
r.acknowledge_entry(session, "filesystem")
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_resolver(ref: str) -> str:
|
||||
calls.append(ref)
|
||||
return {"main": "main-blob-sha-WRONG", pr_ref: "pr-blob-sha"}[ref]
|
||||
|
||||
decision = r.sign_precondition(session, fake_resolver)
|
||||
assert calls == [pr_ref] # never asked the resolver for "main"
|
||||
assert decision.ok is True
|
||||
assert decision.reason is None
|
||||
|
||||
|
||||
def test_sign_precondition_refuses_when_loaded_ref_blob_moved():
|
||||
"""Same seam, the negative case: if the loaded ref's blob SHA has moved
|
||||
since review began (a new commit landed on the reviewed PR/branch), the
|
||||
resolver reflects that and sign_precondition must refuse -- proving this
|
||||
isn't just a hardcoded pass-through."""
|
||||
pr_ref = "refs/pull/42/head"
|
||||
session = r.start_review("pr-blob-sha", _catalog(), _catalog(_entry()), loaded_ref=pr_ref)
|
||||
r.acknowledge_entry(session, "filesystem")
|
||||
|
||||
def fake_resolver(_ref: str) -> str:
|
||||
return "pr-blob-sha-AFTER-A-NEW-PUSH"
|
||||
|
||||
decision = r.sign_precondition(session, fake_resolver)
|
||||
assert decision.ok is False
|
||||
assert "mismatch" in decision.reason.lower() or "changed" in decision.reason.lower()
|
||||
|
||||
|
||||
def test_sign_precondition_defaults_to_main_when_loaded_ref_unset():
|
||||
"""start_review()'s loaded_ref defaults to 'main' for source=main
|
||||
reviews (and backward-compat with callers that don't pass it)."""
|
||||
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
|
||||
assert session.loaded_ref == "main"
|
||||
r.acknowledge_entry(session, "filesystem")
|
||||
|
||||
def fake_resolver(ref: str) -> str:
|
||||
assert ref == "main"
|
||||
return "sha1"
|
||||
|
||||
decision = r.sign_precondition(session, fake_resolver)
|
||||
assert decision.ok is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# find_last_signed_catalog_raw: what source=main diffs against
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_find_last_signed_catalog_raw_returns_matching_candidate():
|
||||
"""Simulates walking catalog.json's git history: the CURRENT signature
|
||||
covers an OLDER version of the bytes (a later commit changed
|
||||
catalog.json without re-signing -- the exact bypass that produced
|
||||
commit b08cf21). The first candidate that verifies against that
|
||||
signature is 'the last catalog a maintainer actually signed'."""
|
||||
seed, pubkey = r.generate_keypair()
|
||||
old_raw = b'{"schema":1,"version":1,"servers":[]}'
|
||||
new_raw = b'{"schema":1,"version":2,"servers":[]}'
|
||||
sig = r.sign_catalog_bytes(old_raw, seed) # signature covers the OLD bytes
|
||||
found = r.find_last_signed_catalog_raw([new_raw, old_raw], sig, [pubkey])
|
||||
assert found == old_raw
|
||||
|
||||
|
||||
def test_find_last_signed_catalog_raw_none_when_nothing_verifies():
|
||||
seed, _pubkey = r.generate_keypair()
|
||||
_other_seed, other_pubkey = r.generate_keypair()
|
||||
raw = b'{"schema":1,"version":1,"servers":[]}'
|
||||
sig = r.sign_catalog_bytes(raw, seed)
|
||||
# Check against a pubkey list that does NOT include the signer's key.
|
||||
assert r.find_last_signed_catalog_raw([raw], sig, [other_pubkey]) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# catalog_signing_message: domain separation must match bcc_core exactly
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
+1716
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user