Skip to content

feat(plugins): Ed25519 signing across workers + daemon TOFU resolver#4600

Merged
houko merged 19 commits into
mainfrom
feat/relax-pubkey-when-sha256
May 4, 2026
Merged

feat(plugins): Ed25519 signing across workers + daemon TOFU resolver#4600
houko merged 19 commits into
mainfrom
feat/relax-pubkey-when-sha256

Conversation

@houko

@houko houko commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

End-to-end Ed25519 signing for the LibreFang plugin registry, plus an in-repo build/sign pipeline that keeps plugin pushes auto-publishing within seconds while staying inside Cloudflare Workers Free quotas.

Closes the original blocker: librefang plugin install <name> was hard-failing with "Plugin registry public key is not configured" because no key existed anywhere in the chain. After this PR, the daemon trusts a compiled-in pubkey, the worker carries no signing material at all, and the registry repo's CI signs the bytes ops actually serves.

What changed

Daemon (librefang-runtime/plugin_manager.rs)

  • Compiled-in EMBEDDED_REGISTRY_PUBKEYS: &[EmbeddedPubkey] slice (slot 0 active, prior keys carry expires_at: Option<i64> for bounded rotation).
  • resolve_registry_pubkey chain: env override → embedded → TOFU/HTTP fallback (only reachable for self-hosted forks, never the official path).
  • TOFU file open hardened with O_NOFOLLOW + regular-file check + 0600.
  • fetch_verified_index defaults to the worker-signed mirror at
    stats.librefang.ai/api/registry/index.json[.sig] for the official
    registry; self-hosted forks fall back to GitHub raw with env overrides.
  • Index signature is mandatory — no soft-fail-by-slug downgrade, no SHA-256 fallback rescue. Missing or unreachable .sig hard-fails after a single 500ms-backoff retry. Self-hosted forks that haven't deployed signing yet must explicitly opt out via LIBREFANG_REGISTRY_VERIFY=0.
  • Single-plugin install replaces the per-plugin Ed25519 archive check (which was dead code that always 404'd and silently passed) with an index-membership check: refuse to install plugins not present in the signed plugins-index.json.

Workers (web/workers/)

  • registry-worker is now a pure transport — it carries no private key,
    performs no signing. Forced refresh fetches plugins-index.json +
    .sig + registry-index.json from the registry repo's raw URLs
    (3 subrequests, constant in registry size) and stores them verbatim.
  • Cron scheduled handler reuses the same path as a daily backstop.
  • Sig length validated at 86 or 88 base64 chars (exact Ed25519 sig size).
  • Cache-purge outcome surfaced in the response body.
  • marketplace-worker bundle_url allowlist matches on (parsed.host, pathname regex) tuples — github.com/.../releases/download/,
    objects.githubusercontent.com/, and marketplace.librefang.ai/bundles/
    only. Tightens bundle_sha256 to ^[0-9a-f]{64}$.
  • constantTimeEqual padded to fixed length.

Pages worker (web/public/_worker.js)

  • /.well-known/registry-pubkey short-circuit before SPA fallback so
    daemons that probe librefang.ai get the raw key instead of HTML.

Sister repo librefang/librefang-registry

  • scripts/build-plugins-index.mjs — walks plugins/<name>/plugin.toml,
    emits sorted flat array as plugins-index.json.
  • scripts/build-registry-index.mjs — walks all 8 category dirs, emits
    dict-shaped registry-index.json for the dashboard.
  • scripts/sign-plugins-index.mjs — signs plugins-index.json with
    REGISTRY_PRIVATE_KEY (GitHub Actions secret) and writes
    plugins-index.json.sig next to it.
  • .github/workflows/refresh-cache.yml — on push to plugins/agents/
    skills/etc, builds + signs + commits + pokes the worker. SHA-pinned
    actions, scoped secret env, post-sign self-verify against the
    committed pubkey.
  • .github/CODEOWNERS/scripts/, /.github/, and wrangler.toml
    require registry-owner approval.
  • All 11 existing plugins under plugins/ got [integrity] SHA-256
    hashes for their hook scripts (daemon's
    manifest_missing_integrity_hooks check would otherwise hard-fail).
  • Branch protection on main enabled via gh api: PR-required,
    CODEOWNERS-enforced, force-push + deletion blocked, bypass for the
    registry owner and the github-actions[bot] app.

Tooling

  • scripts/check-pubkey-lockstep.sh — fails CI if the daemon's slot-0
    pubkey doesn't match the three worker-side REGISTRY_PUBLIC_KEY
    values byte-for-byte (anchored regex, length-checked to 44 chars).

Trust model

Layer Trust root
Daemon → registry index Compiled-in EMBEDDED_REGISTRY_PUBKEYS[0]
Worker → daemon Pure transport — daemon verifies signature, worker doesn't sign
Registry CI → worker REGISTRY_PRIVATE_KEY (GH Actions secret on librefang-registry); CODEOWNERS + branch protection on main
Plugin authors → registry PR review + branch protection on librefang-registry/main

Three rounds of code-reviewer-agent review caught:

  • Round 1: 4 CRITICAL / 5 HIGH / 5 MEDIUM / 3 LOW
  • Round 2: 5 new HIGH / 2 new MEDIUM / 1 LOW introduced by round-1 fixes
  • Round 3: 1 new CRITICAL (branch protection wasn't actually enabled
    via API, only documented) / 1 HIGH (post-sign verify overclaim) /
    1 MEDIUM (no rotation expiry) / 1 LOW (CODEOWNERS gaps)

All 13 distinct issues are now closed; reviewer's stop-iterating verdict
was issued after round 3. Residual threats reduce to "compromised
maintainer account" / "GitHub itself compromised" / "Anthropic SDK chain
compromised" — outside what this PR can mitigate.

Verification

  • cargo check -p librefang-runtime --lib clean
  • cargo clippy -p librefang-runtime --all-targets -- -D warnings clean
  • cargo test -p librefang-runtime --lib plugin_manager: 29 tests pass
  • scripts/check-pubkey-lockstep.sh passes
  • Both worker JS files node --check clean
  • End-to-end live: pushed librefang-registry@fe15ae5, GH Action
    ran build → sign → commit → poke worker; daemon-facing
    https://stats.librefang.ai/api/registry/index.json[.sig] serves
    the new bytes, signature verifies against /api/registry/pubkey
    for all 11 plugins.

Pre-merge actions for ops

  • Back up the new private key (only copy is the librefang-registry
    GitHub Actions secret) to a password manager. Old key (in Bitwarden
    item d8a43180-...) is superseded and can be deleted.
  • Optional: delete unused REGISTRY_PRIVATE_KEY worker secrets on
    librefang-registry and librefang-marketplace workers (no longer
    read by either codebase).

Out-of-scope follow-ups

  • LIBREFANG_REGISTRY_VERIFY=0 env-var bypass should move to a CLI
    flag so it can't be set ambiently by a malicious post-install hook.
  • Marketplace /v1/download/<slug>/<version>/signature path still
    signs publish-time URL claims; consider tightening to a registry-
    controlled CDN-only flow.
  • A daemon-side librefang plugin rotate-pubkey command would smooth
    the rotation UX.

houko added 6 commits May 4, 2026 22:46
…3805)

Worker side
- registry-worker: sign canonical index.json on cron refresh; new endpoints
  /api/registry/index.json, /api/registry/index.json.sig,
  /.well-known/registry-pubkey. Backward-compatible: when REGISTRY_PRIVATE_KEY
  is unset the cron skips signing and the new endpoints return 503.
- marketplace-worker: sign per-version metadata on publish over the canonical
  string \`<slug>@<version>|<bundle_url>|<bundle_sha256>\`; new endpoints
  /v1/pubkey and /v1/download/<slug>/<version>/signature. Adds bundle_sig
  column to package_versions (NULL when the secret is unset).
- web/workers/keygen.mjs: standalone Ed25519 keypair generator. Output format
  (PKCS#8 private + raw 32-byte public) verified compatible end-to-end with
  ed25519_dalek::VerifyingKey::from_bytes.

Daemon side
- Replace the all-zero OFFICIAL_REGISTRY_PUBKEY_B64 placeholder + hard-fail
  with resolve_registry_pubkey(): env LIBREFANG_REGISTRY_PUBKEY > TOFU cache
  ~/.librefang/registry.pub > HTTP fetch from LIBREFANG_REGISTRY_PUBKEY_URL
  (default https://librefang.ai/.well-known/registry-pubkey).
- install_from_registry now downgrades Ed25519 verification to a warning when
  SHA-256 has already verified the manifest, hard-failing only when neither
  integrity check is available — fixes the case where every install attempt
  failed because no real registry pubkey was deployed yet.
- fetch_verified_index keeps the hard-fail (no per-index checksum fallback).
- Drops the three #3799 placeholder regression tests; replaces with three
  resolver tests covering the validator and cache path.

Docs
- web/workers/SIGNING.md: operator runbook (keygen, deploy, rotation).
- docs/architecture/plugin-signing.md: trust model + layered defenses.

Drive-by
- Fix pre-existing clippy doc_lazy_continuation in librefang-types config
  blocking workspace clippy.
cargo check refreshes the librefang-memory dep entry that an upstream
commit left out of Cargo.lock. Trivial single-line drive-by while in this
PR's worktree.
Generated via web/workers/keygen.mjs; private half deployed as Wrangler
secret on both librefang-registry and librefang-marketplace workers.
Public key is non-secret — committed in cleartext per design.
Earlier commit wired julGSb...; the live Cloudflare secret has since
been rotated. Restore PR ↔ deployed-secret consistency.
…d secret)

Replaces the prior placeholder/intermediate keys with the keypair backed
up in Bitwarden and live as REGISTRY_PRIVATE_KEY on both workers.
The daemon's TOFU resolver (resolve_registry_pubkey) defaults to
fetching https://librefang.ai/.well-known/registry-pubkey. Without this
short-circuit the Pages SPA fallback returns index.html, which fails
base64 validation, which makes plugin install hard-fail in
fetch_verified_index.

Pubkey is non-secret. Mirror of REGISTRY_PUBLIC_KEY in the two
worker wrangler.toml files; rotation must update all three in
lockstep.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 4, 2026

Copy link
Copy Markdown

The worker-side signing endpoints landed previously, but the daemon was
still fetching the registry index from raw.githubusercontent.com — and
the official registry repo has no committed `index.json` at all, so
`install_plugin_with_deps` either 404'd or skipped verification entirely.

Connect the chain end-to-end:

* registry-worker now extracts `needs` and `version` from each plugin
  TOML during the cron refresh, and stores a daemon-shaped flat array
  (`[{name, version?, description?, needs?}]`, sorted by name for byte
  determinism) at `kv_store('plugins_index')`. The Ed25519 signature
  covers exactly those bytes and is stored at `plugins_index_sig`.
  `/api/registry/index.json[.sig]` now serve these — the dashboard's
  dict-shaped `/api/registry` is unchanged. The cron's signature-based
  short-circuit also bails out when the new KV row is missing so the
  first deploy after this change actually populates it.

* daemon's `fetch_verified_index` defaults to the worker mirror
  (`https://stats.librefang.ai/api/registry/index.json`) when the
  registry is the official repo. Self-hosted forks keep the GitHub raw
  fallback. Both pairs accept `LIBREFANG_REGISTRY_INDEX_URL` /
  `LIBREFANG_REGISTRY_INDEX_SIG_URL` overrides for air-gapped
  deployments. URL selection is pulled into `registry_index_urls`
  with three new unit tests covering official / fork / env-override.

Result: once the worker is redeployed and the cron has run once,
`librefang plugin install <name>` for plugins with `[[needs]]` will
verify the index against the published Ed25519 key — no longer relying
on HTTPS + GitHub commits as the trust anchor.
@github-actions github-actions Bot added area/docs Documentation and guides area/runtime Agent loop, LLM drivers, WASM sandbox labels May 4, 2026
houko added 6 commits May 5, 2026 00:19
The signed-plugins-index endpoint was 503'ing on Workers Free because
the new `kv_store('plugins_index')` row only gets populated by the
cron path (02:00 UTC) — and forcing a refresh from a request handler
overshoots the 50-subrequest budget for the GitHub manifest fetch.

Pull the build path that's safe under the request budget into a small
`ensurePluginsIndex` helper called from the two daemon-facing
endpoints. It derives the flat `[{name, version?, description?,
needs?}]` array from the existing `registry_data` KV row, sorts by
name (matches the cron path's byte ordering so the signature contract
stays consistent), signs it, and persists both the bytes and the
signature. After that, the cron-path code remains the source of
truth — `ensurePluginsIndex` is a one-shot bootstrap that no-ops once
the row exists.

Also drop the `?refresh=1`-bypasses-staleness shortcut from the
previous commit: same 50-subrequest ceiling makes it useless from
fetch handlers, and the cron already has the higher quota it needs.
The Pages-worker pubkey route at librefang.ai/.well-known/registry-pubkey
only goes live after the next main-branch deploy of web/public/_worker.js.
Until then, requests fall through to the SPA index.html — which
is_valid_registry_pubkey_b64 correctly rejects, but only after taking the
network round trip and erroring out.

Point the daemon directly at the worker's own subdomain
(stats.librefang.ai/.well-known/registry-pubkey), which is the canonical
source anyway and is updated in lockstep with the signed index served at
the same host. The Pages-worker alias remains a stable fallback that
operators can opt into via LIBREFANG_REGISTRY_PUBKEY_URL.
Lets the librefang-registry GitHub Action push fresh content to the
worker's D1 cache + signed plugins index immediately on every push to
main, instead of waiting up to ~24h for the 02:00 UTC cron tick. Auth
is a constant-time bearer-token check against the REGISTRY_REFRESH_TOKEN
worker secret; until that secret is set the endpoint returns 503 and
remains inert (no probing surface).

Pre-emptively drops registry_data / plugins_index / plugins_index_sig
before re-running refreshRegistryCache so a no-op commit (e.g. README
typo) still forces a real rebuild — the existing signature-equality
short-circuit would otherwise skip the new path entirely. Also purges
the Cache-API row so dashboard reads see the new bytes immediately.
Pair with librefang/librefang-registry@f9821d5 — the daemon-shaped
flat plugins index is now built inside the registry repo by
scripts/build-plugins-index.mjs and committed as plugins-index.json
at the repo root.

handleForcedRefresh now does a single GitHub raw fetch for that one
file, validates the JSON shape, and signs+stores it. Constant-cost
refresh regardless of registry size keeps the path under the Workers
Free 50-subrequest budget that the previous walking-Contents-API
approach blew through.

The dict-shaped /api/registry payload (dashboard) and its 02:00 UTC
cron rebuild are unchanged — that lane stays on the slower path
because dashboard tolerance for staleness is much higher.
…exes

Pair with librefang/librefang-registry@ff6f3f2 — registry-index.json
joins plugins-index.json as a checked-in artefact built by
scripts/build-registry-index.mjs.

handleForcedRefresh fetches both files in parallel (2 subrequests
total, constant in registry size) and writes:
  plugins_index     ← plugins-index.json (signed with Ed25519)
  registry_data     ← registry-index.json (dict-shaped, dashboard)
Plus purges the Cache-API entry that backs /api/registry so the next
dashboard hit reads fresh bytes immediately instead of the 1h-cached
previous payload.

Result: dashboard updates land within seconds of a registry push, no
longer waiting up to ~24h for the 02:00 UTC cron tick. The cron path
is kept as a backstop for cases where the GitHub Action cannot
reach the worker (network blips, CI outages).
Daemon-side fixes for the issues raised in code review of PR #4600:

