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) {
β Report
${ext.isOwner ? 'β Edit listing ' : ''}
${ext.isOwner ? 'π‘ Re-scan ' : ''}
+ ${ext.isOwner && !ext.crxId ? 'π Generate signing key ' : ''}
+ ${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)]);
+}