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
|
|
|
|
|
|
2026-09-10 22:25:18 +02:00
|
|
|
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:
|
|
|
|
|
|
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
|
|
|
```
|
|
|
|
|
node scripts/sign-addon-update.mjs \
|
2026-09-10 22:25:18 +02:00
|
|
|
<clean-copy>/bundled-addons/screenshot \
|
|
|
|
|
https://navigate.st/bns/theseus.x/extensions/screenshot \
|
|
|
|
|
"$USERPROFILE/.silentmode/ops/addon-update-key.pem" --out out/
|
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
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
|
2026-09-10 22:25:18 +02:00
|
|
|
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):
|
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
|
|
|
|
|
|
|
|
```
|
2026-09-10 22:25:18 +02:00
|
|
|
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
|
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
|
|
|
```
|
|
|
|
|
|
2026-09-10 22:25:18 +02:00
|
|
|
Then confirm both URLs answer 200 through the gateway before relying on
|
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser
A PDF that needs a signature, a highlight or a page removed currently sends
the user out to a desktop application or, worse, to a web service that wants
the document uploaded first. Both are poor answers for a browser whose point
is that nothing has to leave the machine. This is a full-tab editor that opens
a PDF, marks it up, fills its forms and saves a new copy, entirely locally.
Two engines, vendored rather than installed, because an add-on ships as a
self-contained folder over the signed update channel and nothing runs a
package manager on the way: pdf.js reads and renders, pdf-lib writes. They
share no state. Everything in between lives in PDF user space — points,
origin bottom-left — which is the one coordinate vocabulary both speak, so a
mark survives zooming, rotating and reordering with no conversion table and
save-time needs to know nothing about how a page happened to be displayed.
The page strip is built from pdf.js's PDFPageView components rather than its
PDFViewer, which renders pages in the file's own order and cannot hide,
reorder or individually rotate one — three of the features here. Text layers
are ours and stay attached for every page, drawn or not, because Theseus's
find bar is Chromium's findInPage over the live DOM and a torn-down text layer
is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100%
is 3.4 MB of bitmap.
Redaction is the part worth being careful about. A black box over text hides
nothing — the text stays in the content stream and comes straight out of a
copy-paste — so the editor says so in a modal before the tool can be used,
and on save rebuilds each redacted page as an image, which genuinely removes
it. Pages that were not redacted are untouched. Form widgets and links are
kept, since they were never the leak.
Saving never writes over the original: every save reloads the source bytes and
replays the session onto a fresh copy, so a botched save cannot poison the
next one.
Out of scope for this first version: editing the text that is already in the
document, and writing XFA forms back (pdf-lib cannot, so those are fill-and-
print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
|
|
|
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.
|
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
|
|
|
|
|
|
|
|
## `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.
|