aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 21:48:20 -0700
committerAdam Soltys <[email protected]>2026-08-01 21:48:20 -0700
commit2d1cce8b6b0e7454f187e7144d22f76466065528 (patch)
tree78d01df33a6e97cae8902fa301c22e5441397e87 /tools
parent5c472442a4abf8768b25c544c73328467422538a (diff)
downloadcoinosv3-2d1cce8b6b0e7454f187e7144d22f76466065528.tar.gz
coinosv3-2d1cce8b6b0e7454f187e7144d22f76466065528.zip
Log in with Nostr, and link a wallet to a Nostr account
Three ways in: a NIP-07 browser extension, a NIP-46 remote signer (Amber, Amethyst — paste its bunker:// address), or a pasted nsec/hex key that lives in memory for the session and is never stored. How an account gets a wallet depends on what we can see, and the two cases are genuinely different: * We hold the key (pasted nsec): the seed is DERIVED from it — sha256("coinos:wallet-seed:v1" | privkey) as BIP-39 entropy. The same Nostr key always opens the same wallet, on any device, with nothing stored anywhere. * We only have a signer: deriving is impossible (we never see the key, and neither signatures nor NIP-44 ciphertexts reproduce), so the seed is ASSOCIATED instead — generated once and published as a replaceable event encrypted to the user's own key, which only their signer can decrypt. Later logins find and decrypt it. The second path means the Nostr key can recover the money; the UI says so before publishing anything. Linking an already-open wallet uses the same publish path and refuses to overwrite an account's existing wallet. Co-Authored-By: Claude Fable 5 <[email protected]>
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);