feat: connection initiated by the client (#364)

This commit is contained in:
hoppe
2025-06-03 23:06:56 +09:00
committed by GitHub
parent 6bfae35f6a
commit 74986b1c6e
6 changed files with 741 additions and 2 deletions

View File

@@ -0,0 +1,162 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Loader, Copy, Check } from 'lucide-react'
import { createNostrConnectURI, NostrConnectParams } from '@/providers/NostrProvider/nip46'
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { QRCodeSVG } from 'qrcode.react'
import { useState, useEffect, useRef, useLayoutEffect } from 'react'
import { useTranslation } from 'react-i18next'
export default function NostrConnectLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nostrConnectionLogin, bunkerLogin } = useNostr()
const [pending, setPending] = useState(false)
const [bunkerInput, setBunkerInput] = useState('')
const [copied, setCopied] = useState(false)
const [errMsg, setErrMsg] = useState<string | null>(null)
const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState<string | null>(null)
const qrContainerRef = useRef<HTMLDivElement>(null)
const [qrCodeSize, setQrCodeSize] = useState(100)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBunkerInput(e.target.value)
if (errMsg) setErrMsg(null)
}
const handleLogin = () => {
if (bunkerInput === '') return
setPending(true)
bunkerLogin(bunkerInput)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message || 'Login failed'))
.finally(() => setPending(false))
}
const [loginDetails] = useState(() => {
const newPrivKey = generateSecretKey()
const newMeta: NostrConnectParams = {
clientPubkey: getPublicKey(newPrivKey),
relays: DEFAULT_NOSTRCONNECT_RELAY,
secret: Math.random().toString(36).substring(7),
name: document.location.host,
url: document.location.origin,
}
const newConnectionString = createNostrConnectURI(newMeta)
return {
privKey: newPrivKey,
connectionString: newConnectionString,
}
})
useLayoutEffect(() => {
const calculateQrSize = () => {
if (qrContainerRef.current) {
const containerWidth = qrContainerRef.current.offsetWidth
const desiredSizeBasedOnWidth = Math.min(containerWidth - 8, containerWidth * 0.9)
const newSize = Math.max(100, Math.min(desiredSizeBasedOnWidth, 360))
setQrCodeSize(newSize)
}
}
calculateQrSize()
const resizeObserver = new ResizeObserver(calculateQrSize)
if (qrContainerRef.current) {
resizeObserver.observe(qrContainerRef.current)
}
return () => {
if (qrContainerRef.current) {
resizeObserver.unobserve(qrContainerRef.current)
}
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
if (!loginDetails.privKey || !loginDetails.connectionString) return;
setNostrConnectionErrMsg(null)
nostrConnectionLogin(loginDetails.privKey, loginDetails.connectionString)
.then(() => onLoginSuccess())
.catch((err) => {
console.error("NostrConnectionLogin Error:", err)
setNostrConnectionErrMsg(err.message ? `${err.message}. Please reload.` : 'Connection failed. Please reload.')
})
}, [loginDetails, nostrConnectionLogin, onLoginSuccess])
const copyConnectionString = async () => {
if (!loginDetails.connectionString) return
navigator.clipboard.writeText(loginDetails.connectionString)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<>
<div ref={qrContainerRef} className="flex flex-col items-center w-full space-y-3 mb-3">
<a href={loginDetails.connectionString} aria-label="Open with Nostr signer app">
<QRCodeSVG size={qrCodeSize} value={loginDetails.connectionString} marginSize={1} />
</a>
{nostrConnectionErrMsg && (
<div className="text-xs text-destructive text-center pt-1">
{nostrConnectionErrMsg}
</div>
)}
</div>
<div className="flex justify-center w-full mb-3">
<div
className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-2 rounded-full cursor-pointer transition-all hover:bg-muted/80"
style={{
width: qrCodeSize > 0 ? `${Math.max(150, Math.min(qrCodeSize, 320))}px` : 'auto',
}}
onClick={copyConnectionString}
role="button"
tabIndex={0}
>
<div className="flex-grow min-w-0 truncate select-none">
{loginDetails.connectionString}
</div>
<div className="flex-shrink-0">
{copied ? <Check size={14} /> : <Copy size={14} />}
</div>
</div>
</div>
<div className="flex items-center w-full my-4">
<div className="flex-grow border-t border-border/40"></div>
<span className="px-3 text-xs text-muted-foreground">OR</span>
<div className="flex-grow border-t border-border/40"></div>
</div>
<div className="w-full space-y-1">
<div className="flex items-start space-x-2">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin mr-2' : 'hidden'} />
{t('Login')}
</Button>
</div>
{errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>}
</div>
<Button variant="secondary" onClick={back} className="w-full">
{t('Back')}
</Button>
</>
)
}

