feat(auth): NIP-49 encrypted keys + NIP-46 nostrconnect QR login

Two Nostr-native auth upgrades (read-only npub sessions were declined).

NIP-49 (src/lib/ncryptsec.ts):
- Login: the Secret Key tab gains an 'Encrypted (NIP-49)' mode — paste
  ncryptsec1… + password -> decrypt -> login.
- Signup: the done step's key card exports a password-protected backup, which
  is safe to keep in cloud storage (unlike a raw nsec).
- Verified in Node: round-trips; wrong password, non-ncryptsec and empty
  password all rejected.

NIP-46 nostrconnect (src/lib/nostrConnect.ts):
- Desktop -> phone pairing via QR. Nostrify implements only the bunker
  direction; nostrconnect is signer-initiated, so the handshake lives here
  (ephemeral client keypair + secret, subscribe kind 24133 #p:[clientPub],
  reply {id,result:secret} signed with the ephemeral key), then hands the
  derived bunker:// URI to login.bunker().
- Try NIP-44 then NIP-04 (the transport moved NIP-04 -> NIP-44; support varies).
- LoginDialog's Bunker tab leads with the QR; bunker:// paste stays a fallback.
- NOT verified against a real signer — needs an on-device Amber check.

Docs: AGENTS.md.
This commit is contained in:
SkateSpots 2026-10-01 12:00:08 +07:00
parent f953082217
commit 257ce05a25
5 changed files with 592 additions and 14 deletions

View file

@ -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://<clientPub>?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`**

View file

@ -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<LoginDialogProps> = ({ 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<NostrConnectHandle | null>(null);
const [connectState, setConnectState] = useState<'idle' | 'waiting' | 'error'>('idle');
const [connectError, setConnectError] = useState('');
const [copiedUri, setCopiedUri] = useState(false);
const connectRef = useRef<NostrConnectHandle | null>(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<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ isOpen, onClose, onLog
{/* ── BUNKER TAB ── */}
<TabsContent value='bunker' className='space-y-3'>
{/* 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. */}
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
<div className='text-center'>
<QrCode className='w-10 h-10 mx-auto mb-2 text-blue-400' />
<p className='text-sm text-white font-bold'>Sign in with your phone</p>
<p className='text-xs text-gray-400 mt-1'>
Scan with a signer app (Amber, nsec.app). Your key never leaves your phone.
</p>
</div>
{connectState === 'idle' && (
<Button
className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
onClick={handleNostrConnect}
>
<QrCode className='w-4 h-4 mr-2' />Show pairing code
</Button>
)}
{connectState === 'waiting' && connectSession && (
<div className='space-y-3'>
<div className='mx-auto w-fit rounded-xl bg-white p-3'>
<QRCodeCanvas value={connectSession.uri} size={196} level='M' />
</div>
<p className='flex items-center justify-center gap-2 text-center text-xs text-gray-400'>
<Loader2 className='w-3.5 h-3.5 animate-spin' /> Waiting for your signer…
</p>
<div className='flex gap-2'>
<Button
variant='outline'
className='flex-1 rounded-full text-xs border-blue-500/40 text-blue-300 hover:bg-blue-500/10'
onClick={() => {
navigator.clipboard.writeText(connectSession.uri).then(() => {
setCopiedUri(true);
setTimeout(() => setCopiedUri(false), 1500);
}).catch(() => {});
}}
>
{copiedUri
? <><Check className='w-3.5 h-3.5 mr-1' />Copied</>
: <><Copy className='w-3.5 h-3.5 mr-1' />Copy link</>}
</Button>
<Button
variant='outline'
className='flex-1 rounded-full text-xs border-white/15 text-gray-400 hover:bg-white/5'
onClick={cancelNostrConnect}
>
Cancel
</Button>
</div>
</div>
)}
{connectState === 'error' && (
<div className='space-y-2'>
<p className='text-xs text-red-400 bg-red-500/10 rounded-lg p-2 text-center'>{connectError}</p>
<Button
className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
onClick={handleNostrConnect}
>
Try again
</Button>
</div>
)}
</div>
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-xs text-gray-500'>or paste a bunker URL</span>
<div className='flex-1 border-t border-gray-800' />
</div>
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
<div className='text-center'>
<Cloud className='w-10 h-10 mx-auto mb-2 text-blue-400' />
@ -426,19 +581,80 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div className='p-5 rounded-xl bg-black/50 border-2 border-yellow-500/30 space-y-4'>
<div className='text-center'>
<KeyRound className='w-10 h-10 mx-auto mb-2 text-yellow-400' />
<p className='text-sm text-white font-bold'>Secret Key (nsec)</p>
<p className='text-xs text-gray-400 mt-1'>Paste your Nostr secret key directly.</p>
<p className='text-sm text-white font-bold'>Secret Key</p>
<p className='text-xs text-gray-400 mt-1'>
{nsecMode === 'raw'
? 'Paste your Nostr secret key directly.'
: 'Open a password-protected key backup.'}
</p>
</div>
<div className='p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30'>
<p className='text-xs text-yellow-300'>⚠️ Only use this on a trusted device. Never share your nsec.</p>
{/* Raw nsec vs NIP-49 encrypted key */}
<div className='flex gap-1 rounded-full bg-black/50 border border-white/10 p-1'>
{(['raw', 'encrypted'] as const).map((m) => (
<button
key={m}
type='button'
onClick={() => { setNsecMode(m); setNsecError(''); }}
className={cn(
'min-h-0 flex-1 rounded-full px-2 py-1 text-[11px] font-bold uppercase tracking-wide transition-colors',
nsecMode === m ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white',
)}
>
{m === 'raw' ? 'Raw nsec' : 'Encrypted (NIP-49)'}
</button>
))}
</div>
<div className='space-y-2'>
<Input type="password" placeholder='nsec1...' value={nsecInput} onChange={e => { 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 && <p className="text-xs text-red-400">{nsecError}</p>}
</div>
<Button className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase' disabled={!nsecInput.trim()} onClick={handleNsecLogin}>
<KeyRound className='w-4 h-4 mr-2' />Login
</Button>
{nsecMode === 'raw' ? (
<>
<div className='p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30'>
<p className='text-xs text-yellow-300'>⚠️ Only use this on a trusted device. Never share your nsec.</p>
</div>
<div className='space-y-2'>
<Input type="password" placeholder='nsec1...' value={nsecInput} onChange={e => { 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 && <p className="text-xs text-red-400">{nsecError}</p>}
</div>
<Button className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase' disabled={!nsecInput.trim()} onClick={handleNsecLogin}>
<KeyRound className='w-4 h-4 mr-2' />Login
</Button>
</>
) : (
<>
<div className='p-3 rounded-lg bg-green-500/10 border border-green-500/30'>
<p className='text-xs text-green-300'>
Safer: the key stays encrypted until you enter your password, so the
backup is safe to keep in cloud storage or a password manager.
</p>
</div>
<div className='space-y-2'>
<Input
placeholder='ncryptsec1...'
value={ncryptsecInput}
onChange={e => { 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}
/>
<Input
type='password'
placeholder='Password'
value={ncryptsecPassword}
onChange={e => { 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 && <p className="text-xs text-red-400">{nsecError}</p>}
</div>
<Button
className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase'
disabled={!ncryptsecInput.trim() || !ncryptsecPassword}
onClick={handleNcryptsecLogin}
>
<Unlock className='w-4 h-4 mr-2' />Open encrypted key
</Button>
</>
)}
</div>
</TabsContent>

View file

@ -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<SignupDialogProps> = ({ 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<SignupDialogProps> = ({ 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<HTMLInputElement>) => {
@ -295,6 +330,9 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ 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<SignupDialogProps> = ({ isOpen, onClose, onComplete
</p>
</div>
)}
{/* NIP-49 encrypted backup — the safer artefact to keep. */}
{showNsec && (
<div className='space-y-2 border-t border-amber-500/20 pt-3'>
<button
type='button'
onClick={() => { setShowEncBackup(v => !v); setEncError(''); }}
className='flex w-full items-center justify-between text-left'
>
<span className='text-[11px] font-black uppercase tracking-wide text-amber-300'>
{showEncBackup ? 'Hide encrypted backup' : 'Save an encrypted backup'}
</span>
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-amber-300 transition-transform', showEncBackup && 'rotate-180')} />
</button>
{showEncBackup && (
<div className='space-y-2'>
<p className='text-[11px] leading-snug text-amber-100/70'>
A password-protected copy — safe to keep in cloud storage or a password
manager. You'll need the file <span className='font-bold text-amber-200'>and</span> the password.
</p>
<Input
type='password'
value={encPassword}
onChange={(e) => { setEncPassword(e.target.value); setEncError(''); }}
placeholder='Choose a password'
className='rounded-lg'
autoComplete='new-password'
/>
{encError && <p className='text-[11px] text-red-400'>{encError}</p>}
<Button
size='sm'
className='w-full rounded-full font-bold bg-amber-500 text-black hover:bg-amber-400'
disabled={!encPassword}
onClick={downloadEncryptedKey}
>
<Download className='h-3.5 w-3.5 mr-1.5' />
Save encrypted backup
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>

47
src/lib/ncryptsec.ts Normal file
View file

@ -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);
}

203
src/lib/nostrConnect.ts Normal file
View file

@ -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<void> {
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()}`;
}