aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/nostr-login-test.js60
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);