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 the default set should be editing that list, not moving directories around
and rebuilding. 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 ## The one thing that needs care
If defaults are fetched from the catalogue on first run, a fresh install with 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 row, next to the publisher's name. The separate Community section and its
Install button fold into the single catalogue view. Install button fold into the single catalogue view.
- **`addon-updater.js`**: drop the distinction between the two channel - **`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 - **Gateway**: the catalogue gains first-party entries. Nothing changes
structurally — `/api/ext` already stores whatever a name owner signs. structurally — `/api/ext` already stores whatever a name owner signs.
- **Publishing**: `scripts/sign-addon-update.mjs` and the operator key stop - **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 - **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; 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. 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.