// 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. // Two Windows tar quirks we sidestep with one small trick: // 1. Git-Bash tar (MSYS2) sees drive letters as host:file remote syntax // without --force-local. // 2. Even with --force-local, MSYS2's argument-conversion layer mangles // backslashes it doesn't understand, so `C:\Users\...\Temp\dir` // arrives at tar as `C:\\Users...\\dir` and it can't open the path. // Forward-slash paths dodge both — bsdtar (Win10 built-in), GNU tar, // and MSYS2 tar all accept `C:/Users/...` as a plain path. const posix = (p) => p.replace(/\\/g, "/"); execFileSync("tar", ["--force-local", "-x", "-z", "-f", posix(tmpFile), "-C", posix(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, };