Skip to content

fix: single unlock prompt (readable DMs) + accept nsec on import#24

Merged
jjohare merged 2 commits into
JavaScriptSolidServer:mainfrom
jjohare:fix/unlock-coalesce-and-nsec-import
Jul 23, 2026
Merged

fix: single unlock prompt (readable DMs) + accept nsec on import#24
jjohare merged 2 commits into
JavaScriptSolidServer:mainfrom
jjohare:fix/unlock-coalesce-and-nsec-import

Conversation

@jjohare

@jjohare jjohare commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Two related fixes to the vault/session and key-import paths, found while wiring Podkey up as the NIP-07 signer for a Nostr forum (login → NIP-42 relay AUTH → gift-wrapped DMs).

1. Coalesce concurrent unlocks behind one passphrase prompt

A relying page that logs in fires several key operations back to back:

  • GET_PUBLIC_KEY (identity)
  • SIGN_EVENT (the NIP-42 relay AUTH, kind-22242)
  • nip44.decrypt (reading gift-wrapped DMs)

Each one hit ensureUnlocked() on a locked vault, opened its own unlock popup, and rejected immediately. Two user-visible symptoms:

  • Three passphrase prompts to load a single page.
  • Encrypted DMs silently unreadable: the rejected nip44.decrypt returned a "Podkey is locked" error, which the relying app couldn't distinguish from a genuine decrypt failure, so it showed an empty inbox with no explanation.

ensureUnlocked() now opens one popup and awaits it, coalescing every concurrent caller onto a single shared unlock via chrome.storage.onChanged (which reliably wakes the MV3 service worker). One passphrase entry — which writes the key into chrome.storage.session — resolves them all. A race-window re-check avoids a spurious popup when the key has already landed.

2. Accept nsec keys on import, convert to hex inline

handleImportKeypair only accepted raw 64-char hex; a pasted NIP-19 nsec1… key (how most Nostr apps display keys) was rejected, which surfaced as a silent failure on the import screen.

Import now normalises the input through normalizeSecretKeyToHex: raw hex passes through (lowercased), an nsec1… is bech32-decoded to hex inline — no format nag, the import just provisions. Anything that is neither throws a neutral Invalid key.

The decode is a new self-contained bech32 (BIP-173) decoder (src/keyformat.js) so the extension keeps its dependency-light footprint — no nostr-tools/@scure added, only the existing @noble primitives elsewhere. It enforces the NIP-19 bech32 checksum and rejects wrong-HRP (npub), bad checksums, mixed case, and malformed input.

Tests

  • +10 tests for key normalisation (test/keyformat.test.js): the canonical NIP-19 spec vector (nsec1vl029mg…67dea2ed…) plus hex passthrough, whitespace, uppercase, wrong-HRP, bad-checksum, mixed-case, and malformed-input cases.
  • Full suite: 141 → 151, 0 failures. Lint clean. Bundle regenerated by CI.

How to verify

  1. Load the extension (CI podkey-extension artifact → unzip → Load unpacked).
  2. One prompt / DMs: lock the vault, load a NIP-07 client that AUTHs and reads gift-wrapped DMs — expect a single passphrase prompt and readable DMs.
  3. nsec import: on the import screen, paste an nsec1… — it provisions straight into the vault with no error and no "wrong format" message.

🤖 Generated by Claude Code

jjohare added 2 commits July 23, 2026 19:34
…ompt

A relying page that logs in fires several key operations back to back —
GET_PUBLIC_KEY (identity), SIGN_EVENT (NIP-42 relay AUTH), and nip44.decrypt
(gift-wrapped DMs). Each hit ensureUnlocked() on a locked vault, opened its OWN
popup, and rejected immediately: the user faced three passphrase prompts, and the
rejected nip44.decrypt made encrypted DMs silently un-readable (the app saw a
'locked' error, not a decryptable message).

ensureUnlocked() now opens ONE unlock popup and awaits it, coalescing every
concurrent caller onto a single shared unlock via chrome.storage.onChanged
(which reliably wakes the MV3 service worker). One passphrase entry — which
writes the key to chrome.storage.session — resolves them all. A race-window
re-check avoids a spurious popup when the key has already landed.

Fixes: three-prompts-per-login, and encrypted DMs unreadable under NIP-07.
Tests: 141 pass, 0 fail. Lint clean. Bundle rebuilt (gitignored; CI regenerates).

Co-Authored-By: jjohare <github@thedreamlab.uk>
Importing an existing key only accepted raw 64-char hex; a pasted NIP-19 nsec1…
key (how most Nostr apps display keys) was rejected, which surfaced as a silent
failure. handleImportKeypair now normalises the input via normalizeSecretKeyToHex:
raw hex passes through (lowercased), an nsec1… is bech32-decoded to hex inline —
no format nag, the import just provisions. Anything that is neither throws a
neutral 'Invalid key'.

Self-contained bech32 (BIP-173) decoder in src/keyformat.js keeps Podkey
dependency-light (no nostr-tools/@Scure added). NIP-19 bech32 const (1), rejects
wrong-hrp (npub), bad checksum, mixed case, and malformed input.

Tests: +10 (canonical NIP-19 spec vector + edge cases); suite 141 -> 151, 0 fail.

Co-Authored-By: jjohare <github@thedreamlab.uk>
@jjohare
jjohare merged commit 61e30ff into JavaScriptSolidServer:main Jul 23, 2026
2 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.

1 participant