theseus/addon-updater.js
Local Dev 95d199c2f2 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

259 lines
12 KiB
JavaScript

// 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.
// 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,
};