Skip to content

Commit 61e30ff

Browse files
authored
fix: single unlock prompt (readable DMs) + accept nsec on import (#24)
* fix(background): 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 (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> * feat(import): accept nsec keys, convert to hex inline 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>
1 parent 9d4f135 commit 61e30ff

3 files changed

Lines changed: 275 additions & 15 deletions

File tree

src/background.js

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
getAutoSign
2222
} from './storage.js';
2323
import { createVault, unlockVault, hasVault } from './vault.js';
24+
import { normalizeSecretKeyToHex } from './keyformat.js';
2425
import { sha256 } from '@noble/hashes/sha256';
2526
import { bytesToHex } from '@noble/hashes/utils';
2627

@@ -124,19 +125,69 @@ async function handleMessage (message, sender) {
124125
}
125126
}
126127

128+
/**
129+
* Coalesce concurrent unlock requests behind a SINGLE passphrase prompt.
130+
*
131+
* A page that logs in fires several key-using requests back to back —
132+
* `GET_PUBLIC_KEY` (identity), `SIGN_EVENT` (the NIP-42 relay AUTH), and
133+
* `nip44.decrypt` (gift-wrapped DMs). Each one used to hit `ensureUnlocked`
134+
* while the vault was still locked, open its OWN popup, and reject immediately.
135+
* The user therefore faced three passphrase prompts, and the rejected
136+
* `nip44.decrypt` made encrypted DMs silently un-readable (the relying app saw a
137+
* "locked" error, not a decryptable message). Here the first locked caller opens
138+
* ONE popup and every concurrent caller awaits the same unlock; a single
139+
* passphrase entry — which writes the key into `chrome.storage.session` — resolves
140+
* them all. `chrome.storage.onChanged` reliably wakes the MV3 service worker, so
141+
* the wait survives an idle eviction of the background. Returns `true` if the
142+
* vault became unlocked, `false` on timeout.
143+
*/
144+
let pendingUnlock = null;
145+
146+
function awaitUnlock () {
147+
if (pendingUnlock) return pendingUnlock;
148+
149+
pendingUnlock = new Promise((resolve) => {
150+
let settled = false;
151+
let timer;
152+
const finish = (ok) => {
153+
if (settled) return;
154+
settled = true;
155+
chrome.storage.onChanged.removeListener(onChange);
156+
clearTimeout(timer);
157+
pendingUnlock = null;
158+
resolve(ok);
159+
};
160+
// Any write to session storage may be the unlocked key landing — confirm
161+
// with hasKeypair() rather than assuming.
162+
const onChange = async (_changes, area) => {
163+
if (area === 'session' && (await hasKeypair())) finish(true);
164+
};
165+
chrome.storage.onChanged.addListener(onChange);
166+
// Two minutes for the user to find the popup and type their passphrase.
167+
timer = setTimeout(() => finish(false), 120000);
168+
// Only prompt if the key didn't already land in the race window between the
169+
// caller's hasKeypair() check and this listener being registered.
170+
hasKeypair().then((already) => (already ? finish(true) : openUnlockPopup()));
171+
});
172+
173+
return pendingUnlock;
174+
}
175+
127176
/**
128177
* Ensure the private key is unlocked in the session before a signing or
129178
* key-reading operation. Distinguishes three states so the error is actionable:
130179
* - unlocked (session key present) -> returns
131-
* - locked (encrypted vault on disk, no session key) -> opens the unlock UI
132-
* and throws a clear "locked" error instead of the old "No keypair found"
180+
* - locked (encrypted vault on disk, no session key) -> opens ONE unlock UI,
181+
* waits for the user's single passphrase entry (shared across concurrent
182+
* callers), then returns; only throws if the unlock times out
133183
* - empty (no vault at all) -> throws a "generate or import" error
134184
*/
135185
async function ensureUnlocked () {
136186
if (await hasKeypair()) return;
137187

138188
if (await hasVault()) {
139-
openUnlockPopup();
189+
const unlocked = await awaitUnlock();
190+
if (unlocked && (await hasKeypair())) return;
140191
throw new Error('Podkey is locked. Open Podkey, unlock with your passphrase, and try again.');
141192
}
142193
throw new Error('No key in Podkey. Open the extension to generate or import a key first.');
@@ -354,20 +405,17 @@ async function handleGenerateKeypair (passphrase) {
354405
* Import an existing private key, seal it under the passphrase, and unlock.
355406
*/
356407
async function handleImportKeypair (privateKey, passphrase) {
357-
// Validate private key format
358-
if (!privateKey || privateKey.length !== 64) {
359-
throw new Error('Private key must be 64-char hex');
360-
}
408+
// Accept either raw 64-char hex or an `nsec1…` (NIP-19) key. Most Nostr apps
409+
// display keys in nsec form, so an existing-key import must handle it inline —
410+
// convert to Podkey's canonical hex without treating the format as an error.
411+
// `normalizeSecretKeyToHex` throws a neutral "Invalid key" on anything else.
412+
const hexKey = normalizeSecretKeyToHex(privateKey);
361413

362-
if (!/^[0-9a-fA-F]{64}$/.test(privateKey)) {
363-
throw new Error('Private key must be valid hexadecimal');
364-
}
365-
366-
// Derive public key
367-
const publicKey = getPublicKey(privateKey);
414+
// Derive public key (also validates the scalar is a usable secp256k1 key).
415+
const publicKey = getPublicKey(hexKey);
368416

369-
await createVault(privateKey, passphrase); // encrypted at rest (validates passphrase)
370-
await storeKeypair(privateKey, publicKey); // unlocked session
417+
await createVault(hexKey, passphrase); // encrypted at rest (validates passphrase)
418+
await storeKeypair(hexKey, publicKey); // unlocked session
371419

372420
if (DEBUG) console.log('[Podkey] Keypair imported and sealed');
373421

src/keyformat.js

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Podkey - private-key input normalisation
3+
*
4+
* A user importing an existing key may paste it in either of the two forms that
5+
* are in the wild: raw 64-char hex, or the NIP-19 `nsec1…` bech32 form that most
6+
* Nostr apps display. Podkey stores and operates on hex internally, so this
7+
* module converts either input into canonical lowercase hex at the import
8+
* boundary. The `nsec` case is handled transparently — the caller does not treat
9+
* it as an error or nag the user about format.
10+
*
11+
* Self-contained bech32 (BIP-173) decoder so the extension keeps its
12+
* dependency-light footprint (only @noble primitives elsewhere). NIP-19 uses the
13+
* original bech32 checksum constant (1), not bech32m.
14+
*/
15+
16+
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
17+
const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
18+
const BECH32_CONST = 1;
19+
20+
function polymod (values) {
21+
let chk = 1;
22+
for (const value of values) {
23+
const top = chk >>> 25;
24+
chk = ((chk & 0x1ffffff) << 5) ^ value;
25+
for (let i = 0; i < 5; i++) {
26+
if ((top >>> i) & 1) chk ^= GENERATOR[i];
27+
}
28+
}
29+
return chk >>> 0;
30+
}
31+
32+
function hrpExpand (hrp) {
33+
const out = [];
34+
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >>> 5);
35+
out.push(0);
36+
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
37+
return out;
38+
}
39+
40+
/**
41+
* Decode a bech32 string into its human-readable part and 5-bit data words
42+
* (checksum stripped). Throws on any structural or checksum error.
43+
* @param {string} str
44+
* @returns {{ hrp: string, words: number[] }}
45+
*/
46+
function bech32Decode (str) {
47+
if (typeof str !== 'string' || str.length < 8 || str.length > 1000) {
48+
throw new Error('Invalid key');
49+
}
50+
// Reject mixed case per BIP-173; normalise to lowercase for lookup.
51+
const lower = str.toLowerCase();
52+
const upper = str.toUpperCase();
53+
if (str !== lower && str !== upper) {
54+
throw new Error('Invalid key');
55+
}
56+
const s = lower;
57+
58+
const sep = s.lastIndexOf('1');
59+
if (sep < 1 || sep + 7 > s.length) {
60+
throw new Error('Invalid key');
61+
}
62+
const hrp = s.slice(0, sep);
63+
const dataPart = s.slice(sep + 1);
64+
65+
const words = [];
66+
for (const ch of dataPart) {
67+
const v = CHARSET.indexOf(ch);
68+
if (v === -1) throw new Error('Invalid key');
69+
words.push(v);
70+
}
71+
72+
if (polymod(hrpExpand(hrp).concat(words)) !== BECH32_CONST) {
73+
throw new Error('Invalid key');
74+
}
75+
76+
return { hrp, words: words.slice(0, words.length - 6) };
77+
}
78+
79+
/**
80+
* Regroup a stream of `from`-bit words into `to`-bit words. Used to turn the
81+
* 5-bit bech32 words back into 8-bit bytes (pad=false, no leftover bits).
82+
* @param {number[]} data
83+
* @param {number} from
84+
* @param {number} to
85+
* @param {boolean} pad
86+
* @returns {number[]}
87+
*/
88+
function convertBits (data, from, to, pad) {
89+
let acc = 0;
90+
let bits = 0;
91+
const out = [];
92+
const maxv = (1 << to) - 1;
93+
for (const value of data) {
94+
if (value < 0 || value >>> from !== 0) throw new Error('Invalid key');
95+
acc = (acc << from) | value;
96+
bits += from;
97+
while (bits >= to) {
98+
bits -= to;
99+
out.push((acc >>> bits) & maxv);
100+
}
101+
}
102+
if (pad) {
103+
if (bits > 0) out.push((acc << (to - bits)) & maxv);
104+
} else if (bits >= from || ((acc << (to - bits)) & maxv)) {
105+
throw new Error('Invalid key');
106+
}
107+
return out;
108+
}
109+
110+
function bytesToHex (bytes) {
111+
let hex = '';
112+
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
113+
return hex;
114+
}
115+
116+
/**
117+
* Decode an `nsec1…` (NIP-19) private key into 64-char lowercase hex.
118+
* @param {string} nsec
119+
* @returns {string} 64-char hex private key
120+
*/
121+
export function nsecToHex (nsec) {
122+
const { hrp, words } = bech32Decode(nsec);
123+
if (hrp !== 'nsec') {
124+
throw new Error('Invalid key');
125+
}
126+
const bytes = convertBits(words, 5, 8, false);
127+
if (bytes.length !== 32) {
128+
throw new Error('Invalid key');
129+
}
130+
return bytesToHex(bytes);
131+
}
132+
133+
/**
134+
* Normalise a pasted private key into canonical 64-char lowercase hex, accepting
135+
* either raw hex or an `nsec1…` bech32 key. The nsec form is converted inline so
136+
* an existing-key import "just works" regardless of which form the user pasted.
137+
* Throws a neutral `Invalid key` on anything that is neither.
138+
* @param {string} input
139+
* @returns {string} 64-char hex private key
140+
*/
141+
export function normalizeSecretKeyToHex (input) {
142+
if (typeof input !== 'string') {
143+
throw new Error('Invalid key');
144+
}
145+
const s = input.trim();
146+
if (/^[0-9a-fA-F]{64}$/.test(s)) {
147+
return s.toLowerCase();
148+
}
149+
// Case-insensitive prefix match; bech32Decode enforces the (non-mixed) case
150+
// rule and checksum. NIP-19 keys are lowercase in practice, but an all-caps
151+
// paste is still valid bech32, so accept it rather than reject a real key.
152+
if (/^nsec1[0-9a-z]+$/i.test(s)) {
153+
return nsecToHex(s);
154+
}
155+
throw new Error('Invalid key');
156+
}

test/keyformat.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { nsecToHex, normalizeSecretKeyToHex } from '../src/keyformat.js';
4+
5+
// Canonical NIP-19 spec test vector.
6+
const NSEC = 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5';
7+
const HEX = '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa';
8+
const NPUB = 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg';
9+
10+
test('nsecToHex decodes the NIP-19 spec vector', () => {
11+
assert.equal(nsecToHex(NSEC), HEX);
12+
});
13+
14+
test('normalizeSecretKeyToHex converts nsec -> hex inline', () => {
15+
assert.equal(normalizeSecretKeyToHex(NSEC), HEX);
16+
});
17+
18+
test('normalizeSecretKeyToHex passes hex through, lowercased', () => {
19+
assert.equal(normalizeSecretKeyToHex(HEX), HEX);
20+
assert.equal(normalizeSecretKeyToHex(HEX.toUpperCase()), HEX);
21+
});
22+
23+
test('normalizeSecretKeyToHex trims surrounding whitespace/newlines', () => {
24+
assert.equal(normalizeSecretKeyToHex(` ${NSEC}\n`), HEX);
25+
assert.equal(normalizeSecretKeyToHex(`\t${HEX} `), HEX);
26+
});
27+
28+
test('uppercase nsec is accepted (bech32 is case-insensitive, not mixed)', () => {
29+
assert.equal(normalizeSecretKeyToHex(NSEC.toUpperCase()), HEX);
30+
});
31+
32+
test('rejects an npub (wrong human-readable part)', () => {
33+
assert.throws(() => normalizeSecretKeyToHex(NPUB), /Invalid key/);
34+
});
35+
36+
test('rejects a corrupted nsec (bad checksum)', () => {
37+
const corrupted = NSEC.slice(0, -1) + (NSEC.endsWith('a') ? 'q' : 'a');
38+
assert.throws(() => nsecToHex(corrupted), /Invalid key/);
39+
});
40+
41+
test('rejects mixed-case bech32', () => {
42+
const mixed = NSEC.slice(0, 10).toUpperCase() + NSEC.slice(10);
43+
assert.throws(() => normalizeSecretKeyToHex(mixed), /Invalid key/);
44+
});
45+
46+
test('rejects short hex, long hex, and non-hex junk', () => {
47+
assert.throws(() => normalizeSecretKeyToHex(HEX.slice(0, 63)), /Invalid key/);
48+
assert.throws(() => normalizeSecretKeyToHex(HEX + 'ab'), /Invalid key/);
49+
assert.throws(() => normalizeSecretKeyToHex('not a key'), /Invalid key/);
50+
assert.throws(() => normalizeSecretKeyToHex(''), /Invalid key/);
51+
});
52+
53+
test('rejects non-string input', () => {
54+
assert.throws(() => normalizeSecretKeyToHex(null), /Invalid key/);
55+
assert.throws(() => normalizeSecretKeyToHex(undefined), /Invalid key/);
56+
});

0 commit comments

Comments
 (0)