Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 63 additions & 15 deletions src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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');

Expand Down
156 changes: 156 additions & 0 deletions src/keyformat.js
Original file line number Diff line number Diff line change
@@ -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');
}
56 changes: 56 additions & 0 deletions test/keyformat.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading