Skip to content

feat(store): store-managed CRX signing keys, so Install actually installs - #45

Merged
ralyodio merged 1 commit into
mainfrom
feat/crx-signing-keys
Jul 25, 2026
Merged

feat(store): store-managed CRX signing keys, so Install actually installs#45
ralyodio merged 1 commit into
mainfrom
feat/crx-signing-keys

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Pressing Install on a listing downloads a .zip.

Chromium can only install a signed .crx — a .zip is sideload-only (unzip, then "Load unpacked"). No listing has a .crx, so /download (which redirects to crx_url || bundle_url) hands over the zip and the store's one-click promise silently degrades to a file download.

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 holds the key and signs on their behalf.

Added

  • 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. There is deliberately no rotate or 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 with the bundle.
  • "🔑 Generate signing key" button for owners, with the permanence spelled out in the confirm dialog. The extension id is displayed once it exists, and Install switches to the signed .crx.
  • Migration 0006_extension_signing_keys.sql — already applied to the production DB.

Fixes two bugs this exposed in auto-update

  • 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 update response when there isn't one.
  • appid was our internal uuid. Chromium matches updates on the extension id derived from the signing key.

Notes

CRX3 packing is hand-rolled (three protobuf fields) rather than pulling a dependency. The tests verify the embedded 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 — so the format is checked against the code that consumes it, not just against itself.

CRX_KEY_SECRET is set on the Railway service. Key generation fails loudly if it's ever missing, rather than storing a private key in plaintext.

Verification

tsc --noEmit clean; vitest run src/store47/47 (10 new); node --check on the frontend; migration applied and confirmed via db:migrate --status.

🤖 Generated with Claude Code

…alls

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>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit c688f3f into main Jul 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant