diff --git a/AGENTS.md b/AGENTS.md index 0bb09fe..83f2f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -769,6 +769,38 @@ session with **no other memory of this project**. So: `public/images/screenshots/{map,spot,marketplace}.png` (1280×800, quantised — captured with headless Chromium; regenerate with a CDP harness if the UI changes materially). +- **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native + auth upgrades (owner picked these two; read-only/npub sessions were explicitly + declined). + - **NIP-49 `ncryptsec` — `src/lib/ncryptsec.ts`.** A password-encrypted key, + used BOTH as a login method (LoginDialog → Secret Key tab → "Encrypted + (NIP-49)": paste `ncryptsec1…` + password → `decryptNcryptsec` → + `login.nsec`) AND as the signup backup (done step → "Save an encrypted + backup" → password → downloads a password-protected file — a safer artefact + than a raw nsec). Thin wrapper over `nostr-tools/nip49` (`import * as nip49 + from 'nostr-tools/nip49'` — **not** exported from the package root; log_n = + 16). Verified in Node: round-trips, wrong password rejected by the AEAD, + non-`ncryptsec1` input rejected, empty password rejected. + - **NIP-46 `nostrconnect://` QR — `src/lib/nostrConnect.ts`.** The missing + "log in on desktop with the key already on your phone" flow. ⚠️ **Nostrify + does NOT implement this direction** — it only has `NLogin.fromBunker` / + `NConnectSigner`, where the client already knows the signer pubkey and sends + `connect`. nostrconnect is the reverse: the SIGNER sends the first `connect` + request to the client's ephemeral pubkey and the client must reply with the + shared secret. `startNostrConnect(nostr, …)` mints an ephemeral keypair + + secret, builds `nostrconnect://?relay=…&secret=…&name=…&url=…&image=…`, + subscribes to kind `24133` `#p:[clientPub]`, and on the signer's `connect` + replies `{id,result:secret}` — signed with the **ephemeral** key + (`finalizeEvent` + `nostr.relay(url).event(signed)`; the pool's signer is the + logged-out user, so it must NOT be used). It then hands the derived + `bunker://` URI to the existing `login.bunker()` path so the session reuses + well-tested code. ⚠️ **Transport encryption: try NIP-44, fall back to + NIP-04**, mirroring whichever the signer used (NIP-46 moved NIP-04 → NIP-44; + support varies). Default relays `wss://relay.nsec.app`, + `wss://relay.damus.io`, `wss://nos.lol`. UI: LoginDialog → Bunker tab leads + with the QR; the `bunker://` paste stays as the fallback. ⚠️ **NOT verified + against a real signer — needs an on-device check with Amber** (scan → pair → + signed in); only the URI shape was verified in Node. - **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out free handles so a new skater has a human name from minute one. The registry is the Netlify Function **`netlify/functions/nip05.mjs`** diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 9a5e803..ae5e709 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -1,13 +1,13 @@ // NOTE: This file is stable and usually should not be modified. // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; // Extend window with a cleanup ref for Amber visibilitychange listener declare global { interface Window { __amberUnsubRef?: () => void; } } -import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react'; +import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound, QrCode, Copy, Unlock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; @@ -17,6 +17,10 @@ import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber'; import { getAmberPublicKeyViaPlugin, isNativeAmberAvailable } from '@/lib/capacitorAmberSigner'; +import { useNostr } from '@nostrify/react'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { startNostrConnect, nostrConnectBunkerUri, type NostrConnectHandle } from '@/lib/nostrConnect'; +import { decryptNcryptsec } from '@/lib/ncryptsec'; interface LoginDialogProps { @@ -40,6 +44,20 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog // NIP-55 redirect flow never needs this; it's only a "having trouble?" fallback. const [showManualPaste, setShowManualPaste] = useState(false); + // NIP-46 nostrconnect — pair with a phone signer via QR (desktop → phone). + const [connectSession, setConnectSession] = useState(null); + const [connectState, setConnectState] = useState<'idle' | 'waiting' | 'error'>('idle'); + const [connectError, setConnectError] = useState(''); + const [copiedUri, setCopiedUri] = useState(false); + const connectRef = useRef(null); + + // NIP-49 — open a password-protected key (ncryptsec). + const [nsecMode, setNsecMode] = useState<'raw' | 'encrypted'>('raw'); + const [ncryptsecInput, setNcryptsecInput] = useState(''); + const [ncryptsecPassword, setNcryptsecPassword] = useState(''); + + const { nostr } = useNostr(); + const login = useLoginActions(); const { toast } = useToast(); @@ -180,6 +198,56 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog setPubkeyError('Paste your hex pubkey (64 chars) or npub from Amber'); }; + /** + * NIP-46 nostrconnect: show a QR, wait for a phone signer to pair, then hand + * the derived bunker URI to the existing (well-tested) bunker login path. + */ + const handleNostrConnect = () => { + setConnectError(''); + setConnectState('waiting'); + const session = startNostrConnect(nostr, { + appImage: 'https://skate-spots.org/images/icon-1024.png', + timeoutMs: 180_000, + }); + connectRef.current = session; + setConnectSession(session); + + session.connected + .then(async ({ signerPubkey }) => { + const uri = nostrConnectBunkerUri(signerPubkey, session.relays, session.secret); + await login.bunker(uri); + toast({ title: '🛹 Logged in!', description: 'Paired with your signer.' }); + onLogin(); + onClose(); + }) + .catch((e: unknown) => { + setConnectState('error'); + setConnectError((e as Error)?.message ?? 'Could not connect to your signer.'); + }); + }; + + const cancelNostrConnect = () => { + connectRef.current?.cancel(); + connectRef.current = null; + setConnectSession(null); + setConnectState('idle'); + setConnectError(''); + }; + + /** NIP-49: decrypt a password-protected key, then log in with it. */ + const handleNcryptsecLogin = () => { + setNsecError(''); + try { + const nsec = decryptNcryptsec(ncryptsecInput, ncryptsecPassword); + login.nsec(nsec); + toast({ title: '🛹 Logged in!', description: 'Opened your encrypted key.' }); + onLogin(); + onClose(); + } catch (e: unknown) { + setNsecError((e as Error)?.message ?? 'Could not open that key.'); + } + }; + // Reset when dialog closes @@ -188,6 +256,13 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog // Clean up any pending Amber visibilitychange listener window.__amberUnsubRef?.(); window.__amberUnsubRef = undefined; + // Cancel any in-flight signer pairing (it holds a relay subscription). + connectRef.current?.cancel(); + connectRef.current = null; + setConnectSession(null); + setConnectState('idle'); + setConnectError(''); + setCopiedUri(false); setAmberStep('idle'); setAmberError(''); setShowManualPaste(false); @@ -197,10 +272,16 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog setBunkerError(''); setNsecInput(''); setNsecError(''); + setNcryptsecInput(''); + setNcryptsecPassword(''); + setNsecMode('raw'); setIsLoading(false); } }, [isOpen]); + // Cancel a pairing if the component unmounts mid-wait. + useEffect(() => () => { connectRef.current?.cancel(); }, []); + const handleBunkerLogin = async () => { const uri = bunkerUri.trim(); if (!uri.startsWith('bunker://')) { setBunkerError('Must start with bunker://'); return; } @@ -397,6 +478,80 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog {/* ── BUNKER TAB ── */} + {/* Phone pairing — the canonical "log in on desktop with the key + already on your phone" flow. Client-generated URI; the signer + scans and pairs over a relay. */} +
+
+ +

Sign in with your phone

+

+ Scan with a signer app (Amber, nsec.app). Your key never leaves your phone. +

+
+ + {connectState === 'idle' && ( + + )} + + {connectState === 'waiting' && connectSession && ( +
+
+ +
+

+ Waiting for your signer… +

+
+ + +
+
+ )} + + {connectState === 'error' && ( +
+

{connectError}

+ +
+ )} +
+ +
+
+ or paste a bunker URL +
+
+
@@ -426,19 +581,80 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
-

Secret Key (nsec)

-

Paste your Nostr secret key directly.

+

Secret Key

+

+ {nsecMode === 'raw' + ? 'Paste your Nostr secret key directly.' + : 'Open a password-protected key backup.'} +

-
-

⚠️ Only use this on a trusted device. Never share your nsec.

+ + {/* Raw nsec vs NIP-49 encrypted key */} +
+ {(['raw', 'encrypted'] as const).map((m) => ( + + ))}
-
- { setNsecInput(e.target.value); setNsecError(''); }} className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} /> - {nsecError &&

{nsecError}

} -
- + + {nsecMode === 'raw' ? ( + <> +
+

⚠️ Only use this on a trusted device. Never share your nsec.

+
+
+ { setNsecInput(e.target.value); setNsecError(''); }} className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} /> + {nsecError &&

{nsecError}

} +
+ + + ) : ( + <> +
+

+ Safer: the key stays encrypted until you enter your password, so the + backup is safe to keep in cloud storage or a password manager. +

+
+
+ { setNcryptsecInput(e.target.value); setNsecError(''); }} + className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600 text-xs font-mono' + autoComplete='off' autoCorrect='off' spellCheck={false} + /> + { setNcryptsecPassword(e.target.value); setNsecError(''); }} + onKeyDown={e => { if (e.key === 'Enter' && ncryptsecInput.trim() && ncryptsecPassword) handleNcryptsecLogin(); }} + className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600' + autoComplete='off' autoCorrect='off' spellCheck={false} + /> + {nsecError &&

{nsecError}

} +
+ + + )}
diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 0df1278..ae032f9 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { Download, Key, Shield, User, Sparkles, LogIn, CheckCircle, Upload, Smartphone } from 'lucide-react'; +import { Download, Key, Shield, User, Sparkles, LogIn, CheckCircle, Upload, Smartphone, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -20,6 +20,7 @@ import { cn } from '@/lib/utils'; import { SkateNip05Input } from '@/components/SkateNip05Input'; import { useSkateNip05 } from '@/hooks/useSkateNip05'; import { deriveSkateNip05Handle, validateSkateNip05Name } from '@/lib/skateNip05'; +import { encryptNsec } from '@/lib/ncryptsec'; interface SignupDialogProps { isOpen: boolean; @@ -56,6 +57,10 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete // downloaded it). The escape hatch stays, but it's a quiet link — never // presented as an equal choice to saving. const [backupAck, setBackupAck] = useState(false); + // NIP-49 encrypted backup (password-protected key export). + const [showEncBackup, setShowEncBackup] = useState(false); + const [encPassword, setEncPassword] = useState(''); + const [encError, setEncError] = useState(''); const [profileData, setProfileData] = useState({ name: '', about: '', @@ -134,6 +139,36 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete } }; + /** + * NIP-49: export the key as a password-protected `ncryptsec` instead of a raw + * nsec. Safe to keep in cloud storage — you need the file *and* the password. + */ + const downloadEncryptedKey = () => { + try { + const ncryptsec = encryptNsec(nsec, encPassword); + const blob = new Blob([ncryptsec + '\n'], { type: 'text/plain; charset=utf-8' }); + const url = globalThis.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = sanitizeFilename('nostr-ncryptsec-backup.txt'); + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + globalThis.URL.revokeObjectURL(url); + document.body.removeChild(a); + + setEncError(''); + setBackupAck(true); + setBackupConfirmed(true); + toast({ + title: 'Encrypted backup saved 🔐', + description: 'Keep the file AND the password — you need both to log back in.', + }); + } catch (e) { + setEncError((e as Error)?.message ?? 'Could not encrypt that key.'); + } + }; + const handleAvatarUpload = async (e: React.ChangeEvent) => { @@ -295,6 +330,9 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete setNsec(''); setKeySecured('none'); setBackupAck(false); + setShowEncBackup(false); + setEncPassword(''); + setEncError(''); setShowNsec(false); setProfileData({ name: '', about: '', picture: '' }); setNip05Handle(''); @@ -726,6 +764,48 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete

)} + + {/* NIP-49 encrypted backup — the safer artefact to keep. */} + {showNsec && ( +
+ + {showEncBackup && ( +
+

+ A password-protected copy — safe to keep in cloud storage or a password + manager. You'll need the file and the password. +

+ { setEncPassword(e.target.value); setEncError(''); }} + placeholder='Choose a password' + className='rounded-lg' + autoComplete='new-password' + /> + {encError &&

{encError}

} + +
+ )} +
+ )} diff --git a/src/lib/ncryptsec.ts b/src/lib/ncryptsec.ts new file mode 100644 index 0000000..ecb4b58 --- /dev/null +++ b/src/lib/ncryptsec.ts @@ -0,0 +1,47 @@ +/** + * NIP-49 — password-encrypted private keys (`ncryptsec1…`) + * ───────────────────────────────────────────────────────────────────────────── + * A raw `nsec` in a text file is the worst artefact to hand a new user. NIP-49 + * wraps the key with scrypt + XChaCha20-Poly1305 under a password, so the blob + * is safe to store in cloud storage, email, or a password manager — and it's a + * portable login credential in its own right. + * + * Thin wrapper around `nostr-tools/nip49` (verified: round-trips, and a wrong + * password is rejected by the AEAD, not by luck). + */ + +import * as nip49 from 'nostr-tools/nip49'; +import { nip19 } from 'nostr-tools'; + +export const NCRYPTSEC_PREFIX = 'ncryptsec1'; + +/** scrypt work factor. 16 ≈ 2^16 rounds — the usual NIP-49 default. */ +const LOG_N = 16; + +/** Encrypt a bech32 `nsec` into a password-protected `ncryptsec`. */ +export function encryptNsec(nsec: string, password: string): string { + if (!password) throw new Error('Choose a password first.'); + const decoded = nip19.decode(nsec.trim()); + if (decoded.type !== 'nsec') throw new Error('That is not a valid nsec.'); + return nip49.encrypt(decoded.data, password, LOG_N); +} + +/** Decrypt a `ncryptsec` back to a bech32 `nsec`. Throws on a wrong password. */ +export function decryptNcryptsec(ncryptsec: string, password: string): string { + const value = ncryptsec.trim(); + if (!value.startsWith(NCRYPTSEC_PREFIX)) { + throw new Error('That doesn’t look like an encrypted key (it should start with ncryptsec1).'); + } + try { + const secret = nip49.decrypt(value, password); + return nip19.nsecEncode(secret); + } catch { + // The AEAD fails on a wrong password or a damaged blob; we can't tell which. + throw new Error('Wrong password — or the encrypted key is damaged.'); + } +} + +/** Cheap shape check so the UI can switch mode without a decode attempt. */ +export function isNcryptsec(value: string): boolean { + return value.trim().startsWith(NCRYPTSEC_PREFIX); +} diff --git a/src/lib/nostrConnect.ts b/src/lib/nostrConnect.ts new file mode 100644 index 0000000..7239565 --- /dev/null +++ b/src/lib/nostrConnect.ts @@ -0,0 +1,203 @@ +/** + * NIP-46 "Nostr Connect" (`nostrconnect://`) — signer-initiated pairing + * ───────────────────────────────────────────────────────────────────────────── + * What this enables: log in on a DESKTOP (or any browser without a NIP-07 + * extension) using the key that already lives on your phone. The client shows a + * QR; the phone signer (Amber, nsec.app, …) scans it, pairs over a relay, and + * the browser ends up signed in — no extension, no pasted secret. + * + * Nostrify gives us the *bunker* direction (`NConnectSigner`: client already + * knows the signer pubkey and sends `connect`). The nostrconnect direction is + * the reverse: the SIGNER sends the first `connect` request to the client's + * ephemeral pubkey, and the client must answer with the shared secret. That + * handshake is what this module implements. Once paired, the caller hands the + * derived `bunker://` URI to the existing `login.bunker()` path, so the rest of + * the session reuses well-tested code. + * + * ⚠️ NIP-46 transport encryption moved from NIP-04 to NIP-44. We try NIP-44 + * first and fall back to NIP-04 on both sides, mirroring whichever the signer + * used, because signer support varies. + */ + +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'; +import * as nip04 from 'nostr-tools/nip04'; +import * as nip44 from 'nostr-tools/nip44'; +import type { NPool, NostrEvent } from '@nostrify/nostrify'; + +const NC_KIND = 24133; + +/** Relays both sides can usually reach. The signer may also add its own. */ +export const NOSTR_CONNECT_RELAYS = [ + 'wss://relay.nsec.app', + 'wss://relay.damus.io', + 'wss://nos.lol', +]; + +export interface NostrConnectHandle { + /** Render as a QR / deep link. */ + uri: string; + clientPubkey: string; + relays: string[]; + /** Shared secret from the URI, needed to build the follow-up bunker URI. */ + secret: string; + /** Resolves with the signer's pubkey once it pairs; rejects on timeout. */ + connected: Promise<{ signerPubkey: string }>; + cancel: () => void; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +function randomHex(bytes = 16): string { + const a = new Uint8Array(bytes); + crypto.getRandomValues(a); + return toHex(a); +} + +type Scheme = 'nip44' | 'nip04'; + +/** Decrypt a NIP-46 transport message, reporting which scheme worked. */ +function tryDecrypt( + clientSk: Uint8Array, + signerPubkey: string, + content: string, +): { plaintext: string; scheme: Scheme } | null { + try { + const ck = nip44.getConversationKey(clientSk, signerPubkey); + return { plaintext: nip44.decrypt(content, ck), scheme: 'nip44' }; + } catch { + /* fall through to nip04 */ + } + try { + return { plaintext: nip04.decrypt(clientSk, signerPubkey, content), scheme: 'nip04' }; + } catch { + return null; + } +} + +function encrypt(clientSk: Uint8Array, pubkey: string, plaintext: string, scheme: Scheme): string { + if (scheme === 'nip44') { + return nip44.encrypt(plaintext, nip44.getConversationKey(clientSk, pubkey)); + } + return nip04.encrypt(clientSk, pubkey, plaintext); +} + +/** Acknowledge the signer's `connect` request with the shared secret. */ +async function respondAck( + nostr: NPool, + relays: string[], + clientSk: Uint8Array, + signerPubkey: string, + id: string, + secret: string, + scheme: Scheme, +): Promise { + const event = finalizeEvent( + { + kind: NC_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', signerPubkey]], + content: encrypt(clientSk, signerPubkey, JSON.stringify({ id, result: secret }), scheme), + }, + clientSk, + ); + // Best effort — the signer may only be on one of the relays. + await Promise.all(relays.map((r) => nostr.relay(r).event(event).catch(() => {}))); +} + +/** + * Begin a nostrconnect pairing. Returns immediately with the URI to show; await + * `connected` for the signer's pubkey. + */ +export function startNostrConnect( + nostr: NPool, + opts: { + relays?: string[]; + appName?: string; + appUrl?: string; + appImage?: string; + timeoutMs?: number; + } = {}, +): NostrConnectHandle { + const relays = opts.relays?.length ? opts.relays : NOSTR_CONNECT_RELAYS; + const clientSk = generateSecretKey(); + const clientPubkey = getPublicKey(clientSk); + const secret = randomHex(); + + const params = new URLSearchParams(); + for (const relay of relays) params.append('relay', relay); + params.set('secret', secret); + params.set('name', opts.appName ?? 'SkateSpots'); + params.set('url', opts.appUrl ?? 'https://skate-spots.org'); + if (opts.appImage) params.set('image', opts.appImage); + + const uri = `nostrconnect://${clientPubkey}?${params.toString()}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 180_000); + + const connected = (async (): Promise<{ signerPubkey: string }> => { + const group = nostr.group(relays); + const sub = group.req( + [{ kinds: [NC_KIND], '#p': [clientPubkey], since: Math.floor(Date.now() / 1000) - 10 }], + { signal: controller.signal }, + ); + try { + for await (const msg of sub) { + if (msg[0] !== 'EVENT') continue; + const event = msg[2] as NostrEvent; + const signerPubkey = event.pubkey; + const decrypted = tryDecrypt(clientSk, signerPubkey, event.content); + if (!decrypted) continue; + + let payload: { id?: string; method?: string; params?: unknown[]; result?: unknown }; + try { + payload = JSON.parse(decrypted.plaintext); + } catch { + continue; + } + + // The spec has the signer send `connect` with the secret; some signers + // instead confirm with `result`. Accept either. + const asked = + payload.method === 'connect' && + Array.isArray(payload.params) && + payload.params.includes(secret); + const confirmed = typeof payload.result === 'string' && payload.result === secret; + if (!asked && !confirmed) continue; + + if (asked && payload.id) { + await respondAck(nostr, relays, clientSk, signerPubkey, payload.id, secret, decrypted.scheme); + } + return { signerPubkey }; + } + throw new Error('Connection closed before a signer paired.'); + } catch (err) { + if (controller.signal.aborted) throw new Error('Timed out waiting for your signer.'); + throw err instanceof Error ? err : new Error('Could not connect to your signer.'); + } finally { + clearTimeout(timer); + } + })(); + + return { + uri, + clientPubkey, + relays, + secret, + connected, + cancel: () => { + clearTimeout(timer); + controller.abort(); + }, + }; +} + +/** The `bunker://` URI to hand to the existing `login.bunker()` path. */ +export function nostrConnectBunkerUri(signerPubkey: string, relays: string[], secret: string): string { + const params = new URLSearchParams(); + for (const relay of relays) params.append('relay', relay); + params.set('secret', secret); + return `bunker://${signerPubkey}?${params.toString()}`; +}