// 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;
}