// 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 { generateSecretKey } from 'nostr-tools/pure'; import { decode as nip19decode } from 'nostr-tools/nip19'; import { assetUrl } from './asset-url.js'; // NIP-46 loads lazily from dist/nip46.js (same pattern as the QR decoder): // bunker logins are rare, and the module is heavy. Tests running outside a // browser preset globalThis.__nip46 with the real module instead. let _nip46 = null; function loadNip46() { if (_nip46) return _nip46; if (typeof globalThis !== 'undefined' && globalThis.__nip46) return (_nip46 = Promise.resolve(globalThis.__nip46)); _nip46 = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = assetUrl('nip46.js'); s.onload = () => (window.__nip46 ? resolve(window.__nip46) : reject(new Error('signer module unavailable'))); s.onerror = () => { _nip46 = null; reject(new Error('could not load the remote-signer module')); }; document.head.appendChild(s); }); return _nip46; } 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), // peer variants: NIP-17 DMs seal to the recipient, not to self encryptTo: (peer, txt) => nostr.nip44.encrypt(peer, txt), decryptFrom: (peer, ct) => nostr.nip44.decrypt(peer, ct), }; } // The relay pool a remote signer talks through. nostr-tools' own default // never reconnects: a phone freezing the tab for a minute cut the socket, // every request after that hung to its deadline, and the app declared the // signer gone — "my signer keeps disconnecting", reconnect tapped over and // over. Pinged and reconnecting, the socket comes back on its own and the // subscription is re-fired, so the next request simply gets its answer. const bunkerPool = () => new SimplePool({ enablePing: true, enableReconnect: true }); export async function bunkerSigner(uri, { onAuth } = {}) { const { BunkerSigner, parseBunkerInput } = await loadNip46(); const bp = await parseBunkerInput(String(uri || '').trim()); if (!bp) throw new Error('not a bunker:// address'); if (!bp.relays || !bp.relays.lengt
@tailwind base;
@tailwind components;
@tailwind utilities;

@font-face {
	font-family: 'PublicSans';
	src: url('/fonts/Public_Sans/static/PublicSans-Regular.ttf') format('truetype');
}

html {
	font-family: PublicSans, sans-serif;
}

::selection {
	color: black;
	background: #eaf2ff;
}
"ack" if (resp.result === secret || resp.result === 'ack') { return BunkerSigner.fromBunker(local, { pubkey: ev.pubkey, relays, secret }, { pool: bunkerPool() }); } } catch {} } } catch {} finally { try { pool.close(relays); } catch {} } } throw controller.signal.reason || new Error('cancelled'); })(); replay.catch(() => {}); const ready = (async () => { try { const signer = await Promise.any([live, replay]); const pubkey = await signer.getPublicKey(); try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} return bunkerAdapter(signer, pubkey, local); } catch (e) { // an aborted wait names its real cause (timeout, user cancel); a raw // AggregateError from the race names nothing useful throw (controller.signal.aborted && controller.signal.reason) || (e && e.errors && e.errors[0]) || e; } finally { clearTimeout(timer); document.removeEventListener('visibilitychange', onVis); controller.abort(new Error('settled')); } })(); ready.catch(() => {}); return { uri, ready, cancel: (reason) => { try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {} controller.abort(reason || new Error('cancelled')); }, }; } // A pasted key: signing happens locally, and the key is never persisted. // ---- NIP-55: an Android signer app (Amber), by intent -------------------- // Amber without a bunker (offline, no relays) can still sign for a web // page: the page opens a `nostrsigner:` URL and the app answers. A web page // cannot receive an intent result, so the answer comes back one of two // ways — appended to a callback URL, which NAVIGATES the tab (our TWA would // reload, losing the promise that asked), or copied to the clipboard when // no callback is given. We take the clipboard: the tab stays alive, every // caller's await survives, and when the user comes back here the answer is // read off the clipboard. If the browser won't hand the clipboard over, // the login card offers a box to paste it into (amberDeliver). Each request // is a round trip through the app — NIP-55 is honest that this means a // popup per signature, which is why it is a login door, not the default. export const amberAvailable = () => typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent || ''); let amberReq = null; // { type, ts, resolve, reject, timer } let amberWatch = null; const amberListeners = new Set(); export const onAmberChange = (fn) => { amberListeners.add(fn); return () => amberListeners.delete(fn); }; const amberChanged = () => { for (const fn of amberListeners) { try { fn(); } catch {} } }; export const amberPending = () => (amberReq ? { type: amberReq.type, since: amberReq.ts } : null); const HEX64 = /^[0-9a-f]{64}$/i; // What an answer of this type looks like — anything else on the clipboard // is somebody's grocery list, not a signature. function amberParse(type, text, payload) { const v = String(text || '').trim(); if (!v || v === payload) return null; if (type === 'get_public_key') { if (HEX64.test(v)) return v.toLowerCase(); if (/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(v)) { try { const d = nip19decode(v); if (d.type === 'npub') return d.data; } catch {} } try { const j = JSON.parse(v); if (j && HEX64.test(j.pubkey || '')) return j.pubkey.toLowerCase(); } catch {} return null; } if (type === 'sign_event') { try { const ev = JSON.parse(v); return ev && ev.sig && ev.id && HEX64.test(ev.pubkey || '') ? ev : null; } catch { return null; } } // the clipboard still holds the LAST answer (an npub, say) when the next // request goes out: an answer must look like its own kind, and never // repeat the one before if (v === amberLastAnswer) return null; if (type === 'nip44_encrypt') return /^Ag[A-Za-z0-9+/]{100,}={0,2}$/.test(v) ? v : null; // version byte 2, nonce, body, mac if (type === 'nip04_encrypt') return /^[A-Za-z0-9+/=]+\?iv=[A-Za-z0-9+/=]+$/.test(v) ? v : null; if (/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(v) || HEX64.test(v)) return null; // a key is never a plaintext return v; // a decrypt: the caller knows what it expects and will say if this isn't it } let amberLastAnswer = null; // Resolve the waiting request with an answer, however it arrived. export function amberDeliver(text) { if (!amberReq) return false; const got = amberParse(amberReq.type, text, amberReq.payload); if (got == null) return false; amberLastAnswer = String(text || '').trim(); const r = amberReq; amberStop(); r.resolve(got); return true; } export function amberCancel(reason = 'signer request cancelled') { if (!amberReq) return; const r = amberReq; amberStop(); r.reject(new Error(reason)); } function amberStop() { if (amberReq) clearTimeout(amberReq.timer); amberReq = null; if (amberWatch) { amberWatch(); amberWatch = null; } amberChanged(); } // Read the clipboard whenever the tab comes back to the front, and every // so often while it is — Amber copies its answer just before the user // switches back here. function amberWatchClipboard() { const read = async () => { if (!amberReq || typeof navigator === 'undefined' || !navigator.clipboard) return; try { // a hidden or unfocused page is simply refused by the browser const txt = await navigator.clipboard.readText(); if (amberReq && amberDeliver(txt)) return; } catch {} // no permission: the paste box is the way in }; const onVis = () => { if (document.visibilityState === 'visible') setTimeout(read, 250); }; document.addEventListener('visibilitychange', onVis); window.addEventListener('focus', onVis); const iv = setInterval(read, 800); setTimeout(read, 300); return () => { document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onVis); clearInterval(iv); }; } function amberLaunch(url) { if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') { window.__coinosAmberLaunch(url); return; } // tests window.location.href = url; } // One trip at a time: the app fires several signatures at once right after a // sign-in (the backup, the relay list, the follows), and Amber can only be // asked for one; the rest wait their turn in this chain. let amberQueue = Promise.resolve(); function amberRequest(type, payload, params = {}, { timeoutMs = 180_000 } = {}) { const q = new URLSearchParams({ type, compressionType: 'none', returnType: 'event' }); if (params.pubkey) q.set('pubkey', params.pubkey); const url = 'nostrsigner:' + encodeURIComponent(payload || '') + '?' + q.toString(); const run = () => new Promise((resolve, reject) => { amberReq = { type, payload: payload || '', ts: Date.now(), resolve, reject, timer: setTimeout(() => amberCancel('signer did not answer'), timeoutMs) }; amberWatch = amberWatchClipboard(); amberChanged(); amberLaunch(url); }); const p = amberQueue.then(run, run); amberQueue = p.catch(() => {}); return p; } export async function amberSigner({ pubkey = null } = {}) { if (!pubkey) pubkey = await amberRequest('get_public_key', ''); return amberAdapter(pubkey); } function amberAdapter(pubkey) { return { kind: 'amber', pubkey, label: 'Amber', interactive: true, // every call is a trip through the app: nothing in the background may use it session: { amber: pubkey }, // the pubkey is all a later boot needs; no round trip to come back signEvent: async (e) => { const ev = await amberRequest('sign_event', JSON.stringify({ ...e, pubkey }), { pubkey }); if (ev.pubkey !== pubkey) throw new Error('the signer answered with a different account'); return ev; }, encryptSelf: (txt) => amberRequest('nip44_encrypt', txt, { pubkey }), decryptSelf: (ct) => amberRequest('nip44_decrypt', ct, { pubkey }), encryptTo: (peer, txt) => amberRequest('nip44_encrypt', txt, { pubkey: peer }), decryptFrom: (peer, ct) => amberRequest('nip44_decrypt', ct, { pubkey: peer }), ping: async () => true, }; } 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), encryptTo: async (peer, txt) => nip44.encrypt(txt, nip44.getConversationKey(sk, peer)), decryptFrom: async (peer, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, peer)), }; } // ---- 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. // // "No wallet" is a claim, not a default: acting on it mints a NEW wallet // whose backup would replace this one on every relay it reaches. So absence // is only believed when at least one relay actually answered, and a backup // we can see but not decrypt is an error, never a shrug — each query is // per-relay because a pooled query swallows individual connection failures. export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) { const pool = new SimplePool(); const filter = { kinds: [BACKUP_KIND], authors: [signer.pubkey], '#d': [SEED_DTAG] }; try { const results = await Promise.allSettled(relays.map(async (url) => { await pool.ensureRelay(url, { connectionTimeout: 5000 }); return pool.querySync([url], filter, { maxWait: 6000 }); })); const answered = results.filter((r) => r.status === 'fulfilled'); if (!answered.length) throw new Error('could not reach any relay to look for an existing wallet. Check your connection and try again'); const newest = answered.flatMap((r) => r.value).sort((a, b) => b.created_at - a.created_at)[0]; if (!newest) return null; let body; try { body = JSON.parse(await signer.decryptSelf(newest.content)); } catch { throw new Error('found this account’s wallet but could not decrypt it. Approve the decryption prompt in your signer and try again'); } if (!body || !body.mnemonic) throw new Error('found this account’s wallet backup but it is unreadable'); return body; } finally { try { pool.close(relays); } catch {} } } // Publish (or replace) the encrypted wallet association for this account. // `spending` rides along as a hint: a restored wallet paints its Spending // side from the first frame instead of reflowing when the name lookup proves // it seconds later. export async function publishWalletBackup(signer, { mnemonic, passphrase, spending }, relays = BACKUP_RELAYS) { const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', ...(spending ? { spending: true } : {}) })); 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. // // ONE flow for all three signer kinds, because an identity must open the // SAME wallet however you log into it: look for the published association // FIRST (that's where the money already is), and only then fall back to // deriving (key in hand) or generating (signer only). export async function walletForSigner(signer) { const found = await fetchWalletBackup(signer); if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, mode: 'restored' }; if (signer.secret) { // Publishing the derived seed costs nothing: the derivation is public, // so anyone holding this key could compute it anyway. It buys the thing // that matters — an extension login later finds this same wallet. return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived', publish: true }; } return { mnemonic: null, mode: 'new' }; // caller decides + confirms publishing } // NIP-98 HTTP Auth (kind 27235): the standard way to authenticate an HTTP // request with a nostr key. `u` and `method` bind the signature to this exact // request and `payload` to its body, so a captured header can't be replayed // against a different endpoint or a tampered body.