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
|
|
|
#!/usr/bin/env node
|
|
|
|
|
// Package and sign an add-on update, ready to publish behind an updateURL.
|
|
|
|
|
//
|
|
|
|
|
// node scripts/sign-addon-update.mjs <addon-dir> <base-url> <private-key.pem> [--out <dir>]
|
|
|
|
|
//
|
|
|
|
|
// - <addon-dir> path to the local addon folder to publish (must contain addon.json)
|
|
|
|
|
// - <base-url> canonical URL prefix where the tarball will be hosted;
|
|
|
|
|
// the tarball is placed at <base-url>/<id>-<version>.tar.gz
|
|
|
|
|
// - <private-key.pem> PKCS#8 PEM (as written by generate-update-keypair.mjs)
|
|
|
|
|
// - --out <dir> output dir (default: ./out/); tarball + updates.json entry are written there
|
|
|
|
|
//
|
|
|
|
|
// Produces:
|
|
|
|
|
// <out>/<id>-<version>.tar.gz — the payload the client will fetch
|
|
|
|
|
// <out>/<id>-<version>.entry.json — one signed entry (append to your live updates.json)
|
|
|
|
|
//
|
|
|
|
|
// Publish flow:
|
|
|
|
|
// 1. scp <out>/<id>-<version>.tar.gz to your web root at <base-url>
|
|
|
|
|
// 2. update the live updates.json for that add-on to include this entry
|
|
|
|
|
// (prepend it — the client picks the highest advertised version)
|
|
|
|
|
// 3. wait for existing installs to fetch on their next boot's +30s tick
|
|
|
|
|
|
|
|
|
|
import fs from "node:fs";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import crypto from "node:crypto";
|
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
|
|
|
import process from "node:process";
|
|
|
|
|
import os from "node:os";
|
|
|
|
|
|
|
|
|
|
const SIG_DOMAIN = "silentmode.addon-update-v1";
|
|
|
|
|
|
|
|
|
|
function die(msg) { console.error("error: " + msg); process.exit(1); }
|
|
|
|
|
|
|
|
|
|
const args = process.argv.slice(2);
|
|
|
|
|
const outFlagIdx = args.indexOf("--out");
|
|
|
|
|
const outDir = outFlagIdx >= 0 ? args.splice(outFlagIdx, 2)[1] : path.resolve("out");
|
|
|
|
|
const [addonDir, baseUrl, keyPath] = args;
|
|
|
|
|
if (!addonDir || !baseUrl || !keyPath) {
|
|
|
|
|
console.error("usage: node scripts/sign-addon-update.mjs <addon-dir> <base-url> <private-key.pem> [--out <dir>]");
|
|
|
|
|
process.exit(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addonJsonPath = path.join(addonDir, "addon.json");
|
|
|
|
|
if (!fs.existsSync(addonJsonPath)) die(`no addon.json in ${addonDir}`);
|
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(addonJsonPath, "utf8"));
|
|
|
|
|
const { id, version } = manifest;
|
|
|
|
|
if (!id || !version) die("addon.json missing id or version");
|
|
|
|
|
|
|
|
|
|
const keyPem = fs.readFileSync(keyPath, "utf8");
|
|
|
|
|
let privateKey;
|
|
|
|
|
try { privateKey = crypto.createPrivateKey({ key: keyPem, format: "pem" }); }
|
|
|
|
|
catch (e) { die(`bad private key at ${keyPath}: ${e.message}`); }
|
|
|
|
|
if (privateKey.asymmetricKeyType !== "ed25519") die("private key must be Ed25519");
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
|
const tarName = `${id}-${version}.tar.gz`;
|
|
|
|
|
const tarPath = path.join(outDir, tarName);
|
|
|
|
|
|
fix(theseus/addons): windows-tar fixes for the addon updater, verified end-to-end
An end-to-end drive of the update flow against a local HTTP server hit
two Windows-only tar quirks that a first-cut MVP wouldn't catch:
1. Git-Bash tar (MSYS2), which comes first on PATH when Git-for-Windows
is installed, treats drive-letter paths as `host:file` remote-archive
syntax. Sidestepped with --force-local (also silently accepted by
Win10's built-in bsdtar and by GNU tar).
2. Even with --force-local, MSYS2's argv-conversion layer mangles
backslashes in Windows paths, so `C:\Users\...\tmp\dir` arrives at
tar as `C:\Users...\dir` and it can't open the path. Passing
forward-slash paths (`C:/Users/.../tmp/dir`) dodges the mangler;
bsdtar and GNU tar accept them as-is.
3. sign-addon-update.mjs was tar'ing the addon directory as a subfolder
(`screenshot/addon.json` inside the archive), so the client
extracted to `<tmp>/screenshot/` and then failed the id+version
re-check because addon.json wasn't at the root. Now the signer
tars the CONTENTS of the addon dir via `tar -C <addon-dir> .`, so
entries live at the archive root where the client expects them.
All three surfaced from `scratchpad/decoupling-test/run-test.mjs`, which
now walks the full path — sign, serve, fetch, verify, download,
extract, stage, promote, backup — plus three signature-tamper negatives
and the empty-pubkey short-circuit. 15/15 checks pass.
2026-09-07 22:28:55 +02:00
|
|
|
// Tar the CONTENTS of the addon folder (not the folder itself) so entries
|
|
|
|
|
// live at the archive root: `addon.json`, `editor.html`, etc. The client
|
|
|
|
|
// extracts into a fresh dir and expects to find addon.json directly there.
|
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
|
|
|
try {
|
fix(theseus/addons): windows-tar fixes for the addon updater, verified end-to-end
An end-to-end drive of the update flow against a local HTTP server hit
two Windows-only tar quirks that a first-cut MVP wouldn't catch:
1. Git-Bash tar (MSYS2), which comes first on PATH when Git-for-Windows
is installed, treats drive-letter paths as `host:file` remote-archive
syntax. Sidestepped with --force-local (also silently accepted by
Win10's built-in bsdtar and by GNU tar).
2. Even with --force-local, MSYS2's argv-conversion layer mangles
backslashes in Windows paths, so `C:\Users\...\tmp\dir` arrives at
tar as `C:\Users...\dir` and it can't open the path. Passing
forward-slash paths (`C:/Users/.../tmp/dir`) dodges the mangler;
bsdtar and GNU tar accept them as-is.
3. sign-addon-update.mjs was tar'ing the addon directory as a subfolder
(`screenshot/addon.json` inside the archive), so the client
extracted to `<tmp>/screenshot/` and then failed the id+version
re-check because addon.json wasn't at the root. Now the signer
tars the CONTENTS of the addon dir via `tar -C <addon-dir> .`, so
entries live at the archive root where the client expects them.
All three surfaced from `scratchpad/decoupling-test/run-test.mjs`, which
now walks the full path — sign, serve, fetch, verify, download,
extract, stage, promote, backup — plus three signature-tamper negatives
and the empty-pubkey short-circuit. 15/15 checks pass.
2026-09-07 22:28:55 +02:00
|
|
|
// On Windows, Git-Bash tar (MSYS2) both mistakes drive letters for
|
|
|
|
|
// host:file remote-archive syntax AND mangles backslashes on its way to
|
|
|
|
|
// tar's argv. --force-local kills the first, forward-slash paths kill
|
|
|
|
|
// the second. Win10 built-in bsdtar and GNU tar both accept the flag.
|
|
|
|
|
const posix = (p) => p.replace(/\\/g, "/");
|
|
|
|
|
execFileSync("tar", ["--force-local", "-c", "-z", "-f", posix(tarPath), "-C", posix(path.resolve(addonDir)), "."], { stdio: "inherit" });
|
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
|
|
|
} catch (e) { die(`tar failed: ${e.message}`); }
|
|
|
|
|
|
|
|
|
|
const tarBytes = fs.readFileSync(tarPath);
|
|
|
|
|
const sha256 = crypto.createHash("sha256").update(tarBytes).digest("hex");
|
|
|
|
|
const url = baseUrl.replace(/\/+$/, "") + "/" + tarName;
|
|
|
|
|
|
|
|
|
|
const canonical = `${SIG_DOMAIN}|${id}|${version}|${sha256}`;
|
|
|
|
|
const sig = crypto.sign(null, Buffer.from(canonical), privateKey).toString("base64");
|
|
|
|
|
|
|
|
|
|
const entry = { version, url, sha256, sig };
|
|
|
|
|
const entryPath = path.join(outDir, `${id}-${version}.entry.json`);
|
|
|
|
|
fs.writeFileSync(entryPath, JSON.stringify(entry, null, 2) + "\n");
|
|
|
|
|
|
|
|
|
|
console.log(`tarball: ${tarPath} (${tarBytes.length} bytes, ${sha256})`);
|
|
|
|
|
console.log(`entry: ${entryPath}`);
|
|
|
|
|
console.log("");
|
|
|
|
|
console.log("To publish, host the tarball at " + url + " and add this entry to updates.json:");
|
|
|
|
|
console.log(JSON.stringify(entry, null, 2));
|