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.
5.8 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>/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.gzout/screenshot-<version>.entry.json— prepend to your liveupdates.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
{
"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
generate-update-keypair.mjsa new one.- Publish next
updates.jsonentries signed by BOTH old and new key (the client accepts a signature from any pubkey inPUBKEYS_HEX). - Ship a Theseus release that adds the new pubkey.
- 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
sigfield to an array) - on-BCNR pubkey publication: pin the current pubkey set to a BNS record so rotation is auditable (roadmap)
- multi-sig: require signatures from N of M operator keys (not
implemented — would extend the
- 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 viaseedBundledAddonsunless the bundle is older still. - Path traversal in tarball —
tarrefuses..entries by default; we do not pass-P. Extracted manifest'sid+versionare 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.