diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/nostr-login-test.js | 60 |
1 files changed, 60 insertions, 0 deletions
diff --git a/tools/nostr-login-test.js b/tools/nostr-login-test.js new file mode 100644 index 0000000..039ff84 --- /dev/null +++ b/tools/nostr-login-test.js @@ -0,0 +1,60 @@ +// Nostr login: key parsing, deterministic seed derivation, and the encrypted +// wallet association round-trip through a signer. +// Run: bun tools/nostr-login-test.js +import { hex, bech32 } from '@scure/base'; +import { validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { + parseNostrSecret, seedFromNostrKey, keySigner, walletForSigner, +} from '../src/nostr-login.js'; + +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 100) : ''}`); + if (!ok) fails++; +}; + +console.log('[key parsing]'); +const sk = generateSecretKey(); +const nsec = bech32.encode('nsec', bech32.toWords(sk), 1000); +check('parses an nsec', parseNostrSecret(nsec) && hex.encode(parseNostrSecret(nsec)) === hex.encode(sk)); +check('parses hex', hex.encode(parseNostrSecret(hex.encode(sk))) === hex.encode(sk)); +check('rejects an npub', parseNostrSecret(bech32.encode('npub', bech32.toWords(sk), 1000)) === null); +check('rejects junk', parseNostrSecret('hello world') === null); + +console.log('\n[seed derivation]'); +const m1 = seedFromNostrKey(sk); +const m2 = seedFromNostrKey(sk); +check('is a valid 12-word mnemonic', validateMnemonic(m1, wordlist) && m1.split(' ').length === 12, m1.split(' ').slice(0, 3).join(' ') + '…'); +check('is deterministic', m1 === m2); +const other = seedFromNostrKey(generateSecretKey()); +check('differs per nostr key', other !== m1); +check('does not leak the key', !m1.includes(hex.encode(sk).slice(0, 8))); + +console.log('\n[login modes]'); +const signer = keySigner(sk); +check('key signer exposes the right pubkey', signer.pubkey === getPublicKey(sk)); +const res = await walletForSigner(signer); +check('a pasted key derives (no lookup)', res.mode === 'derived' && res.mnemonic === m1); + +console.log('\n[encrypted association]'); +// a signer that can't hand over its key — like an extension or bunker +const remote = { ...keySigner(sk), secret: undefined }; +const stored = []; +const backupSigner = { + ...remote, + encryptSelf: remote.encryptSelf, decryptSelf: remote.decryptSelf, +}; +const ct = await backupSigner.encryptSelf(JSON.stringify({ mnemonic: m1, passphrase: '' })); +stored.push(ct); +const back = JSON.parse(await backupSigner.decryptSelf(stored[0])); +check('seed survives encrypt→decrypt to self', back.mnemonic === m1); +check('ciphertext hides the mnemonic', !ct.includes(m1.split(' ')[0])); +const stranger = keySigner(generateSecretKey()); +let denied = false; +try { await stranger.decryptSelf(ct); } catch { denied = true; } +check('another account cannot decrypt it', denied); + +console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ nostr login behaves'); +process.exit(fails ? 1 : 0); |
