theseus/lib/publisher-sig.mjs
Local Dev 8ff8bc51ff 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).
2026-09-20 15:26:30 +02:00

44 lines
2.3 KiB
JavaScript

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