feat: community extensions — publish with a BCDN name, install from Settings, theseus.x catalog
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).
This commit is contained in:
parent
68735ad490
commit
8ff8bc51ff
5 changed files with 257 additions and 35 deletions
141
addon-updater.js
141
addon-updater.js
|
|
@ -143,47 +143,54 @@ function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- single-add-on staging ----------------------------------------
|
// ---------- single-add-on staging ----------------------------------------
|
||||||
async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, timeoutMs }) {
|
// 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;
|
let manifestBuf;
|
||||||
try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); }
|
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 }; }
|
catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; }
|
||||||
|
|
||||||
let manifest;
|
let manifest;
|
||||||
try { manifest = JSON.parse(manifestBuf.toString("utf8")); }
|
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 }; }
|
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 : [];
|
const addons = Array.isArray(manifest?.addons) ? manifest.addons : [];
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const e of addons) {
|
for (const e of addons) {
|
||||||
if (!e?.version || !e?.url || !e?.sha256 || !e?.sig) continue;
|
if (!e?.version || !e?.url || !e?.sha256 || !(e?.sig || e?.publisherSig)) continue;
|
||||||
if (currentVer && cmpVer(e.version, currentVer) <= 0) continue;
|
if (currentVer && cmpVer(e.version, currentVer) <= 0) continue;
|
||||||
if (!best || cmpVer(e.version, best.version) > 0) best = e;
|
if (!best || cmpVer(e.version, best.version) > 0) best = e;
|
||||||
}
|
}
|
||||||
if (!best) return { status: "up-to-date" };
|
if (!best) return { status: "up-to-date" };
|
||||||
|
let trusted = false;
|
||||||
if (!verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex)) {
|
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`);
|
log(`updates: ${id}@${best.version} signature INVALID, skipping`);
|
||||||
return { status: "signature-invalid", newVer: best.version };
|
return { status: "signature-invalid", newVer: best.version };
|
||||||
}
|
}
|
||||||
|
return { status: "ok", best };
|
||||||
|
}
|
||||||
|
|
||||||
const stageOut = path.join(stagedDir, `${id}-${best.version}`);
|
// Download a package, check its sha256 against the signed value, and extract
|
||||||
const stagedVer = readAddonJson(stageOut)?.version;
|
// it to a temp dir whose addon.json must match id + version. Returns
|
||||||
if (stagedVer === best.version) { log(`updates: ${id}@${best.version} already staged`); return { status: "already-staged", newVer: best.version, stagePath: stageOut }; }
|
// { ok, tmpDir, tmpFile } or { status, detail }. The caller moves tmpDir.
|
||||||
if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} }
|
async function fetchVerifiedPackage({ id, version, url, sha256, log }) {
|
||||||
|
|
||||||
let tarball;
|
let tarball;
|
||||||
try { tarball = await httpGet(best.url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); }
|
try { tarball = await httpGet(url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); }
|
||||||
catch (e) { log(`updates: tarball ${id}@${best.version} fetch failed:`, e.message); return { status: "fetch-failed", newVer: best.version, detail: "tarball: " + e.message }; }
|
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");
|
const gotHash = crypto.createHash("sha256").update(tarball).digest("hex");
|
||||||
if (gotHash.toLowerCase() !== String(best.sha256).toLowerCase()) {
|
if (gotHash.toLowerCase() !== String(sha256).toLowerCase()) {
|
||||||
log(`updates: ${id}@${best.version} sha256 mismatch (${gotHash} vs ${best.sha256}), skipping`);
|
log(`updates: ${id}@${version} sha256 mismatch (${gotHash} vs ${sha256}), skipping`);
|
||||||
return { status: "sha256-mismatch", newVer: best.version };
|
return { status: "sha256-mismatch", newVer: version };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
||||||
const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.tgz`);
|
const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.tgz`);
|
||||||
const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.dir`);
|
const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.dir`);
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmpFile, tarball);
|
await fsp.writeFile(tmpFile, tarball);
|
||||||
await fsp.mkdir(tmpDir, { recursive: true });
|
await fsp.mkdir(tmpDir, { recursive: true });
|
||||||
|
|
@ -216,30 +223,97 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`updates: extract ${id}@${best.version} failed:`, e.message);
|
log(`updates: extract ${id}@${version} failed:`, e.message);
|
||||||
try { fs.rmSync(tmpFile, { force: true }); } catch {}
|
try { fs.rmSync(tmpFile, { force: true }); } catch {}
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
return { status: "extract-failed", newVer: best.version, detail: e.message };
|
return { status: "extract-failed", newVer: version, detail: e.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = readAddonJson(tmpDir);
|
// A package may wrap everything in one top-level folder (tar -czf x.tgz my-ext);
|
||||||
if (!extracted || extracted.id !== id || extracted.version !== best.version) {
|
// unwrap it so addon.json sits at the root like the bundled add-ons.
|
||||||
log(`updates: extracted ${id}@${best.version} manifest mismatch (got ${extracted?.id}@${extracted?.version}), dropping`);
|
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(tmpFile, { force: true }); } catch {}
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
return { status: "manifest-mismatch", newVer: best.version, detail: `got ${extracted?.id}@${extracted?.version}` };
|
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 { fs.mkdirSync(stagedDir, { recursive: true }); } catch {}
|
||||||
try { fs.renameSync(tmpDir, stageOut); }
|
try { placeDir(pkg.tmpDir, stageOut); }
|
||||||
catch {
|
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.cpSync(tmpDir, stageOut, { recursive: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); }
|
try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {}
|
||||||
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.tmpFile, { force: true }); } catch {}
|
||||||
}
|
|
||||||
try { fs.rmSync(tmpFile, { force: true }); } catch {}
|
|
||||||
log(`updates: staged ${id}@${best.version} — will apply on next launch`);
|
log(`updates: staged ${id}@${best.version} — will apply on next launch`);
|
||||||
return { status: "staged", newVer: best.version, stagePath: stageOut };
|
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
|
// Returns a `report` array: one entry per installed add-on the client
|
||||||
// considered — { id, currentVer, updateURL, status, detail? }. Status is
|
// considered — { id, currentVer, updateURL, status, detail? }. Status is
|
||||||
// one of: "no-update-url" | "fetch-failed" | "up-to-date" | "signature-invalid"
|
// one of: "no-update-url" | "fetch-failed" | "up-to-date" | "signature-invalid"
|
||||||
|
|
@ -247,10 +321,10 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log,
|
||||||
// | "staged" | "already-staged". The manual UI in Settings > Extensions
|
// | "staged" | "already-staged". The manual UI in Settings > Extensions
|
||||||
// uses this to tell the user WHY nothing landed instead of a single
|
// uses this to tell the user WHY nothing landed instead of a single
|
||||||
// "up to date" that hides fetch/verify failures.
|
// "up to date" that hides fetch/verify failures.
|
||||||
async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, timeoutMs = 15000, logger }) {
|
async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPublisher, timeoutMs = 15000, logger }) {
|
||||||
const log = logger || (() => {});
|
const log = logger || (() => {});
|
||||||
const report = [];
|
const report = [];
|
||||||
if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) {
|
if ((!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) && typeof verifyPublisher !== "function") {
|
||||||
log("updates: no operator pubkeys configured — skipping update check");
|
log("updates: no operator pubkeys configured — skipping update check");
|
||||||
return { report, skipped: "no-pubkeys" };
|
return { report, skipped: "no-pubkeys" };
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +345,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, timeoutM
|
||||||
id: manifest.id,
|
id: manifest.id,
|
||||||
currentVer: manifest.version,
|
currentVer: manifest.version,
|
||||||
updateURL: manifest.updateURL,
|
updateURL: manifest.updateURL,
|
||||||
stagedDir, pubkeysHex, log, timeoutMs,
|
stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs,
|
||||||
})
|
})
|
||||||
.then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r }))
|
.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 }); })
|
.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 }); })
|
||||||
|
|
@ -285,6 +359,7 @@ module.exports = {
|
||||||
cmpVer,
|
cmpVer,
|
||||||
promoteStagedUpdates,
|
promoteStagedUpdates,
|
||||||
checkAndStageUpdates,
|
checkAndStageUpdates,
|
||||||
|
installCommunity,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
SIG_DOMAIN,
|
SIG_DOMAIN,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
44
lib/publisher-sig.mjs
Normal file
44
lib/publisher-sig.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Publisher signatures on community extensions.
|
||||||
|
//
|
||||||
|
// A community extension's updates.json entry carries `publisherSig`: a
|
||||||
|
// 65-byte BCH message signature (base64) over
|
||||||
|
// sha256("silentmode.extension-v1|<id>|<version>|<tarball-sha256>|<publisher-name>")
|
||||||
|
// made with the key that holds the publisher name's NFT. The gateway checks
|
||||||
|
// it before storing the entry; Theseus checks it AGAIN against the owner it
|
||||||
|
// reads from its own chain index, so a tampered catalog or a compromised
|
||||||
|
// relay cannot hand the browser someone else's code under a trusted name.
|
||||||
|
//
|
||||||
|
// ESM because @bitauth/libauth is ESM-only; main.js loads it with import().
|
||||||
|
import { secp256k1, sha256, ripemd160, encodeCashAddress, base64ToBin, utf8ToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
export const EXT_SIG_DOMAIN = "silentmode.extension-v1";
|
||||||
|
|
||||||
|
export function entryMessage({ id, version, sha256: tarballSha256, publisher }) {
|
||||||
|
return `${EXT_SIG_DOMAIN}|${id}|${version}|${String(tarballSha256).toLowerCase()}|${publisher}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover the signer of `message` and return it as a CashAddress with the
|
||||||
|
// same prefix as `likeAddress` (bitcoincash: / bchtest:). null on any error.
|
||||||
|
export function recoverSigner(message, signatureBase64, likeAddress) {
|
||||||
|
try {
|
||||||
|
const sig = base64ToBin(String(signatureBase64 || "").trim());
|
||||||
|
if (!(sig instanceof Uint8Array) || sig.length !== 65) return null;
|
||||||
|
const header = sig[0];
|
||||||
|
if (header < 27 || header > 34) return null;
|
||||||
|
const digest = sha256.hash(utf8ToBin(message));
|
||||||
|
const recover = header >= 31 ? secp256k1.recoverPublicKeyCompressed : secp256k1.recoverPublicKeyUncompressed;
|
||||||
|
const pub = recover(sig.slice(1), (header - 27) & 3, digest);
|
||||||
|
if (typeof pub === "string") return null;
|
||||||
|
const prefix = String(likeAddress || "bitcoincash:").split(":")[0] || "bitcoincash";
|
||||||
|
const enc = encodeCashAddress({ prefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(pub)) });
|
||||||
|
return typeof enc === "string" ? enc : (enc?.address ?? null);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the entry was signed by `ownerAddress` (the publisher name's
|
||||||
|
// current NFT holder as the caller's own index reports it).
|
||||||
|
export function verifyPublisherEntry(entry, ownerAddress) {
|
||||||
|
if (!entry || !ownerAddress || !entry.publisherSig) return false;
|
||||||
|
const who = recoverSigner(entryMessage(entry), entry.publisherSig, ownerAddress);
|
||||||
|
return !!who && who === ownerAddress;
|
||||||
|
}
|
||||||
59
main.js
59
main.js
|
|
@ -3647,6 +3647,63 @@ ipcMain.handle("addons-open-dir", () => {
|
||||||
// Manual "Check for updates" from Settings > Extensions. Runs the same
|
// Manual "Check for updates" from Settings > Extensions. Runs the same
|
||||||
// checkAndStageUpdates the boot timer runs; returns a snapshot of the
|
// checkAndStageUpdates the boot timer runs; returns a snapshot of the
|
||||||
// staged dir so the UI can render "Update to <ver> — restart to apply".
|
// staged dir so the UI can render "Update to <ver> — restart to apply".
|
||||||
|
// ---- community extensions (theseus.x/extensions) ----------------------------
|
||||||
|
// Anyone who owns a BNS name can publish an extension through the gateway
|
||||||
|
// (PUT /api/ext/<name>/<id>/<version>); the catalog and every package live
|
||||||
|
// on Sia and are served through the public relay. Trust: each channel entry
|
||||||
|
// carries the publisher's BCH signature over id|version|sha256|publisher.
|
||||||
|
// Before installing or updating, Theseus recovers the signer and compares
|
||||||
|
// it with the name's current NFT owner from ITS OWN chain index — so neither
|
||||||
|
// the relay nor a tampered catalog can slip in code under a trusted name.
|
||||||
|
const COMMUNITY_CATALOG_URL = "https://navigate.st/api/ext/catalog";
|
||||||
|
let publisherSigLib = null;
|
||||||
|
async function getPublisherSig() {
|
||||||
|
if (!publisherSigLib) publisherSigLib = await import(`file://${path.join(__dirname, "lib", "publisher-sig.mjs").replace(/\\/g, "/")}`);
|
||||||
|
return publisherSigLib;
|
||||||
|
}
|
||||||
|
async function verifyPublisherEntry(entry) {
|
||||||
|
try {
|
||||||
|
const name = String(entry?.publisher || "").toLowerCase();
|
||||||
|
if (!name) return false;
|
||||||
|
const owner = (await resolveHost(name))?.owner;
|
||||||
|
if (!owner) { console.warn(`[addons] publisher ${name}: owner unknown to the local index`); return false; }
|
||||||
|
const lib = await getPublisherSig();
|
||||||
|
const ok = lib.verifyPublisherEntry(entry, owner);
|
||||||
|
if (!ok) console.warn(`[addons] publisher signature for ${entry.id}@${entry.version} does not match ${name}'s owner`);
|
||||||
|
return ok;
|
||||||
|
} catch (e) { console.warn("[addons] publisher verify failed:", e?.message); return false; }
|
||||||
|
}
|
||||||
|
async function fetchCommunityCatalog() {
|
||||||
|
const r = await fetch(COMMUNITY_CATALOG_URL, { signal: AbortSignal.timeout(15000), cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error(`catalog HTTP ${r.status}`);
|
||||||
|
const j = await r.json();
|
||||||
|
return Array.isArray(j?.extensions) ? j.extensions : [];
|
||||||
|
}
|
||||||
|
ipcMain.handle("addons-community-catalog", async () => {
|
||||||
|
try {
|
||||||
|
const list = await fetchCommunityCatalog();
|
||||||
|
const installed = addonHost ? addonHost.snapshot().installed : [];
|
||||||
|
return { ok: true, extensions: list.map((e) => {
|
||||||
|
const cur = installed.find((a) => a.id === e.id);
|
||||||
|
return { ...e, installedVersion: cur ? cur.version : null, canUpdate: !!(cur && addonUpdater.cmpVer(e.latest, cur.version) > 0) };
|
||||||
|
}) };
|
||||||
|
} catch (e) { return { ok: false, error: e?.message || String(e), extensions: [] }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle("addons-install-community", async (_e, id) => {
|
||||||
|
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,63}$/.test(id)) return { ok: false, error: "bad id" };
|
||||||
|
try {
|
||||||
|
const card = (await fetchCommunityCatalog()).find((e) => e.id === id);
|
||||||
|
if (!card) return { ok: false, error: "not in the catalog" };
|
||||||
|
const r = await addonUpdater.installCommunity({
|
||||||
|
id, updatesUrl: card.updatesUrl,
|
||||||
|
addonsDir: addonsUserDir(), backupsDir: addonsBackupDir(),
|
||||||
|
verifyPublisher: verifyPublisherEntry,
|
||||||
|
log: (...a) => console.log("[addons]", ...a),
|
||||||
|
});
|
||||||
|
if (r.ok && addonHost) addonHost.discoverAndActivate();
|
||||||
|
return r;
|
||||||
|
} catch (e) { return { ok: false, error: e?.message || String(e) }; }
|
||||||
|
});
|
||||||
ipcMain.handle("addons-check-updates", async () => {
|
ipcMain.handle("addons-check-updates", async () => {
|
||||||
const stagedDir = addonsStagedDir();
|
const stagedDir = addonsStagedDir();
|
||||||
let report = [];
|
let report = [];
|
||||||
|
|
@ -3656,6 +3713,7 @@ ipcMain.handle("addons-check-updates", async () => {
|
||||||
addonsDir: addonsUserDir(),
|
addonsDir: addonsUserDir(),
|
||||||
stagedDir,
|
stagedDir,
|
||||||
pubkeysHex: ADDON_UPDATE_PUBKEYS,
|
pubkeysHex: ADDON_UPDATE_PUBKEYS,
|
||||||
|
verifyPublisher: verifyPublisherEntry,
|
||||||
logger: (...a) => console.log("[addons]", ...a),
|
logger: (...a) => console.log("[addons]", ...a),
|
||||||
});
|
});
|
||||||
report = result?.report || [];
|
report = result?.report || [];
|
||||||
|
|
@ -5478,6 +5536,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
|
||||||
addonsDir: addonsUserDir(),
|
addonsDir: addonsUserDir(),
|
||||||
stagedDir: addonsStagedDir(),
|
stagedDir: addonsStagedDir(),
|
||||||
pubkeysHex: ADDON_UPDATE_PUBKEYS,
|
pubkeysHex: ADDON_UPDATE_PUBKEYS,
|
||||||
|
verifyPublisher: verifyPublisherEntry,
|
||||||
logger: (...a) => console.log("[addons]", ...a),
|
logger: (...a) => console.log("[addons]", ...a),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ contextBridge.exposeInMainWorld("cfg", {
|
||||||
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
|
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
|
||||||
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
|
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
|
||||||
reloadAddons: () => ipcRenderer.invoke("addons-reload"),
|
reloadAddons: () => ipcRenderer.invoke("addons-reload"),
|
||||||
|
// Community extensions from theseus.x/extensions: the catalog (with what
|
||||||
|
// is installed already) and a verified install/update of one entry.
|
||||||
|
communityCatalog: () => ipcRenderer.invoke("addons-community-catalog"),
|
||||||
|
installCommunity: (id) => ipcRenderer.invoke("addons-install-community", id),
|
||||||
checkAddonUpdates: () => ipcRenderer.invoke("addons-check-updates"),
|
checkAddonUpdates: () => ipcRenderer.invoke("addons-check-updates"),
|
||||||
listStagedAddonUpdates: () => ipcRenderer.invoke("addons-list-staged"),
|
listStagedAddonUpdates: () => ipcRenderer.invoke("addons-list-staged"),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,7 @@
|
||||||
<section id="plugins" hidden>
|
<section id="plugins" hidden>
|
||||||
<h1>Plug-ins</h1>
|
<h1>Plug-ins</h1>
|
||||||
<p class="lede">Silent Mode components that live alongside Theseus — the system-wide resolver and the built-in wallet. Each ships with its own install / update / on-off controls.</p>
|
<p class="lede">Silent Mode components that live alongside Theseus — the system-wide resolver and the built-in wallet. Each ships with its own install / update / on-off controls.</p>
|
||||||
|
<div class="row" style="justify-content:flex-end;gap:8px"><a class="btn" style="text-decoration:none;display:inline-flex;align-items:center" href="https://theseus.x/plug-ins/" target="_blank" rel="noopener" title="Current signed versions, hashes and how plug-ins update">About plug-ins on theseus.x ↗</a></div>
|
||||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||||
<div class="txt">
|
<div class="txt">
|
||||||
<div class="t">System-wide resolver — Ariadne's Thread</div>
|
<div class="t">System-wide resolver — Ariadne's Thread</div>
|
||||||
|
|
@ -605,6 +606,12 @@
|
||||||
<div id="addonsUpdStatus" class="pmuted" style="font-size:12.5px;margin-top:6px;text-align:right">—</div>
|
<div id="addonsUpdStatus" class="pmuted" style="font-size:12.5px;margin-top:6px;text-align:right">—</div>
|
||||||
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2>
|
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2>
|
||||||
<div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div>
|
<div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div>
|
||||||
|
<h2 class="sub" style="margin-top:1.5rem">Community</h2>
|
||||||
|
<p class="lede" style="margin-top:0">Extensions anyone can publish from <a href="https://theseus.x/extensions/" target="_blank" rel="noopener">theseus.x/extensions</a>
|
||||||
|
by signing the package with the wallet that holds a BCDN name. Before installing, Theseus checks that signature
|
||||||
|
against the name's current owner in its own chain index — the relay can't substitute code under a trusted name.</p>
|
||||||
|
<div class="row" style="justify-content:flex-end;gap:8px"><button id="communityRefresh" class="btn" type="button">Refresh catalog</button></div>
|
||||||
|
<div id="communityList"><div class="d" style="color:var(--dim)">Loading…</div></div>
|
||||||
<div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable.
|
<div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable.
|
||||||
Only load extensions whose source you trust.</div>
|
Only load extensions whose source you trust.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -1326,6 +1333,39 @@
|
||||||
btn.addEventListener("click", () => C.revealAddon(btn.dataset.reveal));
|
btn.addEventListener("click", () => C.revealAddon(btn.dataset.reveal));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ---- community catalog (theseus.x/extensions) ----
|
||||||
|
const communityList = document.getElementById("communityList");
|
||||||
|
async function loadCommunity() {
|
||||||
|
communityList.innerHTML = '<div class="d" style="color:var(--dim)">Loading…</div>';
|
||||||
|
let r;
|
||||||
|
try { r = await C.communityCatalog(); } catch (e) { r = { ok: false, error: e?.message || String(e), extensions: [] }; }
|
||||||
|
if (!r.ok) { communityList.innerHTML = '<div class="d" style="color:#f6768a">Catalog unavailable: ' + escapeHtml(r.error) + '</div>'; return; }
|
||||||
|
if (!r.extensions.length) { communityList.innerHTML = '<div class="d" style="color:var(--dim)">Nothing published yet. Be the first — see theseus.x/extensions.</div>'; return; }
|
||||||
|
communityList.innerHTML = r.extensions.map((e) => {
|
||||||
|
const state = e.installedVersion
|
||||||
|
? (e.canUpdate ? '<button class="btn" data-install="' + escapeAttr(e.id) + '">Update to ' + escapeHtml(e.latest) + '</button>'
|
||||||
|
: '<span style="color:var(--acid);font-size:12.5px">Installed v' + escapeHtml(e.installedVersion) + '</span>')
|
||||||
|
: '<button class="btn" data-install="' + escapeAttr(e.id) + '">Install</button>';
|
||||||
|
return '<div class="row"><div class="txt"><div class="t">' + escapeHtml(e.icon || "🧩") + ' ' + escapeHtml(e.name)
|
||||||
|
+ ' <span style="color:var(--dim);font-weight:400">v' + escapeHtml(e.latest) + '</span></div>'
|
||||||
|
+ '<div class="d">' + escapeHtml(e.description || "") + '</div>'
|
||||||
|
+ '<div class="d" style="color:var(--dim);font-size:12px">Published by <b>' + escapeHtml(e.publisher) + '</b> · ' + escapeHtml(String(e.versions || 1)) + ' version' + (e.versions === 1 ? "" : "s")
|
||||||
|
+ (e.capabilities && e.capabilities.length ? ' · ' + escapeHtml(e.capabilities.join(", ")) : "") + '</div>'
|
||||||
|
+ '<div class="d" data-status="' + escapeAttr(e.id) + '"></div></div><div>' + state + '</div></div>';
|
||||||
|
}).join("");
|
||||||
|
communityList.querySelectorAll("button[data-install]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const id = btn.dataset.install;
|
||||||
|
const status = communityList.querySelector('[data-status="' + CSS.escape(id) + '"]');
|
||||||
|
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Verifying…";
|
||||||
|
let res;
|
||||||
|
try { res = await C.installCommunity(id); } catch (e) { res = { ok: false, error: e?.message || String(e) }; }
|
||||||
|
if (res && res.ok) { status.style.color = "var(--acid)"; status.textContent = "✓ Installed v" + res.version + " (signed by " + res.publisher + ")"; loadAddons(); loadCommunity(); }
|
||||||
|
else { status.style.color = "#f6768a"; status.textContent = "✗ " + (res?.error || "install failed"); btn.disabled = false; btn.textContent = orig; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById("communityRefresh").addEventListener("click", loadCommunity);
|
||||||
function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
||||||
function escapeAttr(s) { return escapeHtml(s); }
|
function escapeAttr(s) { return escapeHtml(s); }
|
||||||
async function loadAddons() {
|
async function loadAddons() {
|
||||||
|
|
@ -1346,7 +1386,7 @@
|
||||||
});
|
});
|
||||||
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
|
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
|
||||||
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => {
|
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => {
|
||||||
await loadAddonUpdates(); loadAddons();
|
await loadAddonUpdates(); loadAddons(); loadCommunity();
|
||||||
});
|
});
|
||||||
// Plug-ins tab: refresh Ariadne's live daemon state on every visit so it
|
// Plug-ins tab: refresh Ariadne's live daemon state on every visit so it
|
||||||
// doesn't display stale "checking…" text if the background poll finished
|
// doesn't display stale "checking…" text if the background poll finished
|
||||||
|
|
@ -1402,7 +1442,7 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Populate on first paint so the tab is ready when the user clicks in.
|
// Populate on first paint so the tab is ready when the user clicks in.
|
||||||
(async () => { await loadAddonUpdates(); loadAddons(); })();
|
(async () => { await loadAddonUpdates(); loadAddons(); loadCommunity(); })();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue