diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 21:48:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 21:48:20 -0700 |
| commit | 2d1cce8b6b0e7454f187e7144d22f76466065528 (patch) | |
| tree | 78d01df33a6e97cae8902fa301c22e5441397e87 | |
| parent | 5c472442a4abf8768b25c544c73328467422538a (diff) | |
| download | coinosv3-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]>
| -rw-r--r-- | build.js | 2 | ||||
| -rw-r--r-- | src/app.js | 3 | ||||
| -rw-r--r-- | src/features/index.js | 3 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 163 | ||||
| -rw-r--r-- | src/i18n.js | 21 | ||||
| -rw-r--r-- | src/nostr-login.js | 161 | ||||
| -rw-r--r-- | tools/nostr-login-test.js | 60 |
7 files changed, 411 insertions, 2 deletions
@@ -181,7 +181,7 @@ export async function buildSwNwc({ minify = true } = {}) { // feature's code (and its network endpoints) never enters the bundle. // NB order matters and must match src/features/index.js — nwc sits after ark // because it drives ark's headless pay/balance seam. -const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', sync: 'syncFeature' }; +const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', nostrlogin: 'nostrLoginFeature', sync: 'syncFeature' }; export function enabledFeatures(spec = process.env.HAL_FEATURES) { if (spec == null) return Object.keys(ALL_FEATURES); @@ -500,6 +500,7 @@ function unlockScreen() { ui.unlockTab === 'create' ? createPane() : importPane(), ui.unlockError && h('div', { class: 'notice err' }, ui.unlockError) ), + featureHook('unlockExtra'), ui.fromWallet && accounts.length ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.fromWallet = false; ui.screen = 'wallet'; ui.unlockError = ''; render(); } }, t('back')) : null @@ -2995,6 +2996,8 @@ const ctx = { // support) — lazy so it resolves against the final FEATURES list, and a // build without the target feature simply gets null back hook: (name, ...args) => featureHook(name, ...args), + // Open (or create) a wallet from a mnemonic — used by nostr login. + openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), }; const FEATURES = buildFeatures(ctx); diff --git a/src/features/index.js b/src/features/index.js index e34de3d..8f3e5d1 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -5,6 +5,7 @@ // bundle. See featureIndexSource() in build.js. import { giftsFeature } from './gifts.js'; +import { nostrLoginFeature } from './nostrlogin.js'; import { arkFeature } from './ark.js'; import { namesFeature } from './names.js'; import { zapsFeature } from './zaps.js'; @@ -19,5 +20,5 @@ import { nwcFeature } from './nwc.js'; // names sits between ark and zaps: a pasted user@domain resolves over DNS // (BIP-353) before the zaps feature treats it as a plain Lightning address. export function buildFeatures(ctx) { - return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), nostrLoginFeature(ctx), syncFeature(ctx)]; } diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js new file mode 100644 index 0000000..60e3759 --- /dev/null +++ b/src/features/nostrlogin.js @@ -0,0 +1,163 @@ +// "Log in with Nostr" — open (or create) a wallet from a nostr identity, and +// link an existing wallet to one after the fact. +// +// The mechanics and their tradeoffs live in ../nostr-login.js. In short: a +// pasted key DERIVES its wallet deterministically; an extension or remote +// signer ASSOCIATES one via a seed published encrypted to the user's own key. +// The second is what makes "log in on a new device" work without the key +// ever reaching us, and it is spelled out in the UI before anything is +// published, because it means the nostr key can recover the money. + +import { newMnemonic } from '../wallet.js'; +import { npubOf } from '../nostr.js'; +import { t } from '../i18n.js'; +import { + extensionSigner, bunkerSigner, keySigner, parseNostrSecret, + walletForSigner, publishWalletBackup, +} from '../nostr-login.js'; + +export function nostrLoginFeature(ctx) { + const { h, ui, render, wallet, toast } = ctx; + + const load = () => wallet.loadFeatureState('nostrlogin', {}); + const save = (st) => wallet.saveFeatureState('nostrlogin', st); + + const busy = (v) => { ui.nostrLoginBusy = v; render(); }; + const fail = (e) => { ui.nostrLoginError = e.message || String(e); busy(false); }; + + // Open the wallet a signer identifies. New accounts confirm first, because + // publishing an encrypted seed to relays deserves an explicit yes. + async function loginWith(makeSigner) { + ui.nostrLoginError = ''; + busy(true); + let signer; + try { + signer = await makeSigner(); + const res = await walletForSigner(signer); + if (res.mode === 'new') { + // no wallet on this identity yet — ask before publishing one + ui.nostrLoginNew = { signer, npub: npubOf(signer.pubkey) }; + busy(false); + return; + } + await ctx.hook('openMnemonic', res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); + toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored')); + busy(false); + } catch (e) { fail(e); } + finally { if (signer && signer.close && !ui.nostrLoginNew) signer.close(); } + } + + async function createForSigner() { + const st = ui.nostrLoginNew; + if (!st) return; + busy(true); + try { + const mnemonic = newMnemonic(); + await publishWalletBackup(st.signer, { mnemonic }); + ui.nostrLoginNew = null; + await ctx.hook('openMnemonic', mnemonic, '', { nostrPubkey: st.signer.pubkey }); + toast(t('nlCreated')); + busy(false); + } catch (e) { fail(e); } + } + + // Link the wallet that's already open to a nostr identity, so logging in + // with it on another device opens this same wallet. + async function linkOpenWallet(makeSigner) { + ui.nostrLoginError = ''; + busy(true); + let signer; + try { + signer = await makeSigner(); + if (!wallet.mnemonic) throw new Error(t('nlNeedSeed')); + const existing = await walletForSigner(signer); + if (existing.mnemonic && existing.mnemonic !== wallet.mnemonic) { + throw new Error(t('nlAlreadyLinked')); + } + await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); + save({ ...load(), pubkey: signer.pubkey, linked: Date.now() }); + toast(t('nlLinked')); + busy(false); + } catch (e) { fail(e); } + finally { if (signer && signer.close) signer.close(); } + } + + // ---- UI --------------------------------------------------------------- + + // The three ways in, as buttons. `run` receives a signer factory. + function signerButtons(run) { + const hasExt = typeof window !== 'undefined' && !!window.nostr; + return h('div', { class: 'col', style: 'gap:8px' }, + hasExt + ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, + onClick: () => run(() => extensionSigner()) }, t('nlExtension')) + : null, + h('div', { class: 'row gap6' }, + h('input', { + type: 'password', placeholder: t('nlKeyOrBunker'), style: 'flex:1', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + value: ui.nostrLoginInput || '', + onInput: (e) => { ui.nostrLoginInput = e.target.value.trim(); }, + }), + h('button', { class: 'btn-sm', disabled: ui.nostrLoginBusy, onClick: () => { + const v = (ui.nostrLoginInput || '').trim(); + const sk = parseNostrSecret(v); + if (sk) return run(() => keySigner(sk)); + if (/^bunker:\/\//i.test(v)) { + return run(() => bunkerSigner(v, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } })); + } + ui.nostrLoginError = t('nlUnrecognized'); + render(); + } }, ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlGo'))), + h('div', { class: 'small faint' }, t('nlHint')), + ui.nostrLoginAuthUrl + ? h('a', { href: ui.nostrLoginAuthUrl, target: '_blank', class: 'small' }, t('nlApprove')) + : null, + ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null); + } + + // Shown under the create/import tabs on the unlock screen. + function unlockExtra() { + if (ui.nostrLoginNew) { + return h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('nlNewTitle')), + h('div', { class: 'small muted' }, ui.nostrLoginNew.npub || ''), + h('p', { class: 'small muted', style: 'margin:0' }, t('nlNewDesc')), + h('div', { class: 'notice info small' }, t('nlBackupWarning')), + ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost', onClick: () => { + if (ui.nostrLoginNew.signer.close) ui.nostrLoginNew.signer.close(); + ui.nostrLoginNew = null; render(); + } }, t('cancel')), + h('button', { class: 'btn-primary grow', disabled: ui.nostrLoginBusy, onClick: createForSigner }, + ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlCreateBtn')))); + } + return h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('nlTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('nlDesc')), + signerButtons(loginWith)); + } + + function settingsCard() { + if (wallet.watchOnly || !wallet.mnemonic) return null; + const st = load(); + if (st.pubkey) { + return h('div', { class: 'card col' }, + h('h3', {}, t('nlLinkTitle')), + h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey), + h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc'))); + } + return h('div', { class: 'card col' }, + h('h3', {}, t('nlLinkTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')), + h('div', { class: 'notice info small' }, t('nlBackupWarning')), + signerButtons(linkOpenWallet)); + } + + return { + id: 'nostrlogin', + unlockExtra() { return unlockExtra(); }, + settingsCards() { return [settingsCard()]; }, + }; +} diff --git a/src/i18n.js b/src/i18n.js index 7003769..825c9ad 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -428,6 +428,27 @@ const STR = { arkLnPayFailed: 'Payment failed', arkLnRefunded: 'Payment failed — funds returned to your Ark balance.', arkLnPaidTitle: 'Paid!', + nlTitle: 'Log in with Nostr', + nlDesc: 'Use your Nostr account to open your wallet — on any device, with the same account.', + nlExtension: 'Use browser extension', + nlKeyOrBunker: 'nsec, private key, or bunker://…', + nlGo: 'Log in', + nlHint: 'Works with Amber, Amethyst and other remote signers (paste their bunker:// address), or a browser extension. A pasted key is used once and never stored.', + nlApprove: 'Approve the request in your signer →', + nlUnrecognized: 'That doesn’t look like a private key or a bunker:// address.', + nlOpenedDerived: 'Wallet opened from your Nostr key', + nlOpenedRestored: 'Your wallet was restored', + nlNewTitle: 'Create a wallet for this account', + nlNewDesc: 'This Nostr account has no wallet yet. We’ll make one and save it, encrypted to your account, so you can open it anywhere you use this account.', + nlBackupWarning: 'Your wallet will be recoverable by anyone who controls this Nostr account — treat that account like a key to your money.', + nlCreateBtn: 'Create my wallet', + nlCreated: 'Wallet created and linked', + nlLinkTitle: 'Nostr account', + nlLinkDesc: 'Link this wallet to your Nostr account so you can open it on other devices by logging in with Nostr.', + nlLinkedDesc: 'This wallet opens when you log in with this Nostr account.', + nlLinked: 'Wallet linked to your Nostr account', + nlNeedSeed: 'This wallet has no recovery phrase loaded.', + nlAlreadyLinked: 'That Nostr account already has a different wallet.', namesTitle: 'Payment address', receiveNameTab: 'Payment address', namesCustom: 'Choose a custom name', diff --git a/src/nostr-login.js b/src/nostr-login.js new file mode 100644 index 0000000..6f23e2a --- /dev/null +++ b/src/nostr-login.js @@ -0,0 +1,161 @@ +// Log in with Nostr. +// +// Three ways to prove control of a nostr identity, in descending order of how +// much the browser gets to see: +// +// extension NIP-07 (Alby, nos2x, …) — the key stays in the extension +// bunker NIP-46 remote signer (Amber, Amethyst, nsec.app) — the key +// stays on the signing device +// key a pasted nsec / hex private key — held in memory for this +// session only, NEVER written to storage +// +// HOW A NOSTR ACCOUNT GETS A WALLET +// Two mechanisms, because the two situations are genuinely different: +// +// 1. We hold the private key (pasted nsec). The wallet seed is DERIVED from +// it: entropy = sha256("coinos:wallet-seed:v1" | privkey). Deterministic, +// so the same nostr key always opens the same wallet, on any device, +// with nothing stored anywhere by anyone. +// +// 2. We only have a signer (extension/bunker). Deriving is impossible — we +// never see the key, and neither signatures (random aux) nor NIP-44 +// ciphertexts (random nonce) are reproducible. So instead the wallet seed +// is ASSOCIATED: a random seed is generated once and published as a +// replaceable event encrypted to the user's own nostr key, which only +// their signer can decrypt. Logging in later anywhere finds and decrypts +// it, opening the same wallet. +// +// THE TRADEOFF, PLAINLY: mechanism 2 means whoever controls the nostr key can +// recover the wallet seed from public relays. That is a real escalation — +// a nostr key is usually treated as an identity, not as money. It is the +// price of "log in on a new device and your wallet is there" without the key +// itself ever reaching us. Mechanism 1 has no such exposure. The UI says so +// before publishing anything. + +import { hex, bech32 } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { entropyToMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { getPublicKey, finalizeEvent, nip44 } from './nostr.js'; +import { SimplePool } from 'nostr-tools/pool'; +import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'; +import { generateSecretKey } from 'nostr-tools/pure'; + +const SEED_DTAG = 'coinos:wallet:v1'; +const BACKUP_KIND = 30078; +const DERIVE_TAG = 'coinos:wallet-seed:v1'; + +export const BACKUP_RELAYS = [ + 'wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', +]; + +// nsec1… or 64-hex → 32-byte secret key, else null. +export function parseNostrSecret(input) { + const s = String(input || '').trim(); + if (/^[0-9a-f]{64}$/i.test(s)) return hex.decode(s.toLowerCase()); + try { + const { prefix, words } = bech32.decode(s, 1000); + if (prefix !== 'nsec') return null; + const b = bech32.fromWords(words); + return b.length === 32 ? Uint8Array.from(b) : null; + } catch { return null; } +} + +// Mechanism 1: the same nostr key always yields the same wallet. +export function seedFromNostrKey(sk) { + const tag = new TextEncoder().encode(DERIVE_TAG); + const buf = new Uint8Array(tag.length + sk.length); + buf.set(tag, 0); + buf.set(sk, tag.length); + return entropyToMnemonic(sha256(buf).slice(0, 16), wordlist); // 12 words +} + +// ---- signers ------------------------------------------------------------- +// Every signer exposes: pubkey, signEvent, encryptSelf, decryptSelf, label. + +export async function extensionSigner() { + const nostr = typeof window !== 'undefined' && window.nostr; + if (!nostr) throw new Error('no nostr extension found in this browser'); + const pubkey = await nostr.getPublicKey(); + const canNip44 = !!(nostr.nip44 && nostr.nip44.encrypt && nostr.nip44.decrypt); + if (!canNip44) throw new Error('this extension cannot encrypt (NIP-44 support required)'); + return { + kind: 'extension', pubkey, label: 'browser extension', + signEvent: (e) => nostr.signEvent(e), + encryptSelf: (txt) => nostr.nip44.encrypt(pubkey, txt), + decryptSelf: (ct) => nostr.nip44.decrypt(pubkey, ct), + }; +} + +export async function bunkerSigner(uri, { onAuth } = {}) { + const bp = await parseBunkerInput(String(uri || '').trim()); + if (!bp) throw new Error('not a bunker:// address'); + const local = generateSecretKey(); + const signer = new BunkerSigner(local, bp, { + onauth: (url) => { if (onAuth) onAuth(url); else if (typeof window !== 'undefined') window.open(url, '_blank'); }, + }); + await signer.connect(); + const pubkey = await signer.getPublicKey(); + return { + kind: 'bunker', pubkey, label: 'remote signer', + signEvent: (e) => signer.signEvent(e), + encryptSelf: (txt) => signer.nip44Encrypt(pubkey, txt), + decryptSelf: (ct) => signer.nip44Decrypt(pubkey, ct), + close: () => { try { signer.close(); } catch {} }, + }; +} + +// A pasted key: signing happens locally, and the key is never persisted. +export function keySigner(sk) { + const pubkey = getPublicKey(sk); + const conv = nip44.getConversationKey(sk, pubkey); + return { + kind: 'key', pubkey, label: 'private key', secret: sk, + signEvent: async (e) => finalizeEvent(e, sk), + encryptSelf: async (txt) => nip44.encrypt(txt, conv), + decryptSelf: async (ct) => nip44.decrypt(ct, conv), + }; +} + +// ---- the encrypted wallet association (mechanism 2) ---------------------- + +// Look for a wallet this nostr account already published. Returns the +// mnemonic, or null when the account has no wallet yet. +export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) { + const pool = new SimplePool(); + try { + const evs = await pool.querySync(relays, + { kinds: [BACKUP_KIND], authors: [signer.pubkey], '#d': [SEED_DTAG] }, { maxWait: 6000 }); + const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; + if (!newest) return null; + const body = JSON.parse(await signer.decryptSelf(newest.content)); + return body && body.mnemonic ? body : null; + } catch { return null; } finally { try { pool.close(relays); } catch {} } +} + +// Publish (or replace) the encrypted wallet association for this account. +export async function publishWalletBackup(signer, { mnemonic, passphrase }, relays = BACKUP_RELAYS) { + const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '' })); + const evt = await signer.signEvent({ + kind: BACKUP_KIND, created_at: Math.floor(Date.now() / 1000), + tags: [['d', SEED_DTAG]], content, + }); + const pool = new SimplePool(); + try { + const res = await Promise.allSettled(pool.publish(relays, evt)); + if (!res.some((r) => r.status === 'fulfilled')) throw new Error('no relay accepted the backup'); + } finally { try { pool.close(relays); } catch {} } + return true; +} + +// The whole login: given a signer, produce the wallet mnemonic to open. +// `mode` reports what happened so the UI can be honest about it. +export async function walletForSigner(signer) { + // A pasted key derives its wallet — no lookup, no storage, no exposure. + if (signer.secret) { + return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived' }; + } + const found = await fetchWalletBackup(signer); + if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, mode: 'restored' }; + return { mnemonic: null, mode: 'new' }; // caller decides + confirms publishing +} 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); |