* CRITICAL #3 / MEDIUM #12 — install_from_registry was calling
  verify_archive_signature against {listing_url}.sig where listing_url
  is a GitHub Contents API URL. That .sig file never exists in the
  official registry layout, so the function silently returned Ok(()) on
  every install — meaning the entire Ed25519 archive lane was dead code
  that always passed. Replaced with index-membership check: refuse to
  install plugins not present in the signed plugins-index. The unused
  function is removed; the trust path now flows through the worker-
  signed flat index instead of a per-plugin .sig that never existed.

* HIGH #5 / #16 — TOFU pubkey resolver was vulnerable to first-install
  MITM (cafe wifi, hostile DNS, subdomain takeover) silently pinning an
  attacker key forever. Added EMBEDDED_REGISTRY_PUBKEY compiled into
  the daemon binary as the primary trust root for the official registry.
  Resolution chain is now: env override > embedded > TOFU/HTTP (the
  latter two only consulted for self-hosted forks that opt in).

* HIGH #6 — fetch_verified_index treated a missing or unreachable
  index.json.sig as a soft warning even when pubkey resolution was
  required. An attacker who could serve a doctored index but suppress
  the .sig (404 or network error) bypassed verification entirely. Now
  hard-fails for the official mirror; self-hosted forks keep the soft
  path so they can adopt signing incrementally.

* MEDIUM #13 — TOFU cache file open was vulnerable to symlink attacks
  from compromised post-install hooks. Added O_NOFOLLOW + regular-file
  check on read, mode 0600 + O_NOFOLLOW on write (Unix). Windows path
  validates regular-file status and relies on NTFS ACLs.

* MEDIUM #15 — added scripts/check-pubkey-lockstep.sh that fails when
  the pubkey constant drifts between the daemon's EMBEDDED_REGISTRY_PUBKEY
  and the three worker locations (registry-worker wrangler.toml,
  marketplace-worker wrangler.toml, _worker.js). Wire into CI before
  any cargo / wrangler build to catch silent rotation footguns.

clippy + plugin_manager tests pass.
@github-actions github-actions Bot added the size/XL 1000+ lines changed label May 4, 2026
houko added 2 commits May 5, 2026 01:06
Worker-side fixes paired with the daemon hardening in 1/3 and the
in-repo signing in librefang-registry@74745f1.

