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:
parent
f953082217
commit
257ce05a25
5 changed files with 592 additions and 14 deletions
32
AGENTS.md
32
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://<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`**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
47
src/lib/ncryptsec.ts
Normal 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
203
src/lib/nostrConnect.ts
Normal 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()}`;
|
||||
}
|
||||
Loading…
Reference in a new issue