// 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 /extensions-staged/-/. // The NEXT launch's promoteStagedUpdates() moves the staged copy into // /extensions//, 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. // `only(manifest)` limits the promotion to some staged entries (the live // "Install update" path applies extensions in place but leaves plug-ins for // the next launch). Returns the list of { id, version } actually promoted. function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger, only }) { const log = logger || (() => {}); const promoted = []; if (!fs.existsSync(stagedDir)) return promoted; let entries; try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); } catch (e) { log("promote: readdir failed:", e?.message); return promoted; } 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; } if (typeof only === "function" && !only(manifest)) 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}`); promoted.push({ id: manifest.id, version: 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 }); promoted.push({ id: manifest.id, version: manifest.version }); log(`promote: ${manifest.id} -> ${manifest.version} (via copy)`); } catch (ee) { log(`promote: move ${manifest.id} failed:`, ee.message); } } } return promoted; } // ---------- 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 ---------------------------------------- // Read a channel manifest and pick its best entry. An entry is trusted when // EITHER the operator signed it (Ed25519 `sig`, bundled add-ons) OR the // publisher signed it (`publisherSig`, community extensions — verified by the // caller-supplied verifyPublisher against the publisher name's NFT owner). async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }) { let manifestBuf; try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); } catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; } let manifest; try { manifest = JSON.parse(manifestBuf.toString("utf8")); } catch (e) { log(`updates: manifest ${id} unparseable:`, e.message); return { status: "fetch-failed", detail: "manifest not JSON: " + e.message }; } 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 || e?.publisherSig)) continue; if (currentVer && cmpVer(e.version, currentVer) <= 0) continue; if (!best || cmpVer(e.version, best.version) > 0) best = e; } if (!best) return { status: "up-to-date" }; let trusted = false; if (best.sig && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex); if (!trusted && best.publisherSig && typeof verifyPublisher === "function") { try { trusted = !!(await verifyPublisher({ ...best, id })); } catch (e) { log(`updates: publisher verify ${id}@${best.version} threw:`, e.message); } } if (!trusted) { log(`updates: ${id}@${best.version} signature INVALID, skipping`); return { status: "signature-invalid", newVer: best.version }; } return { status: "ok", best }; } // Download a package, check its sha256 against the signed value, and extract // it to a temp dir whose addon.json must match id + version. Returns // { ok, tmpDir, tmpFile } or { status, detail }. The caller moves tmpDir. async function fetchVerifiedPackage({ id, version, url, sha256, log }) { let tarball; try { tarball = await httpGet(url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); } catch (e) { log(`updates: tarball ${id}@${version} fetch failed:`, e.message); return { status: "fetch-failed", newVer: version, detail: "tarball: " + e.message }; } const gotHash = crypto.createHash("sha256").update(tarball).digest("hex"); if (gotHash.toLowerCase() !== String(sha256).toLowerCase()) { log(`updates: ${id}@${version} sha256 mismatch (${gotHash} vs ${sha256}), skipping`); return { status: "sha256-mismatch", newVer: version }; } const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.tgz`); const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${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. // BUT: Windows 10's built-in bsdtar does NOT recognise --force-local at // all and errors out with "unknown option". Try WITHOUT the flag first // (safe with posix paths on every tar we care about) and fall back to // WITH it for MSYS2 tar which parses `C:/…` as a host prefix. Either // path is a single tar invocation — the fallback only fires on the // exit-code failure of the first. const posix = (p) => p.replace(/\\/g, "/"); const baseArgs = ["-x", "-z", "-f", posix(tmpFile), "-C", posix(tmpDir)]; try { execFileSync("tar", baseArgs, { stdio: "ignore" }); } catch (e1) { try { execFileSync("tar", ["--force-local", ...baseArgs], { stdio: "ignore" }); } catch (e2) { // Surface the first error; --force-local retry is opportunistic. throw e1; } } } catch (e) { log(`updates: extract ${id}@${version} failed:`, e.message); try { fs.rmSync(tmpFile, { force: true }); } catch {} try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} return { status: "extract-failed", newVer: version, detail: e.message }; } // A package may wrap everything in one top-level folder (tar -czf x.tgz my-ext); // unwrap it so addon.json sits at the root like the bundled add-ons. let root = tmpDir; if (!readAddonJson(root)) { const kids = fs.readdirSync(root, { withFileTypes: true }).filter((d) => !d.name.startsWith(".")); if (kids.length === 1 && kids[0].isDirectory() && readAddonJson(path.join(root, kids[0].name))) root = path.join(root, kids[0].name); } const extracted = readAddonJson(root); if (!extracted || extracted.id !== id || extracted.version !== version) { log(`updates: extracted ${id}@${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 { status: "manifest-mismatch", newVer: version, detail: `got ${extracted?.id}@${extracted?.version}` }; } return { ok: true, tmpDir: root, tmpTop: tmpDir, tmpFile, manifest: extracted }; } // Move an extracted package into place (rename, with copy fallback across volumes). function placeDir(from, to) { try { fs.renameSync(from, to); } catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); } } async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs }) { const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }); if (picked.status !== "ok") return picked; const best = picked.best; 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 { status: "already-staged", newVer: best.version, stagePath: stageOut }; } if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} } const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log }); if (!pkg.ok) return pkg; try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {} try { placeDir(pkg.tmpDir, stageOut); } catch (ee) { log(`updates: stage move ${id}@${best.version} failed:`, ee.message); return { status: "extract-failed", newVer: best.version, detail: "stage move: " + ee.message }; } try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {} try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {} log(`updates: staged ${id}@${best.version} — will apply on next launch`); return { status: "staged", newVer: best.version, stagePath: stageOut }; } // ---------- community install (theseus.x/extensions) --------------------- // Install or update one community extension straight into addonsDir from // its channel manifest. Trust is the publisher's signature (verifyPublisher // checks it against the name's NFT owner); a prior copy is kept in backupsDir // like every other add-on swap. The installed addon.json gets `updateURL` // and `publisher` so the regular update check covers it from then on. async function installCommunity({ id, updatesUrl, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) { if (typeof verifyPublisher !== "function") return { ok: false, error: "no publisher verifier" }; const dest = path.join(addonsDir, id); const currentVer = readAddonJson(dest)?.version || null; const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, log, timeoutMs }); if (picked.status === "up-to-date") return { ok: false, error: currentVer ? `already installed (v${currentVer})` : "channel has no installable version" }; if (picked.status !== "ok") return { ok: false, error: picked.status + (picked.detail ? ": " + picked.detail : ""), status: picked.status }; const best = picked.best; const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log }); if (!pkg.ok) return { ok: false, error: pkg.status + (pkg.detail ? ": " + pkg.detail : ""), status: pkg.status }; try { // Record where updates come from and who signed this copy. const mf = { ...pkg.manifest }; if (!mf.updateURL) mf.updateURL = updatesUrl; mf.publisher = best.publisher; fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2)); fs.mkdirSync(addonsDir, { recursive: true }); if (fs.existsSync(dest)) { fs.mkdirSync(backupsDir, { recursive: true }); const backup = path.join(backupsDir, `${id}-${currentVer || "unknown"}-${Date.now()}`); placeDir(dest, backup); log(`community: previous ${id} moved to ${backup}`); } placeDir(pkg.tmpDir, dest); } catch (e) { try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {} return { ok: false, error: "install: " + e.message }; } finally { try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {} try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {} } log(`community: installed ${id}@${best.version} signed by ${best.publisher}`); return { ok: true, id, version: best.version, publisher: best.publisher, previous: currentVer }; } // Returns a `report` array: one entry per installed add-on the client // considered — { id, currentVer, updateURL, status, detail? }. Status is // one of: "no-update-url" | "fetch-failed" | "up-to-date" | "signature-invalid" // | "sha256-mismatch" | "extract-failed" | "manifest-mismatch" // | "staged" | "already-staged". The manual UI in Settings > Extensions // uses this to tell the user WHY nothing landed instead of a single // "up to date" that hides fetch/verify failures. async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPublisher, timeoutMs = 15000, logger }) { const log = logger || (() => {}); const report = []; if ((!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) && typeof verifyPublisher !== "function") { log("updates: no operator pubkeys configured — skipping update check"); return { report, skipped: "no-pubkeys" }; } let entries; try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); } catch { return { report, skipped: "no-addons-dir" }; } const pending = []; for (const de of entries) { if (!de.isDirectory()) continue; const manifest = readAddonJson(path.join(addonsDir, de.name)); if (!manifest?.id) continue; if (!manifest?.updateURL) { report.push({ id: manifest.id, currentVer: manifest.version, updateURL: null, status: "no-update-url" }); continue; } pending.push( stageOne({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs, }) .then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r })) .catch((e) => { log(`updates: ${manifest.id} unexpected error:`, e.message); report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, status: "unexpected-error", detail: e.message }); }) ); } await Promise.all(pending); return { report }; } module.exports = { cmpVer, promoteStagedUpdates, checkAndStageUpdates, installCommunity, verifySignature, SIG_DOMAIN, };