Skip to content

Security overhaul: consolidated hardening of the key-holding signer (consent UI, session-only key, NIP-44, NIP-98 replay fix, CI)#22

Merged
jjohare merged 34 commits into
JavaScriptSolidServer:mainfrom
jjohare:chore/podkey-up-to-scratch
Jun 15, 2026
Merged

Security overhaul: consolidated hardening of the key-holding signer (consent UI, session-only key, NIP-44, NIP-98 replay fix, CI)#22
jjohare merged 34 commits into
JavaScriptSolidServer:mainfrom
jjohare:chore/podkey-up-to-scratch

Conversation

@jjohare

@jjohare jjohare commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

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 test133 pass / 0 fail ✓ · npm run lint0 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

DreamLab-AI Mega-Sprint and others added 30 commits May 12, 2026 14:20
Both src/injected.js (content script context) and
src/nip98-interceptor.js (page context) were independently overriding
window.fetch and XMLHttpRequest.prototype.send. This caused double
interception of every request, double auth header injection, and double
401 retry loops.

The content script's interception was also coupled to a
window.__podkey_setAuthFn callback that was never properly wired —
getAuthHeaderFn started as null and relied on an async setup race.

This commit removes all fetch/XHR interception code from injected.js
and leaves NIP-98 request interception solely to nip98-interceptor.js,
which already has its own clean implementation using CustomEvent-based
message passing to the content script. The content script now only
handles what it should: relaying podkey-request/podkey-response events
for NIP-07 and podkey-nip98-request/response events for NIP-98.

Co-Authored-By: claude-flow <ruv@ruv.net>
…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>
Replace String.includes() with exact hostname matching via URL parsing.
The previous substring check allowed malicious domains containing the
trusted substring to pass (e.g. evil-solid.social.attacker.com matched
solid.social). Now uses new URL().hostname with exact match or subdomain
check (hostname.endsWith('.' + trusted)). Also removes the never-matching
'/.well-known/solid' entry since origins don't contain paths.

Co-Authored-By: claude-flow <ruv@ruv.net>
Remove the nip98Cache Map and all caching logic. The cache reused signed
events for identical URL+method+bodyHash within 60 seconds, causing the
same event ID to be sent multiple times. Servers with replay protection
reject duplicate event IDs, and the predictable cache keys create an
additional attack surface. NIP-98 event creation is computationally cheap
(~1ms with noble/secp256k1), so caching provides no meaningful benefit.

Co-Authored-By: claude-flow <ruv@ruv.net>
Add an ALLOWED_TYPES whitelist to validate the type field from page
events before forwarding to the background script. Previously, the
type field came directly from the page with no validation, and the
spread operator forwarded arbitrary fields, allowing a malicious page
to invoke internal extension message handlers or inject unexpected
data. Now only known NIP message types are forwarded, and only safe
fields per message type are included in the forwarded message.

Co-Authored-By: claude-flow <ruv@ruv.net>
The previous showPermissionPrompt() unconditionally returned true,
meaning ANY website could silently sign Nostr events as the user
without any confirmation. This is a complete bypass of the extension's
security model -- equivalent to giving every website full access to the
user's private key.

This commit replaces the auto-approve stub with a proper popup-based
approval flow:

- New popup/approve.html: Approval dialog showing the requesting
  origin, action description, and event preview data
- New popup/approve.js: Handles approve/deny button clicks and sends
  the decision back to the background service worker via
  chrome.runtime.sendMessage
- Modified src/background.js:
  - showPermissionPrompt() now opens a chrome.windows.create() popup
    and returns a Promise that resolves when the user clicks
    approve or deny
  - Pending approvals tracked via Map<requestId, resolve>
  - 60-second auto-deny timeout prevents abandoned prompts from
    blocking indefinitely
  - APPROVE_SIGNING message type handled before the main message
    router to avoid deadlock
  - Event preview data (kind, truncated content, tag count) passed
    to the approval popup for informed consent

