theseus/docs/DESIGN-one-extension-catalogue.md
Local Dev 3d9b05ba25 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.
2026-09-22 00:47:00 +02:00

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. 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 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. 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 being the first-party route. Silent Mode's own extensions go through PUT /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.