theseus/docs/ADDON-UPDATES.md

132 lines
4.7 KiB
Markdown
Raw Normal View History

feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI Decouples bundled-add-on updates from Theseus releases. An add-on whose addon.json declares an updateURL can be republished at any time without shipping a new Theseus installer; existing installs pick it up on the next boot's +30 s background check. Client flow (main-process only, no UI touchpoints in this commit): initAddons() ├── promoteStagedUpdates() # promote signed stage if newer ├── seedBundledAddons() # bundle wins over on-disk if newer └── AddonHost.discoverAndActivate() 30 s later: └── checkAndStageUpdates() # fetch, verify, download, stage Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>", verified against a hardcoded set of operator pubkeys living in addon-update-pubkeys.js. Domain-separated so the operator key can't be tricked into signing a message with a different purpose. Empty pubkey array is the shipping default — checkAndStageUpdates() then short-circuits and no outbound requests are made, which is the safe posture until the operator ceremonies a key in. Payload: gzipped tar, extracted with the system tar (present on Win10 1803+, macOS, Linux). Path traversal defended by tar's default refusal of `..` entries; the extracted manifest's id + version are re-checked against the signed values before staging. Staged updates go to <userData>/addons-updates-staged/<id>-<version>/. Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's backup dance: existing folder moves to <userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local edits survive. New files: - addon-updater.js — client - addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate) - scripts/generate-update-keypair.mjs — one-time keygen - scripts/sign-addon-update.mjs — operator packager+signer - docs/ADDON-UPDATES.md — operator brief + threat model Wired into main.js at boot; screenshot add-on's addon.json advertises the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
# Signed add-on updates
An add-on can be updated at runtime, without waiting for the next Theseus
release, if its `addon.json` declares an `updateURL`. The client polls
that URL, verifies an Ed25519 signature against a hardcoded set of
operator pubkeys, stages the new copy under
`<userData>/addons-updates-staged/<id>-<version>/`, and promotes it on the
next launch — reusing the same backup-then-swap logic as
`seedBundledAddons`, so any local edits the user made survive as
`<userData>/addons-backups/<id>-<oldver>-<timestamp>/`.
## Client boot flow
```
initAddons()
├── promoteStagedUpdates() # staged copy wins if newer than installed
├── seedBundledAddons() # bundle wins if newer than what's on disk
└── AddonHost.discoverAndActivate()
```
Then, 30 s after boot, `checkAndStageUpdates()` fetches every installed
add-on's `updateURL`, verifies its signed manifest, and lands any newer
signed version in the staged dir for the NEXT launch to promote.
## Publishing an update
### One-time: generate the operator keypair
```
node scripts/generate-update-keypair.mjs "$USERPROFILE/.silentmode/ops"
```
The private key stays there. Copy the printed pubkey hex into
`addon-update-pubkeys.js` and ship a Theseus release that bakes it in;
until you do, `checkAndStageUpdates` skips silently and the client makes
no outbound requests.
### Every update
```
node scripts/sign-addon-update.mjs \
bundled-addons/screenshot \
https://addons.silentmode.st/screenshot \
"$USERPROFILE/.silentmode/ops/addon-update-key.pem"
```
Produces:
- `out/screenshot-<version>.tar.gz` — upload to `<base-url>/<id>-<version>.tar.gz`
- `out/screenshot-<version>.entry.json` — prepend to your live `updates.json`
Then:
```
scp out/screenshot-*.tar.gz silentmode:/opt/silent-mode/site/addons/screenshot/
scp updates.json silentmode:/opt/silent-mode/site/addons/screenshot/
```
The Sia autosync timer picks up `/opt/silent-mode/site/` on its usual
5-minute cadence, so both mirrors update together (see
`shipping-mirrors` for background).
## `updates.json` schema
```json
{
"addons": [
{
"version": "0.3.0",
"url": "https://addons.silentmode.st/screenshot/screenshot-0.3.0.tar.gz",
"sha256": "hex",
"sig": "base64"
},
{ "version": "0.2.1", "url": "…", "sha256": "hex", "sig": "base64" }
]
}
```
Order does not matter — the client picks the highest `version` greater
than what's installed. Keep old entries around only if you want to
support rollback via a manual downgrade tool; the client never picks
anything older than the installed copy.
## Signature domain
```
message = "silentmode.addon-update-v1|<id>|<version>|<sha256>"
signature = Ed25519_sign(operator_privkey, message)
```
Domain-separated so the operator key can't be tricked into signing
something else (a website login token, a wallet message) that happens
to have the right shape.
## Rotating the operator key
1. `generate-update-keypair.mjs` a new one.
2. Publish next `updates.json` entries signed by BOTH old and new key
(the client accepts a signature from any pubkey in `PUBKEYS_HEX`).
3. Ship a Theseus release that adds the new pubkey.
4. After enough time has passed for existing installs to upgrade, ship
a follow-up release removing the old pubkey. Stop signing with it.
## Threat model
- **Compromised operator key** = update-tunnel compromise. Attackers can
push a malicious add-on to every install that has the endpoint
reachable. Mitigations under consideration:
- multi-sig: require signatures from N of M operator keys (not
implemented — would extend the `sig` field to an array)
- on-BCNR pubkey publication: pin the current pubkey set to a BNS
record so rotation is auditable (roadmap)
- **Manifest downgrade** — a malicious mirror serves an older signed
entry. The client refuses versions ≤ what's installed, so a rollback
attack cannot land an older signed payload as if it were an update.
A user who wipes `<userData>/addons/` and boots against a hostile
mirror could receive a stale-but-signed copy; the bundled fallback
from the Theseus release will beat it via `seedBundledAddons` unless
the bundle is older still.
- **Path traversal in tarball** — `tar` refuses `..` entries by default;
we do not pass `-P`. Extracted manifest's `id` + `version` are
re-checked against the signed values before staging.
- **Empty pubkey list** = feature off. No outbound requests, no updates,
no attack surface. Safe default for fresh builds.
## Files
- `addon-updater.js` — client-side fetch, verify, stage, promote.
- `addon-update-pubkeys.js` — hardcoded pubkey array (edit and rebuild
to rotate).
- `scripts/generate-update-keypair.mjs` — one-time keygen.
- `scripts/sign-addon-update.mjs` — package + sign an update.