From c4643b2d8bf37f045aaee602e09f1f3a7d8bcfab Mon Sep 17 00:00:00 2001 From: hoppe <41467626+tajava2006@users.noreply.github.com> Date: Sun, 21 Sep 2025 22:44:50 +0900 Subject: [PATCH] refactor: replace temporary nip46 file with official nostr-tools (#583) --- package-lock.json | 9 +- package.json | 2 +- .../AccountManager/NostrConnectionLogin.tsx | 2 +- src/providers/NostrProvider/bunker.signer.ts | 2 +- src/providers/NostrProvider/nip46.ts | 485 ------------------ .../NostrProvider/nostrConnection.signer.ts | 7 +- 6 files changed, 11 insertions(+), 496 deletions(-) delete mode 100644 src/providers/NostrProvider/nip46.ts diff --git a/package-lock.json b/package-lock.json index 3ecb4ae..f315617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "lru-cache": "^11.0.2", "lucide-react": "^0.469.0", "next-themes": "^0.4.6", - "nostr-tools": "^2.16.2", + "nostr-tools": "^2.17.0", "nstart-modal": "^2.0.0", "path-to-regexp": "^8.2.0", "qr-code-styling": "^1.9.2", @@ -9875,9 +9875,9 @@ } }, "node_modules/nostr-tools": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.16.2.tgz", - "integrity": "sha512-ZxH9EbSt5ypURZj2TGNJxZd0Omb5ag5KZSu8IyJMCdLyg2KKz+2GA0sP/cSawCQEkyviIN4eRT4G2gB/t9lMRw==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.17.0.tgz", + "integrity": "sha512-lrvHM7cSaGhz7F0YuBvgHMoU2s8/KuThihDoOYk8w5gpVHTy0DeUCAgCN8uLGeuSl5MAWekJr9Dkfo5HClqO9w==", "license": "Unlicense", "dependencies": { "@noble/ciphers": "^0.5.1", @@ -9901,6 +9901,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "license": "MIT", "engines": { "node": ">= 16" }, diff --git a/package.json b/package.json index 0c0f48d..84293f3 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "lru-cache": "^11.0.2", "lucide-react": "^0.469.0", "next-themes": "^0.4.6", - "nostr-tools": "^2.16.2", + "nostr-tools": "^2.17.0", "nstart-modal": "^2.0.0", "path-to-regexp": "^8.2.0", "qr-code-styling": "^1.9.2", diff --git a/src/components/AccountManager/NostrConnectionLogin.tsx b/src/components/AccountManager/NostrConnectionLogin.tsx index c049e96..c81efb0 100644 --- a/src/components/AccountManager/NostrConnectionLogin.tsx +++ b/src/components/AccountManager/NostrConnectionLogin.tsx @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants' import { useNostr } from '@/providers/NostrProvider' -import { createNostrConnectURI, NostrConnectParams } from '@/providers/NostrProvider/nip46' +import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46' import { Check, Copy, Loader } from 'lucide-react' import { generateSecretKey, getPublicKey } from 'nostr-tools' import { useEffect, useLayoutEffect, useRef, useState } from 'react' diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts index f286122..47e09cf 100644 --- a/src/providers/NostrProvider/bunker.signer.ts +++ b/src/providers/NostrProvider/bunker.signer.ts @@ -18,7 +18,7 @@ export class BunkerSigner implements ISigner { throw new Error('Invalid bunker') } - this.signer = new NBunkerSigner(this.clientSecretKey, bunkerPointer, { + this.signer = NBunkerSigner.fromBunker(this.clientSecretKey, bunkerPointer, { onauth: (url) => { window.open(url, '_blank') } diff --git a/src/providers/NostrProvider/nip46.ts b/src/providers/NostrProvider/nip46.ts deleted file mode 100644 index cbcfad7..0000000 --- a/src/providers/NostrProvider/nip46.ts +++ /dev/null @@ -1,485 +0,0 @@ -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 index d64a4e9..3c79de6 100644 --- a/src/providers/NostrProvider/nostrConnection.signer.ts +++ b/src/providers/NostrProvider/nostrConnection.signer.ts @@ -1,6 +1,6 @@ import { ISigner, TDraftEvent } from '@/types' import { bytesToHex } from '@noble/hashes/utils' -import { BunkerSigner as NBunkerSigner } from './nip46' +import { BunkerSigner as NBunkerSigner, toBunkerURL } from 'nostr-tools/nip46' export class NostrConnectionSigner implements ISigner { signer: NBunkerSigner | null = null @@ -8,7 +8,6 @@ export class NostrConnectionSigner implements ISigner { 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 @@ -23,12 +22,12 @@ export class NostrConnectionSigner implements ISigner { } } - this.signer = new NBunkerSigner(this.clientSecretKey, this.connectionString, { + this.signer = await NBunkerSigner.fromURI(this.clientSecretKey, this.connectionString, { onauth: (url) => { window.open(url, '_blank') } }) - this.bunkerString = await this.signer.connect(this.CONNECTION_TIMEOUT) + this.bunkerString = toBunkerURL(this.signer.bp) this.pubkey = await this.signer.getPublicKey() return { bunkerString: this.bunkerString,