Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions apps/extensions/public/seal.js
Original file line number Diff line number Diff line change
@@ -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);
83 changes: 82 additions & 1 deletion apps/extensions/public/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
`<input class="acct-select" data-f="${i}" type="${f.type || 'password'}" placeholder="${esc(f.placeholder)}" autocomplete="${f.type === 'text' ? 'off' : 'current-password'}">`).join('');
dialog.innerHTML = modalShell(title, `${message ? `<p class="modal-text">${esc(message)}</p>` : ''}${inputs}`,
`<button type="button" class="btn secondary" data-cancel>Cancel</button>
<button type="button" class="btn" data-ok>${esc(confirmLabel)}</button>`);
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 <select> beats asking someone to type a keyword. */
function uiChoose(title, message, options, { confirmLabel = 'Submit' } = {}) {
return openModal((dialog, done) => {
Expand Down Expand Up @@ -339,6 +398,7 @@ async function renderDetail(slug, root) {
${ext.isOwner ? '<button class="btn secondary" id="editBtn">✎ Edit listing</button>' : ''}
${ext.isOwner ? '<button class="btn ghost" id="rescanBtn">🛡 Re-scan</button>' : ''}
${ext.isOwner && !ext.crxId ? '<button class="btn ghost" id="keyBtn">🔑 Generate signing key</button>' : ''}
${ext.isOwner && ext.crxId ? '<button class="btn ghost" id="signBtn">✍ Sign latest release</button>' : ''}
</div>
${ext.isOwner && !ext.crxId ? `<p class="hint">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.</p>` : ''}
${ext.crxId ? `<p class="hint">Extension ID: <code>${esc(ext.crxId)}</code></p>` : ''}
Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down
17 changes: 17 additions & 0 deletions packages/storage/migrations-pg/0009_publisher_key_seals.sql
Original file line number Diff line number Diff line change
@@ -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);
25 changes: 25 additions & 0 deletions packages/storage/migrations/0009_publisher_key_seals.sql
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 3 additions & 0 deletions scripts/publish-extension.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions scripts/store-publish.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
* --store <base url> 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.
Expand Down Expand Up @@ -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),
Expand All @@ -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)}`);
}
Loading
Loading