security: move private key to session-only storage (never persisted to disk)#12
security: move private key to session-only storage (never persisted to disk)#12jjohare wants to merge 1 commit into
Conversation
…e.session chrome.storage.local persists data to disk as unencrypted JSON at a well-known path (~/.config/google-chrome/.../Local Extension Settings/<id>/). Any process with filesystem access -- malware, other extensions with nativeMessaging, backup tools -- can read the raw private key hex. chrome.storage.session is MV3-only, in-memory storage that is: - Never written to disk - Cleared when the service worker terminates - Scoped to the extension's origin Changes: - storeKeypair() now writes private key to session storage, public key to local storage (public key is not secret and is needed by the popup to display identity without unlocking) - getKeypair() reads private key from session, public key from local - deleteKeypair() clears from both storages (including legacy local copy) - Added getStoredPublicKey() for popup identity display - popup/popup.js handleExport() now reads from session storage first, with a fallback to local for legacy installs - Added legacy private key cleanup: storeKeypair removes any old chrome.storage.local copy of the private key Trade-off: Users must re-import their key after browser restart (session storage is cleared). This is an acceptable UX cost for the security gain of never persisting the private key to disk. Co-Authored-By: claude-flow <ruv@ruv.net>
There was a problem hiding this comment.
Pull request overview
This PR hardens key handling by ensuring the Nostr private key is kept in in-memory-only extension storage (chrome.storage.session) so it is not persisted to disk, while keeping the public key persisted (chrome.storage.local) to support identity display.
Changes:
- Update
storeKeypair/getKeypair/deleteKeypairto store the private key inchrome.storage.sessionand the public key inchrome.storage.local, and add agetStoredPublicKey()helper. - Add legacy cleanup intent by removing any
podkey_private_keyleft inchrome.storage.localduringstoreKeypair(). - Update popup export to read from session first with a legacy local fallback, and broaden the storage change listener to include
session.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/storage.js |
Moves private key to session storage, keeps public key in local, adds getStoredPublicKey(), and expands delete/cleanup behavior. |
popup/popup.js |
Updates export flow to prefer session storage and adjusts storage-change listening behavior. |
Comments suppressed due to low confidence (4)
src/storage.js:46
- Legacy private keys stored in
chrome.storage.localare only removed whenstoreKeypair()is called. After upgrading from an older version, the private key will remain persisted on disk until the user re-imports/generates (contradicts the PR’s goal/threat model). Consider adding an explicit migration/cleanup path on extension startup/update to move/remove the legacy local private key immediately.
// Remove any legacy private key from local storage left by older versions
await chrome.storage.local.remove([STORAGE_KEYS.PRIVATE_KEY]);
src/storage.js:88
hasKeypair()now requires a session private key, which makes "exists" false after a service worker restart even though the public key is persisted. That means callers like GET_KEYPAIR_STATUS can’t show identity from disk, and the newgetStoredPublicKey()helper is unused. Consider introducing a separate "hasPublicKey/getPublicIdentity" path (and/or adjusting status responses) so the popup can still display pubkey/DID without an unlocked session key.
/**
* Check if a usable keypair exists (private key in session + public key on disk).
* @returns {Promise<boolean>}
*/
export async function hasKeypair() {
const keypair = await getKeypair();
return keypair !== null;
}
/**
* Check if a public key exists on disk (may not have a private key in session).
* Useful for the popup to show identity even when the session has expired.
* @returns {Promise<string|null>} The public key hex, or null
*/
export async function getStoredPublicKey() {
const { [STORAGE_KEYS.PUBLIC_KEY]: publicKey } =
await chrome.storage.local.get([STORAGE_KEYS.PUBLIC_KEY]);
return publicKey || null;
src/storage.js:62
getKeypair()performs two independent storage reads sequentially. These can be fetched concurrently (e.g., viaPromise.all) to reduce latency in hot paths like signing/auth header generation.
const { [STORAGE_KEYS.PRIVATE_KEY]: privateKey } =
await chrome.storage.session.get([STORAGE_KEYS.PRIVATE_KEY]);
const { [STORAGE_KEYS.PUBLIC_KEY]: publicKey } =
await chrome.storage.local.get([STORAGE_KEYS.PUBLIC_KEY]);
popup/popup.js:294
- The storage change listener now triggers
loadTrustedSites()for anysessionchanges, even though trusted sites are stored inlocal. Consider filtering byareaName === 'local'or by relevant keys inchangesto avoid unnecessary reloads on private-key/session updates.
// Listen for storage changes (local for trusted sites / pubkey, session for private key)
chrome.storage.onChanged.addListener((changes, areaName) => {
if ((areaName === 'local' || areaName === 'session') && currentScreen === 'main') {
loadTrustedSites();
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Private key: session storage only (in-memory, never written to disk) | ||
| await chrome.storage.session.set({ | ||
| [STORAGE_KEYS.PRIVATE_KEY]: privateKey | ||
| }); |
| let { podkey_private_key: privateKey } = await chrome.storage.session.get(['podkey_private_key']); | ||
| if (!privateKey) { | ||
| ({ podkey_private_key: privateKey } = await chrome.storage.local.get(['podkey_private_key'])); | ||
| } |
|
Folded into #22 — a single CI-green, adversarially-reviewed PR that consolidates this branch with the other security fixes plus NIP-44 and residual gap-fixes (exact-host trust, NIP-98 nonce, signEvent self-verify, honest provider surface, auto-sign default OFF). Suggest reviewing/merging #22; this can be closed in its favour. |
|
DreamLab-AI Mega-Sprint seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
Consolidated hardening of the Podkey key-holding signer: brings seven in-flight security/feature branches plus residual gap-fills, a polished CSP-clean UI, a CI build/test/lint gate, and a 133-test suite into one reviewable PR. The fix set was independently re-derived from a full audit of the extension and every item below was verified fixed *in the final tree* by an adversarial review pass (not just claimed). **Supersedes** (folded in here; safe to close in favour of this PR): #12, #14, #17, #18, #19, #20, #21. ## Security posture — before → after | Area | Before | After | |---|---|---| | Consent | Auto-approve everything (`showPermissionPrompt` hard-returned `true`) | **Explicit per-origin consent** popup that blocks on the user's decision; closing or 60s timeout denies | | Key at rest | `storage.local` (persisted plaintext) | **`storage.session` only** (in-memory, cleared on browser close); raw key never crosses to the page | | Crypto | A fake `crypto-browser.js` in the tree (`pubkey = sha256(privkey)`) | Deleted; **real `@noble` crypto** only, with `signEvent` schnorr self-verify before return | | NIP-98 | Signed-event cache → byte-identical id → replayable | **Fresh per-token 16-byte nonce**, redirect-aware `u` tag, page-side body-hash binding | | Solid-host trust | Substring match (`inrupt.net.evil.com` accepted) | **Exact-host match**, case-insensitive; lookalikes rejected | | Interception | Double fetch/XHR interception + double 401-retry | **Single interception path**, one retry | | Message channel | Page could inject arbitrary fields / origin / `privateKey` | Content script **whitelists 4 message types, forwards only safe fields, enforces the real origin** | | Provider surface | Advertised `nip04`/`getRelays` that throw / return `{}` | **Honest**: `getPublicKey`, `signEvent`, `nip44.{encrypt,decrypt}` only | | UI | Inline scripts + `innerHTML` XSS sinks | **CSP `script-src 'self'`**; all DOM via `textContent`/`createElement` | | Auto-sign | Defaulted **ON** (first-run silent trust + NIP-98 mint) | Defaults **OFF** — silent trusted-origin Solid signing and NIP-98 auto-trust are strictly opt-in | ## Notes for review - **Consent timeout:** `approve.js` actively sends `approved:false` + closes at the 60s mark (one-shot `decisionSent` guard) so the visible countdown is truthful; the background `chrome.windows` 60s timer remains a backstop. If you'd prefer the popup stay passive and let the background own the timeout, that is the single behavioural line to reconsider — everything else on the UI is restyling. - The page-context `nip98-interceptor.js` still duplicates a couple of `auth-header-utils.js` helpers because page context can't ESM-import the module; intentional, tracked separately. ## Verification - `npm run build` ✓ · `npm test` → **133 pass / 0 fail** ✓ · `npm run lint` → **0 errors** (now `no-unused-vars: error`) ✓ - New `.github/workflows/ci.yml` runs build → test → lint on every PR and push to `main`. 🤖 Generated by Claude Code
Summary
chrome.storage.localtochrome.storage.session-- private key is never written to diskchrome.storage.local-- needed for popup identity display without unlockingpopup/popup.jsto read private key from session storage (with legacy fallback)Threat Model
chrome.storage.localpersists data to disk as unencrypted JSON at a well-known, predictable path:This means:
nativeMessagingpermission can read arbitrary fileschrome.storage.session(MV3 only) stores data exclusively in memory:UX Trade-off
Users must re-import their private key after a full browser restart, since session storage is cleared when the service worker terminates. This is the same model used by hardware wallet extensions (Ledger Live) and is an acceptable trade-off for never exposing the private key on disk.
The public key remains persisted so the popup can still display the user's Nostr identity (
npub/did:nostr:) without requiring re-import.Changes
src/storage.jsstoreKeypair()writes private key tosession, public key tolocal;getKeypair()reads from both;deleteKeypair()clears both; addedgetStoredPublicKey()helper; removes legacy local copypopup/popup.jshandleExport()reads from session storage first with local fallback; storage change listener handles bothlocalandsessionarea namesTest plan
Local Extension Settings/<id>/on disk -- verify no private key present