# 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 `/addons-updates-staged/-/`, 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 `/addons-backups/--/`. ## 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 Sign from a clean copy of the committed add-on (`git archive HEAD …`), not the working tree, so another session's uncommitted edits don't ship: ``` node scripts/sign-addon-update.mjs \ /bundled-addons/screenshot \ https://navigate.st/bns/theseus.x/extensions/screenshot \ "$USERPROFILE/.silentmode/ops/addon-update-key.pem" --out out/ ``` Produces: - `out/screenshot-.tar.gz` — upload to `/-.tar.gz` - `out/screenshot-.entry.json` — prepend to your live `updates.json` The channel files live on Sia, under `s3://bns/theseus/extensions//`, and are served through the public gateway as `https://navigate.st/bns/theseus.x/extensions//…` — that is what every bundled add-on's `updateURL` points at. Nothing is on the VPS filesystem. Upload with the Sia uploader (single file → directory prefix, not a full target path): ``` node ../Argus/src/lib/sia-upload.js out/screenshot-.tar.gz bns/theseus/extensions/screenshot node ../Argus/src/lib/sia-upload.js updates.json bns/theseus/extensions/screenshot ``` Then confirm both URLs answer 200 through the gateway before relying on the entry. Live channels: `screenshot`, `aegis` and `pdf-editor`. A first release has no `updates.json` to prepend to — create one containing just the new entry and upload both files. Until it exists the client logs a 404 on every check, which is harmless but noisy: ``` echo '{"addons":[]}' > updates.json # then merge the .entry.json into it ``` `pdf-editor` ships about 4 MB of vendored pdf.js and pdf-lib, so its tarball is far larger than the others'. Nothing in the pipeline cares, but the first check after a release takes a moment longer. ## `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|||" 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 `/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.