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
23 changes: 22 additions & 1 deletion apps/extensions/public/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `<span class="perm">${esc(p)}</span>`).join('') || '<span class="muted">none requested</span>';
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 = `
<div class="top" style="gap:16px;margin-bottom:8px">
${avatar(ext, 56)}
Expand All @@ -266,7 +268,10 @@ async function renderDetail(slug, root) {
<button class="btn ghost" id="flagBtn">⚑ Report</button>
${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>' : ''}
</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>` : ''}
${ext.isOwner ? editForm(ext) : ''}
${ext.homepageUrl ? `<p class="hint">Homepage: <a href="${esc(ext.homepageUrl)}" rel="noopener noreferrer">${esc(ext.homepageUrl)}</a></p>` : ''}
<h3>Permissions</h3>
Expand All @@ -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) => {
Expand Down
22 changes: 22 additions & 0 deletions packages/storage/migrations/0006_extension_signing_keys.sql
Original file line number Diff line number Diff line change
@@ -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);
35 changes: 35 additions & 0 deletions services/api/src/store/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtensionSigningKey | null> {
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<ExtensionSigningKey> {
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<Extension | null> {
const r = await db().execute({ sql: 'SELECT * FROM extensions WHERE id = ?', args: [id] });
return (r.rows[0] as unknown as Extension) ?? null;
Expand Down
93 changes: 88 additions & 5 deletions services/api/src/store/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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}`,
};
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -527,7 +603,9 @@ store.get('/updates.xml', async (c) => {
return c.body(
`<?xml version='1.0' encoding='UTF-8'?>\n` +
`<gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'>\n` +
` <app appid='${xmlEscape(ext.id)}'>\n` +
// appid must be the Chromium extension id (derived from the signing key),
// not our internal uuid — the browser matches updates on the former.
` <app appid='${xmlEscape(key?.crx_id || ext.id)}'>\n` +
` <updatecheck codebase='${xmlEscape(codebase)}' version='${xmlEscape(ver.version)}' />\n` +
` </app>\n` +
`</gupdate>\n`,
Expand All @@ -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);
});

Expand Down
119 changes: 119 additions & 0 deletions services/api/src/store/signing.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading