feat(plugins): Ed25519 signing across workers + daemon TOFU resolver#4600
Merged
Conversation
…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.
Deploying librefang-docs with
|
| Latest commit: |
e47bbb5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://99e80fbe.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-relax-pubkey-when-sha25.librefang-docs-web.pages.dev |
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.
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.
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.
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.
Deploying librefang with
|
| 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 |
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.
This was referenced May 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)EMBEDDED_REGISTRY_PUBKEYS: &[EmbeddedPubkey]slice (slot 0 active, prior keys carryexpires_at: Option<i64>for bounded rotation).resolve_registry_pubkeychain: env override → embedded → TOFU/HTTP fallback (only reachable for self-hosted forks, never the official path).O_NOFOLLOW+ regular-file check + 0600.fetch_verified_indexdefaults to the worker-signed mirror atstats.librefang.ai/api/registry/index.json[.sig]for the officialregistry; self-hosted forks fall back to GitHub raw with env overrides.
.sighard-fails after a single 500ms-backoff retry. Self-hosted forks that haven't deployed signing yet must explicitly opt out viaLIBREFANG_REGISTRY_VERIFY=0.plugins-index.json.Workers (
web/workers/)registry-workeris now a pure transport — it carries no private key,performs no signing. Forced refresh fetches
plugins-index.json+.sig+registry-index.jsonfrom the registry repo's raw URLs(3 subrequests, constant in registry size) and stores them verbatim.
scheduledhandler reuses the same path as a daily backstop.marketplace-workerbundle_urlallowlist matches on(parsed.host, pathname regex)tuples —github.com/.../releases/download/,objects.githubusercontent.com/, andmarketplace.librefang.ai/bundles/only. Tightens
bundle_sha256to^[0-9a-f]{64}$.constantTimeEqualpadded to fixed length.Pages worker (
web/public/_worker.js)/.well-known/registry-pubkeyshort-circuit before SPA fallback sodaemons that probe
librefang.aiget the raw key instead of HTML.Sister repo
librefang/librefang-registryscripts/build-plugins-index.mjs— walksplugins/<name>/plugin.toml,emits sorted flat array as
plugins-index.json.scripts/build-registry-index.mjs— walks all 8 category dirs, emitsdict-shaped
registry-index.jsonfor the dashboard.scripts/sign-plugins-index.mjs— signsplugins-index.jsonwithREGISTRY_PRIVATE_KEY(GitHub Actions secret) and writesplugins-index.json.signext 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/, andwrangler.tomlrequire registry-owner approval.
plugins/got[integrity]SHA-256hashes for their hook scripts (daemon's
manifest_missing_integrity_hookscheck would otherwise hard-fail).mainenabled viagh 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-0pubkey doesn't match the three worker-side
REGISTRY_PUBLIC_KEYvalues byte-for-byte (anchored regex, length-checked to 44 chars).
Trust model
EMBEDDED_REGISTRY_PUBKEYS[0]REGISTRY_PRIVATE_KEY(GH Actions secret on librefang-registry); CODEOWNERS + branch protection on mainThree rounds of code-reviewer-agent review caught:
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 --libcleancargo clippy -p librefang-runtime --all-targets -- -D warningscleancargo test -p librefang-runtime --lib plugin_manager: 29 tests passscripts/check-pubkey-lockstep.shpassesnode --checkcleanlibrefang-registry@fe15ae5, GH Actionran build → sign → commit → poke worker; daemon-facing
https://stats.librefang.ai/api/registry/index.json[.sig]servesthe new bytes, signature verifies against
/api/registry/pubkeyfor all 11 plugins.
Pre-merge actions for ops
GitHub Actions secret) to a password manager. Old key (in Bitwarden
item
d8a43180-...) is superseded and can be deleted.REGISTRY_PRIVATE_KEYworker secrets onlibrefang-registryandlibrefang-marketplaceworkers (no longerread by either codebase).
Out-of-scope follow-ups
LIBREFANG_REGISTRY_VERIFY=0env-var bypass should move to a CLIflag so it can't be set ambiently by a malicious post-install hook.
/v1/download/<slug>/<version>/signaturepath stillsigns publish-time URL claims; consider tightening to a registry-
controlled CDN-only flow.
librefang plugin rotate-pubkeycommand would smooththe rotation UX.