// Checks the extension directory's signature on a marketplace bundle before // it is installed. // // The directory signs a release statement for every approved bundle with an // online key (headers X-Bundle-Statement, X-Bundle-Signature, // X-Bundle-Signature-Key). Its online keys are listed in a keyring at // /api/v2/keys, signed by an offline root key. The root keys are pinned // here for the official directory; a self-hosted directory configures its // own (extensionDirectoryRootKeys). Rotating the online key needs no // webmail release; only a new root key does. // // With root keys known, an unsigned or badly signed bundle is refused. With // none (a custom directory that set none), bundles install as before. import { createHash, createPublicKey, verify as nodeVerify } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { canonicalJson } from '@/lib/extension-package'; import { ensureStateDir, getStatePath } from './paths'; export const OFFICIAL_DIRECTORY = 'https://extensions.bulwarkmail.org'; /** keyId (first 16 hex of SHA-256 of the raw key) -> raw Ed25519 public key, base64. */ export const PINNED_ROOT_KEYS: Readonly> = { bfcd5e1fcf810a7d: 'SyGlNKHBY4T7Z7BmDP80G8Eh58yn6W32SpKUHpRzHk4=', }; const STATEMENT_TYPE = 'https://extensions.bulwarkmail.org/release/v1'; const KEYRING_TYPE = 'https://extensions.bulwarkmail.org/keyring/v1'; const KEYRING_TTL_MS = 10 * 60_000; export function keyIdOf(raw: Uint8Array): string { return createHash('sha256').update(raw).digest('hex').slice(0, 16); } function publicKey(rawB64: string) { const raw = Buffer.from(rawB64, 'base64'); if (raw.length !== 32) throw new Error('not an Ed25519 public key'); return createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]), format: 'der', type: 'spki' }); } function ed25519Verify(rawPublicB64: string, data: string, signatureB64: string): boolean { try { return nodeVerify(null, Buffer.from(data, 'utf8'), publicKey(rawPublicB64), Buffer.from(signatureB64, 'base64')); } catch { return false; } } /** Root keys for a directory: the pinned ones for the official directory, plus configured ones. */ export function rootKeysFor(directoryUrl: string, configured: string | undefined | null): Record { const keys: Record = {}; if (new URL(directoryUrl).origin === OFFICIAL_DIRECTORY) Object.assign(keys, PINNED_ROOT_KEYS); for (const part of (configured ?? '').split(',').map((s) => s.trim()).filter(Boolean)) { const raw = Buffer.from(part, 'base64'); if (raw.length === 32) keys[keyIdOf(raw)] = part; } return keys; } interface KeyringKey { keyId: string; publicKey: string; status: 'active' | 'retired' | 'revoked' } interface Keyring { _type: string; rootKeyId: string; issuedAt: string; keys: KeyringKey[]; signature: string } export function verifyKeyring(doc: Keyring, rootKeys: Record): boolean { if (!doc || doc._type !== KEYRING_TYPE || typeof doc.signature !== 'string') return false; const root = rootKeys[doc.rootKeyId]; if (!root) return false; const { signature, ...body } = doc; return ed25519Verify(root, canonicalJson(body), signature); } const keyringCache = new Map(); /** Clears cached keyrings (tests, and after a directory rotates its key). */ export function clearKeyringCache(): void { keyringCache.clear(); } async function onlineKeys(directoryUrl: string, rootKeys: Record, fetchImpl: typeof fetch): Promise { const origin = new URL(directoryUrl).origin; const cached = keyringCache.get(origin); if (cached && Date.now() - cached.at < KEYRING_TTL_MS) return cached.keys; const res = await fetchImpl(new URL('/api/v2/keys', directoryUrl).toString(), { signal: AbortSignal.timeout(10_000), headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error(`the directory's keyring could not be loaded (${res.status})`); const doc = (await res.json()) as Keyring; if (!verifyKeyring(doc, rootKeys)) throw new Error("the directory's keyring is not signed by a trusted root key"); await assertKeyringIsCurrent(origin, doc.issuedAt); keyringCache.set(origin, { at: Date.now(), keys: doc.keys }); return doc.keys; } const KEYRING_ISSUED_FILE = 'directory-keyrings.json'; /** * A root-signed keyring stays valid forever on its own, so whoever controls * the directory host (or the path to it) could serve an old one in which a * since-revoked online key is still active, and sign bundles with that * leaked key. The newest `issuedAt` seen per directory is kept in the state * dir; an older keyring is refused. */ async function assertKeyringIsCurrent(origin: string, issuedAt: string): Promise { const issued = Date.parse(issuedAt); if (!Number.isFinite(issued)) throw new Error("the directory's keyring has no valid issue date"); const file = getStatePath(KEYRING_ISSUED_FILE); let seen: Record = {}; try { seen = JSON.parse(await readFile(file, 'utf-8')) as Record; } catch { /* none recorded yet */ } const previous = Date.parse(seen[origin] ?? ''); if (Number.isFinite(previous) && issued < previous) { throw new Error(`the directory's keyring (issued ${issuedAt}) is older than the one seen before (${seen[origin]})`); } if (!Number.isFinite(previous) || issued > previous) { await ensureStateDir(); await writeFile(file, JSON.stringify({ ...seen, [origin]: new Date(issued).toISOString() }), { mode: 0o600 }); } } export type SignatureCheck = | { status: 'verified'; keyId: string; commit: string | null; repo: string | null } | { status: 'unsigned' } | { status: 'invalid'; reason: string }; /** * Check the signature headers of a bundle downloaded from the directory. * `required` is true when root keys are known for this directory. */ export async function checkBundleSignature(opts: { directoryUrl: string; configuredRootKeys?: string | null; slug: string; version: string; kind: 'plugin' | 'theme'; bytes: ArrayBuffer | Uint8Array; headers: Headers; fetchImpl?: typeof fetch; }): Promise { const rootKeys = rootKeysFor(opts.directoryUrl, opts.configuredRootKeys); const required = Object.keys(rootKeys).length > 0; const statementB64 = opts.headers.get('x-bundle-statement'); const signature = opts.headers.get('x-bundle-signature'); const keyId = opts.headers.get('x-bundle-signature-key'); if (!statementB64 || !signature || !keyId) { return required ? { status: 'invalid', reason: 'the bundle is not signed by the directory' } : { status: 'unsigned' }; } if (!required) return { status: 'unsigned' }; let statement: { _type?: string; slug?: string; version?: string; kind?: string; bundle?: { sha256?: string; size?: number }; source?: { repo?: string; commit?: string | null } }; let statementText: string; try { statementText = Buffer.from(statementB64, 'base64').toString('utf8'); statement = JSON.parse(statementText); } catch { return { status: 'invalid', reason: 'the signature statement is unreadable' }; } if (statement._type !== STATEMENT_TYPE) return { status: 'invalid', reason: 'unknown signature statement' }; // The statement must be the canonical form the directory signed. if (canonicalJson(statement) !== statementText) return { status: 'invalid', reason: 'the signature statement is not canonical' }; let keys: KeyringKey[]; try { keys = await onlineKeys(opts.directoryUrl, rootKeys, opts.fetchImpl ?? fetch); } catch (err) { return { status: 'invalid', reason: err instanceof Error ? err.message : String(err) }; } const key = keys.find((k) => k.keyId === keyId); if (!key) return { status: 'invalid', reason: `signed with an unknown key (${keyId})` }; if (key.status === 'revoked') return { status: 'invalid', reason: `signed with a revoked key (${keyId})` }; if (!ed25519Verify(key.publicKey, statementText, signature)) return { status: 'invalid', reason: 'the signature does not match' }; const bytes = opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes); const sha256 = createHash('sha256').update(bytes).digest('hex'); if (statement.bundle?.sha256 !== sha256 || statement.bundle?.size !== bytes.byteLength) { return { status: 'invalid', reason: 'the downloaded bundle is not the one the directory signed' }; } if (statement.slug !== opts.slug || statement.version !== opts.version || statement.kind !== opts.kind) { return { status: 'invalid', reason: `the signature is for ${statement.slug} ${statement.version}, not ${opts.slug} ${opts.version}` }; } return { status: 'verified', keyId, commit: statement.source?.commit ?? null, repo: statement.source?.repo ?? null }; }