diff options
| -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); |
