Signed catalog: Ed25519 verification, remote refresh, anti-rollback #61

Open
opened 2026-07-12 17:11:25 -04:00 by the_og · 1 comment
Owner

Why

The server catalog (#10) ships as data/catalog.json and is refreshed from a remote URL so entries can be fixed without cutting a release. But a catalog entry is a command + args that 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:

  1. The maintainer merges a friendly-looking PR that quietly changes one args entry. Human review is the only gate, and human review fails.
  2. A MITM / compromised host serves a modified catalog.
  3. Replay of an older, legitimately-published catalog to reintroduce a bad entry we already removed.

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.

data/catalog.json         # payload — anyone may PR this
data/catalog.json.sig     # detached signature — only the maintainer can produce this
bcc_core.CATALOG_PUBKEYS  # list of trusted pubkeys, compiled into the binary

Load order for a remote catalog — fail any step, discard the whole file:

  1. Fetch catalog.json + catalog.json.sig from a hardcoded HTTPS raw URL (no redirects followed).
  2. verify_catalog_signature(raw_bytes, sig, CATALOG_PUBKEYS) → bool.
  3. Anti-rollback: reject if version < the version of the cached/bundled copy already in hand.
  4. 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).
  5. Only now cache it and use it.

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

  • Add cryptography dependency (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_PUBKEYS as 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).
  • Pure core functions, GUI-free and unit-tested: verify_catalog_signature(), validate_catalog() (incl. allowlist), catalog_version(), resolve_catalog(bundled, cached, remote).
  • Remote fetch reusing the fail-quiet HTTP path from the update checker (#19). Tokenless, short timeout, never raises into the UI.
  • Cache under AppDataLocation.
  • CI gate: job that verifies catalog.json.sig against catalog.json on every push to main. A merged-but-unsigned catalog PR turns main red in 60 seconds instead of never — and it catches a forgotten re-sign.
  • Test vectors: valid sig, tampered payload (1 byte), wrong key, missing sig, rolled-back version, disallowed command, malformed JSON. Each must fall back cleanly, not crash.

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).

## Why The server catalog (#10) ships as `data/catalog.json` and is refreshed from a remote URL so entries can be fixed without cutting a release. But **a catalog entry is a `command` + `args` that 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: 1. **The maintainer merges a friendly-looking PR** that quietly changes one `args` entry. Human review is the only gate, and human review fails. 2. A MITM / compromised host serves a modified catalog. 3. Replay of an older, legitimately-published catalog to reintroduce a bad entry we already removed. 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. ``` data/catalog.json # payload — anyone may PR this data/catalog.json.sig # detached signature — only the maintainer can produce this bcc_core.CATALOG_PUBKEYS # list of trusted pubkeys, compiled into the binary ``` **Load order for a remote catalog — fail any step, discard the whole file:** 1. Fetch `catalog.json` + `catalog.json.sig` from a hardcoded HTTPS raw URL (no redirects followed). 2. `verify_catalog_signature(raw_bytes, sig, CATALOG_PUBKEYS)` → bool. 3. Anti-rollback: reject if `version` < the version of the cached/bundled copy already in hand. 4. `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). 5. Only now cache it and use it. **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 - [ ] Add `cryptography` dependency (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_PUBKEYS` as 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). - [ ] Pure core functions, GUI-free and unit-tested: `verify_catalog_signature()`, `validate_catalog()` (incl. allowlist), `catalog_version()`, `resolve_catalog(bundled, cached, remote)`. - [ ] Remote fetch reusing the fail-quiet HTTP path from the update checker (#19). Tokenless, short timeout, never raises into the UI. - [ ] Cache under `AppDataLocation`. - [ ] **CI gate:** job that verifies `catalog.json.sig` against `catalog.json` on every push to `main`. A merged-but-unsigned catalog PR turns main red in 60 seconds instead of never — and it catches a forgotten re-sign. - [ ] Test vectors: valid sig, tampered payload (1 byte), wrong key, missing sig, rolled-back version, disallowed command, malformed JSON. Each must fall back cleanly, not crash. ## 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).
the_og added the P1 label 2026-07-12 17:11:25 -04:00
Author
Owner

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.json on main. PyInstaller datas bundles 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 bumps version in the PR, the bundled copy wins the max(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.sig in datas too 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 /:/host all pass. It's worth keeping (it blocks bash/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:

  • Pin exact versions in args (@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.
  • Arg-level deny rules: -e/--eval/-c for node/python; --privileged and broad volume mounts for docker.
  • The security actually lives in maintainer review + package pinning. Nowhere else.

🟠 Anti-rollback freeze footgun + silent-failure blindness

  • Anyone who ever obtains one signature over version: 2^31 bricks 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.
  • "Always silent" makes an active signature-stripping attack indistinguishable from a flaky network, forever. Log verification failures; surface a quiet indicator after N consecutive failures. (Still no modal dialogs.)
  • Verify the cached copy on every load, not just at fetch time — store the sig beside it. Otherwise anything that can write to AppDataLocation injects 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 published branch 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:

  1. Domain-separate the signature with a context prefix (bcc-catalog-v1| + bytes) so the key can't be cross-purposed later.
  2. Signing timestamp inside the signed payload, alongside version.
  3. Parse verified bytes with strict 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

  • The client validator must hard-reject any non-empty env_required value. Console highlighting is a soft control a tired reviewer clicks through; make it structurally impossible.
  • args are not covered by secret masking (masking is env-keyed). A catalog entry steering users toward --api-key=XYZ in 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.)
  • App should refuse to save a config still containing <PLACEHOLDER> tokens.
  • Enforce HTTPS + domain sanity on docs_url/homepage/source. A link-only entry 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_PUBKEYS and every control here evaporates. PyInstaller datas have 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

  • Verify bundled catalog at runtime (same path as remote) — ship catalog.json.sig in datas
  • Release workflow hard-fails if signature doesn't verify
  • Verify cached catalog on every load
  • Domain-separation prefix + signing timestamp in signed payload
  • Strict json.loads on catalog — repair pipeline explicitly excluded
  • Hard-reject non-empty env_required values in the validator
  • Pin exact package versions across all 20 seed entries
  • Arg-level deny rules (-e/--eval/-c, --privileged, broad mounts)
  • HTTPS + domain checks on all URL fields
  • Refuse to save configs containing unfilled <PLACEHOLDER> tokens
  • Log + quietly surface repeated verification failures
  • Version-jump sanity cap (anti-freeze)
## 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.json` on main. PyInstaller `datas` bundles 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 bumps `version` in the PR, the bundled copy *wins* the `max(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.sig` in `datas` too 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 /:/host` all pass. It's worth keeping (it blocks `bash`/`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: - **Pin exact versions in args** (`@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. - **Arg-level deny rules:** `-e`/`--eval`/`-c` for node/python; `--privileged` and broad volume mounts for docker. - The security actually lives in maintainer review + package pinning. Nowhere else. ### 🟠 Anti-rollback freeze footgun + silent-failure blindness - Anyone who ever obtains one signature over `version: 2^31` **bricks 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. - "Always silent" makes an active signature-stripping attack indistinguishable from a flaky network, forever. Log verification failures; surface a quiet indicator after N consecutive failures. (Still no modal dialogs.) - **Verify the cached copy on every load**, not just at fetch time — store the sig beside it. Otherwise anything that can write to `AppDataLocation` injects 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 `published` branch 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: 1. **Domain-separate the signature** with a context prefix (`bcc-catalog-v1|` + bytes) so the key can't be cross-purposed later. 2. Signing **timestamp inside the signed payload**, alongside `version`. 3. **Parse verified bytes with strict `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 - The **client validator** must hard-reject any non-empty `env_required` value. Console highlighting is a soft control a tired reviewer clicks through; make it structurally impossible. - **`args` are not covered by secret masking** (masking is env-keyed). A catalog entry steering users toward `--api-key=XYZ` in 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.) - App should refuse to save a config still containing `<PLACEHOLDER>` tokens. - Enforce HTTPS + domain sanity on `docs_url`/`homepage`/`source`. A `link-only` entry 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_PUBKEYS` and every control here evaporates. PyInstaller `datas` have 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 - [ ] Verify **bundled** catalog at runtime (same path as remote) — ship `catalog.json.sig` in `datas` - [ ] Release workflow **hard-fails** if signature doesn't verify - [ ] Verify **cached** catalog on every load - [ ] Domain-separation prefix + signing timestamp in signed payload - [ ] Strict `json.loads` on catalog — repair pipeline explicitly excluded - [ ] Hard-reject non-empty `env_required` values in the validator - [ ] Pin exact package versions across all 20 seed entries - [ ] Arg-level deny rules (`-e`/`--eval`/`-c`, `--privileged`, broad mounts) - [ ] HTTPS + domain checks on all URL fields - [ ] Refuse to save configs containing unfilled `<PLACEHOLDER>` tokens - [ ] Log + quietly surface repeated verification failures - [ ] Version-jump sanity cap (anti-freeze)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: the_og/better-claude-config#61