Skip to content

Seal CRX signing keys under the publisher's passphrase - #172

Merged
sh1pt-actions-fleet[bot] merged 2 commits into
mainfrom
feat/publisher-sealed-crx-keys
Oct 28, 2026
Merged

sh1pt-actions-fleet[bot] merged 2 commits into
mainfrom
feat/publisher-sealed-crx-keys

Conversation

@sh1pt-actions-fleet

Copy link
Copy Markdown
Contributor

The store held every publisher's CRX signing private key under one CRX_KEY_SECRET (scrypt, fixed salt), and packed a .crx with it on every public download.

Now

  • Each publisher sets a signing passphrase. apps/extensions/public/seal.js (WebCrypto) runs PBKDF2-SHA256 600k + HKDF to get a KEK. The KEK wraps a random data key, and a one-time recovery key wraps it too. The store keeps only this record (publisher_key_seals).
  • Signing keys are sealed under the data key (AES-256-GCM, AAD = publisher + extension). The data key, never the passphrase, is sent only with a request that needs the key: create a key, migrate one, POST /extensions/:id/versions with dek, or POST /extensions/:id/sign.
  • The .crx is packed during that request and stored (extension_signed_crx). download.crx, updates.xml and /download serve the last signed release. Nothing is packed on download any more.
  • Migration: POST /publisher/seal re-seals the publisher's legacy keys in the same request, then signs each listing's latest release. LEGACY_KEY_MIGRATION=off is the cutoff. bun /api/dist/store/prepack-legacy.js packs the current release of unmigrated listings once, so installs keep working until the publisher sets a passphrase.
  • UI: a passphrase dialog when generating a key, plus a "Sign latest release" button. store-publish.mjs signs when TRON_SIGNING_PASSPHRASE is set.
  • Lost passphrase and recovery key: the extension id can never ship a signed update again. The UI says so.

Tests: signing.test.ts covers the browser seal accepted by the server, AAD binding, no env fallback, and migration with the switch on and off. The store suite passes 58/58. src/mcp/tron.test.ts fails before and after this change because @tronbrowser/sdk isn't built locally.

🤖 Generated with Claude Code

…le present

The store held every publisher's CRX signing private key under one
CRX_KEY_SECRET (scrypt, fixed salt) and packed a .crx with it on every public
download. Root on the box = every extension identity.

Now (seal v2):
- Each publisher sets a signing passphrase in the store (seal.js, WebCrypto:
  PBKDF2-SHA256 600k + HKDF -> KEK wraps a random data key; one-time recovery
  key). The store keeps only that record.
- Signing keys are sealed under the data key (AES-256-GCM, AAD = publisher +
  extension). The browser/script derives the data key and sends it, never the
  passphrase, only with the request that needs the key: create a key, migrate
  one, or sign a release (POST /versions with dek, POST /extensions/:id/sign).
- The .crx is packed in that request and stored (extension_signed_crx);
  download.crx and updates.xml serve the last signed release. Nothing packs on
  download any more.
- Migration: setting the passphrase re-seals the publisher's legacy keys in the
  same request and signs each listing's latest release. LEGACY_KEY_MIGRATION=off
  is the cutoff; prepack-legacy.js packs current releases of unmigrated
  listings once so installs keep working.
- store-publish.mjs signs when TRON_SIGNING_PASSPHRASE is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
const rraw = rand(32);
const recoveryKey = encodeRecovery(rraw);
rraw.fill(0);
const kdf = { alg: 'PBKDF2-SHA256', iterations: ITER, salt: b64(rand(16)) };
@github-actions

Copy link
Copy Markdown
Contributor

ThreatCrush Security Scan

51 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 28 | LOW: 21