This follows the same pattern used by established NIP-07 extensions
(nos2x, Alby) where every signing request requires explicit user
approval unless the origin has been previously trusted.

Co-Authored-By: claude-flow <ruv@ruv.net>
Add window.nostr.nip44.{encrypt,decrypt} to the injected NIP-07 provider so
apps that use NIP-17 / NIP-59 gift-wrapped direct messages (e.g. the DreamLab
nostr-rust-forum) can encrypt/decrypt DMs through the Podkey extension.

Implementation mirrors the existing signing/nip04 message path exactly:
page provider (nostr-provider.js) posts a podkey-request CustomEvent ->
content script (injected.js) relays via chrome.runtime.sendMessage ->
background service worker (background.js) routes NIP44_ENCRYPT/NIP44_DECRYPT/
NIP44_GET_CONVERSATION_KEY to handlers that resolve the keypair the same way
GET_PUBLIC_KEY/SIGN_EVENT do. The raw private key never leaves the background;
only the base64 payload / plaintext / conversation key crosses to the page.

NIP-44 v2 (src/nip44.js) uses the vetted @noble stack the extension already
depends on for nip04-era crypto:
  - @noble/secp256k1  : ECDH shared secret (x-coordinate)
  - @noble/hashes     : hkdf (extract/expand), hmac, sha256
  - @noble/ciphers    : chacha20 (added dependency)
conversation key = hkdf_extract(salt="nip44-v2", ikm=ECDH.x);
per-message keys = hkdf_expand(conv_key, info=nonce, 76) ->
chacha_key|chacha_nonce|hmac_key; payload = base64(0x02|nonce|ct|mac),
mac = hmac_sha256(hmac_key, nonce|ct). Padding and unpadding per spec.

Tests (test/nip44.test.js, node --test) cover symmetric conversation keys,
round-trips (unicode/long/random-nonce), background-path helpers, HMAC tamper
detection, and the official NIP-44 spec vectors (deterministic conversation
key + encrypt/decrypt). All 34 tests pass; npm run build bundles cleanly.

Co-Authored-By: jjohare <github@thedreamlab.uk>
… duplicate fetch/XHR interception from content script

NIP-98 fetch/XHR interception now lives exclusively in nip98-interceptor.js;
injected.js is purely the NIP-07 message relay.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ate key in session storage only

Private key moves from chrome.storage.local (on-disk, plaintext) to
chrome.storage.session (in-memory, cleared on SW termination). Public key
stays in local storage so the popup can show identity. Adds getStoredPublicKey
and legacy-local-key cleanup.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…er-origin signing consent

Replaces the auto-approve stub with a real approval popup (popup/approve.html
+ approve.js). background.js opens a popup window per untrusted request and
awaits an APPROVE_SIGNING message (requestId-keyed), auto-denying after 60s.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…lidate page->bg message types

Content script whitelists message types and forwards only validated fields,
rejecting unknown types before they reach the background service worker.

Integration with JavaScriptSolidServer#21 (nip44) and provider honesty:
- whitelist field for NIP-44 args aligned to 'pubkey' (was 'peer') to match
  nostr-provider.js and background.js handlers.
- dropped GET_RELAYS and NIP04_* from the whitelist: the provider no longer
  advertises nip04 or getRelays (see provider-honesty commit), so the relay
  surface is getPublicKey / signEvent / nip44 encrypt+decrypt only.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…exact-host Solid auto-trust

isLikelySolidServer now parses the URL and matches hostname exactly or as a
subdomain suffix, instead of substring includes() (which trusted
inrupt.net.evil.com).

Co-Authored-By: jjohare <github@thedreamlab.uk>
…P-98 replay cache

Removes the 60s nip98Cache that returned a byte-identical signed event (same
event id) for repeated requests, enabling replay. Each NIP-98 auth header is
now signed fresh.

Co-Authored-By: jjohare <github@thedreamlab.uk>
The encrypted-key-storage change (JavaScriptSolidServer#12) moved the private key to
chrome.storage.session; the test mock only stubbed chrome.storage.local,
so storeKeypair/getKeypair/hasKeypair/deleteKeypair threw on the missing
session area. Back each area with its own store, matching production.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…ypt/decrypt for window.nostr

Adds src/nip44.js (HKDF + ChaCha20 + HMAC over @noble primitives), background
handlers (NIP44_ENCRYPT/DECRYPT/GET_CONVERSATION_KEY) that gate on origin trust
and keep the private key in the service worker, and window.nostr.nip44 in the
provider. Enables NIP-17/NIP-59 gift-wrapped DMs.

Co-Authored-By: jjohare <github@thedreamlab.uk>
A key-holder must never emit a signature it has not verified. signEvent now
checks schnorr.verify(sig, eventId, pubkey) and throws on mismatch, catching a
faulty signing path (bad RNG, library regression) before the bad signature
leaves the service worker. This also exercises the previously unused verify
primitive on the hot signing path.

Co-Authored-By: jjohare <github@thedreamlab.uk>
window.nostr.nip04 delegated to a background handler that throws "not
implemented", and getRelays returned {}. Feature-detection
(window.nostr.nip04 / .getRelays) was therefore lying to callers. NIP-04 is a
distinct, deprecated scheme we do not implement (Podkey ships NIP-44 v2 only),
and Podkey holds no relay list, so the honest signal is the absence of both
methods. The dead background cases are removed alongside the NIP-98 work.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…try; drop dead code; gate secret logs

NIP-98 hardening:
- Add a random 'nonce' tag to every kind-27235 event. created_at has 1s
  resolution, so two identical requests in the same second previously produced
  a byte-identical event id (a replayable token even after the cache removal).
- Compute the 'payload' body hash in the page context (nip98-interceptor.js),
  where FormData / URLSearchParams / Blob / typed-array bodies are still intact,
  and send only the hex digest. The raw body no longer crosses the message
  channel (it could not be structured-cloned faithfully), which also fixes the
  missing payload hash for URLSearchParams/binary bodies. background.js consumes
  the precomputed hash, falling back to hashing string/ArrayBuffer/Blob locally.
- On the 401 retry, re-sign against response.url when the original request was
  redirected, so the 'u' tag matches the endpoint that issued the challenge.

Dead code:
- Remove unused retryState map and formatEventForPrompt (the approval popup
  inlines its own preview).
- Remove the NIP04_ENCRYPT/DECRYPT and GET_RELAYS message cases now that the
  provider no longer advertises them.

Secret-log hygiene:
- Introduce a DEBUG flag (default off) in background.js, popup.js and
  nip98-interceptor.js and gate every console statement that prints a public
  key, DID, signed event, Authorization header, or message result behind it.
  Error logs and benign lifecycle logs are kept.

Co-Authored-By: jjohare <github@thedreamlab.uk>
eslint 8.57.1 is pinned in package-lock, so this uses the .eslintrc.json
(eslintrc) format. Extends eslint:recommended with browser, webextensions
(chrome.*), serviceworker (self), and es2022 environments; test/ and
scripts/ get the node environment via overrides.

Security/correctness rules are errors: no-eval, no-implied-eval, no-undef.
no-unused-vars is a warning (argsIgnorePattern ^_) so pre-existing unused
args/imports in src and test surface in CI logs without blocking the gate,
keeping defaults sensible rather than a pedantic wall. Ignores node_modules,
dist, and the gitignored **/*.bundle.js build output.

Co-Authored-By: jjohare <github@thedreamlab.uk>
New CI workflow runs on pull_request and push to main: a single
ubuntu-latest job on Node 20 with npm cache, running npm ci -> build ->
test -> lint. Minimal contents:read permissions; gh-pages.yml is left
untouched.

Co-Authored-By: jjohare <github@thedreamlab.uk>
Replace the purple/blue gradient theme with a calm slate palette and a single
trust-blue accent (semantic green/red reserved for safe/destructive actions).
Add a token-based light/dark theme via prefers-color-scheme, generous spacing,
rounded cards, subtle shadows, accessible focus rings, and inline SVG icons in
place of emoji.

- popup.css: full rewrite as a shared design system (CSS custom properties for
  surfaces, text, borders, accent, semantic colours, shape) consumed by both
  popup.html and approve.html.
- popup.html: restructured identity view — npub/DID in monospace inset cards, a
  "Session" lock pill making the session-only key legible at a glance, a
  settings toggle with a description, and a tidy trusted-sites list.
- popup.js: copy/generate now update a label span instead of overwriting button
  contents (preserves icons); loadTrustedSites rebuilds via the DOM API
  (replaceChildren/createElement) so no innerHTML is used on a surface that
  renders web-origin strings. All message protocols and DEBUG gating unchanged.

Co-Authored-By: jjohare <github@thedreamlab.uk>
The consent prompt is the surface a user sees when a site asks to sign with
their key, so it now reads as a clear, calm security decision.

- approve.css: dedicated stylesheet (inherits the popup design tokens). The
  requesting origin is the focal point — large monospace in a bordered inset
  that wraps/scrolls long or hostile origins without overflowing the layout or
  hiding the buttons. Deny is the safe default (solid, autofocused); Approve is
  deliberate (outlined danger-tone, not pre-focused, so a blind Enter denies).
  An amber note states that approving lets the site act as the user.
- approve.html: restructured DOM (shield header, origin block, action row,
  scrollable payload, trust note, button row, live countdown). No inline styles
  with untrusted data; all dynamic fields are empty containers populated by JS.
- approve.js: drives a visible, accurate 60s auto-deny countdown (rAF, with an
  "urgent" state under 10s). Security contract preserved byte-for-byte: reads
  id/origin/action/preview from the query string, sends
  {type:'APPROVE_SIGNING', requestId, approved} on choice, denies on
  beforeunload (close = deny), and auto-denies at 60s. A one-shot guard ensures
  the decision is sent exactly once. Origin and payload are written via
  textContent only — never innerHTML.

Co-Authored-By: jjohare <github@thedreamlab.uk>
Rework the public test/install page to match the extension's calm design and
to drop inline scripts/handlers.

- app.css: new stylesheet matching the popup design tokens, with light/dark via
  prefers-color-scheme.
- app.js: extracted from the inline <script>; buttons are wired with
  addEventListener keyed by data-action (no onclick=). Result panels and the
  event log are built with createElement/textContent — no innerHTML with
  interpolated signed-event fields or error messages.
- index.html: cleaner brand header, status card, a numbered install flow with
  code blocks, colour-coded notices, and key-management/signing test sections.
  Links external app.css/app.js; no inline <style> or <script> remain.

Co-Authored-By: jjohare <github@thedreamlab.uk>
Drive background.js through its captured chrome.runtime.onMessage listener
(it exports nothing and wires handlers at import time), mirroring the real
content-script / approve-popup message contract.

consent-approval.test.js (11): approve resolves the pending request; deny and
APPROVE_SIGNING{approved:false} (popup beforeunload) reject; the 60s timeout
auto-denies (and does not fire early); a late response after timeout is a
no-op; trusted+autoSign auto-signs kind-27235 Solid auth with no prompt while
other kinds and autoSign-off still prompt; approving an untrusted origin trusts
it for next time.

nip98-token.test.js (16): per-token random 16-byte nonce yields distinct event
ids/sigs for the same (url,method) within one second (no replayable duplicate);
payload tag carries the sha-256 of a string body and prefers a page-computed
bodyHash; exact-origin Solid auto-trust accepts inrupt.net and real subdomains
but rejects inrupt.net.evil.com / evilinrupt.net / non-Solid hosts; autoSign-off
and no-keypair return null instead of a header.

Co-Authored-By: jjohare <github@thedreamlab.uk>
sign-event-verify.test.js (7): a correctly-signed event passes and round-trips
through verifySignature; the self-verify guard throws (never returns a bad
signature) when the shared @noble/secp256k1 schnorr.sign singleton is patched
to emit a flipped-byte or wrong-message signature; verifySignature rejects
mutated content/sig/pubkey. Patches and restores the module singleton without
touching src.

provider-surface.test.js (15): window.nostr exposes exactly getPublicKey,
signEvent and nip44.{encrypt,decrypt} and does NOT expose nip04 or getRelays
(truthful feature-detection); signEvent input validation rejects bad shapes
before any wire dispatch; the podkey-request CustomEvent wiring forwards the
right type/fields (nip44 field is "pubkey", not "peer") and propagates a
background error back as a rejection. Runs the page-context IIFE against a
minimal EventTarget-backed window.

Co-Authored-By: jjohare <github@thedreamlab.uk>
body-hash.test.js (11): exercise the real bodyToHashHex inside the
nip98-interceptor IIFE by running it in a vm sandbox with a minimal page
(window/fetch/XHR) and capturing the bodyHash it emits on podkey-nip98-request.
Correct sha-256 hex for string, URLSearchParams, Blob, ArrayBuffer and
typed-array bodies (respecting view offset); "" for no/empty body; "" for
FormData (no canonical NIP-98 multipart hash). A page-set Authorization
suppresses NIP-98 injection; absent auth emits a request.

content-whitelist.test.js (9): injected.js forwards only the four NIP-07 types,
rejects anything else (incl. CREATE_NIP98_AUTH_HEADER, which has its own
channel) with "Unknown request type" and never calls sendMessage for it;
forwards only safe fields per type, always overriding origin with the real page
origin (drops attacker-supplied privateKey/peer/extra) and coerces nip44 fields
to strings.

Co-Authored-By: jjohare <github@thedreamlab.uk>
nip44-edges.test.js (8): plaintext bounds (1..65535) — empty and oversized are
rejected, the single-byte minimum and exact 65535-byte maximum round-trip; key
isolation — a payload encrypted under convKey(alice,bob) fails the MAC under
convKey(alice,eve) rather than silently decrypting; malformed/too-short and
non-base64 payloads are rejected. Complements (does not duplicate) the spec
vectors and basic round-trip already in nip44.test.js.

Co-Authored-By: jjohare <github@thedreamlab.uk>
jjohare added 2 commits June 14, 2026 20:35
…error

Cleanup flagged by the CI review, then harden the gate so unused
identifiers fail lint instead of merely warning:

- background.js: the two consent handlers (handleGetPublicKey,
  handleSignEvent) receive `sender` but never use it -> `_sender`.
- nip98-interceptor.js: the XHR setRequestHeader override only inspects
  the header `name` (and forwards `arguments`); the `value` arg is
  unused -> `_value`.
- test/storage.test.js: drop the unused `before`/`after` imports (the
  suite uses inline clearStorage() calls).
- .eslintrc.json: no-unused-vars "warn" -> "error", keeping the
  ^_ ignore patterns for args/vars/caught-errors.

Lint is 0 errors / 0 warnings; build and the 133-test suite stay green.

Co-Authored-By: jjohare <github@thedreamlab.uk>
…tly trust

getAutoSign() defaulted to `true`, which made the silent NIP-98 path in
createNip98AuthHeader (background.js: `!trusted && isSolid && autoSign`)
active out of the box: a freshly installed extension visiting any of the
hardcoded Solid hosts (solid.social, solidcommunity.net, inrupt.net,
solidweb.org) would mint a NIP-98 Authorization header AND persist the
origin to trusted-origins with zero user interaction -- a silent
auto-approve / auto-trust on install.

Flip the default to OFF in storage.getAutoSign so the trusted-origin
Solid (kind 27235) auto-sign and the Solid NIP-98 auto-trust are strictly
opt-in: the user enables "Auto-sign for Solid" from the popup
deliberately. Untrusted-origin signing was already prompt-gated and is
unaffected.

Align the popup default-display accordingly: popup.js reads the same OFF
default, and the popup.html toggle drops its hardcoded `checked` so the
markup matches the real (off) state instead of flashing on at load.

No test pins the autoSign default (storage.test only asserts it is a
boolean; the consent/NIP-98 suites set it explicitly), so the full
133-test suite, build and lint remain green.

Co-Authored-By: jjohare <github@thedreamlab.uk>
@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 2 commits June 14, 2026 20:59
Adds Package + upload-artifact steps so every CI run produces
podkey-extension.zip (manifest + src + popup + icons), loadable via
chrome://extensions -> Load unpacked. Answers 'does CI produce a
sideloadable extension'.

Co-Authored-By: jjohare <github@thedreamlab.uk>
node --test on the runner's non-globstar sh never expanded
test/**/*.test.js, so CI reported 'Could not find'. All test files are
flat in test/, so a single * is correct and portable. Pre-existing
fragility surfaced by adding CI.

Co-Authored-By: jjohare <github@thedreamlab.uk>
@jjohare jjohare merged commit 384918d into JavaScriptSolidServer:main Jun 15, 2026
1 of 2 checks passed
@jjohare jjohare deleted the chore/podkey-up-to-scratch branch June 15, 2026 13:18
jjohare added a commit to jjohare/podkey that referenced this pull request Jun 16, 2026
PR JavaScriptSolidServer#22's hardening stored the private key only in chrome.storage.session
(in-memory), so it was wiped on every browser restart — getPublicKey() then
threw the non-intuitive "No keypair found" and the user had to re-import their
nsec each time. That is "no at-rest persistence", not "encryption at rest".

Add a real encrypted-at-rest vault:
- src/vault.js: AES-256-GCM ciphertext in chrome.storage.local, key wrapped by
  scrypt(passphrase) (N=2^16, r=8, p=1; params sealed in the blob for future
  upgrade). Pure encrypt/decrypt split from the storage I/O so the crypto is
  unit-tested off-platform. Wrong passphrase / tamper fail closed via the GCM tag.
- Session stays the hot cache: on unlock the decrypted key is held in
  chrome.storage.session for fast signing; a browser restart clears it and the
  user re-unlocks. The raw key never touches disk.
- background.js: generate/import now take a passphrase and seal the vault;
  new UNLOCK_VAULT / LOCK_VAULT; GET_KEYPAIR_STATUS reports none|locked|unlocked.
  A locked vault on getPublicKey/sign/nip44 opens the unlock popup and throws a
  clear "Podkey is locked — unlock and try again" instead of "No keypair found".
- popup: set-passphrase step on generate, passphrase fields on import, an unlock
  screen (with forget-key/start-over), and a Lock action. Pill reads "Unlocked".

Tests: 8 vault crypto tests (round-trip, wrong passphrase, tamper, validation,
salt/iv freshness). Full suite 141 pass; eslint src/ clean.

Co-Authored-By: jjohare <github@thedreamlab.uk>
jjohare added a commit that referenced this pull request Jun 16, 2026
…icons (#23)

* fix(signing): trusted origins sign without a prompt (general NIP-07 signer)

handleSignEvent gated auto-signing on `trusted && autoSign && isSolidAuth`,
so a trusted origin only signed kind-27235 (Solid auth) silently and
re-prompted for every other event kind. A normal Nostr client — the forum
publishes kind 0 / 10002 / 22242 on every load — therefore raised three
approval popups on each page load, making Podkey unusable as a single
general-purpose signer.

Align signing with the trust model already used by GET_PUBLIC_KEY and
nip44.{encrypt,decrypt}: a trusted origin is sufficient to skip the prompt
(decrypting DMs is strictly more sensitive than signing, and is already
silent for trusted origins). An untrusted origin still ALWAYS prompts, and
approving it establishes revocable trust — no silent first-use.

    let shouldSign = trusted && autoSign && isSolidAuth;
    ->  let shouldSign = trusted;

Tests updated to the general-signer contract (133 pass, lint clean).

Co-Authored-By: jjohare <github@thedreamlab.uk>

* docs+ui: refactor README/USAGE/CHANGELOG, fit popup to surface, add key icons

- README/USAGE/CHANGELOG: rewrite to match the current capability and security
  model (session-only key, per-origin consent, trusted-origin signing, NIP-98
  with fresh nonce + body/URL binding, honest window.nostr surface). UK English.
- popup: cap the trusted-sites list and tighten section spacing so the Export /
  GitHub footer stays within the browser popup height instead of scrolling off;
  widen to 400px.
- icons: add 16/48/128px key icons (icons/icon.svg master) and wire them into
  manifest icons + action.default_icon so the toolbar shows the Podkey key.

Co-Authored-By: jjohare <github@thedreamlab.uk>

* feat(vault): encrypted-at-rest key persistence with passphrase unlock

PR #22's hardening stored the private key only in chrome.storage.session
(in-memory), so it was wiped on every browser restart — getPublicKey() then
threw the non-intuitive "No keypair found" and the user had to re-import their
nsec each time. That is "no at-rest persistence", not "encryption at rest".

Add a real encrypted-at-rest vault:
- src/vault.js: AES-256-GCM ciphertext in chrome.storage.local, key wrapped by
  scrypt(passphrase) (N=2^16, r=8, p=1; params sealed in the blob for future
  upgrade). Pure encrypt/decrypt split from the storage I/O so the crypto is
  unit-tested off-platform. Wrong passphrase / tamper fail closed via the GCM tag.
- Session stays the hot cache: on unlock the decrypted key is held in
  chrome.storage.session for fast signing; a browser restart clears it and the
  user re-unlocks. The raw key never touches disk.
- background.js: generate/import now take a passphrase and seal the vault;
  new UNLOCK_VAULT / LOCK_VAULT; GET_KEYPAIR_STATUS reports none|locked|unlocked.
  A locked vault on getPublicKey/sign/nip44 opens the unlock popup and throws a
  clear "Podkey is locked — unlock and try again" instead of "No keypair found".
- popup: set-passphrase step on generate, passphrase fields on import, an unlock
  screen (with forget-key/start-over), and a Lock action. Pill reads "Unlocked".

Tests: 8 vault crypto tests (round-trip, wrong passphrase, tamper, validation,
salt/iv freshness). Full suite 141 pass; eslint src/ clean.

Co-Authored-By: jjohare <github@thedreamlab.uk>

* docs: privacy policy, vault-current docs, and Actions-based Pages site

- PRIVACY.md: repo-hosted privacy policy (local signer; key encrypted at rest;
  no servers/accounts/telemetry; per-permission rationale incl. <all_urls>).
  Doubles as the Chrome Web Store privacy disclosure basis.
- README/USAGE/CHANGELOG: bring the security model up to date with the encrypted
  vault (was "session-only"), document the passphrase + unlock/lock/forget flow,
  bump version 0.0.7 -> 0.0.8, test count 133 -> 141, add vault.js to the file
  map, and add a "Where Podkey fits" section positioning it against existing
  NIP-07 signers, the did:nostr ecosystem (github.com/topics/did-nostr), and
  Solid (solidproject.org) — extends + consolidates rather than reinvents.
- site/: a polished, self-contained docs landing (index.html) + hosted privacy
  page (privacy.html) for a clean Web Store privacy URL.
- gh-pages.yml: complete the migration off the legacy gh-pages branch — assemble
  the docs site + test page and deploy via actions/deploy-pages (the proper
  GitHub Actions Pages workflow), triggered on site/test-page changes.

Co-Authored-By: jjohare <github@thedreamlab.uk>
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