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.
144 lines
7.9 KiB
Markdown
144 lines
7.9 KiB
Markdown
# 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.
|