Severity Rule Location
HIGH py-ssrf-outbound-request apps/desktop/launcher/tron-tor-helper:303
HIGH py-ssrf-outbound-request apps/desktop/launcher/tron-tor-helper:437
MEDIUM js-open-redirect apps/desktop/extensions/ai-sidebar/install-helper.js:156
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/media.js:34
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/media.js:57
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/newtab.js:246
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/newtab.js:275
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/newtab.js:345
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/options.js:306
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/sidepanel.js:78
MEDIUM js-unescaped-html-sink apps/desktop/extensions/ai-sidebar/sidepanel.js:166
MEDIUM redos-nested-quantifier apps/desktop/extensions/ai-sidebar/w3bs.js:17
MEDIUM insecure-randomness-for-secret apps/extensions/public/seal.js:107
MEDIUM js-unescaped-html-sink apps/extensions/public/store.js:77
MEDIUM js-unescaped-html-sink apps/extensions/public/store.js:286
MEDIUM js-unescaped-html-sink apps/extensions/public/store.js:652
MEDIUM js-unescaped-html-sink apps/web/public/app.js:29
MEDIUM js-unescaped-html-sink apps/web/public/dns.js:54
MEDIUM sh-remote-script-execution apps/web/public/install.sh:182
MEDIUM sh-remote-script-execution apps/web/public/install.sh:422
MEDIUM sh-remote-script-execution apps/web/public/install.sh:440
MEDIUM js-unescaped-html-sink apps/web/public/settings.js:96
MEDIUM js-unescaped-html-sink apps/web/public/settings.js:168
MEDIUM js-dynamic-code-execution packages/sdk/src/mcp/tools.ts:125
MEDIUM js-dynamic-code-execution packages/sdk/src/page.ts:68
MEDIUM js-dynamic-code-execution packages/sdk/src/page.ts:73
MEDIUM js-unescaped-html-sink services/api/src/profile.ts:59
MEDIUM sql-template-interpolation services/api/src/store/db.ts:116
MEDIUM js-dynamic-code-execution services/api/src/store/scanner.ts:44
MEDIUM sh-predictable-temp-path start.sh:28
LOW js-dynamic-code-execution apps/desktop/extensions/ai-sidebar/pit-proxy.test.js:8
LOW js-dynamic-code-execution packages/agent-runtime/src/analyze/form-script.test.ts:6
LOW js-unescaped-html-sink packages/agent-runtime/src/analyze/form-script.test.ts:26
LOW js-unescaped-html-sink packages/agent-runtime/src/analyze/form-script.test.ts:49
LOW js-dynamic-code-execution packages/browser-core/src/automation/extract-script.test.ts:6
LOW js-unescaped-html-sink packages/browser-core/src/automation/extract-script.test.ts:16
LOW js-unescaped-html-sink packages/browser-core/src/automation/extract-script.test.ts:45
LOW js-unescaped-html-sink packages/browser-core/src/automation/extract-script.test.ts:57
LOW js-unescaped-html-sink packages/browser-core/src/automation/extract-script.test.ts:80
LOW js-unescaped-html-sink packages/browser-core/src/automation/extract-script.test.ts:99
LOW js-dynamic-code-execution packages/browser-core/src/automation/snapshot-script.test.ts:11
LOW js-unescaped-html-sink packages/browser-core/src/automation/snapshot-script.test.ts:62
LOW js-unescaped-html-sink packages/browser-core/src/automation/snapshot-script.test.ts:101
LOW js-unescaped-html-sink packages/provenance/src/scan.test.ts:47
LOW js-unescaped-html-sink packages/provenance/src/scan.test.ts:170
LOW js-unescaped-html-sink packages/provenance/src/scan.test.ts:216
LOW js-dynamic-code-execution packages/sdk/src/mcp/automate.test.ts:261
LOW secret-generic-api-key packages/storage/src/config.ts:51
LOW secret-generic-credential packages/storage/src/config.ts:51
LOW js-dynamic-code-execution services/api/src/store/scanner.test.ts:32

…and 1 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sh1pt-actions-fleet
sh1pt-actions-fleet Bot merged commit 83179b2 into main Oct 28, 2026
8 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.

2 participants