Skip to content

Commit e521ba9

Browse files
ralyodioclaude
andcommitted
feat(store): store-managed CRX signing keys, so Install actually installs
Pressing Install downloaded a .zip. Chromium can only install a signed .crx — a .zip is sideload-only (unzip, then "Load unpacked") — and no listing had a .crx, so the store's "one-click install" promise fell back to a file download for everyone. Rather than make publishers run openssl and guard a .pem forever (which is how listings end up zip-only in the first place), the store now holds the key and signs on their behalf: - POST /extensions/:id/signing-key — owner-only, create-once. Generates RSA-2048, derives the Chromium extension id, stores the private key AES-256-GCM-encrypted under CRX_KEY_SECRET. The private key is never returned. No rotate/delete: the key *is* the extension id, so replacing it would orphan every install. - GET /extensions/:slug/download.crx packs the published .zip into a CRX3 on the fly, so a listing becomes installable the moment it has a key — no re-upload, no second artifact to keep in sync. - "🔑 Generate signing key" button for owners, with the permanence spelled out in the confirm; the extension id is shown once it exists. Also fixes two things this exposed in the auto-update path: - updates.xml advertised `codebase` = crx_url || bundle_url, i.e. it handed Chromium a .zip it cannot install. It now only ever advertises a signed .crx, and returns an empty response when there isn't one. - appid was our internal uuid; Chromium matches updates on the extension id derived from the signing key. CRX3 packing is hand-rolled (three protobuf fields) rather than pulling a dependency. Tests verify the signature with node:crypto over the real CRX3 signed payload, and round-trip a packed .crx back through the store's own parser and scanner. 47/47 store tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 014872f commit e521ba9

6 files changed

Lines changed: 412 additions & 6 deletions

File tree