CRITICAL #1 — registry-worker is no longer a sign-anything oracle.
handleForcedRefresh now fetches plugins-index.json + .sig from raw
(committed by the registry repo's CI, which holds the private key)
and stores both verbatim. The worker carries no private key and
performs no signing — REGISTRY_REFRESH_TOKEN can no longer be used
to coerce the worker into signing attacker-supplied bytes.

CRITICAL #4 — ensurePluginsIndex deleted entirely. The lazy-build
path produced bytes that were not byte-identical to what
refreshRegistryCache produced (different empty-string handling),
so any signature built by cron over bytes A and served as bytes B
from lazy-build would fail daemon verification.

refreshRegistryCache (~165-line Contents-API walker) deleted too —
it had been silently dropping half the plugins under Workers Free's
50-subrequest budget. Cron now re-runs the same forced-refresh path
(3 subrequests) as a daily backstop.

signWithRegistryKey + b64FromBytes + bytesFromB64 deleted — no
remaining caller. -169 net lines on the registry worker.

CRITICAL #2 — marketplace-worker handlePublishVersion now refuses
bundle_url not on an approved CDN prefix. Without this, an author
could publish bundle_url=https://attacker.example/payload.tgz with
a valid sha256 and get a registry-signed signature back. Also
tightens bundle_sha256 to ^[0-9a-f]{64}$.

MEDIUM #11 — constantTimeEqual padded to fixed length so timing
leaks at most "they are not the same", not "yours is shorter than
mine".

Key rotation: new keypair generated as part of decoupling the
worker from key custody. New pub:
  ClGa0Ucap8NdrKAy1rw9Tt6A9I8eg4zJ53+xIuKMuq0=
in lockstep across daemon EMBEDDED_REGISTRY_PUBKEY + 3 worker
locations. Private key now ONLY in librefang-registry's GitHub
Actions store. Old worker-side keys are unused but kept (rotation
one-way; deleting them is purely cleanup).

Cloudflare routing: stats.librefang.ai routes only /api/* to the
worker, so the daemon's HTTP rotation-probe path needed an
/api/registry/pubkey alias (the /.well-known/ form only resolves
on workers.dev). Daemon's OFFICIAL_PUBKEY_URL now points at the
routed alias.

29 plugin_manager tests pass; clippy clean; both worker JS files
node --check clean; end-to-end signature verifies against served
pubkey for all 11 plugins.
Round-2 review of PR #4600 found 5 new HIGH defects introduced by the
round-1 fixes plus 2 MEDIUM and 1 LOW. Code-only follow-ups here;
the relocated CRITICAL oracle (workflow signing on push) is its own
follow-up commit.

HIGH-NEW-B — fetch_verified_index require_sig flag was keyed on
registry slug == OFFICIAL_REGISTRY_REPO. An attacker who could change
the configured registry (e.g. via a self-hosted-fork-style override)
flipped require_sig false; the daemon then verified against the
embedded official pubkey, fell through to the soft-warn branch when
verification failed, and silently accepted unsigned bytes. Now
require_sig is unconditional. Self-hosted forks must explicitly
opt out via LIBREFANG_REGISTRY_VERIFY=0; no implicit downgrade path.

HIGH-NEW-C — install_from_registry's index-membership check had an
"Err(e) if checksum_verified => warn" arm that downgraded on any
fetch error. Combined with the manifest-only SHA-256 check (which an
attacker who controls the GitHub repo can forge), DoS of
stats.librefang.ai bypassed verification entirely. Removed the
checksum-rescue arm; index-fetch errors now hard-fail after a single
500ms-backoff retry to absorb transient blips. checksum_verified
binding is gone (the SHA-256 step still runs and still hard-errors
on mismatch, but doesn't gate any other check).

HIGH-NEW-D — marketplace bundle_url allowlist matched on the raw
input string post-URL.parse, so WHATWG normalization tricks
(`https://github.com/../../attacker/x`) bypassed it. Reworked to
match on (parsed.host, parsed.pathname) tuples and tightened the
GitHub allowance to /releases/download/ only — the previous prefix
also matched .../raw/main/... which is a mutable branch HEAD and
defeats the immutability rationale. Also rejects URLs with hash
fragments.

HIGH-NEW-E — check-pubkey-lockstep.sh regex was loose enough to
match a renamed LEGACY_REGISTRY_PUBLIC_KEY or a base64-shaped
fragment in a comment. Anchored to start-of-line, length-limited
the captured value to exactly 44 chars (Ed25519 raw 32-byte b64),
forbade other quoted strings between the name and the value.

MEDIUM-NEW-F — plugins-index.json.sig length check was `>= 86`,
admitting arbitrarily long garbage. Tightened to exactly 86 or 88
chars and stricter regex.

MEDIUM-NEW-G — Cache-API purge failure was silently swallowed by
try/catch (_), so the worker reported success while the dashboard
served stale bytes for up to 1h. Now surfaces cache_purged: bool
(plus cache_purge_error when present) in the JSON response.

LOW — EMBEDDED_REGISTRY_PUBKEY was a single &str, leaving no
acceptance window for daemon ↔ worker rotations to overlap.
Promoted to EMBEDDED_REGISTRY_PUBKEYS: &[&str]. Added
verify_registry_index_multi which tries the resolved key first
then falls back to other embedded slice entries (with a warn so
ops sees they're on the prior key). The lockstep script extracts
the active slot 0 only.

29 plugin_manager tests + clippy clean; both worker JS files
node --check clean.
@github-actions github-actions Bot added the area/ci CI/CD and build tooling label May 4, 2026
Round-3 PR re-review noted that EMBEDDED_REGISTRY_PUBKEYS as &[&str]
left rotation-window keys accepted indefinitely — a future leak of a
retired private key would still be exploitable against any daemon
binary still carrying it.

Promoted slice element to a struct:
  EmbeddedPubkey { b64, expires_at: Option<i64> }

Slot 0 (active) keeps expires_at: None. Rotation procedure (per the
updated doc-comment): when shipping a new active key, move the prior
slot-0 entry to slot 1 with expires_at: Some(now + ~4 weeks). After
the deprecation window passes, drop the prior entry in a follow-up
release.

verify_registry_index_multi now skips embedded entries whose expiry
is in the past, with a debug log so ops can tell when daemons stop
falling back. Active key path is unchanged.

Lockstep script extended to handle the struct-field syntax (matches
the first `b64: "..."` in the file, which is slot 0). Same 44-char
length validation as before.

29 plugin_manager tests + clippy clean.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying librefang with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3a8868a
Status: ✅  Deploy successful!
Preview URL: https://bfb56d50.librefang.pages.dev
Branch Preview URL: https://feat-relax-pubkey-when-sha25.librefang.pages.dev

View logs

github-actions Bot and others added 3 commits May 4, 2026 17:40
Round-4 reviewer flagged 4 hardening items (all MEDIUM/LOW; PR was
already MERGEABLE per the verdict). Closing the three code-side ones
here; the branch-protection MEDIUM is documented as an intentional
trade-off for the single-maintainer + auto-publish flow.

LOW round 4 — `EmbeddedPubkey.b64` field renamed to `pubkey_b64`. The
old generic name made the lockstep regex's "find any 44-char base64
in a `b64: \"...\"` field" rule fragile against unrelated future
fields with the same shape. The unique name is grep-anchored and
disambiguates intent. Lockstep script updated in lockstep.

LOW round 4 — added `embedded_pubkeys_slot0_has_no_expiry` test +
`debug_assert!` in resolve_registry_pubkey. A maintainer who absent-
mindedly sets `expires_at: Some(...)` on slot 0 during a rotation
edit would silently break installs the moment that timestamp passed.
The runtime assert catches it pre-ship; the test catches it in CI.

MEDIUM round 4 — `verify_registry_index_multi` dedup now compares
against `resolved_pubkey.trim()` rather than raw. All current call
sites pre-trim, but a future code path that forgets would otherwise
verify the same key twice (wasted CPU on one extra Ed25519 verify;
not unsafe). Cheap to harden, defensive against future drift.

MEDIUM round 4 — when slot-0 verification fails AND every prior
embedded pubkey is past expiry, the returned error now appends
"(N prior embedded pubkey(s) past expiry — this daemon binary is
past its rotation window; upgrade librefang to restore plugin
installs)". Gives ops an actionable next step instead of a bare
"Signature verification failed" mystery.

MEDIUM round 4 (branch protection) — kept as documented trade-off:
`bypass_pull_request_allowances.users:["houko"]` allows the single
registry maintainer to direct-push to main, defeating CODEOWNERS for
that one identity. `enforce_admins:false` similarly allows admin
direct-push. Removing the bypass would force the maintainer through
a self-approval PR cycle for every plugin commit — and the CI bot
(`github-actions[bot]`, identity `apps:[]` per the API since the
github-actions app slug isn't bypass-eligible) would also be blocked
from `git push`, breaking the auto-publish UX. Multi-maintainer
project would justify the strict stance; single-maintainer doesn't.

30 plugin_manager tests + clippy clean.
The round-4 follow-up commit (9aca54a) shipped without running
`cargo fmt` locally — CI Quality job's `Check formatting` step
caught it. Reformatted plugin_manager.rs against the workspace
rustfmt config; no semantic change.
@houko
houko merged commit 26903b7 into main May 4, 2026
21 of 29 checks passed
@houko
houko deleted the feat/relax-pubkey-when-sha256 branch May 4, 2026 23:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci CI/CD and build tooling area/docs Documentation and guides area/runtime Agent loop, LLM drivers, WASM sandbox size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant