Signed catalog: Ed25519 verification, remote refresh, anti-rollback #61
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
The server catalog (#10) ships as
data/catalog.jsonand is refreshed from a remote URL so entries can be fixed without cutting a release. But a catalog entry is acommand+argsthat BCC writes into a config file which Claude then executes. That makes the catalog a code-execution supply-chain surface.The threat model is deliberately not "an attacker pushes to our repo." It is:
argsentry. Human review is the only gate, and human review fails.Signing closes all three, and crucially moves the approval act out of the PR flow: a contributor can change the JSON but cannot produce a valid signature, so a blindly-merged PR is inert — every client rejects the remote catalog and keeps the last good one.
Design
Detached Ed25519 signature over the raw catalog bytes. Private key never enters the repo or CI.
Load order for a remote catalog — fail any step, discard the whole file:
catalog.json+catalog.json.sigfrom a hardcoded HTTPS raw URL (no redirects followed).verify_catalog_signature(raw_bytes, sig, CATALOG_PUBKEYS)→ bool.version< the version of the cached/bundled copy already in hand.validate_catalog()— schema + command allowlist (npx,uvx,docker,node,python; the 20-entry seed uses only npx/uvx/docker, so the allowlist costs nothing today).Fallback chain, always silent: verified remote → cached → bundled. No dialogs, no blocking spinner, no network at app startup (refresh is triggered by opening the Browse dialog, and only if the cache is >24h old).
max(bundled, cached)on load. A fresh install with a newer bundled catalog must not be pinned to an older cached one.Tasks
cryptographydependency (Ed25519 verify). Deliberate call over hand-rolling RFC 8032 in stdlib — bundle size is not a constraint at BCC's footprint, and not hand-rolling crypto is worth a few MB. Confirm it bundles cleanly under PyInstaller on all 3 platforms.CATALOG_PUBKEYSas a list, not a single constant, so a new key can ship alongside the old one for one release before the old is dropped (rotation without a flag day).verify_catalog_signature(),validate_catalog()(incl. allowlist),catalog_version(),resolve_catalog(bundled, cached, remote).AppDataLocation.catalog.json.sigagainstcatalog.jsonon every push tomain. A merged-but-unsigned catalog PR turns main red in 60 seconds instead of never — and it catches a forgotten re-sign.Key handling
Private key lives on the maintainer's machine only, encrypted at rest, never in the repo and never in CI (CI verifies, it never signs). Signing is performed via the Catalog Console in #62 — never by pasting a key into a script.
Notes
This layers on top of an existing guarantee, it does not replace it: catalog entries still route through the import + editor flow, so the user sees the exact command before it is written. Signing protects the users who don't look; the editor protects the ones who do.
Related: #10 (catalog), #62 (maintainer signing tool), #19 (fail-quiet HTTP path this reuses).
Adversarial review (Fable) — design amended, one real hole found
🔴 BLOCKER: the headline property was only half-true
"A blindly-merged malicious PR reaches no users" holds for the remote path and FAILS at the release boundary. The malicious payload sits in
data/catalog.jsonon main. PyInstallerdatasbundles whatever is in the working tree at build time, and the design above gave the bundled copy implicit trust — it's the root of the fallback chain and it's never verified. Worse: if the attacker bumpsversionin the PR, the bundled copy wins themax(bundled, cached)resolution against every legitimately-signed cache. Red CI only helps if the release pipeline hard-fails on it — and the whole premise is a maintainer who merges without looking; that same maintainer can tag a release over a red main a week later.Fix (≈10 lines): ship
catalog.json.sigindatastoo and verify the bundled catalog at runtime with the identical path — verify → anti-rollback → schema+allowlist. Bundled gets no special trust. Plus: the release workflow must refuse to build if the signature doesn't verify. With this the stated property genuinely holds; without it, it's half theatre.🟠 Reclassify: the allowlist is NOT a security boundary
npx -y <pkg>is arbitrary code execution by design — allowlisting the launcher constrains nothing about what runs.node -e,python -c,docker run --privileged -v /:/hostall pass. It's worth keeping (it blocksbash/curl/raw paths) but must be treated as a UX affordance, not a security layer. Same for "the user sees the command before it lands" — a user clicking a curated catalog is precisely a user who is not evaluating@modelcontextprotocol/server-fllesystem.The dominant real attack is package-name substitution (typosquat / dependency confusion) inside
args. It passes signature, schema, allowlist, and a tired human review. Mitigations, added to scope:@scope/pkg@1.2.3) so a later-compromised package doesn't auto-upgrade into every user. Affects the seed data — needs a pass over all 20 entries.-e/--eval/-cfor node/python;--privilegedand broad volume mounts for docker.🟠 Anti-rollback freeze footgun + silent-failure blindness
version: 2^31bricks catalog updates for every client until a binary ships with a higher bundled version — and our updater is notify-only. Bound it: put a signing timestamp inside the signed payload, and sanity-cap version jumps.AppDataLocationinjects arbitrary subprocess specs. Weak local threat model, but re-verification is free.🟠 CI gate as specified trains alarm fatigue
If signing happens after merge, main is red between every catalog merge and the signing commit — "red main" becomes routine and ignorable, which is exactly the failure mode we're defending against. Restructure: unsigned catalog changes must not be able to land on main. The Console emits a single commit containing payload + sig together (see #62), or published catalog state lives on a
publishedbranch while main carries proposals.🟢 Ed25519-detached is the right primitive — keep it
Explicitly reviewed against TUF (all roles held by one person = zero added security, large operational cost), Sigstore (needs network + OIDC at verify time; wrong shape for a desktop app) and git signed tags (would need git shipped or tag verification reimplemented). Ed25519-over-bytes is the minisign/signify model and is correct at this scale. Three tweaks adopted:
bcc-catalog-v1|+ bytes) so the key can't be cross-purposed later.version.json.loads— the lenient repair pipeline must NEVER touch catalog bytes, or "verified bytes" and "used data" diverge. Non-negotiable given repair is this app's core feature.🟡 Secrets / placeholders — tighten from soft to structural
env_requiredvalue. Console highlighting is a soft control a tired reviewer clicks through; make it structurally impossible.argsare not covered by secret masking (masking is env-keyed). A catalog entry steering users toward--api-key=XYZin args bypasses both UI masking and diagnostics redaction. Schema-forbid secret-looking arg patterns; catalog rule is "secrets go in env." (This is the recurring "masking drifts on new surfaces" pattern — third time.)<PLACEHOLDER>tokens.docs_url/homepage/source. Alink-onlyentry whose only payload is a link to a malware install guide is the cheapest possible PR to sneak through — and 28 of the researched entries are link-only, so these fields are load-bearing.⬛ Out of scope but flagged: the real elephant
Binary distribution is the root of trust. If BCC releases aren't code-signed/notarized, an attacker who tampers with the binary simply replaces
CATALOG_PUBKEYSand every control here evaporates. PyInstallerdatashave no integrity of their own. Worth a separate issue — polishing catalog security while the binary is unsigned is polishing the wrong surface.Updated task list
catalog.json.sigindatasjson.loadson catalog — repair pipeline explicitly excludedenv_requiredvalues in the validator-e/--eval/-c,--privileged, broad mounts)<PLACEHOLDER>tokens