diff --git a/apps/extensions/public/store.js b/apps/extensions/public/store.js index 24b6a42..668db89 100644 --- a/apps/extensions/public/store.js +++ b/apps/extensions/public/store.js @@ -245,7 +245,9 @@ async function renderDetail(slug, root) { const ext = await api(`/extensions/${encodeURIComponent(slug)}`); const v = ext.version; const perms = (v?.permissions || []).map((p) => `${esc(p)}`).join('') || 'none requested'; - const dl = v ? `${API}/extensions/${encodeURIComponent(ext.slug)}/download` : '#'; + // installUrl is the signed .crx once the listing has a signing key; without + // one the browser can only download a .zip for manual sideloading. + const dl = ext.installUrl || (v ? `${API}/extensions/${encodeURIComponent(ext.slug)}/download` : '#'); root.innerHTML = `
${avatar(ext, 56)} @@ -266,7 +268,10 @@ async function renderDetail(slug, root) { ${ext.isOwner ? '' : ''} ${ext.isOwner ? '' : ''} + ${ext.isOwner && !ext.crxId ? '' : ''}
+ ${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)}

` : ''} ${ext.isOwner ? editForm(ext) : ''} ${ext.homepageUrl ? `

Homepage: ${esc(ext.homepageUrl)}

` : ''}

Permissions

@@ -287,6 +292,22 @@ async function renderDetail(slug, root) { wireEditForm(ext, () => renderDetail(slug, root)); + document.getElementById('keyBtn')?.addEventListener('click', async (e) => { + if (!confirm('Generate a signing key for this extension?\n\nThe key permanently sets the extension ID β€” it cannot be rotated later without every install having to be redone.')) return; + const btn = e.currentTarget; + btn.disabled = true; + btn.textContent = 'πŸ”‘ Generating…'; + try { + const { crxId } = await api(`/extensions/${encodeURIComponent(ext.id)}/signing-key`, { method: 'POST' }); + alert(`Signing key created.\n\nExtension ID: ${crxId}\n\nInstall now serves a signed .crx.`); + await renderDetail(slug, root); + } catch (err) { + alert('Could not generate key: ' + err.message); + btn.disabled = false; + btn.textContent = 'πŸ”‘ Generate signing key'; + } + }); + // 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('rescanBtn')?.addEventListener('click', async (e) => { diff --git a/packages/storage/migrations/0006_extension_signing_keys.sql b/packages/storage/migrations/0006_extension_signing_keys.sql new file mode 100644 index 0000000..133738b --- /dev/null +++ b/packages/storage/migrations/0006_extension_signing_keys.sql @@ -0,0 +1,22 @@ +-- CRX signing keys, one per extension. +-- +-- Chromium can only install a signed .crx; a .zip is sideload-only. Publishers +-- shouldn't have to run openssl and mind a .pem forever, so the store holds the +-- key and packs the .crx on demand. +-- +-- The key permanently determines the extension id (it is the SHA-256 of the +-- public key), so a row here is effectively immutable: rotating it would orphan +-- every existing install. Hence PRIMARY KEY on extension_id and no update path. +-- +-- private_key_enc is AES-256-GCM, keyed by CRX_KEY_SECRET β€” never plaintext. +CREATE TABLE IF NOT EXISTS extension_signing_keys ( + extension_id TEXT PRIMARY KEY, -- extensions.id + crx_id TEXT NOT NULL, -- 32-char a-p Chromium extension id + public_key_der TEXT NOT NULL, -- base64 SPKI DER + private_key_enc TEXT NOT NULL, -- base64 iv:tag:ciphertext (AES-256-GCM) + key_algo TEXT NOT NULL DEFAULT 'rsa-2048', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (extension_id) REFERENCES extensions(id) +); + +CREATE INDEX IF NOT EXISTS idx_signing_keys_crx_id ON extension_signing_keys(crx_id); diff --git a/services/api/src/store/db.ts b/services/api/src/store/db.ts index fd8278c..ae9bb44 100644 --- a/services/api/src/store/db.ts +++ b/services/api/src/store/db.ts @@ -119,6 +119,41 @@ export async function updateExtension(id: string, patch: ExtensionPatch): Promis return extensionById(id); } +export interface ExtensionSigningKey { + extension_id: string; + crx_id: string; + public_key_der: string; + private_key_enc: string; + key_algo: string; + created_at: string; +} + +export async function signingKeyFor(extensionId: string): Promise { + const r = await db().execute({ + sql: 'SELECT * FROM extension_signing_keys WHERE extension_id = ?', + args: [extensionId], + }); + return (r.rows[0] as unknown as ExtensionSigningKey) ?? null; +} + +/** + * Store a signing key. INSERT-only on purpose: the key *is* the extension id, + * so replacing it would orphan every install that already trusts the old one. + */ +export async function insertSigningKey(k: { + extensionId: string; + crxId: string; + publicKeyDer: string; + privateKeyEnc: string; +}): Promise { + await db().execute({ + sql: `INSERT INTO extension_signing_keys (extension_id, crx_id, public_key_der, private_key_enc) + VALUES (?, ?, ?, ?)`, + args: [k.extensionId, k.crxId, k.publicKeyDer, k.privateKeyEnc], + }); + return (await signingKeyFor(k.extensionId))!; +} + export async function extensionById(id: string): Promise { const r = await db().execute({ sql: 'SELECT * FROM extensions WHERE id = ?', args: [id] }); return (r.rows[0] as unknown as Extension) ?? null; diff --git a/services/api/src/store/routes.ts b/services/api/src/store/routes.ts index 3fc13f2..5f5a2e5 100644 --- a/services/api/src/store/routes.ts +++ b/services/api/src/store/routes.ts @@ -12,7 +12,7 @@ import { markPaidByRef, hasPaidListing, latestScan, addFlag, openFlagCount, publisherKey, handleTaken, upsertPublisherKey, createPublisherToken, userByPublisherToken, listPublisherTokens, revokePublisherToken, - updateExtension, + updateExtension, signingKeyFor, insertSigningKey, } from './db.js'; import { validateManifest, slugify } from './manifest.js'; import { @@ -24,8 +24,9 @@ import { } from './payments.js'; import { enqueueScan } from './vu1nz.js'; import { mirrorListing } from './mirror.js'; -import { fetchArtifact, extractListingFromCrx } from './crx.js'; +import { fetchArtifact, artifactToZip, extractListingFromCrx } from './crx.js'; import { scanArtifact, type ExtensionScanResult } from './scanner.js'; +import { generateSigningKey, encryptPrivateKey, decryptPrivateKey, packCrx } from './signing.js'; import { createScan, updateScan } from './db.js'; const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev'; @@ -54,10 +55,11 @@ function xmlEscape(s: string): string { // `viewer` is optional: when the signed-in user owns the listing we say so, so // the detail page can offer an edit form instead of making them reach for curl. async function listingView(ext: any, viewer?: User | null) { - const [ver, scan, flags] = await Promise.all([ + const [ver, scan, flags, key] = await Promise.all([ latestVersion(ext.id), latestScan(ext.id), openFlagCount(ext.id), + signingKeyFor(ext.id), ]); return { id: ext.id, @@ -81,6 +83,12 @@ async function listingView(ext: any, viewer?: User | null) { scan: scan ? { status: scan.status, score: scan.score, severity: scan.severity } : null, flags, isOwner: !!viewer && viewer.id === ext.owner_user_id, + // Present once the listing has a signing key: the Chromium extension id, and + // a real (installable) .crx instead of a zip the browser can only download. + crxId: key?.crx_id ?? null, + installUrl: key && ver + ? `${APP_URL}/api/store/extensions/${ext.slug}/download.crx` + : ver ? `${APP_URL}/api/store/extensions/${ext.slug}/download` : null, updateUrl: `${APP_URL}/api/store/updates.xml?id=${ext.id}`, }; } @@ -153,6 +161,67 @@ store.patch('/extensions/:id', async (c) => { return c.json({ ok: true, listing: await listingView(updated) }); }); +/* ---------- 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. */ +store.post('/extensions/:id/signing-key', 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 existing = await signingKeyFor(ext.id); + if (existing) { + return c.json({ + error: 'signing key already exists', + message: 'The key sets the extension id permanently β€” rotating it would orphan every install.', + crxId: existing.crx_id, + }, 409); + } + + try { + const key = generateSigningKey(); + await insertSigningKey({ + extensionId: ext.id, + crxId: key.crxId, + publicKeyDer: key.publicKeyDer, + privateKeyEnc: encryptPrivateKey(key.privateKeyPem), + }); + // The private key is never returned β€” the store signs on the publisher's behalf. + return c.json({ ok: true, crxId: key.crxId }); + } catch (e: any) { + return c.json({ error: e?.message || 'could not generate signing key' }, 500); + } +}); + +/* ---------- 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. */ +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); + const crx = packCrx(artifactToZip(buf), 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); + } +}); + /* ---------- publisher: re-scan the current version ---------- A scan verdict is written at publish time, so a listing published while the scanner was unavailable (or before zip bundles were scannable) keeps showing @@ -518,7 +587,14 @@ store.get('/updates.xml', async (c) => { const id = c.req.query('id') || ''; const ext = id ? await extensionById(id) : null; const ver = ext && ext.status === 'live' ? await latestVersion(ext.id) : null; - const codebase = ver?.crx_url || ver?.bundle_url; + const key = ext ? await signingKeyFor(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 + ? `${APP_URL}/api/store/extensions/${ext!.slug}/download.crx` + : ver?.crx_url || null; c.header('content-type', 'application/xml; charset=utf-8'); if (!ext || !ver || !codebase) { @@ -527,7 +603,9 @@ store.get('/updates.xml', async (c) => { return c.body( `\n` + `\n` + - ` \n` + + // 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`, @@ -541,6 +619,11 @@ store.get('/extensions/:slug/download', async (c) => { const ver = await latestVersion(ext.id); const url = ver?.crx_url || ver?.bundle_url; 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)) { + 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 new file mode 100644 index 0000000..d7f97f5 --- /dev/null +++ b/services/api/src/store/signing.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { createHash, createVerify } from 'node:crypto'; +import { + generateSigningKey, crxIdFromPublicKey, packCrx, + encryptPrivateKey, decryptPrivateKey, +} from './signing.js'; +import { artifactToZip } from './crx.js'; +import { scanArtifact } from './scanner.js'; + +const bundle = () => zipSync({ + 'manifest.json': strToU8(JSON.stringify({ manifest_version: 3, name: 'x', version: '0.2.0' })), + 'background.js': strToU8('const x = await fetch(url);'), +}); + +describe('crxIdFromPublicKey', () => { + it('is 32 chars in the a–p alphabet Chromium uses', () => { + const id = crxIdFromPublicKey(Buffer.from('some-public-key')); + expect(id).toHaveLength(32); + expect(id).toMatch(/^[a-p]{32}$/); + }); + + it('maps the first 16 bytes of the SHA-256 digest, nibble by nibble', () => { + const key = Buffer.from('deterministic'); + const expected = createHash('sha256').update(key).digest().subarray(0, 16).toString('hex') + .split('').map((c) => String.fromCharCode(97 + parseInt(c, 16))).join(''); + expect(crxIdFromPublicKey(key)).toBe(expected); + }); + + it('is stable for one key and different across keys', () => { + const a = generateSigningKey(); + const b = generateSigningKey(); + expect(crxIdFromPublicKey(Buffer.from(a.publicKeyDer, 'base64'))).toBe(a.crxId); + expect(a.crxId).not.toBe(b.crxId); + }); +}); + +describe('packCrx', () => { + it('produces a CRX3 the store can unpack back to the original zip', () => { + const key = generateSigningKey(); + const zip = bundle(); + const crx = packCrx(zip, key.privateKeyPem, Buffer.from(key.publicKeyDer, 'base64')); + + expect(crx.subarray(0, 4).toString('utf8')).toBe('Cr24'); + expect(crx.readUInt32LE(4)).toBe(3); + // Round-trips through the same parser the scanner and ingest use. + expect(Buffer.from(artifactToZip(crx))).toEqual(Buffer.from(zip)); + }); + + it('signs over the archive, so a swapped bundle fails verification', () => { + const key = generateSigningKey(); + const publicKeyDer = Buffer.from(key.publicKeyDer, 'base64'); + const zip = bundle(); + const crx = packCrx(zip, key.privateKeyPem, publicKeyDer); + + // Rebuild the signed payload and check the embedded signature verifies. + const headerLen = crx.readUInt32LE(8); + const header = crx.subarray(12, 12 + headerLen); + const archive = crx.subarray(12 + headerLen); + + // signed_header_data is the last length-delimited field (10000) in the header. + const crxId = createHash('sha256').update(publicKeyDer).digest().subarray(0, 16); + const signedHeaderData = Buffer.concat([Buffer.from([0x0a, 0x10]), crxId]); + const prefix = Buffer.alloc(4); + prefix.writeUInt32LE(signedHeaderData.length, 0); + + // Pull the signature out of the header (field 2 of AsymmetricKeyProof). + const sigIndex = header.indexOf(Buffer.from([0x12]), header.indexOf(publicKeyDer) + publicKeyDer.length); + expect(sigIndex).toBeGreaterThan(0); + const sigLen = header.readUInt8(sigIndex + 1) | (header.readUInt8(sigIndex + 2) << 7 & 0); + const signature = header.subarray(sigIndex + 3, sigIndex + 3 + 256); + expect(sigLen).toBeGreaterThan(0); + + const v = createVerify('sha256'); + v.update(Buffer.concat([Buffer.from('CRX3 SignedData', 'utf8'), Buffer.from([0])])); + v.update(prefix); + v.update(signedHeaderData); + v.update(archive); + expect(v.verify({ key: publicKeyDer, format: 'der', type: 'spki' }, signature)).toBe(true); + }); + + it('stays scannable once packed', () => { + const key = generateSigningKey(); + const crx = packCrx(bundle(), key.privateKeyPem, Buffer.from(key.publicKeyDer, 'base64')); + const r = scanArtifact(crx, ['storage']); + expect(r.green).toBe(true); + }); +}); + +describe('private key encryption at rest', () => { + beforeEach(() => vi.stubEnv('CRX_KEY_SECRET', 'x'.repeat(48))); + afterEach(() => vi.unstubAllEnvs()); + + it('round-trips', () => { + const { privateKeyPem } = generateSigningKey(); + expect(decryptPrivateKey(encryptPrivateKey(privateKeyPem))).toBe(privateKeyPem); + }); + + it('never stores the key in the clear, and uses a fresh iv each time', () => { + const { privateKeyPem } = generateSigningKey(); + const a = encryptPrivateKey(privateKeyPem); + const b = encryptPrivateKey(privateKeyPem); + expect(a).not.toContain('PRIVATE KEY'); + expect(a).not.toBe(b); + }); + + 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('fails loudly when CRX_KEY_SECRET is unset rather than storing plaintext', () => { + vi.stubEnv('CRX_KEY_SECRET', ''); + expect(() => encryptPrivateKey('pem')).toThrow(/CRX_KEY_SECRET/); + }); +}); diff --git a/services/api/src/store/signing.ts b/services/api/src/store/signing.ts new file mode 100644 index 0000000..ef8749f --- /dev/null +++ b/services/api/src/store/signing.ts @@ -0,0 +1,126 @@ +// CRX signing: generate a per-extension key, derive its Chromium id, and pack a +// published .zip into an installable .crx3. +// +// Why the store holds the key: Chromium only installs a signed .crx β€” a .zip is +// sideload-only (unzip + "Load unpacked"). Asking every publisher to run +// openssl and guard a .pem forever is how listings end up zip-only, which is +// exactly the state this store was in. +// +// The key is the extension's identity: the id is the SHA-256 of the public key, +// so it can never be rotated without orphaning existing installs. +import { + createCipheriv, createDecipheriv, createHash, createSign, + generateKeyPairSync, randomBytes, scryptSync, +} from 'node:crypto'; + +export interface SigningKeyMaterial { + /** 32-char a-p Chromium extension id, derived from the public key. */ + crxId: string; + /** base64 SPKI DER. */ + publicKeyDer: string; + /** PKCS#8 PEM β€” encrypt before it goes anywhere near storage. */ + privateKeyPem: string; +} + +/** Chromium maps the first 16 bytes of SHA-256(SPKI) from hex onto a–p. */ +export function crxIdFromPublicKey(publicKeyDer: Buffer): string { + const digest = createHash('sha256').update(publicKeyDer).digest(); + return [...digest.subarray(0, 16)] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .split('') + .map((c) => String.fromCharCode('a'.charCodeAt(0) + parseInt(c, 16))) + .join(''); +} + +export function generateSigningKey(): SigningKeyMaterial { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const der = publicKey.export({ type: 'spki', format: 'der' }) as Buffer; + return { + crxId: crxIdFromPublicKey(der), + publicKeyDer: der.toString('base64'), + privateKeyPem: (privateKey.export({ type: 'pkcs8', format: 'pem' }) as string), + }; +} + +/* ---------- encryption at rest ---------- */ + +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'); + } + // 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. + return scryptSync(secret, 'tronbrowser-crx-signing', 32); +} + +export function encryptPrivateKey(pem: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', secretKey(), 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 { + 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')); + decipher.setAuthTag(Buffer.from(tagB64, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64')), decipher.final()]).toString('utf8'); +} + +/* ---------- CRX3 packing ---------- */ + +// Minimal protobuf writer β€” the CRX3 header has three fields, so a dependency +// (or generated stubs) would be more machinery than the format needs. +function varint(value: number): Buffer { + const out: number[] = []; + let v = value; + while (v > 0x7f) { + out.push((v & 0x7f) | 0x80); + v >>>= 7; + } + out.push(v); + return Buffer.from(out); +} + +/** Length-delimited (wire type 2) field. */ +function field(fieldNumber: number, payload: Buffer): Buffer { + return Buffer.concat([varint((fieldNumber << 3) | 2), varint(payload.length), payload]); +} + +const CRX3_SIGNATURE_CONTEXT = Buffer.concat([Buffer.from('CRX3 SignedData', 'utf8'), Buffer.from([0])]); + +/** + * Wrap a ZIP bundle in a signed CRX3 container. + * + * Layout: "Cr24" | uint32le(3) | uint32le(headerLen) | CrxFileHeader | zip + * The signature covers a context string, the length-prefixed SignedData, and + * the archive β€” so the id can't be swapped onto someone else's bundle. + */ +export function packCrx(zip: Uint8Array, privateKeyPem: string, publicKeyDer: Buffer): Buffer { + const crxId = createHash('sha256').update(publicKeyDer).digest().subarray(0, 16); + const signedHeaderData = field(1, crxId); // SignedData { bytes crx_id = 1; } + + const signer = createSign('sha256'); + signer.update(CRX3_SIGNATURE_CONTEXT); + const lengthPrefix = Buffer.alloc(4); + lengthPrefix.writeUInt32LE(signedHeaderData.length, 0); + signer.update(lengthPrefix); + signer.update(signedHeaderData); + signer.update(Buffer.from(zip)); + const signature = signer.sign(privateKeyPem); + + // AsymmetricKeyProof { public_key = 1; signature = 2; } + const proof = Buffer.concat([field(1, publicKeyDer), field(2, signature)]); + // CrxFileHeader { sha256_with_rsa = 2; signed_header_data = 10000; } + const header = Buffer.concat([field(2, proof), field(10000, signedHeaderData)]); + + const prelude = Buffer.alloc(12); + prelude.write('Cr24', 0, 'utf8'); + prelude.writeUInt32LE(3, 4); + prelude.writeUInt32LE(header.length, 8); + + return Buffer.concat([prelude, header, Buffer.from(zip)]); +}