aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js2
-rw-r--r--src/app.js3
-rw-r--r--src/features/index.js3
-rw-r--r--src/features/nostrlogin.js163
-rw-r--r--src/i18n.js21
-rw-r--r--src/nostr-login.js161
-rw-r--r--tools/nostr-login-test.js60
7 files changed, 411 insertions, 2 deletions
diff --git a/build.js b/build.js
index 1b1453b..6a596e8 100644
--- a/build.js
+++ b/build.js
@@ -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);
diff --git a/src/app.js b/src/app.js
index b02cd58..ded5ee4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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);