diff --git a/src/components/AccountManager/NostrConnectionLogin.tsx b/src/components/AccountManager/NostrConnectionLogin.tsx new file mode 100644 index 00000000..487be073 --- /dev/null +++ b/src/components/AccountManager/NostrConnectionLogin.tsx @@ -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(null) + const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState(null) + const qrContainerRef = useRef(null) + const [qrCodeSize, setQrCodeSize] = useState(100) + + const handleInputChange = (e: React.ChangeEvent) => { + 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 ( + <> +
+ + + + {nostrConnectionErrMsg && ( +
+ {nostrConnectionErrMsg} +
+ )} +
+
+
0 ? `${Math.max(150, Math.min(qrCodeSize, 320))}px` : 'auto', + }} + onClick={copyConnectionString} + role="button" + tabIndex={0} + > +
+ {loginDetails.connectionString} +
+
+ {copied ? : } +
+
+
+ +
+
+ OR +
+
+ +
+
+ + +
+ {errMsg &&
{errMsg}
} +
+ + + ) +} \ No newline at end of file diff --git a/src/components/AccountManager/index.tsx b/src/components/AccountManager/index.tsx index e0bf7a48..2cfca6fe 100644 --- a/src/components/AccountManager/index.tsx +++ b/src/components/AccountManager/index.tsx @@ -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' ? ( setPage(null)} onLoginSuccess={() => close?.()} /> ) : page === 'bunker' ? ( - setPage(null)} onLoginSuccess={() => close?.()} /> + setPage(null)} onLoginSuccess={() => close?.()} /> ) : page === 'generate' ? ( setPage(null)} onLoginSuccess={() => close?.()} /> ) : page === 'npub' ? ( diff --git a/src/constants.ts b/src/constants.ts index 9e7c530e..99010773 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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/']; \ No newline at end of file diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 760bb0f9..186067f9 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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 nip07Login: () => Promise bunkerLogin: (bunker: string) => Promise + nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise npubLogin(npub: string): Promise 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 => { 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, diff --git a/src/providers/NostrProvider/nip46.ts b/src/providers/NostrProvider/nip46.ts new file mode 100644 index 00000000..61f741d0 --- /dev/null +++ b/src/providers/NostrProvider/nip46.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (!this.cachedPubKey) { + this.cachedPubKey = await this.sendRequest('get_public_key', []) + } + return this.cachedPubKey + } + + /** + * @deprecated removed from NIP + */ + async getRelays(): Promise { + 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 { + 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 { + return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext]) + } + + async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise { + return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext]) + } + + async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise { + return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext]) + } + + async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise { + 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 { + 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 +} diff --git a/src/providers/NostrProvider/nostrConnection.signer.ts b/src/providers/NostrProvider/nostrConnection.signer.ts new file mode 100644 index 00000000..d64a4e93 --- /dev/null +++ b/src/providers/NostrProvider/nostrConnection.signer.ts @@ -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) + } +}