feat(store): store-managed CRX signing keys, so Install actually installs - #45
Merged
Conversation
…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>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pressing Install on a listing downloads a
.zip.Chromium can only install a signed
.crx— a.zipis sideload-only (unzip, then "Load unpacked"). No listing has a.crx, so/download(which redirects tocrx_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
openssland guard a.pemforever — 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 underCRX_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.zipinto 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..crx.0006_extension_signing_keys.sql— already applied to the production DB.Fixes two bugs this exposed in auto-update
updates.xmladvertisedcodebase=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.appidwas 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:cryptoover the real CRX3 signed payload, and round-trip a packed.crxback 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_SECRETis set on the Railway service. Key generation fails loudly if it's ever missing, rather than storing a private key in plaintext.Verification
tsc --noEmitclean;vitest run src/store→ 47/47 (10 new);node --checkon the frontend; migration applied and confirmed viadb:migrate --status.🤖 Generated with Claude Code