docs: scope the signing allow-list per extension, not per app

Multiple signing keys already work — PUBKEYS_HEX is an array and
verifySignature accepts any entry that verifies, which is an allow-list
already. What it isn't is scoped: every add-on is checked against the same
array, so a key added for one extension can sign an update for any other.
The canonical message binds a signature to an extension; nothing binds an
extension to a signer.

The publisher path doesn't have that problem, because a name answers "who
may sign this" per extension and rotates on chain without an app release.
So the note now argues for generalising that rather than replacing it:
per-extension key bindings, several acceptable keys each, the chain
preferred over a compiled-in array, and co-signing considered only for the
default set — the extensions that reach every fresh profile without anyone
choosing them.

Records the open questions honestly too: where a binding can live without
being restatable by whoever controls the manifest, and that refusing to
update an extension is a much smaller decision than refusing to run it.
This commit is contained in:
Local Dev 2026-09-22 00:47:00 +02:00
parent ed2d2e1d5a
commit 3d9b05ba25

View file

@ -35,6 +35,49 @@ is a list of ids in the app's config, not a folder that gets copied. Changing
the default set should be editing that list, not moving directories around
and rebuilding.
## Which keys may sign what
Multiple signing keys already work, and there is already an allow-list: the
`PUBKEYS_HEX` array in `addon-update-pubkeys.js`. `verifySignature` walks it
and accepts an update if any entry verifies, so adding a second operator key
is a line in that file plus a Theseus release. The file's own comment sets
out the rotation: sign with both, ship a release carrying both, then drop
the old one.
The problem is not that the list is missing. It is that the list is
**global**. Every add-on is verified against the same array, so any key on
it can sign an update for any id — a key meant for the screenshot tool can
push code for the wallet. The signed message is
`silentmode.addon-update-v1|<id>|<version>|<sha256>`, which binds a
signature to an extension, but nothing binds an extension to a signer.
The publisher path does not have this problem. An entry carrying
`publisherSig` is checked against the current owner of that extension's
name, so the permission is per-extension by construction and rotates on
chain without an app release. That is the model to generalise, not replace.
So: keep an allow-list, and scope it.
- **Per-extension, not global.** Each extension names who may sign it — a
BNS name for anything in the catalogue, or a pinned pubkey for a member of
the default set. `verifySignature` takes that extension's keys instead of
the global array, and its callers look the binding up by id.
- **Plural per extension.** Two or three acceptable keys, any one
sufficient, so a rotation or a lost key doesn't need an emergency release.
- **Prefer the name-owner path for anything new.** The chain is an
allow-list that can be corrected the day a key is lost; a compiled-in
array can only be corrected by shipping a build.
- **Consider co-signing for the default set alone.** Those extensions reach
every fresh profile without anyone choosing them, which makes a single
compromised key worst exactly there. Requiring two signatures is a bounded
amount of work when it applies to a handful of ids rather than to the
whole catalogue.
The narrowing itself is small — `verifySignature` and its two callers. The
work is in deciding where the binding lives and showing it honestly in
Settings, so "who is allowed to update this" is something a user can read
rather than infer.
## The one thing that needs care
If defaults are fetched from the catalogue on first run, a fresh install with
@ -66,7 +109,9 @@ everywhere else.
row, next to the publisher's name. The separate Community section and its
Install button fold into the single catalogue view.
- **`addon-updater.js`**: drop the distinction between the two channel
shapes; an extension has a publisher and an updates URL.
shapes; an extension has a publisher and an updates URL. `verifySignature`
takes the keys allowed for *that* id rather than the global array, and
`pickChannelEntry` resolves the binding before it verifies anything.
- **Gateway**: the catalogue gains first-party entries. Nothing changes
structurally — `/api/ext` already stores whatever a name owner signs.
- **Publishing**: `scripts/sign-addon-update.mjs` and the operator key stop
@ -85,3 +130,15 @@ everywhere else.
- **Does the operator key survive at all?** Keeping it as an accepted signer
for the default set is a hedge against the name-owner key being lost;
keeping it forever means the two-rules problem never fully goes away.
- **Where does a per-extension key binding live?** In the manifest, where
the extension states its own publisher and an attacker who can rewrite the
manifest can restate it; in the app's config, where it is trustworthy but
only changes with a release; or on chain, where the name already answers
the question. Probably: on chain for the catalogue, pinned in config for
the default set, and never simply believed from the manifest.
- **What happens to an extension whose signer is no longer allowed?** It
should stop updating, but stopping it from *running* is a different and
much ruder decision, and the two shouldn't be conflated by accident.
- **Does co-signing need a threshold, or just two named keys?** Two keys is
simple to implement and to explain; m-of-n is where this quietly turns
into key management.