apps/extensions/public/store.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ async function renderDetail(slug, root) {
245245
const ext = await api(`/extensions/${encodeURIComponent(slug)}`);
246246
const v = ext.version;
247247
const perms = (v?.permissions || []).map((p) => `<span class="perm">${esc(p)}</span>`).join('') || '<span class="muted">none requested</span>';
248-
const dl = v ? `${API}/extensions/${encodeURIComponent(ext.slug)}/download` : '#';
248+
// installUrl is the signed .crx once the listing has a signing key; without
249+
// one the browser can only download a .zip for manual sideloading.
250+
const dl = ext.installUrl || (v ? `${API}/extensions/${encodeURIComponent(ext.slug)}/download` : '#');
249251
root.innerHTML = `
250252
<div class="top" style="gap:16px;margin-bottom:8px">
251253
${avatar(ext, 56)}
@@ -266,7 +268,10 @@ async function renderDetail(slug, root) {
266268
<button class="btn ghost" id="flagBtn">⚑ Report</button>
267269
${ext.isOwner ? '<button class="btn secondary" id="editBtn">✎ Edit listing</button>' : ''}
268270
${ext.isOwner ? '<button class="btn ghost" id="rescanBtn">🛡 Re-scan</button>' : ''}
271+
${ext.isOwner && !ext.crxId ? '<button class="btn ghost" id="keyBtn">🔑 Generate signing key</button>' : ''}
269272
</div>
273+
${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>` : ''}
274+
${ext.crxId ? `<p class="hint">Extension ID: <code>${esc(ext.crxId)}</code></p>` : ''}
270275
${ext.isOwner ? editForm(ext) : ''}
271276
${ext.homepageUrl ? `<p class="hint">Homepage: <a href="${esc(ext.homepageUrl)}" rel="noopener noreferrer">${esc(ext.homepageUrl)}</a></p>` : ''}
272277
<h3>Permissions</h3>
@@ -287,6 +292,22 @@ async function renderDetail(slug, root) {
287292

288293
wireEditForm(ext, () => renderDetail(slug, root));
289294

295+
document.getElementById('keyBtn')?.addEventListener('click', async (e) => {
296+
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;
297+
const btn = e.currentTarget;
298+
btn.disabled = true;
299+
btn.textContent = '🔑 Generating…';
300+
try {
301+
const { crxId } = await api(`/extensions/${encodeURIComponent(ext.id)}/signing-key`, { method: 'POST' });
302+
alert(`Signing key created.\n\nExtension ID: ${crxId}\n\nInstall now serves a signed .crx.`);
303+
await renderDetail(slug, root);
304+
} catch (err) {
305+
alert('Could not generate key: ' + err.message);
306+
btn.disabled = false;
307+
btn.textContent = '🔑 Generate signing key';
308+
}
309+
});
310+
290311
// Scans are recorded at publish time, so a listing published before its
291312
// bundle could be scanned needs one re-run to earn a badge.
292313
document.getElementById('rescanBtn')?.addEventListener('click', async (e) => {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- CRX signing keys, one per extension.
2+
--
3+
-- Chromium can only install a signed .crx; a .zip is sideload-only. Publishers
4+
-- shouldn't have to run openssl and mind a .pem forever, so the store holds the
5+
-- key and packs the .crx on demand.
6+
--
7+
-- The key permanently determines the extension id (it is the SHA-256 of the
8+
-- public key), so a row here is effectively immutable: rotating it would orphan
9+
-- every existing install. Hence PRIMARY KEY on extension_id and no update path.
10+
--
11+
-- private_key_enc is AES-256-GCM, keyed by CRX_KEY_SECRET — never plaintext.
12+
CREATE TABLE IF NOT EXISTS extension_signing_keys (
13+
extension_id TEXT PRIMARY KEY, -- extensions.id
14+
crx_id TEXT NOT NULL, -- 32-char a-p Chromium extension id
15+
public_key_der TEXT NOT NULL, -- base64 SPKI DER
16+
private_key_enc TEXT NOT NULL, -- base64 iv:tag:ciphertext (AES-256-GCM)
17+
key_algo TEXT NOT NULL DEFAULT 'rsa-2048',
18+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
19+
FOREIGN KEY (extension_id) REFERENCES extensions(id)
20+
);
21+
22+
CREATE INDEX IF NOT EXISTS idx_signing_keys_crx_id ON extension_signing_keys(crx_id);

services/api/src/store/db.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,41 @@ export async function updateExtension(id: string, patch: ExtensionPatch): Promis
119119
return extensionById(id);
120120
}
121121

122+
export interface ExtensionSigningKey {
123+
extension_id: string;
124+
crx_id: string;
125+
public_key_der: string;
126+
private_key_enc: string;
127+
key_algo: string;
128+
created_at: string;
129+
}
130+
131+
export async function signingKeyFor(extensionId: string): Promise<ExtensionSigningKey | null> {
132+
const r = await db().execute({
133+
sql: 'SELECT * FROM extension_signing_keys WHERE extension_id = ?',
134+
args: [extensionId],
135+
});
136+
return (r.rows[0] as unknown as ExtensionSigningKey) ?? null;
137+
}
138+
139+
/**
140+
* Store a signing key. INSERT-only on purpose: the key *is* the extension id,
141+
* so replacing it would orphan every install that already trusts the old one.
142+
*/
143+
export async function insertSigningKey(k: {
144+
extensionId: string;
145+
crxId: string;
146+
publicKeyDer: string;
147+
privateKeyEnc: string;
148+
}): Promise<ExtensionSigningKey> {
149+
await db().execute({
150+
sql: `INSERT INTO extension_signing_keys (extension_id, crx_id, public_key_der, private_key_enc)
151+
VALUES (?, ?, ?, ?)`,
152+
args: [k.extensionId, k.crxId, k.publicKeyDer, k.privateKeyEnc],
153+
});
154+
return (await signingKeyFor(k.extensionId))!;
155+
}
156+
122157
export async function extensionById(id: string): Promise<Extension | null> {
123158
const r = await db().execute({ sql: 'SELECT * FROM extensions WHERE id = ?', args: [id] });
124159
return (r.rows[0] as unknown as Extension) ?? null;

services/api/src/store/routes.ts

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
markPaidByRef, hasPaidListing, latestScan, addFlag, openFlagCount,
1313
publisherKey, handleTaken, upsertPublisherKey,
1414
createPublisherToken, userByPublisherToken, listPublisherTokens, revokePublisherToken,
15-
updateExtension,
15+
updateExtension, signingKeyFor, insertSigningKey,
1616
} from './db.js';
1717
import { validateManifest, slugify } from './manifest.js';
1818
import {
@@ -24,8 +24,9 @@ import {
2424
} from './payments.js';
2525
import { enqueueScan } from './vu1nz.js';
2626
import { mirrorListing } from './mirror.js';
27-
import { fetchArtifact, extractListingFromCrx } from './crx.js';
27+
import { fetchArtifact, artifactToZip, extractListingFromCrx } from './crx.js';
2828
import { scanArtifact, type ExtensionScanResult } from './scanner.js';
29+
import { generateSigningKey, encryptPrivateKey, decryptPrivateKey, packCrx } from './signing.js';
2930
import { createScan, updateScan } from './db.js';
3031

3132
const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev';
@@ -54,10 +55,11 @@ function xmlEscape(s: string): string {
5455
// `viewer` is optional: when the signed-in user owns the listing we say so, so
5556
// the detail page can offer an edit form instead of making them reach for curl.
5657
async function listingView(ext: any, viewer?: User | null) {
57-
const [ver, scan, flags] = await Promise.all([
58+
const [ver, scan, flags, key] = await Promise.all([
5859
latestVersion(ext.id),
5960
latestScan(ext.id),
6061
openFlagCount(ext.id),
62+
signingKeyFor(ext.id),
6163
]);
6264
return {
6365
id: ext.id,
@@ -81,6 +83,12 @@ async function listingView(ext: any, viewer?: User | null) {
8183
scan: scan ? { status: scan.status, score: scan.score, severity: scan.severity } : null,
8284
flags,
8385
isOwner: !!viewer && viewer.id === ext.owner_user_id,
86+
// Present once the listing has a signing key: the Chromium extension id, and
87+
// a real (installable) .crx instead of a zip the browser can only download.
88+
crxId: key?.crx_id ?? null,
89+
installUrl: key && ver
90+
? `${APP_URL}/api/store/extensions/${ext.slug}/download.crx`
91+
: ver ? `${APP_URL}/api/store/extensions/${ext.slug}/download` : null,
8492
updateUrl: `${APP_URL}/api/store/updates.xml?id=${ext.id}`,
8593
};
8694
}
@@ -153,6 +161,67 @@ store.patch('/extensions/:id', async (c) => {
153161
return c.json({ ok: true, listing: await listingView(updated) });
154162
});
155163

164+
/* ---------- publisher: signing key ----------
165+
Chromium only installs a signed .crx; a .zip is sideload-only. Generating the
166+
key here (rather than making publishers run openssl and guard a .pem) is what
167+
turns "Install" into a real install. The key is the extension's identity, so
168+
this is create-once — there is deliberately no rotate or delete. */
169+
store.post('/extensions/:id/signing-key', async (c) => {
170+
const user = await currentUser(c);
171+
if (!user) return c.json({ error: 'unauthorized' }, 401);
172+
const ext = await extensionById(c.req.param('id'));
173+
if (!ext) return c.json({ error: 'not found' }, 404);
174+
if (ext.owner_user_id !== user.id) return c.json({ error: 'forbidden' }, 403);
175+
176+
const existing = await signingKeyFor(ext.id);
177+
if (existing) {
178+
return c.json({
179+
error: 'signing key already exists',
180+
message: 'The key sets the extension id permanently — rotating it would orphan every install.',
181+
crxId: existing.crx_id,
182+
}, 409);
183+
}
184+
185+
try {
186+
const key = generateSigningKey();
187+
await insertSigningKey({
188+
extensionId: ext.id,
189+
crxId: key.crxId,
190+
publicKeyDer: key.publicKeyDer,
191+
privateKeyEnc: encryptPrivateKey(key.privateKeyPem),
192+
});
193+
// The private key is never returned — the store signs on the publisher's behalf.
194+
return c.json({ ok: true, crxId: key.crxId });
195+
} catch (e: any) {
196+
return c.json({ error: e?.message || 'could not generate signing key' }, 500);
197+
}
198+
});
199+
200+
/* ---------- signed .crx download ----------
201+
Packs the published .zip on the fly, so a listing becomes installable the
202+
moment it has a key — no re-upload, no separate .crx artifact to keep in
203+
sync with the bundle. */
204+
store.get('/extensions/:slug/download.crx', async (c) => {
205+
const ext = await extensionBySlug(c.req.param('slug'));
206+
if (!ext || ext.status !== 'live') return c.json({ error: 'not found' }, 404);
207+
const key = await signingKeyFor(ext.id);
208+
if (!key) return c.json({ error: 'this extension has no signing key yet' }, 404);
209+
const ver = await latestVersion(ext.id);
210+
const url = ver?.crx_url || ver?.bundle_url;
211+
if (!url) return c.json({ error: 'no artifact' }, 404);
212+
213+
try {
214+
const buf = await fetchArtifact(url);
215+
const crx = packCrx(artifactToZip(buf), decryptPrivateKey(key.private_key_enc), Buffer.from(key.public_key_der, 'base64'));
216+
c.header('content-type', 'application/x-chrome-extension');
217+
c.header('content-disposition', `attachment; filename="${ext.slug}-${ver!.version}.crx"`);
218+
// Hono wants an ArrayBuffer, not a Node Buffer view over a pooled one.
219+
return c.body(crx.buffer.slice(crx.byteOffset, crx.byteOffset + crx.byteLength) as ArrayBuffer);
220+
} catch (e: any) {
221+
return c.json({ error: `could not pack .crx: ${e?.message || e}` }, 500);
222+
}
223+
});
224+
156225
/* ---------- publisher: re-scan the current version ----------
157226
A scan verdict is written at publish time, so a listing published while the
158227
scanner was unavailable (or before zip bundles were scannable) keeps showing
@@ -518,7 +587,14 @@ store.get('/updates.xml', async (c) => {
518587
const id = c.req.query('id') || '';
519588
const ext = id ? await extensionById(id) : null;
520589
const ver = ext && ext.status === 'live' ? await latestVersion(ext.id) : null;
521-
const codebase = ver?.crx_url || ver?.bundle_url;
590+
const key = ext ? await signingKeyFor(ext.id) : null;
591+
592+
// Chromium can only install a signed .crx, so only ever advertise one. This
593+
// used to fall back to the .zip bundle, which meant zip-only listings served
594+
// an update whose codebase the browser could do nothing with.
595+
const codebase = key && ver
596+
? `${APP_URL}/api/store/extensions/${ext!.slug}/download.crx`
597+
: ver?.crx_url || null;
522598

523599
c.header('content-type', 'application/xml; charset=utf-8');
524600
if (!ext || !ver || !codebase) {
@@ -527,7 +603,9 @@ store.get('/updates.xml', async (c) => {
527603
return c.body(
528604
`<?xml version='1.0' encoding='UTF-8'?>\n` +
529605
`<gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'>\n` +
530-
` <app appid='${xmlEscape(ext.id)}'>\n` +
606+
// appid must be the Chromium extension id (derived from the signing key),
607+
// not our internal uuid — the browser matches updates on the former.
608+
` <app appid='${xmlEscape(key?.crx_id || ext.id)}'>\n` +
531609
` <updatecheck codebase='${xmlEscape(codebase)}' version='${xmlEscape(ver.version)}' />\n` +
532610
` </app>\n` +
533611
`</gupdate>\n`,
@@ -541,6 +619,11 @@ store.get('/extensions/:slug/download', async (c) => {
541619
const ver = await latestVersion(ext.id);
542620
const url = ver?.crx_url || ver?.bundle_url;
543621
if (!url) return c.json({ error: 'no artifact' }, 404);
622+
// Hand over an installable .crx when the listing has a key; the raw .zip is
623+
// a sideload artifact the browser can only save to disk.
624+
if (await signingKeyFor(ext.id)) {
625+
return c.redirect(`${APP_URL}/api/store/extensions/${ext.slug}/download.crx`);
626+
}
544627
return c.redirect(url);
545628
});
546629

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import { zipSync, strToU8 } from 'fflate';
3+
import { createHash, createVerify } from 'node:crypto';
4+
import {
5+
generateSigningKey, crxIdFromPublicKey, packCrx,
6+
encryptPrivateKey, decryptPrivateKey,
7+
} from './signing.js';
8+
import { artifactToZip } from './crx.js';
9+
import { scanArtifact } from './scanner.js';
10+
11+
const bundle = () => zipSync({
12+
'manifest.json': strToU8(JSON.stringify({ manifest_version: 3, name: 'x', version: '0.2.0' })),
13+
'background.js': strToU8('const x = await fetch(url);'),
14+
});
15+
16+
describe('crxIdFromPublicKey', () => {
17+
it('is 32 chars in the a–p alphabet Chromium uses', () => {
18+
const id = crxIdFromPublicKey(Buffer.from('some-public-key'));
19+
expect(id).toHaveLength(32);
20+
expect(id).toMatch(/^[a-p]{32}$/);
21+
});
22+
23+
it('maps the first 16 bytes of the SHA-256 digest, nibble by nibble', () => {
24+
const key = Buffer.from('deterministic');
25+
const expected = createHash('sha256').update(key).digest().subarray(0, 16).toString('hex')
26+
.split('').map((c) => String.fromCharCode(97 + parseInt(c, 16))).join('');
27+
expect(crxIdFromPublicKey(key)).toBe(expected);
28+
});
29+
30+
it('is stable for one key and different across keys', () => {
31+
const a = generateSigningKey();
32+
const b = generateSigningKey();
33+
expect(crxIdFromPublicKey(Buffer.from(a.publicKeyDer, 'base64'))).toBe(a.crxId);
34+
expect(a.crxId).not.toBe(b.crxId);
35+
});
36+
});
37+
38+
describe('packCrx', () => {
39+
it('produces a CRX3 the store can unpack back to the original zip', () => {
40+
const key = generateSigningKey();
41+
const zip = bundle();
42+
const crx = packCrx(zip, key.privateKeyPem, Buffer.from(key.publicKeyDer, 'base64'));
43+
44+
expect(crx.subarray(0, 4).toString('utf8')).toBe('Cr24');
45+
expect(crx.readUInt32LE(4)).toBe(3);
46+
// Round-trips through the same parser the scanner and ingest use.
47+
expect(Buffer.from(artifactToZip(crx))).toEqual(Buffer.from(zip));
48+
});
49+
50+
it('signs over the archive, so a swapped bundle fails verification', () => {
51+
const key = generateSigningKey();
52+
const publicKeyDer = Buffer.from(key.publicKeyDer, 'base64');
53+
const zip = bundle();
54+
const crx = packCrx(zip, key.privateKeyPem, publicKeyDer);
55+
56+
// Rebuild the signed payload and check the embedded signature verifies.
57+
const headerLen = crx.readUInt32LE(8);
58+
const header = crx.subarray(12, 12 + headerLen);
59+
const archive = crx.subarray(12 + headerLen);
60+
61+
// signed_header_data is the last length-delimited field (10000) in the header.
62+
const crxId = createHash('sha256').update(publicKeyDer).digest().subarray(0, 16);
63+
const signedHeaderData = Buffer.concat([Buffer.from([0x0a, 0x10]), crxId]);
64+
const prefix = Buffer.alloc(4);
65+
prefix.writeUInt32LE(signedHeaderData.length, 0);
66+
67+
// Pull the signature out of the header (field 2 of AsymmetricKeyProof).
68+
const sigIndex = header.indexOf(Buffer.from([0x12]), header.indexOf(publicKeyDer) + publicKeyDer.length);
69+
expect(sigIndex).toBeGreaterThan(0);
70+
const sigLen = header.readUInt8(sigIndex + 1) | (header.readUInt8(sigIndex + 2) << 7 & 0);
71+
const signature = header.subarray(sigIndex + 3, sigIndex + 3 + 256);
72+
expect(sigLen).toBeGreaterThan(0);
73+
74+
const v = createVerify('sha256');
75+
v.update(Buffer.concat([Buffer.from('CRX3 SignedData', 'utf8'), Buffer.from([0])]));
76+
v.update(prefix);
77+
v.update(signedHeaderData);
78+
v.update(archive);
79+
expect(v.verify({ key: publicKeyDer, format: 'der', type: 'spki' }, signature)).toBe(true);
80+
});
81+
82+
it('stays scannable once packed', () => {
83+
const key = generateSigningKey();
84+
const crx = packCrx(bundle(), key.privateKeyPem, Buffer.from(key.publicKeyDer, 'base64'));
85+
const r = scanArtifact(crx, ['storage']);
86+
expect(r.green).toBe(true);
87+
});
88+
});
89+
90+
describe('private key encryption at rest', () => {
91+
beforeEach(() => vi.stubEnv('CRX_KEY_SECRET', 'x'.repeat(48)));
92+
afterEach(() => vi.unstubAllEnvs());
93+
94+
it('round-trips', () => {
95+
const { privateKeyPem } = generateSigningKey();
96+
expect(decryptPrivateKey(encryptPrivateKey(privateKeyPem))).toBe(privateKeyPem);
97+
});
98+
99+
it('never stores the key in the clear, and uses a fresh iv each time', () => {
100+
const { privateKeyPem } = generateSigningKey();
101+
const a = encryptPrivateKey(privateKeyPem);
102+
const b = encryptPrivateKey(privateKeyPem);
103+
expect(a).not.toContain('PRIVATE KEY');
104+
expect(a).not.toBe(b);
105+
});
106+
107+
it('refuses to decrypt tampered ciphertext (GCM tag)', () => {
108+
const enc = encryptPrivateKey(generateSigningKey().privateKeyPem);
109+
const [iv, tag, ct] = enc.split(':');
110+
const flipped = Buffer.from(ct, 'base64');
111+
flipped[0] ^= 0xff;
112+
expect(() => decryptPrivateKey([iv, tag, flipped.toString('base64')].join(':'))).toThrow();
113+
});
114+
115+
it('fails loudly when CRX_KEY_SECRET is unset rather than storing plaintext', () => {
116+
vi.stubEnv('CRX_KEY_SECRET', '');
117+
expect(() => encryptPrivateKey('pem')).toThrow(/CRX_KEY_SECRET/);
118+
});
119+
});

0 commit comments

Comments
 (0)