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.
This commit is contained in:
parent
c2be569ac1
commit
cffb956a4c
8 changed files with 552 additions and 0 deletions
21
addon-update-pubkeys.js
Normal file
21
addon-update-pubkeys.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Ed25519 public keys authorized to sign bundled-addon updates.
|
||||
//
|
||||
// A pubkey listed here can, at runtime, cause Theseus to REPLACE any user's
|
||||
// bundled add-on with a signed payload fetched over the network. Only add
|
||||
// pubkeys the Silent Mode operator controls.
|
||||
//
|
||||
// Rotation:
|
||||
// 1. Generate a new keypair with scripts/generate-update-keypair.mjs.
|
||||
// 2. Sign next updates.json entries with BOTH old and new key.
|
||||
// 3. Ship a Theseus release adding the new pubkey to this array (both live).
|
||||
// 4. After users have updated past that release, ship a follow-up release
|
||||
// removing the old pubkey; stop signing with it.
|
||||
//
|
||||
// Empty array means "no update endpoint" — the client short-circuits and
|
||||
// makes no outbound requests. This is the safe default for a fresh build.
|
||||
|
||||
module.exports = {
|
||||
PUBKEYS_HEX: [
|
||||
// "abcdef…" // Silent Mode ops key, generated YYYY-MM-DD
|
||||
],
|
||||
};
|
||||
250
addon-updater.js
Normal file
250
addon-updater.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// Signed add-on update client.
|
||||
//
|
||||
// A bundled add-on can advertise an updateURL in its addon.json. On a
|
||||
// background boot task, Theseus fetches that URL, expects a signed
|
||||
// updates.json manifest, verifies the signature against a hardcoded set
|
||||
// of operator pubkeys (addon-update-pubkeys.js), and stages any newer
|
||||
// signed version under <userData>/addons-updates-staged/<id>-<version>/.
|
||||
// The NEXT launch's promoteStagedUpdates() moves the staged copy into
|
||||
// <userData>/addons/<id>/, reusing the same backup dance seedBundledAddons
|
||||
// already uses so any local edits the user made survive.
|
||||
//
|
||||
// Trust model:
|
||||
// - Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>|
|
||||
// <tarball-sha256>", verified against any pubkey in PUBKEYS_HEX.
|
||||
// - Payload: gzipped tar. SHA-256 is checked against the signed manifest
|
||||
// before extraction. Extraction uses the system `tar` (shipped with
|
||||
// Win10 1803+, present on macOS/Linux) via execFileSync; no npm deps.
|
||||
// - The extracted addon.json's id + version must match the manifest,
|
||||
// otherwise the stage is discarded.
|
||||
// - Empty pubkey list = skip everything, no outbound requests.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const https = require("node:https");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const SIG_DOMAIN = "silentmode.addon-update-v1";
|
||||
const MAX_MANIFEST_BYTES = 128 * 1024; // updates.json shouldn't exceed 128 KB
|
||||
const MAX_TARBALL_BYTES = 16 * 1024 * 1024; // an add-on payload above 16 MB is suspicious
|
||||
const MAX_REDIRECTS = 3;
|
||||
|
||||
function cmpVer(a, b) {
|
||||
const A = String(a || "").split(".").map((n) => parseInt(n, 10) || 0);
|
||||
const B = String(b || "").split(".").map((n) => parseInt(n, 10) || 0);
|
||||
const L = Math.max(A.length, B.length);
|
||||
for (let i = 0; i < L; i++) {
|
||||
const x = A[i] || 0, y = B[i] || 0;
|
||||
if (x !== y) return x < y ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function readAddonJson(dir) {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(dir, "addon.json"), "utf8")); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ---------- staged-update promotion --------------------------------------
|
||||
// Called BEFORE seedBundledAddons at boot. Any staged folder whose version
|
||||
// beats the currently installed copy is promoted; older or matching stages
|
||||
// are cleaned up so they don't loop on every boot.
|
||||
function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger }) {
|
||||
const log = logger || (() => {});
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); }
|
||||
catch (e) { log("promote: readdir failed:", e?.message); return; }
|
||||
for (const de of entries) {
|
||||
if (!de.isDirectory()) continue;
|
||||
const from = path.join(stagedDir, de.name);
|
||||
const manifest = readAddonJson(from);
|
||||
if (!manifest || !manifest.id || !manifest.version) {
|
||||
log(`promote: drop malformed stage ${de.name}`);
|
||||
try { fs.rmSync(from, { recursive: true, force: true }); } catch {}
|
||||
continue;
|
||||
}
|
||||
const target = path.join(addonsDir, manifest.id);
|
||||
const currentVer = readAddonJson(target)?.version;
|
||||
if (currentVer && cmpVer(currentVer, manifest.version) >= 0) {
|
||||
log(`promote: drop stage ${manifest.id}@${manifest.version} — installed ${currentVer} is newer or equal`);
|
||||
try { fs.rmSync(from, { recursive: true, force: true }); } catch {}
|
||||
continue;
|
||||
}
|
||||
try { fs.mkdirSync(backupsDir, { recursive: true }); } catch {}
|
||||
if (fs.existsSync(target)) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backup = path.join(backupsDir, `${manifest.id}-${currentVer ?? "unknown"}-${stamp}`);
|
||||
try { fs.renameSync(target, backup); }
|
||||
catch (e) { log(`promote: backup ${manifest.id} failed, keeping staged for next boot:`, e?.message); continue; }
|
||||
}
|
||||
try { fs.renameSync(from, target); log(`promote: ${manifest.id} -> ${manifest.version}`); }
|
||||
catch (e) {
|
||||
// Cross-drive rename can fail on Windows; copy then rm.
|
||||
try { fs.cpSync(from, target, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); log(`promote: ${manifest.id} -> ${manifest.version} (via copy)`); }
|
||||
catch (ee) { log(`promote: move ${manifest.id} failed:`, ee.message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- HTTP helpers --------------------------------------------------
|
||||
function httpGet(url, { timeoutMs = 15000, maxBytes = MAX_MANIFEST_BYTES, redirectsLeft = MAX_REDIRECTS } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let u;
|
||||
try { u = new URL(url); } catch (e) { return reject(new Error(`bad url: ${url}`)); }
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return reject(new Error(`unsupported scheme: ${u.protocol}`));
|
||||
const lib = u.protocol === "http:" ? http : https;
|
||||
const req = lib.get(u, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
if (redirectsLeft <= 0) return reject(new Error(`too many redirects for ${url}`));
|
||||
const next = new URL(res.headers.location, url).toString();
|
||||
return resolve(httpGet(next, { timeoutMs, maxBytes, redirectsLeft: redirectsLeft - 1 }));
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); }
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
res.on("data", (c) => {
|
||||
total += c.length;
|
||||
if (total > maxBytes) { req.destroy(new Error(`response over ${maxBytes} bytes`)); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout ${timeoutMs}ms for ${url}`)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Ed25519 signature verification -------------------------------
|
||||
// Node accepts raw Ed25519 pubkeys via SPKI-wrapped DER. We prepend the
|
||||
// 12-byte header for the OID + BIT STRING framing so verify() takes it.
|
||||
function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) {
|
||||
const canonical = `${SIG_DOMAIN}|${id}|${version}|${tarballSha256}`;
|
||||
let sig;
|
||||
try { sig = Buffer.from(sigB64, "base64"); } catch { return false; }
|
||||
if (sig.length !== 64) return false;
|
||||
const msg = Buffer.from(canonical);
|
||||
for (const hex of pubkeysHex) {
|
||||
const pkRaw = Buffer.from(hex, "hex");
|
||||
if (pkRaw.length !== 32) continue;
|
||||
const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), pkRaw]);
|
||||
let key;
|
||||
try { key = crypto.createPublicKey({ key: der, format: "der", type: "spki" }); }
|
||||
catch { continue; }
|
||||
try { if (crypto.verify(null, msg, key, sig)) return true; } catch { /* next */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- single-add-on staging ----------------------------------------
|
||||
async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, timeoutMs }) {
|
||||
let manifestBuf;
|
||||
try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); }
|
||||
catch (e) { log(`updates: fetch ${id} failed:`, e.message); return null; }
|
||||
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(manifestBuf.toString("utf8")); }
|
||||
catch (e) { log(`updates: manifest ${id} unparseable:`, e.message); return null; }
|
||||
|
||||
const addons = Array.isArray(manifest?.addons) ? manifest.addons : [];
|
||||
let best = null;
|
||||
for (const e of addons) {
|
||||
if (!e?.version || !e?.url || !e?.sha256 || !e?.sig) continue;
|
||||
if (currentVer && cmpVer(e.version, currentVer) <= 0) continue;
|
||||
if (!best || cmpVer(e.version, best.version) > 0) best = e;
|
||||
}
|
||||
if (!best) return null;
|
||||
|
||||
if (!verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex)) {
|
||||
log(`updates: ${id}@${best.version} signature INVALID, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const stageOut = path.join(stagedDir, `${id}-${best.version}`);
|
||||
const stagedVer = readAddonJson(stageOut)?.version;
|
||||
if (stagedVer === best.version) { log(`updates: ${id}@${best.version} already staged`); return stageOut; }
|
||||
if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} }
|
||||
|
||||
let tarball;
|
||||
try { tarball = await httpGet(best.url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); }
|
||||
catch (e) { log(`updates: tarball ${id}@${best.version} fetch failed:`, e.message); return null; }
|
||||
|
||||
const gotHash = crypto.createHash("sha256").update(tarball).digest("hex");
|
||||
if (gotHash.toLowerCase() !== String(best.sha256).toLowerCase()) {
|
||||
log(`updates: ${id}@${best.version} sha256 mismatch (${gotHash} vs ${best.sha256}), skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
||||
const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.tgz`);
|
||||
const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.dir`);
|
||||
try {
|
||||
await fsp.writeFile(tmpFile, tarball);
|
||||
await fsp.mkdir(tmpDir, { recursive: true });
|
||||
// Refuses `..` entries by default in modern tar. -P NOT passed = paths
|
||||
// stay stripped/relative.
|
||||
execFileSync("tar", ["-x", "-z", "-f", tmpFile, "-C", tmpDir], { stdio: "ignore" });
|
||||
} catch (e) {
|
||||
log(`updates: extract ${id}@${best.version} failed:`, e.message);
|
||||
try { fs.rmSync(tmpFile, { force: true }); } catch {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
const extracted = readAddonJson(tmpDir);
|
||||
if (!extracted || extracted.id !== id || extracted.version !== best.version) {
|
||||
log(`updates: extracted ${id}@${best.version} manifest mismatch (got ${extracted?.id}@${extracted?.version}), dropping`);
|
||||
try { fs.rmSync(tmpFile, { force: true }); } catch {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
return null;
|
||||
}
|
||||
try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {}
|
||||
try { fs.renameSync(tmpDir, stageOut); }
|
||||
catch {
|
||||
try { fs.cpSync(tmpDir, stageOut, { recursive: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); }
|
||||
catch (ee) { log(`updates: stage move ${id}@${best.version} failed:`, ee.message); return null; }
|
||||
}
|
||||
try { fs.rmSync(tmpFile, { force: true }); } catch {}
|
||||
log(`updates: staged ${id}@${best.version} — will apply on next launch`);
|
||||
return stageOut;
|
||||
}
|
||||
|
||||
async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, timeoutMs = 15000, logger }) {
|
||||
const log = logger || (() => {});
|
||||
if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) {
|
||||
log("updates: no operator pubkeys configured — skipping update check");
|
||||
return;
|
||||
}
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
const pending = [];
|
||||
for (const de of entries) {
|
||||
if (!de.isDirectory()) continue;
|
||||
const manifest = readAddonJson(path.join(addonsDir, de.name));
|
||||
if (!manifest?.id || !manifest?.updateURL) continue;
|
||||
pending.push(
|
||||
stageOne({
|
||||
id: manifest.id,
|
||||
currentVer: manifest.version,
|
||||
updateURL: manifest.updateURL,
|
||||
stagedDir, pubkeysHex, log, timeoutMs,
|
||||
}).catch((e) => { log(`updates: ${manifest.id} unexpected error:`, e.message); return null; })
|
||||
);
|
||||
}
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cmpVer,
|
||||
promoteStagedUpdates,
|
||||
checkAndStageUpdates,
|
||||
verifySignature,
|
||||
SIG_DOMAIN,
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"icon": "📸",
|
||||
"main": "index.js",
|
||||
"capabilities": ["toolbar-menu", "capture-tab", "open-tab"],
|
||||
"updateURL": "https://addons.silentmode.st/screenshot/updates.json",
|
||||
"toolbar-menu": {
|
||||
"title": "Screenshot",
|
||||
"icon": "📸",
|
||||
|
|
|
|||
131
docs/ADDON-UPDATES.md
Normal file
131
docs/ADDON-UPDATES.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# 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
|
||||
|
||||
```
|
||||
node scripts/sign-addon-update.mjs \
|
||||
bundled-addons/screenshot \
|
||||
https://addons.silentmode.st/screenshot \
|
||||
"$USERPROFILE/.silentmode/ops/addon-update-key.pem"
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
scp out/screenshot-*.tar.gz silentmode:/opt/silent-mode/site/addons/screenshot/
|
||||
scp updates.json silentmode:/opt/silent-mode/site/addons/screenshot/
|
||||
```
|
||||
|
||||
The Sia autosync timer picks up `/opt/silent-mode/site/` on its usual
|
||||
5-minute cadence, so both mirrors update together (see
|
||||
`shipping-mirrors` for background).
|
||||
|
||||
## `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.
|
||||
26
main.js
26
main.js
|
|
@ -1288,9 +1288,12 @@ let addonHost = null;
|
|||
// current proxy.
|
||||
let proxyLoginHandler = null;
|
||||
const { AddonHost } = require("./addons-host.js");
|
||||
const addonUpdater = require("./addon-updater.js");
|
||||
const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js");
|
||||
function addonsUserDir() { return path.join(app.getPath("userData"), "addons"); }
|
||||
function addonsDataDir() { return path.join(app.getPath("userData"), "addons-data"); }
|
||||
function addonsBackupDir() { return path.join(app.getPath("userData"), "addons-backups"); }
|
||||
function addonsStagedDir() { return path.join(app.getPath("userData"), "addons-updates-staged"); }
|
||||
function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); }
|
||||
// Copy bundled reference add-ons (shipped inside resources/) into the user's
|
||||
// addons directory. Users can then edit, disable, or delete them — the
|
||||
|
|
@ -1338,6 +1341,16 @@ function seedBundledAddons() {
|
|||
}
|
||||
}
|
||||
function initAddons() {
|
||||
// Promote any signed add-on update staged by a previous run BEFORE we
|
||||
// reseed from the bundle — a fresh install of a newer version from
|
||||
// updateURL should win over the older bundled copy shipped inside the
|
||||
// Theseus installer.
|
||||
addonUpdater.promoteStagedUpdates({
|
||||
addonsDir: addonsUserDir(),
|
||||
backupsDir: addonsBackupDir(),
|
||||
stagedDir: addonsStagedDir(),
|
||||
logger: (...a) => console.log("[addons]", ...a),
|
||||
});
|
||||
seedBundledAddons();
|
||||
addonHost = new AddonHost({
|
||||
addonsDir: addonsUserDir(),
|
||||
|
|
@ -3793,6 +3806,19 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
|
|||
protocol.handle("bns", serveBns);
|
||||
installDownloadTracker();
|
||||
initAddons();
|
||||
// Kick off signed add-on update polling 30 s after boot so it never
|
||||
// slows launch. Any staged update lands in <userData>/addons-updates-
|
||||
// staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons.
|
||||
// Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a
|
||||
// key in) short-circuits inside checkAndStageUpdates — no HTTP is made.
|
||||
setTimeout(() => {
|
||||
addonUpdater.checkAndStageUpdates({
|
||||
addonsDir: addonsUserDir(),
|
||||
stagedDir: addonsStagedDir(),
|
||||
pubkeysHex: ADDON_UPDATE_PUBKEYS,
|
||||
logger: (...a) => console.log("[addons]", ...a),
|
||||
}).catch(() => {});
|
||||
}, 30_000);
|
||||
createWindow();
|
||||
// Multi-source BNS warm-up so the first .bch page opens near-instantly and
|
||||
// stays fresh for as long as the browser is running. Every source runs in
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@
|
|||
"approval.html",
|
||||
"addon-inject-preload.js",
|
||||
"addons-host.js",
|
||||
"addon-updater.js",
|
||||
"addon-update-pubkeys.js",
|
||||
"link-status.html",
|
||||
"link-status-preload.js",
|
||||
"collision.html",
|
||||
|
|
|
|||
41
scripts/generate-update-keypair.mjs
Normal file
41
scripts/generate-update-keypair.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env node
|
||||
// Generate an Ed25519 keypair for signing add-on updates.
|
||||
//
|
||||
// node scripts/generate-update-keypair.mjs <out-dir>
|
||||
//
|
||||
// Writes:
|
||||
// <out-dir>/addon-update-key.pem — PKCS#8 PEM private key (keep secret)
|
||||
// Prints:
|
||||
// <64-char hex pubkey> — copy into addon-update-pubkeys.js
|
||||
//
|
||||
// The out-dir MUST NOT be inside the repo. Recommended: a machine-local
|
||||
// ops folder outside git, e.g. `%USERPROFILE%\.silentmode\ops`.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import process from "node:process";
|
||||
|
||||
const outDir = process.argv[2];
|
||||
if (!outDir) {
|
||||
console.error("usage: node scripts/generate-update-keypair.mjs <out-dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const keyPath = path.join(outDir, "addon-update-key.pem");
|
||||
if (fs.existsSync(keyPath)) {
|
||||
console.error(`refuse to overwrite existing key at ${keyPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
fs.writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
|
||||
|
||||
// Extract the 32-byte raw pubkey from the SPKI DER.
|
||||
const der = publicKey.export({ type: "spki", format: "der" });
|
||||
const raw = der.subarray(der.length - 32);
|
||||
const hex = raw.toString("hex");
|
||||
|
||||
console.log(`private key written to ${keyPath}`);
|
||||
console.log(`public key (paste into addon-update-pubkeys.js PUBKEYS_HEX):`);
|
||||
console.log(` "${hex}"`);
|
||||
80
scripts/sign-addon-update.mjs
Normal file
80
scripts/sign-addon-update.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#!/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);
|
||||
|
||||
// Tar the addon folder from its parent so archived paths are addon-relative.
|
||||
const parent = path.dirname(path.resolve(addonDir));
|
||||
const folder = path.basename(path.resolve(addonDir));
|
||||
try {
|
||||
execFileSync("tar", ["-c", "-z", "-f", tarPath, "-C", parent, folder], { stdio: "inherit" });
|
||||
} 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));
|
||||
Loading…
Add table
Reference in a new issue