Updates published after launch never showed: the add-on channel was checked once, 30 s after boot, and staged copies only applied on the next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3 and VPN 0.1.3 landed minutes after the app's only check and stayed invisible through manual scans made earlier and a restart made before they were published. Now: the check repeats every 4 hours; every check (boot, timer, manual, add-on-driven) reports what is staged to the chrome, which shows a chip for staged extensions; clicking it, or the "Update to vX" button that appears on the extension's row and detail in Settings, promotes the staged folder over the installed one and rebuilds the add-on host, so the new version runs without a restart. Plug-ins (Aegis) are excluded from the chip and the hot swap — a wallet updates from its own panel and applies on the next launch. Also from the same review: the new-tab button follows the last tab and parks after the scroll arrow only when the strip overflows; Tor sits left of the Aegis chip; plug-ins no longer appear in Settings › Extensions (they have Plug-ins); the extension detail view has a labelled Back button, a close button and a Check now button; the promotion helper returns what it promoted and accepts a filter.
371 lines
19 KiB
JavaScript
371 lines
19 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>/extensions-staged/<id>-<version>/.
|
|
// The NEXT launch's promoteStagedUpdates() moves the staged copy into
|
|
// <userData>/extensions/<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.
|
|
// `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,
|
|
};
|