theseus/docs/ADDON-UPDATES.md
Local Dev 43d0021bf6 docs(theseus): add-on update channel lives on Sia, not the VPS — publish with sia-upload
The publishing steps pointed at scp to /opt/silent-mode/site/addons/, which
never existed; the screenshot channel (and now aegis) is served from
s3://bns/theseus/extensions/<id>/ through navigate.st/bns/theseus.x/….
Also: sign from a git-archive copy so uncommitted edits don't ship.
2026-09-10 22:25:18 +02:00

5.3 KiB

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

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 \
    <clean-copy>/bundled-addons/screenshot \
    https://navigate.st/bns/theseus.x/extensions/screenshot \
    "$USERPROFILE/.silentmode/ops/addon-update-key.pem" --out out/

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

The channel files live on Sia, under s3://bns/theseus/extensions/<id>/, and are served through the public gateway as https://navigate.st/bns/theseus.x/extensions/<id>/… — 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-<version>.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 and aegis.

updates.json schema

{
  "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 tarballtar 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.