View File

@@ -7,7 +7,7 @@ import { NstartModal } from 'nstart-modal'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList'
import BunkerLogin from './BunkerLogin'
import NostrConnectLogin from './NostrConnectionLogin'
import GenerateNewAccount from './GenerateNewAccount'
import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
@@ -22,7 +22,7 @@ export default function AccountManager({ close }: { close?: () => void }) {
{page === 'nsec' ? (
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? (
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? (

View File

@@ -82,3 +82,5 @@ export const NIP_96_SERVICE = [
'https://files.sovbit.host'
]
export const DEFAULT_NIP_96_SERVICE = 'https://nostr.build'
export const DEFAULT_NOSTRCONNECT_RELAY = ['wss://relay.nsec.app/'];

View File

@@ -24,6 +24,7 @@ import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer'
import { NpubSigner } from './npub.signer'
import { NsecSigner } from './nsec.signer'
import { NostrConnectionSigner } from './nostrConnection.signer'
type TNostrContext = {
isInitialized: boolean
@@ -45,6 +46,7 @@ type TNostrContext = {
ncryptsecLogin: (ncryptsec: string) => Promise<string>
nip07Login: () => Promise<string>
bunkerLogin: (bunker: string) => Promise<string>
nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string>
npubLogin(npub: string): Promise<string>
removeAccount: (account: TAccountPointer) => void
/**
@@ -404,6 +406,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
}
const nostrConnectionLogin = async (clientSecretKey: Uint8Array, connectionString: string) => {
const bunkerSigner = new NostrConnectionSigner(clientSecretKey, connectionString)
const loginResult = await bunkerSigner.login()
if (!loginResult.pubkey) {
throw new Error('Invalid bunker')
}
return login(bunkerSigner, {
pubkey: loginResult.pubkey,
signerType: 'bunker',
bunker: loginResult.bunkerString!,
bunkerClientSecretKey: bunkerSigner.getClientSecretKey()
})
}
const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
let account = storage.findAccount(act)
if (!account) {
@@ -655,6 +671,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
ncryptsecLogin,
nip07Login,
bunkerLogin,
nostrConnectionLogin,
npubLogin,
removeAccount,
publish,

View File

@@ -0,0 +1,485 @@
import { EventTemplate, finalizeEvent, getPublicKey as calculatePukbey, nip04, NostrEvent, SimplePool, VerifiedEvent, verifyEvent } from "nostr-tools"
import { AbstractSimplePool, SubCloser } from "nostr-tools/abstract-pool"
import { Handlerinformation, NostrConnect } from "nostr-tools/kinds"
import { NIP05_REGEX } from "nostr-tools/nip05"
import { decrypt, encrypt, getConversationKey } from "nostr-tools/nip44"
import { RelayRecord } from "nostr-tools/relay"
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?/w:.=&%-]*)$/
// const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export type BunkerPointer = {
relays: string[]
pubkey: string
secret: null | string
}
export function toBunkerURL(bunkerPointer: BunkerPointer): string {
const bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`)
bunkerPointer.relays.forEach(relay => {
bunkerURL.searchParams.append('relay', relay)
})
if (bunkerPointer.secret) {
bunkerURL.searchParams.set('secret', bunkerPointer.secret)
}
return bunkerURL.toString()
}
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
and returns a BunkerPointer -- or null in case of error */
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
const match = input.match(BUNKER_REGEX)
if (match) {
try {
const pubkey = match[1]
const qs = new URLSearchParams(match[2])
return {
pubkey,
relays: qs.getAll('relay'),
secret: qs.get('secret'),
}
} catch (_err) {
console.log(_err)
/* just move to the next case */
}
}
return queryBunkerProfile(input)
}
export type NostrConnectParams = {
clientPubkey: string;
relays: string[];
secret: string;
perms?: string[];
name?: string;
url?: string;
image?: string;
}
export type ParsedNostrConnectURI = {
protocol: 'nostrconnect';
clientPubkey: string;
params: {
relays: string[];
secret: string;
perms?: string[];
name?: string;
url?: string;
image?: string;
};
originalString: string;
}
export function createNostrConnectURI(params: NostrConnectParams): string {
if (!params.clientPubkey) {
throw new Error('clientPubkey is required.');
}
if (!params.relays || params.relays.length === 0) {
throw new Error('At least one relay is required.');
}
if (!params.secret) {
throw new Error('secret is required.');
}
const queryParams = new URLSearchParams();
params.relays.forEach(relay => {
queryParams.append('relay', relay);
});
queryParams.append('secret', params.secret);
if (params.perms && params.perms.length > 0) {
queryParams.append('perms', params.perms.join(','));
}
if (params.name) {
queryParams.append('name', params.name);
}
if (params.url) {
queryParams.append('url', params.url);
}
if (params.image) {
queryParams.append('image', params.image);
}
return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}`;
}
export function parseNostrConnectURI(uri: string): ParsedNostrConnectURI {
if (!uri.startsWith('nostrconnect://')) {
throw new Error('Invalid nostrconnect URI: Must start with "nostrconnect://".');
}
const [protocolAndPubkey, queryString] = uri.split('?');
if (!protocolAndPubkey || !queryString) {
throw new Error('Invalid nostrconnect URI: Missing query string.');
}
const clientPubkey = protocolAndPubkey.substring('nostrconnect://'.length);
if (!clientPubkey) {
throw new Error('Invalid nostrconnect URI: Missing client-pubkey.');
}
const queryParams = new URLSearchParams(queryString);
const relays = queryParams.getAll('relay');
if (relays.length === 0) {
throw new Error('Invalid nostrconnect URI: Missing "relay" parameter.');
}
const secret = queryParams.get('secret');
if (!secret) {
throw new Error('Invalid nostrconnect URI: Missing "secret" parameter.');
}
const permsString = queryParams.get('perms');
const perms = permsString ? permsString.split(',') : undefined;
const name = queryParams.get('name') || undefined;
const url = queryParams.get('url') || undefined;
const image = queryParams.get('image') || undefined;
return {
protocol: 'nostrconnect',
clientPubkey,
params: {
relays,
secret,
perms,
name,
url,
image,
},
originalString: uri,
};
}
export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
const match = nip05.match(NIP05_REGEX)
if (!match) return null
const [, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await fetch(url, { redirect: 'error' })).json()
const pubkey = res.names[name]
const relays = res.nip46[pubkey] || []
return { pubkey, relays, secret: null }
} catch (_err) {
console.log(_err)
return null
}
}
export type BunkerSignerParams = {
pool?: AbstractSimplePool
onauth?: (url: string) => void
}
export class BunkerSigner {
private pool: AbstractSimplePool
private subCloser: SubCloser | undefined
private isOpen: boolean
private serial: number
private idPrefix: string
private listeners: {
[id: string]: {
resolve: (_: string) => void
reject: (_: string) => void
}
}
private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array
private conversationKey: Uint8Array | undefined
public bp: BunkerPointer | undefined
private parsedConnectionString: ParsedNostrConnectURI
private cachedPubKey: string | undefined
/**
* Creates a new instance of the Nip46 class.
* @param relays - An array of relay addresses.
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair.
*/
public constructor(clientSecretKey: Uint8Array, connectionString: string, params: BunkerSignerParams = {}) {
this.parsedConnectionString = parseNostrConnectURI(connectionString)
this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey
this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0
this.listeners = {}
this.waitingForAuth = {}
}
async connect(maxWait: number = 300_000): Promise<string> {
if (this.isOpen) {
return ''
}
return new Promise((resolve, reject) => {
try {
const connectionSubCloser = this.pool.subscribe(
this.parsedConnectionString.params.relays,
{ kinds: [NostrConnect], '#p': [calculatePukbey(this.secretKey)] },
{
onevent: async (event: NostrEvent) => {
const remoteSignerPubkey = event.pubkey
let decrypted: string
if (event.content.includes('?iv=')) {
decrypted = nip04.decrypt(this.secretKey, remoteSignerPubkey, event.content)
} else {
const tempConvKey = getConversationKey(this.secretKey, remoteSignerPubkey)
decrypted = decrypt(event.content, tempConvKey)
}
const o = JSON.parse(decrypted)
const { result } = o
if (result === this.parsedConnectionString.params.secret) {
this.bp = {
relays: this.parsedConnectionString.params.relays,
pubkey: event.pubkey,
secret: this.parsedConnectionString.params.secret,
}
this.conversationKey = getConversationKey(this.secretKey, event.pubkey)
this.isOpen = true
this.setupSubscription()
connectionSubCloser.close()
const bunkerInput = toBunkerURL(this.bp)
resolve(bunkerInput)
} else {
console.warn('Attack from ', remoteSignerPubkey, 'with secret', decrypted, 'expected', this.parsedConnectionString.params.secret)
return
}
},
onclose: () => {
connectionSubCloser.close()
reject(new Error('Connection closed'))
},
maxWait,
}
)
} catch (error) {
reject(error)
}
})
}
private setupSubscription(params: BunkerSignerParams = {}) {
const listeners = this.listeners
const waitingForAuth = this.waitingForAuth
const convKey = this.conversationKey
this.subCloser = this.pool.subscribe(
this.bp!.relays,
{ kinds: [NostrConnect], authors: [this.bp!.pubkey], '#p': [calculatePukbey(this.secretKey)] },
{
onevent: async (event: NostrEvent) => {
const remoteSignerPubkey = event.pubkey
let decrypted: string
if (event.content.includes('?iv=')) {
decrypted = nip04.decrypt(this.secretKey, remoteSignerPubkey, event.content)
} else {
decrypted = decrypt(event.content, convKey!)
}
const o = JSON.parse(decrypted)
const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id]
if (params.onauth) {
params.onauth(error)
} else {
console.warn(
`nostr-tools/nip46: remote signer ${this.bp!.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
)
}
return
}
const handler = listeners[id]
if (handler) {
if (error) handler.reject(error)
else if (result) handler.resolve(result)
delete listeners[id]
}
},
onclose: () => {
this.subCloser!.close()
},
},
)
this.isOpen = true
}
// closes the subscription -- this object can't be used anymore after this
async close() {
this.isOpen = false
this.subCloser!.close()
}
async sendRequest(method: string, params: string[]): Promise<string> {
return new Promise((resolve, reject) => {
try {
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
if (!this.subCloser) this.setupSubscription()
this.serial++
const id = `${this.idPrefix}-${this.serial}`
const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey!)
// the request event
const verifiedEvent: VerifiedEvent = finalizeEvent(
{
kind: NostrConnect,
tags: [['p', this.bp!.pubkey]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000),
},
this.secretKey,
)
// setup callback listener
this.listeners[id] = { resolve, reject }
this.waitingForAuth[id] = true
// publish the event
Promise.any(this.pool.publish(this.bp!.relays, verifiedEvent))
} catch (err) {
reject(err)
}
})
}
/**
* Calls the "connect" method on the bunker.
* The promise will be rejected if the response is not "pong".
*/
async ping(): Promise<void> {
const resp = await this.sendRequest('ping', [])
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
}
/**
* Calls the "get_public_key" method on the bunker.
* (before we would return the public key hardcoded in the bunker parameters, but
* that is not correct as that may be the bunker pubkey and the actual signer
* pubkey may be different.)
*/
async getPublicKey(): Promise<string> {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', [])
}
return this.cachedPubKey
}
/**
* @deprecated removed from NIP
*/
async getRelays(): Promise<RelayRecord> {
return JSON.parse(await this.sendRequest('get_relays', []))
}
/**
* Signs an event using the remote private key.
* @param event - The event to sign.
* @returns A Promise that resolves to the signed event.
*/
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
const resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
const signed: NostrEvent = JSON.parse(resp)
if (verifyEvent(signed)) {
return signed
} else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
}
}
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
}
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
}
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
}
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
}
}
/**
* Fetches info on available providers that announce themselves using NIP-89 events.
* @returns A promise that resolves to an array of available bunker objects.
*/
export async function fetchBunkerProviders(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
const events = await pool.querySync(relays, {
kinds: [Handlerinformation],
'#k': [NostrConnect.toString()],
})
events.sort((a, b) => b.created_at - a.created_at)
// validate bunkers by checking their NIP-05 and pubkey
// map to a more useful object
const validatedBunkers = await Promise.all(
events.map(async (event, i) => {
try {
const content = JSON.parse(event.content)
// skip duplicates
try {
if (events.findIndex(ev => JSON.parse(ev.content).nip05 === content.nip05) !== i) return undefined
} catch (err) {
console.log(err)
/***/
}
const bp = await queryBunkerProfile(content.nip05)
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
return {
bunkerPointer: bp,
nip05: content.nip05,
domain: content.nip05.split('@')[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false,
}
}
} catch (err) {
console.log(err)
return undefined
}
}),
)
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
}
export type BunkerProfile = {
bunkerPointer: BunkerPointer
domain: string
nip05: string
name: string
picture: string
about: string
website: string
local: boolean
}

View File

@@ -0,0 +1,73 @@
import { ISigner, TDraftEvent } from '@/types'
import { bytesToHex } from '@noble/hashes/utils'
import { BunkerSigner as NBunkerSigner } from './nip46'
export class NostrConnectionSigner implements ISigner {
signer: NBunkerSigner | null = null
private clientSecretKey: Uint8Array
private pubkey: string | null = null
private connectionString: string
private bunkerString: string | null = null
private readonly CONNECTION_TIMEOUT = 300_000 // 300 seconds
constructor(clientSecretKey: Uint8Array, connectionString: string) {
this.clientSecretKey = clientSecretKey
this.connectionString = connectionString
}
async login() {
if (this.pubkey) {
return {
bunkerString: this.bunkerString,
pubkey: this.pubkey
}
}
this.signer = new NBunkerSigner(this.clientSecretKey, this.connectionString, {
onauth: (url) => {
window.open(url, '_blank')
}
})
this.bunkerString = await this.signer.connect(this.CONNECTION_TIMEOUT)
this.pubkey = await this.signer.getPublicKey()
return {
bunkerString: this.bunkerString,
pubkey: this.pubkey
}
}
async getPublicKey() {
if (!this.signer) {
throw new Error('Not logged in')
}
if (!this.pubkey) {
this.pubkey = await this.signer.getPublicKey()
}
return this.pubkey
}
async signEvent(draftEvent: TDraftEvent) {
if (!this.signer) {
throw new Error('Not logged in')
}
return this.signer.signEvent(draftEvent)
}
async nip04Encrypt(pubkey: string, plainText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip04Encrypt(pubkey, plainText)
}
async nip04Decrypt(pubkey: string, cipherText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip04Decrypt(pubkey, cipherText)
}
getClientSecretKey() {
return bytesToHex(this.clientSecretKey)
}
}