Skip to content

security: move private key to session-only storage (never persisted to disk)#12

Closed
jjohare wants to merge 1 commit into
JavaScriptSolidServer:mainfrom
jjohare:security/encrypted-key-storage
Closed

security: move private key to session-only storage (never persisted to disk)#12
jjohare wants to merge 1 commit into
JavaScriptSolidServer:mainfrom
jjohare:security/encrypted-key-storage

Conversation

@jjohare

@jjohare jjohare commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Move private key from chrome.storage.local to chrome.storage.session -- private key is never written to disk
  • Keep public key in chrome.storage.local -- needed for popup identity display without unlocking
  • Update popup/popup.js to read private key from session storage (with legacy fallback)
  • Clean up legacy copies of private keys left in local storage by older versions

Threat Model

chrome.storage.local persists data to disk as unencrypted JSON at a well-known, predictable path:

~/.config/google-chrome/Default/Local Extension Settings/<extension-id>/

This means:

  • Any process with filesystem read access can extract the raw private key hex
  • Backup software (Time Machine, rsync, cloud sync) silently copies the key to additional locations
  • Malware targeting browser extensions can scan this directory
  • Other extensions with nativeMessaging permission can read arbitrary files
  • Forensic recovery can retrieve the key even after "deletion" (data remains on disk until overwritten)

chrome.storage.session (MV3 only) stores data exclusively in memory:

  • Never serialized to disk
  • Cleared when the service worker terminates (browser restart, idle timeout)
  • Scoped to the extension's origin -- inaccessible to other extensions

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

File Change
src/storage.js storeKeypair() writes private key to session, public key to local; getKeypair() reads from both; deleteKeypair() clears both; added getStoredPublicKey() helper; removes legacy local copy
popup/popup.js handleExport() reads from session storage first with local fallback; storage change listener handles both local and session area names

Test plan

  • Generate a new keypair -- verify it works, popup shows identity
  • Sign an event -- verify signing still works (private key loaded from session)
  • Export private key from popup -- verify it reads from session storage
  • Restart browser -- verify private key is gone (user must re-import), but public key / DID still displays
  • Inspect Local Extension Settings/<id>/ on disk -- verify no private key present
  • Legacy upgrade: install old version, generate key, upgrade to this version -- verify storeKeypair removes local copy

…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/deleteKeypair to store the private key in chrome.storage.session and the public key in chrome.storage.local, and add a getStoredPublicKey() helper.
  • Add legacy cleanup intent by removing any podkey_private_key left in chrome.storage.local during storeKeypair().
  • 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.local are only removed when storeKeypair() 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 new getStoredPublicKey() 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., via Promise.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 any session changes, even though trusted sites are stored in local. Consider filtering by areaName === 'local' or by relevant keys in changes to 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.

Comment thread src/storage.js
Comment on lines +33 to +36
// Private key: session storage only (in-memory, never written to disk)
await chrome.storage.session.set({
[STORAGE_KEYS.PRIVATE_KEY]: privateKey
});
Comment thread popup/popup.js
Comment on lines +224 to +227
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']));
}
@jjohare

jjohare commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


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.

jjohare added a commit that referenced this pull request Jun 15, 2026
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
@jjohare

jjohare commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #22 (merged as 384918d), which consolidates this branch's fixes into one reviewed, CI-green change. Closing in favour of that.

@jjohare jjohare closed this Jun 15, 2026
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.

3 participants