diff --git a/apps/extensions/public/seal.js b/apps/extensions/public/seal.js
new file mode 100644
index 0000000..3f0c41f
--- /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 { 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 {
+ 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 34619a3..84aae6f 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 ? `
${esc(message)}
` : ''}${inputs}`,
+ `Cancel
+ ${esc(confirmLabel)} `);
+ 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 beats asking someone to type a keyword. */
function uiChoose(title, message, options, { confirmLabel = 'Submit' } = {}) {
return openModal((dialog, done) => {
@@ -339,6 +398,7 @@ async function renderDetail(slug, root) {
${ext.isOwner ? 'β Edit listing ' : ''}
${ext.isOwner ? 'π‘ Re-scan ' : ''}
${ext.isOwner && !ext.crxId ? 'π Generate signing key ' : ''}
+ ${ext.isOwner && ext.crxId ? 'β Sign latest release ' : ''}
${ext.isOwner && !ext.crxId ? `This listing has no signing key, so Install hands over a .zip that has to be sideloaded. Generating one lets the store serve a signed .crx that installs in one click and auto-updates. The key sets the extension id permanently and can't be rotated.
` : ''}
${ext.crxId ? `Extension ID: ${esc(ext.crxId)}
` : ''}
@@ -381,7 +441,11 @@ async function renderDetail(slug, root) {
btn.disabled = true;
btn.textContent = 'π Generatingβ¦';
try {
- const { crxId } = await api(`/extensions/${encodeURIComponent(ext.id)}/signing-key`, { method: 'POST' });
+ const dek = await signingDek();
+ if (!dek) { btn.disabled = false; btn.textContent = 'π Generate signing key'; return; }
+ const { crxId } = await api(`/extensions/${encodeURIComponent(ext.id)}/signing-key`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dek }),
+ });
await uiAlert('Signing key created', `Extension ID: ${crxId} β Install now serves a signed .crx.`);
await renderDetail(slug, root);
} catch (err) {
@@ -391,6 +455,23 @@ async function renderDetail(slug, root) {
}
});
+ document.getElementById('signBtn')?.addEventListener('click', async (e) => {
+ const btn = e.currentTarget;
+ btn.disabled = true;
+ try {
+ const dek = await signingDek();
+ if (!dek) return;
+ const r = await api(`/extensions/${encodeURIComponent(ext.id)}/sign`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dek }),
+ });
+ await uiAlert('Signed', `v${r.version} is now the installable .crx.`);
+ } catch (err) {
+ await uiAlert('Could not sign', err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+
// Scans are recorded at publish time, so a listing published before its
// bundle could be scanned needs one re-run to earn a badge.
document.getElementById('payBtn')?.addEventListener('click', async (e) => {
diff --git a/packages/storage/migrations-pg/0009_publisher_key_seals.sql b/packages/storage/migrations-pg/0009_publisher_key_seals.sql
new file mode 100644
index 0000000..e7e3ea4
--- /dev/null
+++ b/packages/storage/migrations-pg/0009_publisher_key_seals.sql
@@ -0,0 +1,17 @@
+-- TronBrowser Postgres schema: 0009_publisher_key_seals
+-- Mirrors packages/storage/migrations/0009_publisher_key_seals.sql (see there).
+create table if not exists publisher_key_seals (
+ user_id text PRIMARY KEY,
+ seal_json text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+create table if not exists extension_signed_crx (
+ version_id text PRIMARY KEY,
+ extension_id text NOT NULL,
+ version text NOT NULL,
+ crx_b64 text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_signed_crx_ext ON extension_signed_crx(extension_id, created_at);
diff --git a/packages/storage/migrations/0009_publisher_key_seals.sql b/packages/storage/migrations/0009_publisher_key_seals.sql
new file mode 100644
index 0000000..7432e9c
--- /dev/null
+++ b/packages/storage/migrations/0009_publisher_key_seals.sql
@@ -0,0 +1,25 @@
+-- Seal v2 for CRX signing keys.
+--
+-- A publisher's signing keys are sealed under a passphrase only they know
+-- (apps/extensions/public/seal.js). This table holds the publisher's seal
+-- record: KDF params + salt + the wrapped data key + a key check. Nothing in
+-- it opens anything. extension_signing_keys.private_key_enc values starting
+-- with 'v2:' are sealed under that data key; anything else is a legacy
+-- CRX_KEY_SECRET value awaiting the publisher's one-shot migration.
+CREATE TABLE IF NOT EXISTS publisher_key_seals (
+ user_id TEXT PRIMARY KEY,
+ seal_json TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- The .crx is packed when the publisher is present (publish / sign), and the
+-- packed bytes are served from here. The store never packs on download.
+CREATE TABLE IF NOT EXISTS extension_signed_crx (
+ version_id TEXT PRIMARY KEY,
+ extension_id TEXT NOT NULL,
+ version TEXT NOT NULL,
+ crx_b64 TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_signed_crx_ext ON extension_signed_crx(extension_id, created_at);
diff --git a/scripts/publish-extension.sh b/scripts/publish-extension.sh
index 4161e5d..4837dbc 100755
--- a/scripts/publish-extension.sh
+++ b/scripts/publish-extension.sh
@@ -65,6 +65,9 @@ resp="$(curl -fsS -X POST "${STORE_URL}/api/store/extensions/${id}/versions" \
-H 'content-type: application/json' -d "$body")"
echo "published ${STORE_SLUG}: $resp"
+# Signing needs the publisher's passphrase (seal v2); the store holds no key
+# that can sign by itself. Sign from the listing page ("Sign latest release"),
+# or publish with scripts/store-publish.mjs and TRON_SIGNING_PASSPHRASE set.
# 4) Sync the listing copy, if the repo carries one. Publishing a version only
# refreshes the bundle β without this the description keeps describing an
diff --git a/scripts/store-publish.mjs b/scripts/store-publish.mjs
index 8093ba2..d68f4b0 100644
--- a/scripts/store-publish.mjs
+++ b/scripts/store-publish.mjs
@@ -19,6 +19,12 @@
* --store defaults to https://tronbrowser.dev
* --dry-run resolve and validate, write nothing
*
+ * Signing: set TRON_SIGNING_PASSPHRASE to sign the release in the same request.
+ * The data key is derived here (apps/extensions/public/seal.js) and only that
+ * is sent; the store opens the signing key inside the request and forgets it.
+ * Without it the version is listed but installs keep the last signed release
+ * until the publisher signs it from the listing page.
+ *
* Auth: TRONBROWSER_STORE_TOKEN, a `tbpub_` publisher token. Minted once from a
* signed-in browser session at tronbrowser.dev β the API refuses to mint one
* from a token, deliberately, so a leaked CI token cannot mint more.
@@ -132,6 +138,17 @@ if (DRY) {
process.exit(0);
}
+if (process.env.TRON_SIGNING_PASSPHRASE) {
+ await import('../apps/extensions/public/seal.js');
+ const sealed = await api('/publisher/seal', { method: 'GET' });
+ if (!sealed.ok || !sealed.body?.seal) die('TRON_SIGNING_PASSPHRASE is set but the publisher has no signing passphrase yet');
+ try {
+ payload.dek = await globalThis.TronSeal.unlock(sealed.body.seal, process.env.TRON_SIGNING_PASSPHRASE, sealed.body.userId);
+ } catch (e) {
+ die(`signing passphrase: ${e.message}`);
+ }
+}
+
const published = await api(`/extensions/${listing.id}/versions`, {
method: 'POST',
body: JSON.stringify(payload),
@@ -150,6 +167,9 @@ if (!published.ok) {
}
console.log(` published ${manifest.name} ${manifest.version} to ${STORE}/store/`);
+if (published.body?.signed && !published.body.signed.signed && published.body.signed.error) {
+ console.log(` not signed yet: ${published.body.signed.error}`);
+}
if (published.body?.scan) {
console.log(` scan: ${JSON.stringify(published.body.scan)}`);
}
diff --git a/services/api/src/store/db.ts b/services/api/src/store/db.ts
index 6ecebdf..f3938fb 100644
--- a/services/api/src/store/db.ts
+++ b/services/api/src/store/db.ts
@@ -440,3 +440,57 @@ export async function revokePublisherToken(userId: string, id: string): Promise<
const r = await db().execute({ sql: 'DELETE FROM publisher_tokens WHERE id = ? AND user_id = ?', args: [id, userId] });
return (r.rowsAffected ?? 0) > 0;
}
+
+/* ---------- publisher key seals (seal v2) ---------- */
+
+export async function publisherSeal(userId: string): Promise {
+ const r = await db().execute({ sql: 'SELECT seal_json FROM publisher_key_seals WHERE user_id = ?', args: [userId] });
+ const raw = (r.rows[0] as unknown as { seal_json?: string } | undefined)?.seal_json;
+ if (!raw) return null;
+ try { return JSON.parse(raw); } catch { return null; }
+}
+
+export async function savePublisherSeal(userId: string, seal: unknown): Promise {
+ await db().execute({
+ sql: `INSERT INTO publisher_key_seals (user_id, seal_json) VALUES (?, ?)
+ ON CONFLICT (user_id) DO UPDATE SET seal_json = excluded.seal_json, updated_at = CURRENT_TIMESTAMP`,
+ args: [userId, JSON.stringify(seal)],
+ });
+}
+
+export async function replaceSigningKeyCiphertext(extensionId: string, privateKeyEnc: string): Promise {
+ await db().execute({
+ sql: 'UPDATE extension_signing_keys SET private_key_enc = ? WHERE extension_id = ?',
+ args: [privateKeyEnc, extensionId],
+ });
+}
+
+export async function extensionsOwnedBy(userId: string): Promise {
+ const r = await db().execute({ sql: 'SELECT * FROM extensions WHERE owner_user_id = ?', args: [userId] });
+ return r.rows as unknown as Extension[];
+}
+
+export interface SignedCrx { version_id: string; extension_id: string; version: string; crx_b64: string; created_at: string }
+
+export async function saveSignedCrx(v: { versionId: string; extensionId: string; version: string; crx: Buffer }): Promise {
+ await db().execute({
+ sql: `INSERT INTO extension_signed_crx (version_id, extension_id, version, crx_b64) VALUES (?, ?, ?, ?)
+ ON CONFLICT (version_id) DO UPDATE SET crx_b64 = excluded.crx_b64`,
+ args: [v.versionId, v.extensionId, v.version, v.crx.toString('base64')],
+ });
+}
+
+/** The newest packed .crx for a listing (what download.crx and updates.xml serve). */
+export async function latestSignedCrx(extensionId: string): Promise {
+ const r = await db().execute({
+ sql: 'SELECT * FROM extension_signed_crx WHERE extension_id = ? ORDER BY created_at DESC LIMIT 1',
+ args: [extensionId],
+ });
+ return (r.rows[0] as unknown as SignedCrx) ?? null;
+}
+
+/** Signing keys still under the legacy CRX_KEY_SECRET (for the cutoff decision). */
+export async function unmigratedSigningKeyCount(): Promise {
+ const r = await db().execute({ sql: "SELECT COUNT(*) AS n FROM extension_signing_keys WHERE private_key_enc NOT LIKE 'v2:%'", args: [] });
+ return Number((r.rows[0] as unknown as { n: number } | undefined)?.n ?? 0);
+}
diff --git a/services/api/src/store/prepack-legacy.ts b/services/api/src/store/prepack-legacy.ts
new file mode 100644
index 0000000..7ff1965
--- /dev/null
+++ b/services/api/src/store/prepack-legacy.ts
@@ -0,0 +1,40 @@
+// One-shot, run by hand at the seal-v2 cutover:
+// docker exec bun /api/dist/store/prepack-legacy.js
+//
+// Downloads used to be packed on the fly with CRX_KEY_SECRET. The store no
+// longer packs on download, so each listing whose key is still legacy gets its
+// current release packed ONCE here and stored, keeping installs and updates
+// working until the publisher sets a signing passphrase (which migrates the key
+// and signs every later release while they are present). Prints counts only.
+// Refuses when LEGACY_KEY_MIGRATION=off.
+import { db } from '../db.js';
+import { fetchArtifact, artifactToZip, stampUpdateUrl } from './crx.js';
+import { latestSignedCrx, latestVersion, saveSignedCrx, unmigratedSigningKeyCount } from './db.js';
+import { legacyDecryptPrivateKey, legacyMigrationEnabled, packCrx, isSealedKey } from './signing.js';
+
+const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev';
+
+async function main(): Promise {
+ if (!legacyMigrationEnabled()) throw new Error('LEGACY_KEY_MIGRATION=off: nothing to do');
+ const r = await db().execute({
+ sql: 'SELECT k.extension_id, k.public_key_der, k.private_key_enc FROM extension_signing_keys k',
+ args: [],
+ });
+ let packed = 0;
+ let skipped = 0;
+ for (const row of r.rows as unknown as { extension_id: string; public_key_der: string; private_key_enc: string }[]) {
+ if (isSealedKey(row.private_key_enc)) { skipped++; continue; }
+ const ver = await latestVersion(row.extension_id);
+ const have = await latestSignedCrx(row.extension_id);
+ if (!ver || have?.version_id === ver.id) { skipped++; continue; }
+ const url = ver.crx_url || ver.bundle_url;
+ if (!url) { skipped++; continue; }
+ const zip = stampUpdateUrl(artifactToZip(await fetchArtifact(url)), `${APP_URL}/api/store/updates.xml?id=${row.extension_id}`);
+ const crx = packCrx(zip, legacyDecryptPrivateKey(row.private_key_enc), Buffer.from(row.public_key_der, 'base64'));
+ await saveSignedCrx({ versionId: ver.id, extensionId: row.extension_id, version: ver.version, crx });
+ packed++;
+ }
+ console.log(JSON.stringify({ packed, skipped, unmigrated_keys: await unmigratedSigningKeyCount() }));
+}
+
+main().then(() => process.exit(0), (e) => { console.error(String(e?.message || e)); process.exit(1); });
diff --git a/services/api/src/store/routes.ts b/services/api/src/store/routes.ts
index 6efaa40..228686a 100644
--- a/services/api/src/store/routes.ts
+++ b/services/api/src/store/routes.ts
@@ -13,6 +13,8 @@ import {
publisherKey, handleTaken, upsertPublisherKey,
createPublisherToken, userByPublisherToken, listPublisherTokens, revokePublisherToken,
updateExtension, signingKeyFor, insertSigningKey,
+ publisherSeal, savePublisherSeal, replaceSigningKeyCiphertext, extensionsOwnedBy,
+ saveSignedCrx, latestSignedCrx,
} from './db.js';
import { validateManifest, slugify } from './manifest.js';
import {
@@ -23,7 +25,10 @@ import { enqueueScan } from './vu1nz.js';
import { mirrorListing } from './mirror.js';
import { fetchArtifact, artifactToZip, extractListingFromCrx, stampUpdateUrl } from './crx.js';
import { scanArtifact, type ExtensionScanResult } from './scanner.js';
-import { generateSigningKey, encryptPrivateKey, decryptPrivateKey, packCrx } from './signing.js';
+import {
+ generateSigningKey, packCrx, isPublisherSeal, verifyDataKey, sealPrivateKey, openPrivateKey,
+ isSealedKey, legacyDecryptPrivateKey, legacyMigrationEnabled, type PublisherSeal,
+} from './signing.js';
import { createScan, updateScan } from './db.js';
const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev';
@@ -161,11 +166,118 @@ store.patch('/extensions/:id', async (c) => {
return c.json({ ok: true, listing: await listingView(updated) });
});
+/* ---------- publisher: key seal (seal v2) ----------
+ The publisher's CRX signing keys are sealed under a passphrase only they
+ know (apps/extensions/public/seal.js). The browser or script derives the
+ data key (DEK) locally and sends it, never the passphrase, with the one
+ request that needs a signing key: create one, migrate one, or sign a
+ release. The key is opened inside that request and dropped; the packed .crx
+ is stored, so downloads and updates never need the key. */
+
+type DekResult = { ok: true; dek: Buffer; seal: PublisherSeal } | { ok: false; status: 400 | 403 | 409; error: string };
+
+async function publisherDek(userId: string, dekB64: unknown): Promise {
+ const seal = await publisherSeal(userId);
+ if (!isPublisherSeal(seal)) {
+ return { ok: false, status: 409, error: 'set a signing passphrase first (store: Signing passphrase)' };
+ }
+ if (typeof dekB64 !== 'string' || !dekB64) {
+ return { ok: false, status: 400, error: 'signing passphrase required: send the derived key as "dek"' };
+ }
+ const dek = Buffer.from(dekB64, 'base64');
+ if (!verifyDataKey(dek, seal, userId)) {
+ dek.fill(0);
+ return { ok: false, status: 403, error: 'wrong signing passphrase' };
+ }
+ return { ok: true, dek, seal };
+}
+
+/** Pack the version's artifact with the (already opened) key and store the .crx. */
+async function packAndStore(ext: { id: string; slug: string }, ver: { id: string; version: string; crx_url?: string | null; bundle_url?: string | null }, pem: string, publicKeyDer: string): Promise {
+ const url = ver.crx_url || ver.bundle_url;
+ if (!url) throw new Error('version has no artifact');
+ const buf = await fetchArtifact(url);
+ // Stamp our gupdate feed in before signing, or the install pins itself to
+ // this version forever: Chromium polls the manifest's own update_url and
+ // most publishers never set one. See stampUpdateUrl.
+ const zip = stampUpdateUrl(artifactToZip(buf), `${APP_URL}/api/store/updates.xml?id=${ext.id}`);
+ const crx = packCrx(zip, pem, Buffer.from(publicKeyDer, 'base64'));
+ await saveSignedCrx({ versionId: ver.id, extensionId: ext.id, version: ver.version, crx });
+}
+
+/** Open the listing's key with the DEK and sign its latest version. */
+async function signLatest(ext: { id: string; slug: string }, userId: string, dek: Buffer): Promise<{ signed: boolean; version?: string; error?: string }> {
+ const key = await signingKeyFor(ext.id);
+ if (!key) return { signed: false, error: 'no signing key' };
+ const ver = await latestVersion(ext.id);
+ if (!ver) return { signed: false, error: 'no version yet' };
+ // Opened for this request only; the PEM string goes out of scope on return.
+ await packAndStore(ext, ver, openPrivateKey(key.private_key_enc, dek, userId, ext.id), key.public_key_der);
+ return { signed: true, version: ver.version };
+}
+
+store.get('/publisher/seal', async (c) => {
+ const user = await currentUser(c);
+ if (!user) return c.json({ error: 'unauthorized' }, 401);
+ const seal = await publisherSeal(user.id);
+ return c.json({ userId: user.id, seal: isPublisherSeal(seal) ? seal : null, legacyMigration: legacyMigrationEnabled() });
+});
+
+/* Set the passphrase (or reset it after losing it and the recovery key), then
+ migrate this publisher's legacy keys in the same request and sign each
+ listing's latest version. */
+store.post('/publisher/seal', async (c) => {
+ const user = await currentUser(c);
+ if (!user) return c.json({ error: 'unauthorized' }, 401);
+ const body = await c.req.json().catch(() => ({} as any));
+ if (!isPublisherSeal(body.seal) || typeof body.dek !== 'string') return c.json({ error: 'invalid seal' }, 400);
+ const dek = Buffer.from(body.dek, 'base64');
+ try {
+ if (!verifyDataKey(dek, body.seal, user.id)) return c.json({ error: 'seal does not match the key' }, 400);
+ const existing = await publisherSeal(user.id);
+ if (isPublisherSeal(existing) && body.reset !== true) {
+ return c.json({ error: 'a signing passphrase is already set (reset loses every key sealed under it)' }, 409);
+ }
+ await savePublisherSeal(user.id, body.seal);
+
+ let migrated = 0;
+ let failed = 0;
+ const signed: string[] = [];
+ for (const ext of await extensionsOwnedBy(user.id)) {
+ const key = await signingKeyFor(ext.id);
+ if (!key) continue;
+ if (!isSealedKey(key.private_key_enc)) {
+ if (!legacyMigrationEnabled()) continue;
+ try {
+ await replaceSigningKeyCiphertext(
+ ext.id,
+ sealPrivateKey(legacyDecryptPrivateKey(key.private_key_enc), dek, user.id, ext.id),
+ );
+ migrated++;
+ } catch {
+ failed++;
+ continue;
+ }
+ }
+ try {
+ const r = await signLatest(ext, user.id, dek);
+ if (r.signed) signed.push(ext.slug);
+ } catch {
+ // A key sealed under a previous (reset) passphrase stays unreadable.
+ }
+ }
+ return c.json({ ok: true, migrated, failed, signed });
+ } finally {
+ dek.fill(0);
+ }
+});
+
/* ---------- publisher: signing key ----------
Chromium only installs a signed .crx; a .zip is sideload-only. Generating the
key here (rather than making publishers run openssl and guard a .pem) is what
turns "Install" into a real install. The key is the extension's identity, so
- this is create-once β there is deliberately no rotate or delete. */
+ this is create-once β there is deliberately no rotate or delete. It is sealed
+ under the publisher's passphrase before it is stored. */
store.post('/extensions/:id/signing-key', async (c) => {
const user = await currentUser(c);
if (!user) return c.json({ error: 'unauthorized' }, 401);
@@ -182,53 +294,73 @@ store.post('/extensions/:id/signing-key', async (c) => {
}, 409);
}
+ const body = await c.req.json().catch(() => ({} as any));
+ const auth = await publisherDek(user.id, body.dek);
+ if (!auth.ok) return c.json({ error: auth.error }, auth.status);
try {
const key = generateSigningKey();
await insertSigningKey({
extensionId: ext.id,
crxId: key.crxId,
publicKeyDer: key.publicKeyDer,
- privateKeyEnc: encryptPrivateKey(key.privateKeyPem),
+ privateKeyEnc: sealPrivateKey(key.privateKeyPem, auth.dek, user.id, ext.id),
});
- // The private key is never returned β the store signs on the publisher's behalf.
+ const ver = await latestVersion(ext.id);
+ let signed = false;
+ if (ver) {
+ try {
+ await packAndStore(ext, ver, key.privateKeyPem, key.publicKeyDer);
+ signed = true;
+ } catch { /* the publisher can sign again from the listing */ }
+ }
// The private key is never returned β the store signs on the publisher's
- // behalf. The PUBLIC half is not secret, and is what pins the extension id:
- // dropped into manifest.json's "key" field it makes an unpacked install
- // resolve to the same id as the store-packed .crx, which anything keyed off
- // that id (an OAuth redirect URI, for one) depends on.
- return c.json({ ok: true, crxId: key.crxId, publicKey: key.publicKeyDer });
+ // behalf while they are present. The PUBLIC half is not secret, and is
+ // what pins the extension id: dropped into manifest.json's "key" field it
+ // makes an unpacked install resolve to the same id as the store-packed .crx.
+ return c.json({ ok: true, crxId: key.crxId, publicKey: key.publicKeyDer, signed });
} catch (e: any) {
return c.json({ error: e?.message || 'could not generate signing key' }, 500);
+ } finally {
+ auth.dek.fill(0);
+ }
+});
+
+/* ---------- publisher: sign the latest version ----------
+ For a release submitted without the passphrase (a script, a PR mirror),
+ or a listing whose key was just migrated. */
+store.post('/extensions/:id/sign', async (c) => {
+ const user = await currentUser(c);
+ if (!user) return c.json({ error: 'unauthorized' }, 401);
+ const ext = await extensionById(c.req.param('id'));
+ if (!ext) return c.json({ error: 'not found' }, 404);
+ if (ext.owner_user_id !== user.id) return c.json({ error: 'forbidden' }, 403);
+ const body = await c.req.json().catch(() => ({} as any));
+ const auth = await publisherDek(user.id, body.dek);
+ if (!auth.ok) return c.json({ error: auth.error }, auth.status);
+ try {
+ const r = await signLatest(ext, user.id, auth.dek);
+ if (!r.signed) return c.json({ error: r.error }, 409);
+ return c.json({ ok: true, version: r.version });
+ } catch (e: any) {
+ return c.json({ error: `could not sign: ${e?.message || e}` }, 422);
+ } finally {
+ auth.dek.fill(0);
}
});
/* ---------- signed .crx download ----------
- Packs the published .zip on the fly, so a listing becomes installable the
- moment it has a key β no re-upload, no separate .crx artifact to keep in
- sync with the bundle. */
+ Serves the .crx packed when the publisher last signed. The store holds no
+ key that could pack one now. */
store.get('/extensions/:slug/download.crx', async (c) => {
const ext = await extensionBySlug(c.req.param('slug'));
if (!ext || ext.status !== 'live') return c.json({ error: 'not found' }, 404);
- const key = await signingKeyFor(ext.id);
- if (!key) return c.json({ error: 'this extension has no signing key yet' }, 404);
- const ver = await latestVersion(ext.id);
- const url = ver?.crx_url || ver?.bundle_url;
- if (!url) return c.json({ error: 'no artifact' }, 404);
-
- try {
- const buf = await fetchArtifact(url);
- // Stamp our gupdate feed in before signing, or the install pins itself to
- // this version forever: Chromium polls the manifest's own update_url and
- // most publishers never set one. See stampUpdateUrl.
- const zip = stampUpdateUrl(artifactToZip(buf), `${APP_URL}/api/store/updates.xml?id=${ext.id}`);
- const crx = packCrx(zip, decryptPrivateKey(key.private_key_enc), Buffer.from(key.public_key_der, 'base64'));
- c.header('content-type', 'application/x-chrome-extension');
- c.header('content-disposition', `attachment; filename="${ext.slug}-${ver!.version}.crx"`);
- // Hono wants an ArrayBuffer, not a Node Buffer view over a pooled one.
- return c.body(crx.buffer.slice(crx.byteOffset, crx.byteOffset + crx.byteLength) as ArrayBuffer);
- } catch (e: any) {
- return c.json({ error: `could not pack .crx: ${e?.message || e}` }, 500);
- }
+ const signed = await latestSignedCrx(ext.id);
+ if (!signed) return c.json({ error: 'no signed release yet: the publisher signs one with their passphrase' }, 404);
+ const crx = Buffer.from(signed.crx_b64, 'base64');
+ c.header('content-type', 'application/x-chrome-extension');
+ c.header('content-disposition', `attachment; filename="${ext.slug}-${signed.version}.crx"`);
+ // Hono wants an ArrayBuffer, not a Node Buffer view over a pooled one.
+ return c.body(crx.buffer.slice(crx.byteOffset, crx.byteOffset + crx.byteLength) as ArrayBuffer);
});
/* ---------- publisher: re-scan the current version ----------
@@ -500,7 +632,26 @@ store.post('/extensions/:id/versions', async (c) => {
await enqueueScan(ext.id, version);
}
- return c.json({ ok: true, versionId: version.id, version: version.version, warnings: v.warnings, scan: scanResult });
+ // Sign now when the publisher sent their signing key (derived from the
+ // passphrase); otherwise the release waits for POST /extensions/:id/sign and
+ // installs keep getting the last signed one.
+ let signed: { signed: boolean; error?: string } = { signed: false };
+ if (await signingKeyFor(ext.id)) {
+ const auth = body.dek ? await publisherDek(user.id, body.dek) : null;
+ if (auth?.ok) {
+ try {
+ signed = await signLatest(ext, user.id, auth.dek);
+ } catch (e: any) {
+ signed = { signed: false, error: e?.message || String(e) };
+ } finally {
+ auth.dek.fill(0);
+ }
+ } else {
+ signed = { signed: false, error: auth ? auth.error : 'not signed: send "dek" or sign it from the listing' };
+ }
+ }
+
+ return c.json({ ok: true, versionId: version.id, version: version.version, warnings: v.warnings, scan: scanResult, signed });
});
/* ---------- pay the $1 listing fee ---------- */
@@ -595,13 +746,14 @@ store.get('/updates.xml', async (c) => {
const ext = id ? await extensionById(id) : null;
const ver = ext && ext.status === 'live' ? await latestVersion(ext.id) : null;
const key = ext ? await signingKeyFor(ext.id) : null;
+ const signed = key && ext ? await latestSignedCrx(ext.id) : null;
- // Chromium can only install a signed .crx, so only ever advertise one. This
- // used to fall back to the .zip bundle, which meant zip-only listings served
- // an update whose codebase the browser could do nothing with.
- const codebase = key && ver
+ // Chromium can only install a signed .crx, so only ever advertise one: the
+ // release the publisher last signed (its version, not an unsigned newer one).
+ const codebase = signed
? `${APP_URL}/api/store/extensions/${ext!.slug}/download.crx`
- : ver?.crx_url || null;
+ : key ? null : ver?.crx_url || null;
+ const advertised = signed ? signed.version : ver?.version;
c.header('content-type', 'application/xml; charset=utf-8');
if (!ext || !ver || !codebase) {
@@ -613,7 +765,7 @@ store.get('/updates.xml', async (c) => {
// appid must be the Chromium extension id (derived from the signing key),
// not our internal uuid β the browser matches updates on the former.
` \n` +
- ` \n` +
+ ` \n` +
` \n` +
`\n`,
);
@@ -628,7 +780,7 @@ store.get('/extensions/:slug/download', async (c) => {
if (!url) return c.json({ error: 'no artifact' }, 404);
// Hand over an installable .crx when the listing has a key; the raw .zip is
// a sideload artifact the browser can only save to disk.
- if (await signingKeyFor(ext.id)) {
+ if (await latestSignedCrx(ext.id)) {
return c.redirect(`${APP_URL}/api/store/extensions/${ext.slug}/download.crx`);
}
return c.redirect(url);
diff --git a/services/api/src/store/signing.test.ts b/services/api/src/store/signing.test.ts
index d7f97f5..7122154 100644
--- a/services/api/src/store/signing.test.ts
+++ b/services/api/src/store/signing.test.ts
@@ -3,7 +3,8 @@ import { zipSync, strToU8 } from 'fflate';
import { createHash, createVerify } from 'node:crypto';
import {
generateSigningKey, crxIdFromPublicKey, packCrx,
- encryptPrivateKey, decryptPrivateKey,
+ sealPrivateKey, openPrivateKey, verifyDataKey, isPublisherSeal, isSealedKey,
+ legacyEncryptForTests, legacyDecryptPrivateKey,
} from './signing.js';
import { artifactToZip } from './crx.js';
import { scanArtifact } from './scanner.js';
@@ -87,33 +88,59 @@ describe('packCrx', () => {
});
});
-describe('private key encryption at rest', () => {
- beforeEach(() => vi.stubEnv('CRX_KEY_SECRET', 'x'.repeat(48)));
+describe('seal v2: signing keys sealed under the publisher passphrase', () => {
+ const USER = 'user-1';
+ const EXT = 'ext-1';
+ let seal: any;
+ let dek: Buffer;
+
+ beforeEach(async () => {
+ await import('../../../../apps/extensions/public/seal.js');
+ const T = (globalThis as any).TronSeal;
+ const made = await T.createSeal('correct horse battery', USER);
+ seal = made.seal;
+ dek = Buffer.from(made.dek, 'base64');
+ });
afterEach(() => vi.unstubAllEnvs());
- it('round-trips', () => {
- const { privateKeyPem } = generateSigningKey();
- expect(decryptPrivateKey(encryptPrivateKey(privateKeyPem))).toBe(privateKeyPem);
+ it('the browser seal is accepted, and the passphrase or recovery key yield the same data key', async () => {
+ expect(isPublisherSeal(seal)).toBe(true);
+ expect(verifyDataKey(dek, seal, USER)).toBe(true);
+ expect(verifyDataKey(Buffer.alloc(32), seal, USER)).toBe(false);
+ expect(verifyDataKey(dek, seal, 'someone-else')).toBe(false);
+ const T = (globalThis as any).TronSeal;
+ expect(await T.unlock(seal, 'correct horse battery', USER)).toBe(dek.toString('base64'));
+ await expect(T.unlock(seal, 'not the passphrase', USER)).rejects.toThrow(/wrong/);
+ expect(JSON.stringify(seal)).not.toContain('correct horse');
+ expect(seal.kdf.iterations).toBeGreaterThanOrEqual(600_000);
});
- it('never stores the key in the clear, and uses a fresh iv each time', () => {
+ it('round-trips only with the data key, bound to publisher and extension', () => {
const { privateKeyPem } = generateSigningKey();
- const a = encryptPrivateKey(privateKeyPem);
- const b = encryptPrivateKey(privateKeyPem);
- expect(a).not.toContain('PRIVATE KEY');
- expect(a).not.toBe(b);
+ const stored = sealPrivateKey(privateKeyPem, dek, USER, EXT);
+ expect(isSealedKey(stored)).toBe(true);
+ expect(stored).not.toContain('PRIVATE KEY');
+ expect(openPrivateKey(stored, dek, USER, EXT)).toBe(privateKeyPem);
+ expect(() => openPrivateKey(stored, Buffer.alloc(32), USER, EXT)).toThrow();
+ expect(() => openPrivateKey(stored, dek, USER, 'ext-2')).toThrow();
});
- it('refuses to decrypt tampered ciphertext (GCM tag)', () => {
- const enc = encryptPrivateKey(generateSigningKey().privateKeyPem);
- const [iv, tag, ct] = enc.split(':');
- const flipped = Buffer.from(ct, 'base64');
- flipped[0] ^= 0xff;
- expect(() => decryptPrivateKey([iv, tag, flipped.toString('base64')].join(':'))).toThrow();
+ it('no env fallback: CRX_KEY_SECRET cannot open a v2 key, and a legacy key never opens as v2', () => {
+ vi.stubEnv('CRX_KEY_SECRET', 'x'.repeat(48));
+ const { privateKeyPem } = generateSigningKey();
+ const legacy = legacyEncryptForTests(privateKeyPem);
+ expect(() => openPrivateKey(legacy, dek, USER, EXT)).toThrow(/not sealed/);
+ const sealed = sealPrivateKey(privateKeyPem, dek, USER, EXT);
+ expect(() => legacyDecryptPrivateKey(sealed)).toThrow();
});
- it('fails loudly when CRX_KEY_SECRET is unset rather than storing plaintext', () => {
- vi.stubEnv('CRX_KEY_SECRET', '');
- expect(() => encryptPrivateKey('pem')).toThrow(/CRX_KEY_SECRET/);
+ it('migration: a legacy key opens with CRX_KEY_SECRET only while LEGACY_KEY_MIGRATION is on', () => {
+ vi.stubEnv('CRX_KEY_SECRET', 'x'.repeat(48));
+ const { privateKeyPem } = generateSigningKey();
+ const legacy = legacyEncryptForTests(privateKeyPem);
+ const migrated = sealPrivateKey(legacyDecryptPrivateKey(legacy), dek, USER, EXT);
+ expect(openPrivateKey(migrated, dek, USER, EXT)).toBe(privateKeyPem);
+ vi.stubEnv('LEGACY_KEY_MIGRATION', 'off');
+ expect(() => legacyDecryptPrivateKey(legacy)).toThrow(/off/);
});
});
diff --git a/services/api/src/store/signing.ts b/services/api/src/store/signing.ts
index ef8749f..d5d904c 100644
--- a/services/api/src/store/signing.ts
+++ b/services/api/src/store/signing.ts
@@ -18,7 +18,7 @@ export interface SigningKeyMaterial {
crxId: string;
/** base64 SPKI DER. */
publicKeyDer: string;
- /** PKCS#8 PEM β encrypt before it goes anywhere near storage. */
+ /** PKCS#8 PEM β seal (sealPrivateKey) before it goes anywhere near storage. */
privateKeyPem: string;
}
@@ -43,29 +43,107 @@ export function generateSigningKey(): SigningKeyMaterial {
};
}
-/* ---------- encryption at rest ---------- */
+/* ---------- sealing (seal v2) ----------
+ The store holds no key that opens a signing key. Each publisher has a seal
+ record (apps/extensions/public/seal.js): passphrase -> PBKDF2 600k -> HKDF
+ -> KEK, which wraps a random data key (DEK). The browser/script derives the
+ DEK and sends it with the one request that needs the signing key (create,
+ migrate, sign a release); the key is opened inside that request and dropped.
+ private_key_enc = "v2:" iv:tag:ciphertext under the DEK, AAD binds the
+ publisher and the extension. */
+
+export interface PublisherSeal {
+ v: 2;
+ kdf: { alg: 'PBKDF2-SHA256'; iterations: number; salt: string };
+ wrapped_dek: { ciphertext: string; iv: string; tag: string };
+ recovery_wrapped_dek: { ciphertext: string; iv: string; tag: string } | null;
+ key_check: { ciphertext: string; iv: string; tag: string };
+}
-function secretKey(): Buffer {
- const secret = process.env.CRX_KEY_SECRET;
- if (!secret || secret.length < 16) {
- throw new Error('CRX_KEY_SECRET must be set (32+ random chars) to generate or use signing keys');
+const KEY_CHECK = 'tronbrowser-key-check-v2';
+
+function isBlob(v: unknown): v is { ciphertext: string; iv: string; tag: string } {
+ const b = v as Record | null;
+ return !!b && typeof b.ciphertext === 'string' && typeof b.iv === 'string' && typeof b.tag === 'string';
+}
+
+export function isPublisherSeal(v: unknown): v is PublisherSeal {
+ const s = v as Record | null;
+ return !!s && s.v === 2 && s.kdf?.alg === 'PBKDF2-SHA256' && Number(s.kdf?.iterations) >= 600_000
+ && typeof s.kdf?.salt === 'string' && isBlob(s.wrapped_dek) && isBlob(s.key_check)
+ && (s.recovery_wrapped_dek === null || isBlob(s.recovery_wrapped_dek));
+}
+
+function aad(userId: string, field: string): Buffer {
+ return Buffer.from(`tronbrowser:v2:${userId}:${field}`, 'utf8');
+}
+
+/** True when `dek` (32 bytes) is this publisher's data key. */
+export function verifyDataKey(dek: Buffer, seal: PublisherSeal, userId: string): boolean {
+ if (dek.length !== 32) return false;
+ try {
+ const d = createDecipheriv('aes-256-gcm', dek, Buffer.from(seal.key_check.iv, 'base64'), { authTagLength: 16 });
+ d.setAAD(aad(userId, 'key_check'));
+ d.setAuthTag(Buffer.from(seal.key_check.tag, 'base64'));
+ const out = Buffer.concat([d.update(Buffer.from(seal.key_check.ciphertext, 'base64')), d.final()]);
+ return out.toString('utf8') === KEY_CHECK;
+ } catch {
+ return false;
}
- // Fixed salt: the secret is already high-entropy and the DB row is the only
- // ciphertext, so a per-row salt would buy nothing but a schema column.
+}
+
+export function isSealedKey(stored: string): boolean {
+ return stored.startsWith('v2:');
+}
+
+export function sealPrivateKey(pem: string, dek: Buffer, userId: string, extensionId: string): string {
+ const iv = randomBytes(12);
+ const cipher = createCipheriv('aes-256-gcm', dek, iv, { authTagLength: 16 });
+ cipher.setAAD(aad(userId, `signing_key:${extensionId}`));
+ const ct = Buffer.concat([cipher.update(pem, 'utf8'), cipher.final()]);
+ return 'v2:' + [iv.toString('base64'), cipher.getAuthTag().toString('base64'), ct.toString('base64')].join(':');
+}
+
+export function openPrivateKey(stored: string, dek: Buffer, userId: string, extensionId: string): string {
+ if (!isSealedKey(stored)) throw new Error('signing key is not sealed yet (migrate it first)');
+ const [ivB64, tagB64, ctB64] = stored.slice(3).split(':');
+ if (!ivB64 || !tagB64 || !ctB64) throw new Error('malformed sealed signing key');
+ const d = createDecipheriv('aes-256-gcm', dek, Buffer.from(ivB64, 'base64'), { authTagLength: 16 });
+ d.setAAD(aad(userId, `signing_key:${extensionId}`));
+ d.setAuthTag(Buffer.from(tagB64, 'base64'));
+ return Buffer.concat([d.update(Buffer.from(ctB64, 'base64')), d.final()]).toString('utf8');
+}
+
+/* ---------- legacy (CRX_KEY_SECRET) ----------
+ Readable ONLY by the publisher's one-shot migration and the one-time
+ pre-pack of already-published releases (scripts/prepack-legacy-crx). Off
+ when LEGACY_KEY_MIGRATION=off; the key itself stays in env until Anthony's
+ dated cutoff. */
+
+export function legacyMigrationEnabled(): boolean {
+ const v = (process.env.LEGACY_KEY_MIGRATION ?? 'on').trim().toLowerCase();
+ return !(v === 'off' || v === '0' || v === 'false');
+}
+
+function legacySecretKey(): Buffer {
+ const secret = process.env.CRX_KEY_SECRET;
+ if (!secret || secret.length < 16) throw new Error('legacy CRX_KEY_SECRET is not set');
return scryptSync(secret, 'tronbrowser-crx-signing', 32);
}
-export function encryptPrivateKey(pem: string): string {
+/** Test fixture only: a legacy-format ciphertext. */
+export function legacyEncryptForTests(pem: string): string {
const iv = randomBytes(12);
- const cipher = createCipheriv('aes-256-gcm', secretKey(), iv);
+ const cipher = createCipheriv('aes-256-gcm', legacySecretKey(), iv);
const ct = Buffer.concat([cipher.update(pem, 'utf8'), cipher.final()]);
return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), ct.toString('base64')].join(':');
}
-export function decryptPrivateKey(stored: string): string {
+export function legacyDecryptPrivateKey(stored: string): string {
+ if (!legacyMigrationEnabled()) throw new Error('legacy key migration is off');
const [ivB64, tagB64, ctB64] = stored.split(':');
if (!ivB64 || !tagB64 || !ctB64) throw new Error('malformed encrypted signing key');
- const decipher = createDecipheriv('aes-256-gcm', secretKey(), Buffer.from(ivB64, 'base64'));
+ const decipher = createDecipheriv('aes-256-gcm', legacySecretKey(), Buffer.from(ivB64, 'base64'), { authTagLength: 16 });
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64')), decipher.final()]).toString('utf8');
}