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.
7.9 KiB
One catalogue, one set of rules
Status: proposed, 2026-09-22. Not implemented.
Theseus currently runs two extension systems that happen to produce the same kind of thing.
| Bundled add-ons | Community extensions | |
|---|---|---|
| How it arrives | compiled into the build, seeded into the profile on first run | installed from the gateway catalogue |
| Who signs | the Silent Mode operator's Ed25519 key, pubkeys compiled into the app | the owner of a BNS name, checked against the chain |
| Update channel | theseus.x/extensions/<id>/updates.json |
theseus.x/extensions/community/<id>/updates.json |
| In Settings | a section headed "Built into Theseus" | a separate section headed "Community" |
| How to publish | scripts/sign-addon-update.mjs + sia-upload |
the publish page, or PUT /api/ext/… |
Two catalogues in the UI, two trust rules, two publishing paths — for a difference that is really only "who wrote it" and "is it there on day one".
What this should be instead
One catalogue. Every extension is listed in the same place, whoever wrote it. Silent Mode publishes under a Silent Mode name the same way anyone else publishes under theirs. Being first-party is a fact about the publisher, not a separate distribution system, and the UI should say so with a line of text rather than a second list.
One rule for trust. An extension is installable because a signature
checks out against the current owner of the publisher's name. That is
already what verifyPublisherEntry does; the operator Ed25519 key becomes
either one more accepted signer or nothing at all. pickChannelEntry
already accepts both, so this is mostly deletion.
Defaults are configuration. Which extensions a fresh Theseus starts with 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.
verifySignaturetakes 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 no network — or with the gateway down — has no editor, no wallet and no screenshot tool. That is a real regression from today, where the build carries them.
So keep shipping the code, but stop treating it as a different kind of thing:
- The build continues to include the default extensions' folders. They are a pre-seeded cache, not a separate class of add-on.
- On first run they are installed from that cache, then verified and updated against the catalogue exactly like anything the user installed by hand.
- If a seeded copy fails verification, it is quarantined rather than run — a cache is only a cache.
That keeps offline first-run working while leaving one set of rules everywhere else.
Sketch of the work
- Config:
defaultExtensions: [{ id, publisher }]in the app's settings, read at first run and on profile migration. Ids the user has removed are remembered and not re-seeded — uninstalling a default must stick. main.js: seeding installs through the normal install path instead of copying a folder and calling it built-in. One code path, one place where a signature is checked.settings.html: one list. "Installed by default" becomes a badge on a 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.verifySignaturetakes the keys allowed for that id rather than the global array, andpickChannelEntryresolves the binding before it verifies anything.- Gateway: the catalogue gains first-party entries. Nothing changes
structurally —
/api/extalready stores whatever a name owner signs. - Publishing:
scripts/sign-addon-update.mjsand the operator key stop being the first-party route. Silent Mode's own extensions go throughPUT /api/ext/<our-name>/<id>/<version>like everyone else's, which means the release process needs that name's wallet rather than a key file.
Open questions
- Which name publishes Silent Mode's extensions? Whoever goes first owns each id permanently. Worth deciding once, deliberately.
- Pinning. A default extension is installed without anyone choosing it, so a compromised or coerced catalogue entry reaches every fresh profile. Pinning the default set to a version and a publisher in the app's config bounds that.
- 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.