Anyone who owns a BCDN name can now publish a Theseus extension, and every Theseus can install it with the publisher's signature verified locally. Gateway (Argus/src/gateway/public-gateway.mjs): PUT /api/ext/<name>/<id>/<version> takes the gzipped tar, checks two BCH message signatures against the name's current NFT owner (one authorises the upload, one is stored in the channel), inspects the package (addon.json at the root, id/version/main match, 8 MB cap), enforces first-publisher ownership of an id and monotonic versions, and writes the tarball, the extension's updates.json and community/catalog.json to Sia. GET /api/ext/catalog reads the catalog back with CORS. Theseus: lib/publisher-sig.mjs recovers the signer of a channel entry; main.js compares it with the publisher name's owner from Theseus's own chain index before installing or updating, so neither the relay nor a tampered catalog can pass off code under a trusted name. addon-updater.js gains installCommunity() and accepts publisher-signed entries in the regular update check (operator Ed25519 entries unchanged). Settings › Extensions shows the community catalog with Install / Update; Settings › Plug-ins links to theseus.x/plug-ins. theseus.x: /plug-ins/ is a separate page for the first-party plug-ins (Aegis, Ariadne's Thread) with live versions and hashes; /extensions/ lists the bundled extensions, the community catalog, and how to build and publish; /extensions/publish/ signs and uploads a package in the browser with the wallet that holds the publisher's name (session helper + wallet bundle copied alongside).
365 lines
19 KiB
JavaScript
365 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>/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 ----------------------------------------
|
|
// 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,
|
|
};
|