From cffb956a4ca7d6cc2b0a6a0705008997fb36746f Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 21:58:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(theseus/addons):=20signed=20add-on=20updat?= =?UTF-8?q?e=20endpoint,=20=C3=A0=20la=20Firefox=20XPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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|||", 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 /addons-updates-staged/-/. Promotion into /addons// reuses seedBundledAddons's backup dance: existing folder moves to /addons-backups/--/ 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. --- addon-update-pubkeys.js | 21 +++ addon-updater.js | 250 +++++++++++++++++++++++++++ bundled-addons/screenshot/addon.json | 1 + docs/ADDON-UPDATES.md | 131 ++++++++++++++ main.js | 26 +++ package.json | 2 + scripts/generate-update-keypair.mjs | 41 +++++ scripts/sign-addon-update.mjs | 80 +++++++++ 8 files changed, 552 insertions(+) create mode 100644 addon-update-pubkeys.js create mode 100644 addon-updater.js create mode 100644 docs/ADDON-UPDATES.md create mode 100644 scripts/generate-update-keypair.mjs create mode 100644 scripts/sign-addon-update.mjs diff --git a/addon-update-pubkeys.js b/addon-update-pubkeys.js new file mode 100644 index 0000000..c258c89 --- /dev/null +++ b/addon-update-pubkeys.js @@ -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 + ], +}; diff --git a/addon-updater.js b/addon-updater.js new file mode 100644 index 0000000..36de880 --- /dev/null +++ b/addon-updater.js @@ -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 /addons-updates-staged/-/. +// The NEXT launch's promoteStagedUpdates() moves the staged copy into +// /addons//, 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||| +// ", 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, +}; diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json index 2ceba3e..56b305c 100644 --- a/bundled-addons/screenshot/addon.json +++ b/bundled-addons/screenshot/addon.json @@ -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": "📸", diff --git a/docs/ADDON-UPDATES.md b/docs/ADDON-UPDATES.md new file mode 100644 index 0000000..954036f --- /dev/null +++ b/docs/ADDON-UPDATES.md @@ -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 +`/addons-updates-staged/-/`, 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 +`/addons-backups/--/`. + +## 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-.tar.gz` — upload to `/-.tar.gz` +- `out/screenshot-.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|||" +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 `/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. diff --git a/main.js b/main.js index c70c80f..6b2fcb8 100644 --- a/main.js +++ b/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 /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 diff --git a/package.json b/package.json index 1f6e2a0..cc04977 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-update-keypair.mjs b/scripts/generate-update-keypair.mjs new file mode 100644 index 0000000..917a116 --- /dev/null +++ b/scripts/generate-update-keypair.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Generate an Ed25519 keypair for signing add-on updates. +// +// node scripts/generate-update-keypair.mjs +// +// Writes: +// /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 "); + 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}"`); diff --git a/scripts/sign-addon-update.mjs b/scripts/sign-addon-update.mjs new file mode 100644 index 0000000..c04c60f --- /dev/null +++ b/scripts/sign-addon-update.mjs @@ -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 [--out ] +// +// - path to the local addon folder to publish (must contain addon.json) +// - canonical URL prefix where the tarball will be hosted; +// the tarball is placed at /-.tar.gz +// - PKCS#8 PEM (as written by generate-update-keypair.mjs) +// - --out output dir (default: ./out/); tarball + updates.json entry are written there +// +// Produces: +// /-.tar.gz — the payload the client will fetch +// /-.entry.json — one signed entry (append to your live updates.json) +// +// Publish flow: +// 1. scp /-.tar.gz to your web root at +// 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 [--out ]"); + 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));