diff --git a/src/background.js b/src/background.js index a9de1af..0f9b70e 100644 --- a/src/background.js +++ b/src/background.js @@ -21,6 +21,7 @@ import { getAutoSign } from './storage.js'; import { createVault, unlockVault, hasVault } from './vault.js'; +import { normalizeSecretKeyToHex } from './keyformat.js'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; @@ -124,19 +125,69 @@ async function handleMessage (message, sender) { } } +/** + * Coalesce concurrent unlock requests behind a SINGLE passphrase prompt. + * + * A page that logs in fires several key-using requests back to back — + * `GET_PUBLIC_KEY` (identity), `SIGN_EVENT` (the NIP-42 relay AUTH), and + * `nip44.decrypt` (gift-wrapped DMs). Each one used to hit `ensureUnlocked` + * while the vault was still locked, open its OWN popup, and reject immediately. + * The user therefore faced three passphrase prompts, and the rejected + * `nip44.decrypt` made encrypted DMs silently un-readable (the relying app saw a + * "locked" error, not a decryptable message). Here the first locked caller opens + * ONE popup and every concurrent caller awaits the same unlock; a single + * passphrase entry — which writes the key into `chrome.storage.session` — resolves + * them all. `chrome.storage.onChanged` reliably wakes the MV3 service worker, so + * the wait survives an idle eviction of the background. Returns `true` if the + * vault became unlocked, `false` on timeout. + */ +let pendingUnlock = null; + +function awaitUnlock () { + if (pendingUnlock) return pendingUnlock; + + pendingUnlock = new Promise((resolve) => { + let settled = false; + let timer; + const finish = (ok) => { + if (settled) return; + settled = true; + chrome.storage.onChanged.removeListener(onChange); + clearTimeout(timer); + pendingUnlock = null; + resolve(ok); + }; + // Any write to session storage may be the unlocked key landing — confirm + // with hasKeypair() rather than assuming. + const onChange = async (_changes, area) => { + if (area === 'session' && (await hasKeypair())) finish(true); + }; + chrome.storage.onChanged.addListener(onChange); + // Two minutes for the user to find the popup and type their passphrase. + timer = setTimeout(() => finish(false), 120000); + // Only prompt if the key didn't already land in the race window between the + // caller's hasKeypair() check and this listener being registered. + hasKeypair().then((already) => (already ? finish(true) : openUnlockPopup())); + }); + + return pendingUnlock; +} + /** * Ensure the private key is unlocked in the session before a signing or * key-reading operation. Distinguishes three states so the error is actionable: * - unlocked (session key present) -> returns - * - locked (encrypted vault on disk, no session key) -> opens the unlock UI - * and throws a clear "locked" error instead of the old "No keypair found" + * - locked (encrypted vault on disk, no session key) -> opens ONE unlock UI, + * waits for the user's single passphrase entry (shared across concurrent + * callers), then returns; only throws if the unlock times out * - empty (no vault at all) -> throws a "generate or import" error */ async function ensureUnlocked () { if (await hasKeypair()) return; if (await hasVault()) { - openUnlockPopup(); + const unlocked = await awaitUnlock(); + if (unlocked && (await hasKeypair())) return; throw new Error('Podkey is locked. Open Podkey, unlock with your passphrase, and try again.'); } 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) { * Import an existing private key, seal it under the passphrase, and unlock. */ async function handleImportKeypair (privateKey, passphrase) { - // Validate private key format - if (!privateKey || privateKey.length !== 64) { - throw new Error('Private key must be 64-char hex'); - } + // Accept either raw 64-char hex or an `nsec1…` (NIP-19) key. Most Nostr apps + // display keys in nsec form, so an existing-key import must handle it inline — + // convert to Podkey's canonical hex without treating the format as an error. + // `normalizeSecretKeyToHex` throws a neutral "Invalid key" on anything else. + const hexKey = normalizeSecretKeyToHex(privateKey); - if (!/^[0-9a-fA-F]{64}$/.test(privateKey)) { - throw new Error('Private key must be valid hexadecimal'); - } - - // Derive public key - const publicKey = getPublicKey(privateKey); + // Derive public key (also validates the scalar is a usable secp256k1 key). + const publicKey = getPublicKey(hexKey); - await createVault(privateKey, passphrase); // encrypted at rest (validates passphrase) - await storeKeypair(privateKey, publicKey); // unlocked session + await createVault(hexKey, passphrase); // encrypted at rest (validates passphrase) + await storeKeypair(hexKey, publicKey); // unlocked session if (DEBUG) console.log('[Podkey] Keypair imported and sealed'); diff --git a/src/keyformat.js b/src/keyformat.js new file mode 100644 index 0000000..a089606 --- /dev/null +++ b/src/keyformat.js @@ -0,0 +1,156 @@ +/** + * Podkey - private-key input normalisation + * + * A user importing an existing key may paste it in either of the two forms that + * are in the wild: raw 64-char hex, or the NIP-19 `nsec1…` bech32 form that most + * Nostr apps display. Podkey stores and operates on hex internally, so this + * module converts either input into canonical lowercase hex at the import + * boundary. The `nsec` case is handled transparently — the caller does not treat + * it as an error or nag the user about format. + * + * Self-contained bech32 (BIP-173) decoder so the extension keeps its + * dependency-light footprint (only @noble primitives elsewhere). NIP-19 uses the + * original bech32 checksum constant (1), not bech32m. + */ + +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; +const BECH32_CONST = 1; + +function polymod (values) { + let chk = 1; + for (const value of values) { + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < 5; i++) { + if ((top >>> i) & 1) chk ^= GENERATOR[i]; + } + } + return chk >>> 0; +} + +function hrpExpand (hrp) { + const out = []; + for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >>> 5); + out.push(0); + for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31); + return out; +} + +/** + * Decode a bech32 string into its human-readable part and 5-bit data words + * (checksum stripped). Throws on any structural or checksum error. + * @param {string} str + * @returns {{ hrp: string, words: number[] }} + */ +function bech32Decode (str) { + if (typeof str !== 'string' || str.length < 8 || str.length > 1000) { + throw new Error('Invalid key'); + } + // Reject mixed case per BIP-173; normalise to lowercase for lookup. + const lower = str.toLowerCase(); + const upper = str.toUpperCase(); + if (str !== lower && str !== upper) { + throw new Error('Invalid key'); + } + const s = lower; + + const sep = s.lastIndexOf('1'); + if (sep < 1 || sep + 7 > s.length) { + throw new Error('Invalid key'); + } + const hrp = s.slice(0, sep); + const dataPart = s.slice(sep + 1); + + const words = []; + for (const ch of dataPart) { + const v = CHARSET.indexOf(ch); + if (v === -1) throw new Error('Invalid key'); + words.push(v); + } + + if (polymod(hrpExpand(hrp).concat(words)) !== BECH32_CONST) { + throw new Error('Invalid key'); + } + + return { hrp, words: words.slice(0, words.length - 6) }; +} + +/** + * Regroup a stream of `from`-bit words into `to`-bit words. Used to turn the + * 5-bit bech32 words back into 8-bit bytes (pad=false, no leftover bits). + * @param {number[]} data + * @param {number} from + * @param {number} to + * @param {boolean} pad + * @returns {number[]} + */ +function convertBits (data, from, to, pad) { + let acc = 0; + let bits = 0; + const out = []; + const maxv = (1 << to) - 1; + for (const value of data) { + if (value < 0 || value >>> from !== 0) throw new Error('Invalid key'); + acc = (acc << from) | value; + bits += from; + while (bits >= to) { + bits -= to; + out.push((acc >>> bits) & maxv); + } + } + if (pad) { + if (bits > 0) out.push((acc << (to - bits)) & maxv); + } else if (bits >= from || ((acc << (to - bits)) & maxv)) { + throw new Error('Invalid key'); + } + return out; +} + +function bytesToHex (bytes) { + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return hex; +} + +/** + * Decode an `nsec1…` (NIP-19) private key into 64-char lowercase hex. + * @param {string} nsec + * @returns {string} 64-char hex private key + */ +export function nsecToHex (nsec) { + const { hrp, words } = bech32Decode(nsec); + if (hrp !== 'nsec') { + throw new Error('Invalid key'); + } + const bytes = convertBits(words, 5, 8, false); + if (bytes.length !== 32) { + throw new Error('Invalid key'); + } + return bytesToHex(bytes); +} + +/** + * Normalise a pasted private key into canonical 64-char lowercase hex, accepting + * either raw hex or an `nsec1…` bech32 key. The nsec form is converted inline so + * an existing-key import "just works" regardless of which form the user pasted. + * Throws a neutral `Invalid key` on anything that is neither. + * @param {string} input + * @returns {string} 64-char hex private key + */ +export function normalizeSecretKeyToHex (input) { + if (typeof input !== 'string') { + throw new Error('Invalid key'); + } + const s = input.trim(); + if (/^[0-9a-fA-F]{64}$/.test(s)) { + return s.toLowerCase(); + } + // Case-insensitive prefix match; bech32Decode enforces the (non-mixed) case + // rule and checksum. NIP-19 keys are lowercase in practice, but an all-caps + // paste is still valid bech32, so accept it rather than reject a real key. + if (/^nsec1[0-9a-z]+$/i.test(s)) { + return nsecToHex(s); + } + throw new Error('Invalid key'); +} diff --git a/test/keyformat.test.js b/test/keyformat.test.js new file mode 100644 index 0000000..6b36dc0 --- /dev/null +++ b/test/keyformat.test.js @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nsecToHex, normalizeSecretKeyToHex } from '../src/keyformat.js'; + +// Canonical NIP-19 spec test vector. +const NSEC = 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5'; +const HEX = '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa'; +const NPUB = 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg'; + +test('nsecToHex decodes the NIP-19 spec vector', () => { + assert.equal(nsecToHex(NSEC), HEX); +}); + +test('normalizeSecretKeyToHex converts nsec -> hex inline', () => { + assert.equal(normalizeSecretKeyToHex(NSEC), HEX); +}); + +test('normalizeSecretKeyToHex passes hex through, lowercased', () => { + assert.equal(normalizeSecretKeyToHex(HEX), HEX); + assert.equal(normalizeSecretKeyToHex(HEX.toUpperCase()), HEX); +}); + +test('normalizeSecretKeyToHex trims surrounding whitespace/newlines', () => { + assert.equal(normalizeSecretKeyToHex(` ${NSEC}\n`), HEX); + assert.equal(normalizeSecretKeyToHex(`\t${HEX} `), HEX); +}); + +test('uppercase nsec is accepted (bech32 is case-insensitive, not mixed)', () => { + assert.equal(normalizeSecretKeyToHex(NSEC.toUpperCase()), HEX); +}); + +test('rejects an npub (wrong human-readable part)', () => { + assert.throws(() => normalizeSecretKeyToHex(NPUB), /Invalid key/); +}); + +test('rejects a corrupted nsec (bad checksum)', () => { + const corrupted = NSEC.slice(0, -1) + (NSEC.endsWith('a') ? 'q' : 'a'); + assert.throws(() => nsecToHex(corrupted), /Invalid key/); +}); + +test('rejects mixed-case bech32', () => { + const mixed = NSEC.slice(0, 10).toUpperCase() + NSEC.slice(10); + assert.throws(() => normalizeSecretKeyToHex(mixed), /Invalid key/); +}); + +test('rejects short hex, long hex, and non-hex junk', () => { + assert.throws(() => normalizeSecretKeyToHex(HEX.slice(0, 63)), /Invalid key/); + assert.throws(() => normalizeSecretKeyToHex(HEX + 'ab'), /Invalid key/); + assert.throws(() => normalizeSecretKeyToHex('not a key'), /Invalid key/); + assert.throws(() => normalizeSecretKeyToHex(''), /Invalid key/); +}); + +test('rejects non-string input', () => { + assert.throws(() => normalizeSecretKeyToHex(null), /Invalid key/); + assert.throws(() => normalizeSecretKeyToHex(undefined), /Invalid key/); +});