diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 0457ccd..ba5ba12 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -7,9 +7,13 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useLoginActions, generateNostrConnectParams, generateNostrConnectURI, type NostrConnectParams, type NostrConnectStatus } from '@/hooks/useLoginActions'; +import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; +import { NLogin } from '@nostrify/react/login'; +import { useNostrLogin } from '@nostrify/react/login'; +import { NConnectSigner, NSecSigner, NRelay1 } from '@nostrify/nostrify'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; interface LoginDialogProps { isOpen: boolean; @@ -18,11 +22,40 @@ interface LoginDialogProps { onSignup?: () => void; } -const RELAYS = [ +// Relays used for NIP-46 nostrconnect:// handshake +const NOSTRCONNECT_RELAYS = [ 'wss://relay.ditto.pub', 'wss://relay.nostr.band', + 'wss://nostr.mom', ]; +/** Build a nostrconnect:// URI per NIP-46 spec */ +function buildNostrConnectURI(opts: { + clientPubkey: string; + relays: string[]; + secret: string; + name?: string; + url?: string; + perms?: string; +}): string { + const params = new URLSearchParams(); + for (const r of opts.relays) { + params.append('relay', r); + } + params.set('secret', opts.secret); + if (opts.name) params.set('name', opts.name); + if (opts.url) params.set('url', opts.url); + if (opts.perms) params.set('perms', opts.perms); + return `nostrconnect://${opts.clientPubkey}?${params.toString()}`; +} + +/** Generate a random short secret string */ +function randomSecret(): string { + const arr = new Uint8Array(12); + crypto.getRandomValues(arr); + return Array.from(arr, b => b.toString(16).padStart(2, '0')).join(''); +} + export const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const [isLoading, setIsLoading] = useState(false); const [bunkerUri, setBunkerUri] = useState(''); @@ -32,72 +65,216 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'success'>('idle'); const [amberStatus, setAmberStatus] = useState(''); const [nostrConnectUri, setNostrConnectUri] = useState(''); - const [nostrConnectParams, setNostrConnectParams] = useState(null); const abortRef = useRef(null); + const relayRefs = useRef([]); const login = useLoginActions(); + const { addLogin, setLogin } = useNostrLogin(); const { toast } = useToast(); const isAndroid = /Android/i.test(navigator.userAgent); + // Cleanup relay connections + const cleanupRelays = useCallback(() => { + for (const relay of relayRefs.current) { + try { relay.close(); } catch { /* ignore */ } + } + relayRefs.current = []; + }, []); + // Reset state when dialog opens/closes useEffect(() => { if (!isOpen) { abortRef.current?.abort(); abortRef.current = null; + cleanupRelays(); setAmberStep('idle'); setAmberStatus(''); setNostrConnectUri(''); - setNostrConnectParams(null); setBunkerUri(''); setBunkerError(''); setIsLoading(false); } - }, [isOpen]); + }, [isOpen, cleanupRelays]); - // Generate a fresh nostrconnect:// URI and start waiting + /** + * Amber login via NIP-46 nostrconnect:// flow. + * + * 1. Generate a client keypair + random secret + * 2. Build nostrconnect:// URI + * 3. Open Amber (or allow manual copy) + * 4. Listen on relays for Amber's connect response (kind 24133) + * 5. Decrypt, verify secret, extract remote signer pubkey + * 6. Use NConnectSigner to call get_public_key + * 7. Store as NLogin bunker type + */ const startAmberConnect = useCallback(async () => { + // Cancel any previous attempt abortRef.current?.abort(); + cleanupRelays(); + const controller = new AbortController(); abortRef.current = controller; - const params = generateNostrConnectParams({ relays: RELAYS }); - const uri = generateNostrConnectURI(params, { + // Step 1: Generate ephemeral client keypair + const clientSk = generateSecretKey(); + const clientNsec = nip19.nsecEncode(clientSk); + const clientPubkey = getPublicKey(clientSk); + + // Step 2: Random secret to prevent spoofing + const secret = randomSecret(); + + // Step 3: Build the nostrconnect URI + const uri = buildNostrConnectURI({ + clientPubkey, + relays: NOSTRCONNECT_RELAYS, + secret, name: 'SkateSpots', url: 'https://skate-spots.org', - perms: 'sign_event,get_public_key,nip44_encrypt,nip44_decrypt', + perms: 'get_public_key,sign_event,nip44_encrypt,nip44_decrypt', }); - setNostrConnectParams(params); setNostrConnectUri(uri); setAmberStep('connecting'); - setAmberStatus('Waiting for Amber to connect...'); + setAmberStatus('Opening Amber...'); - // Open Amber with the nostrconnect:// URI - // This works in browser, PWA, and Capacitor — it's a standard URI scheme - // that Amber registers as an intent filter - window.location.href = uri; + // Step 4: Open Amber. window.open with '_blank' goes through the browser + // intent path on Android, which makes Amber read URL params correctly. + // For nostrconnect:// Amber always handles this as a standard URI scheme. + try { + window.open(uri, '_blank'); + } catch { + // Some environments block window.open; user can copy manually + } + + setAmberStatus('Waiting for Amber to approve...'); + + // Step 5: Listen on relays for Amber's response + // Amber sends a kind 24133 event encrypted with NIP-04 to our client pubkey + const clientSigner = new NSecSigner(clientSk); + + let resolved = false; + + const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>( + async (resolve, reject) => { + // Listen on abort signal + controller.signal.addEventListener('abort', () => { + if (!resolved) reject(new DOMException('Aborted', 'AbortError')); + }); + + // Connect to all relays in parallel and listen for responses + const relays = NOSTRCONNECT_RELAYS.map(url => new NRelay1(url)); + relayRefs.current = relays; + + const since = Math.floor(Date.now() / 1000) - 10; + + for (const relay of relays) { + (async () => { + try { + // Subscribe to kind 24133 events tagged to our client pubkey + for await (const msg of relay.req([{ + kinds: [24133], + '#p': [clientPubkey], + since, + limit: 0, + }], { signal: controller.signal })) { + if (msg[0] !== 'EVENT') continue; + const event = msg[2]; + + if (resolved) break; + + try { + // Decrypt with NIP-04 (Amber uses NIP-04 for the connect response) + const decrypted = await clientSigner.nip04.decrypt( + event.pubkey, + event.content, + ); + const parsed = JSON.parse(decrypted) as { + id: string; + result: string; + error?: string; + }; + + // Verify this is a connect response with our secret + if (parsed.result !== secret && parsed.result !== 'ack') continue; + if (parsed.error) { + reject(new Error(parsed.error)); + return; + } + + // We found a valid connect response! + const signerPubkey = event.pubkey; + + // Step 6: Use NConnectSigner to get the user's actual pubkey + setAmberStatus('Connected! Getting your pubkey...'); + + const relayForSigning = relay; + const signer = new NConnectSigner({ + pubkey: signerPubkey, + signer: clientSigner, + relay: relayForSigning, + timeout: 30000, + }); + + const userPubkey = await signer.getPublicKey(); + resolved = true; + resolve({ signerPubkey, userPubkey }); + } catch { + // Decryption failed or wrong format — not our event, skip + } + } + } catch (err) { + if (!resolved && (err as Error)?.name !== 'AbortError') { + // Relay connection failed, try others + console.warn('[Amber] Relay failed:', relay, err); + } + } + })(); + } + + // Timeout after 3 minutes + setTimeout(() => { + if (!resolved) { + reject(new Error('Timeout waiting for Amber')); + } + }, 3 * 60 * 1000); + } + ); try { - await login.nostrconnect( - params, - controller.signal, - (status: NostrConnectStatus) => { - if (status === 'pending') setAmberStatus('Waiting for Amber...'); - else if (status === 'connected') setAmberStatus('Connected! Getting your pubkey...'); - else if (status === 'authenticated') setAmberStatus('Authenticated!'); - }, - ); + const { signerPubkey, userPubkey } = await connectPromise; + resolved = true; + + // Step 7: Create NLogin bunker object and add it + // NLogin stores: type="bunker", pubkey=userPubkey, data={ bunkerPubkey, clientNsec, relays } + const nlogin = new NLogin('bunker', userPubkey, { + bunkerPubkey: signerPubkey, + clientNsec, + relays: NOSTRCONNECT_RELAYS, + }); + + addLogin(nlogin); + setLogin(nlogin.id); + + cleanupRelays(); setAmberStep('success'); toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); setTimeout(() => { onLogin(); onClose(); }, 800); } catch (e: unknown) { + cleanupRelays(); if ((e as Error)?.name === 'AbortError') return; setAmberStep('idle'); setAmberStatus(''); - toast({ title: 'Connection failed', description: 'Amber did not connect. Try again.', variant: 'destructive' }); + const msg = (e as Error)?.message ?? 'Unknown error'; + toast({ + title: 'Connection failed', + description: msg.includes('Timeout') + ? 'Amber did not respond. Make sure you approved in Amber, then try again.' + : `Amber did not connect: ${msg}`, + variant: 'destructive', + }); } - }, [login, onLogin, onClose, toast]); + }, [addLogin, setLogin, cleanupRelays, onLogin, onClose, toast]); const handleBunkerLogin = async () => { const uri = bunkerUri.trim(); @@ -112,8 +289,10 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog toast({ title: '🛹 Logged in!', description: 'Connected via bunker!' }); onLogin(); onClose(); - } catch { - setBunkerError('Connection failed. Check your bunker URI and try again.'); + } catch (e: unknown) { + setBunkerError( + `Connection failed: ${(e as Error)?.message ?? 'Check your bunker URI and try again.'}` + ); } finally { setIsLoading(false); } @@ -131,7 +310,17 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const copyUri = () => { navigator.clipboard.writeText(nostrConnectUri).catch(() => {}); - toast({ title: 'Copied!', description: 'Paste in Amber → Connect apps → Add app' }); + toast({ title: 'Copied!', description: 'Paste this in Amber → Scan/Paste → Paste connection string' }); + }; + + const openAmberWithUri = () => { + if (nostrConnectUri) { + try { + window.open(nostrConnectUri, '_blank'); + } catch { + copyUri(); + } + } }; return ( @@ -170,7 +359,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
- {/* Login tabs — mobile: Amber + Bunker only */} + {/* Login tabs */} @@ -197,7 +386,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog

Login with Amber

- Tap below — Amber opens, approve once, you're in. + Uses NIP-46 — no passwords, no clipboard hacks. Tap below, approve in Amber, done.

Need Amber?{' '} - Get it on Zapstore + + Get it on Zapstore +

)} @@ -222,29 +418,42 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog

{amberStatus}

-
-

If Amber didn't open automatically:

+
+

In Amber:

1. Open Amber

-

2. Tap Connect apps → Paste link

-

3. Paste the connection string below

+

2. Tap Scan / Paste link

+

3. Paste the connection string

+

4. Tap Approve

- {nostrConnectUri && ( - - )} +
+ {nostrConnectUri && ( + <> + + + + )} +
@@ -274,15 +483,16 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
-

Login with Bunker (NIP-46)

+

Bunker Login (NIP-46)

- Paste your bunker:// connection string from Amber or nsec.app. + Paste your bunker:// URI from Amber or nsec.app.

-
-

From Amber:

-

Settings → NIP-46 Bunker → Create connection → Copy URI

+
+

How to get a bunker URI:

+

Amber: Settings → NIP-46 Bunker → Create → Copy

+

nsec.app: Connect app → Copy bunker URI

@@ -290,7 +500,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog placeholder='bunker://...' value={bunkerUri} onChange={e => { setBunkerUri(e.target.value); setBunkerError(''); }} - className='bg-black/60 border-blue-500/30 text-white placeholder:text-gray-600 text-xs' + className='bg-black/60 border-blue-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} @@ -308,7 +518,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
-

Or get a bunker from:

+

Or try:

nsec.app → Nostrum → @@ -322,8 +532,8 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
-

Browser Extension

-

Best for desktop. Works with Alby, nos2x, and similar NIP-07 extensions.

+

Browser Extension (NIP-07)

+

Best for desktop. Works with Alby, nos2x, Flamingo, and other NIP-07 extensions.