The profile folder was Electron's default from the product name
("Theseus Navigator") and add-ons lived in addons\ under it. Now:
%APPDATA%\Theseus\extensions\ installed extensions
%APPDATA%\Theseus\extensions-data\ per-extension storage + scratch
%APPDATA%\Theseus\extensions-backups\ replaced copies
%APPDATA%\Theseus\extensions-staged\ staged updates
Both moves are one-time migrations on the first start that finds the old
layout: the profile folder is renamed (same volume, instant) or copied
when a rename is refused, with the old folder left in place in that case;
the four sub-folders are renamed before the extension host first reads
them. Nothing is deleted. THESEUS_USER_DATA still overrides everything.
The host now hands each extension its data folder as api.dataDir; the
Screenshot and PDF editor add-ons used to rebuild the old path from their
own folder for scratch files (so they recreated addons-data\ after the
move) and now use the field, with versions bumped so the bundles reseed.
150 lines
5.8 KiB
Markdown
150 lines
5.8 KiB
Markdown
# 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>/extensions-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>/extensions-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`, `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|<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>/extensions/` 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.
|