From eb47b0ee7e322afc99e5571fa6627b6387e5b832 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 21:00:48 +0000 Subject: [PATCH 1/2] Seal CRX signing keys under the publisher's passphrase; sign only while present The store held every publisher's CRX signing private key under one CRX_KEY_SECRET (scrypt, fixed salt) and packed a .crx with it on every public download. Root on the box = every extension identity. Now (seal v2): - Each publisher sets a signing passphrase in the store (seal.js, WebCrypto: PBKDF2-SHA256 600k + HKDF -> KEK wraps a random data key; one-time recovery key). The store keeps only that record. - Signing keys are sealed under the data key (AES-256-GCM, AAD = publisher + extension). The browser/script derives the data key and sends it, never the passphrase, only with the request that needs the key: create a key, migrate one, or sign a release (POST /versions with dek, POST /extensions/:id/sign). - The .crx is packed in that request and stored (extension_signed_crx); download.crx and updates.xml serve the last signed release. Nothing packs on download any more. - Migration: setting the passphrase re-seals the publisher's legacy keys in the same request and signs each listing's latest release. LEGACY_KEY_MIGRATION=off is the cutoff; prepack-legacy.js packs current releases of unmigrated listings once so installs keep working. - store-publish.mjs signs when TRON_SIGNING_PASSPHRASE is set. Co-Authored-By: Claude Opus 5.5 --- apps/extensions/public/seal.js | 137 ++++++++++ apps/extensions/public/store.js | 83 ++++++- .../0009_publisher_key_seals.sql | 17 ++ .../migrations/0009_publisher_key_seals.sql | 25 ++ scripts/publish-extension.sh | 3 + scripts/store-publish.mjs | 20 ++ services/api/src/store/db.ts | 54 ++++ services/api/src/store/prepack-legacy.ts | 42 ++++ services/api/src/store/routes.ts | 235 +++++++++++++++--- services/api/src/store/signing.test.ts | 67 +++-- services/api/src/store/signing.ts | 102 +++++++- 11 files changed, 712 insertions(+), 73 deletions(-) create mode 100644 apps/extensions/public/seal.js create mode 100644 packages/storage/migrations-pg/0009_publisher_key_seals.sql create mode 100644 packages/storage/migrations/0009_publisher_key_seals.sql create mode 100644 services/api/src/store/prepack-legacy.ts diff --git a/apps/extensions/public/seal.js b/apps/extensions/public/seal.js new file mode 100644 index 00000000..991e2271 --- /dev/null +++ b/apps/extensions/public/seal.js @@ -0,0 +1,137 @@ +/* + * Publisher key seal (seal v2) β€” browser + node (WebCrypto only), no build step. + * + * Your CRX signing keys are sealed under a passphrase only you know: + * passphrase --PBKDF2-SHA256 600k, random 16-byte salt--> HKDF --> KEK + * KEK wraps a random 32-byte data key (DEK); a one-time recovery key wraps it too + * DEK seals a fixed string (key check) + * The store keeps only that record. To create a key, migrate one or sign a + * release, this page derives the DEK locally and sends the DEK (never the + * passphrase) with that one request; the store opens the signing key inside + * the request, packs the .crx and forgets it. + * + * Classic script: sets globalThis.TronSeal (store.js uses it; scripts import it). + */ +(function (root) { + 'use strict'; + const ITER = 600000; + const MIN_PASSPHRASE = 10; + const CHECK = 'tronbrowser-key-check-v2'; + const enc = new TextEncoder(); + const dec = new TextDecoder(); + const subtle = () => root.crypto.subtle; + + function b64(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); + } + function unb64(t) { + const s = atob(t); + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + return out; + } + function rand(n) { + const out = new Uint8Array(n); + root.crypto.getRandomValues(out); + return out; + } + function aad(userId, field) { + return enc.encode('tronbrowser:v2:' + userId + ':' + field); + } + async function gcmEnc(key, pt, ad) { + const iv = rand(12); + const sealed = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: ad, tagLength: 128 }, key, pt)); + return { ciphertext: b64(sealed.slice(0, sealed.length - 16)), iv: b64(iv), tag: b64(sealed.slice(sealed.length - 16)) }; + } + async function gcmDec(key, blob, ad) { + const ct = unb64(blob.ciphertext); + const tag = unb64(blob.tag); + const joined = new Uint8Array(ct.length + tag.length); + joined.set(ct, 0); + joined.set(tag, ct.length); + return new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv: unb64(blob.iv), additionalData: ad, tagLength: 128 }, key, joined)); + } + async function hkdfKey(ikm, salt, info) { + const base = await subtle().importKey('raw', ikm, 'HKDF', false, ['deriveKey']); + return subtle().deriveKey({ name: 'HKDF', hash: 'SHA-256', salt, info: enc.encode(info) }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + } + async function kek(passphrase, kdf) { + if (kdf.alg !== 'PBKDF2-SHA256' || kdf.iterations < ITER) throw new Error('unsupported kdf'); + const salt = unb64(kdf.salt); + const pw = await subtle().importKey('raw', enc.encode(passphrase.normalize('NFKC')), 'PBKDF2', false, ['deriveBits']); + const bits = new Uint8Array(await subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: kdf.iterations }, pw, 256)); + try { return await hkdfKey(bits, salt, 'tronbrowser/kek/v2'); } finally { bits.fill(0); } + } + const B32 = 'ABCDEFGHJKMNPQRSTVWXYZ0123456789'; + function encodeRecovery(raw) { + let bits = 0, value = 0, out = ''; + for (const b of raw) { + value = (value << 8) | b; bits += 8; + while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } + } + if (bits > 0) out += B32[(value << (5 - bits)) & 31]; + return out.match(/.{1,4}/g).join('-'); + } + function decodeRecovery(text) { + const clean = text.toUpperCase().replace(/[^A-Z0-9]/g, ''); + let bits = 0, value = 0; const out = []; + for (const ch of clean) { + const idx = B32.indexOf(ch); + if (idx < 0) throw new Error('invalid recovery key'); + value = ((value << 5) | idx) & 0xffff; bits += 5; + if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; } + } + if (out.length < 32) throw new Error('invalid recovery key'); + return new Uint8Array(out.slice(0, 32)); + } + async function recoveryKek(text) { + const raw = decodeRecovery(text); + try { return await hkdfKey(raw, enc.encode('tronbrowser/recovery'), 'tronbrowser/recovery-kek/v2'); } finally { raw.fill(0); } + } + async function dekKey(dek) { + return subtle().importKey('raw', dek, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); + } + async function checkDek(dek, seal, userId) { + try { return dec.decode(await gcmDec(await dekKey(dek), seal.key_check, aad(userId, 'key_check'))) === CHECK; } catch (e) { return false; } + } + + /** First-time setup. Returns { seal, dek (base64), recoveryKey (show once) }. */ + async function createSeal(passphrase, userId) { + if (passphrase.length < MIN_PASSPHRASE) throw new Error('passphrase must be at least ' + MIN_PASSPHRASE + ' characters'); + const dek = rand(32); + const rraw = rand(32); + const recoveryKey = encodeRecovery(rraw); + rraw.fill(0); + const kdf = { alg: 'PBKDF2-SHA256', iterations: ITER, salt: b64(rand(16)) }; + const seal = { + v: 2, + kdf, + wrapped_dek: await gcmEnc(await kek(passphrase, kdf), dek, aad(userId, 'dek:passphrase')), + recovery_wrapped_dek: await gcmEnc(await recoveryKek(recoveryKey), dek, aad(userId, 'dek:recovery')), + key_check: await gcmEnc(await dekKey(dek), enc.encode(CHECK), aad(userId, 'key_check')), + }; + const out = { seal, dek: b64(dek), recoveryKey }; + dek.fill(0); + return out; + } + + /** Passphrase (or recovery key with {recovery:true}) -> DEK (base64). */ + async function unlock(seal, secret, userId, opts) { + let dek; + try { + dek = opts && opts.recovery + ? await gcmDec(await recoveryKek(secret), seal.recovery_wrapped_dek, aad(userId, 'dek:recovery')) + : await gcmDec(await kek(secret, seal.kdf), seal.wrapped_dek, aad(userId, 'dek:passphrase')); + } catch (e) { + throw Object.assign(new Error(opts && opts.recovery ? 'wrong recovery key' : 'wrong passphrase'), { name: 'WrongPassphraseError' }); + } + if (!(await checkDek(dek, seal, userId))) throw Object.assign(new Error('wrong passphrase'), { name: 'WrongPassphraseError' }); + const out = b64(dek); + dek.fill(0); + return out; + } + + root.TronSeal = { createSeal, unlock, MIN_PASSPHRASE }; +})(globalThis); diff --git a/apps/extensions/public/store.js b/apps/extensions/public/store.js index 34619a3b..84aae6f8 100644 --- a/apps/extensions/public/store.js +++ b/apps/extensions/public/store.js @@ -168,6 +168,65 @@ function uiConfirm(title, message, { confirmLabel = 'Confirm', danger = false } }).then((v) => v === true); } + +/* ---------- signing passphrase (seal v2) ---------- + Signing keys are sealed under a passphrase only the publisher knows + (seal.js). We derive the data key here and send only that, with the one + request that needs it. The passphrase never leaves this page. */ +function uiSecret(title, message, fields, { confirmLabel = 'Continue' } = {}) { + return openModal((dialog, done) => { + const inputs = fields.map((f, i) => + ``).join(''); + dialog.innerHTML = modalShell(title, `${message ? `` : ''}${inputs}`, + ` + `); + dialog.querySelector('[data-cancel]').addEventListener('click', () => done(null)); + dialog.querySelector('[data-ok]').addEventListener('click', () => + done(fields.map((_, i) => dialog.querySelector(`[data-f="${i}"]`).value))); + }); +} + +let sealLib; +function loadSealLib() { + if (globalThis.TronSeal) return Promise.resolve(globalThis.TronSeal); + if (!sealLib) { + sealLib = new Promise((resolve, reject) => { + const el = document.createElement('script'); + el.src = '/store/seal.js'; + el.onload = () => resolve(globalThis.TronSeal); + el.onerror = () => reject(new Error('could not load seal.js')); + document.head.append(el); + }); + } + return sealLib; +} + +/** The publisher's data key (base64), setting up a passphrase on first use. null = cancelled. */ +async function signingDek() { + const T = await loadSealLib(); + const { userId, seal } = await api('/publisher/seal'); + if (!seal) { + const v = await uiSecret('Set a signing passphrase', + 'Your signing keys are sealed under this passphrase. The store keeps no key that can open them, so it signs a release only while you are here. If you lose both this passphrase and the recovery key, your listing can never ship a signed update again.', + [{ placeholder: 'Passphrase (10+ characters)' }, { placeholder: 'Repeat passphrase' }], { confirmLabel: 'Set passphrase' }); + if (!v) return null; + if (v[0] !== v[1]) throw new Error('The two passphrases differ.'); + const made = await T.createSeal(v[0], userId); + const r = await api('/publisher/seal', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ seal: made.seal, dek: made.dek }), + }); + await uiAlert('Save your recovery key (shown once)', + `${made.recoveryKey}\n\nIt is the only way back in if the passphrase is lost.` + + (r.migrated ? ` ${r.migrated} existing key(s) moved under your passphrase.` : '')); + return made.dek; + } + const v = await uiSecret('Signing passphrase', 'Needed to sign this release. Or paste your recovery key in the second box.', + [{ placeholder: 'Passphrase' }, { placeholder: 'Recovery key (optional)', type: 'text' }], { confirmLabel: 'Unlock' }); + if (!v) return null; + return v[1] ? T.unlock(seal, v[1], userId, { recovery: true }) : T.unlock(seal, v[0], userId); +} + /** Choose from a fixed set β€” a