From d14830a8ffccccede08f20593fb47f49c9fd1441 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 11 Feb 2024 19:14:04 -0300 Subject: [PATCH] nip46 big implementation adapted from ignition. --- nip05.ts | 42 ++------- nip46.ts | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 10 +++ 3 files changed, 257 insertions(+), 38 deletions(-) diff --git a/nip05.ts b/nip05.ts index 6bd1cc5..b2155b5 100644 --- a/nip05.ts +++ b/nip05.ts @@ -38,46 +38,16 @@ export async function queryProfile(fullname: string): Promise typeof relay === 'string') - } - } - } - - return result +export async function isValid(pubkey: string, nip05: string): Promise { + let res = await queryProfile(nip05) + return res ? res.pubkey === pubkey : false } diff --git a/nip46.ts b/nip46.ts index 02c7139..b9e24ac 100644 --- a/nip46.ts +++ b/nip46.ts @@ -1,4 +1,10 @@ +import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts' +import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts' +import { AbstractSimplePool, SubCloser } from './abstract-pool.ts' +import { decrypt, encrypt } from './nip04.ts' import { NIP05_REGEX } from './nip05.ts' +import { SimplePool } from './pool.ts' +import { Handlerinformation, NostrConnect } from './kinds.ts' var _fetch: any @@ -11,8 +17,9 @@ export function useFetchImplementation(fetchImplementation: any) { } export const BUNKER_REGEX = /^bunker:\/\/[0-9a-f]{64}\??[?\/\w:.=&%]*$/ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -type BunkerPointer = { +export type BunkerPointer = { relays: string[] pubkey: string secret: null | string @@ -35,7 +42,11 @@ export async function parseBunkerInput(input: string): Promise { + const match = nip05.match(NIP05_REGEX) if (!match) return null const [_, name = '_', domain] = match @@ -52,3 +63,231 @@ export async function parseBunkerInput(input: string): Promise void + reject: (_: string) => void + } + } + private secretKey: Uint8Array + private connectionSecret: string + public remotePubkey: string + + /** + * 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, bp: BunkerPointer) { + this.pool = new SimplePool() + this.secretKey = clientSecretKey + this.relays = bp.relays + this.remotePubkey = bp.pubkey + this.connectionSecret = bp.secret || '' + this.isOpen = false + this.idPrefix = Math.random().toString(36).substring(7) + this.serial = 0 + this.listeners = {} + + const listeners = this.listeners + + this.subCloser = this.pool.subscribeMany( + this.relays, + [{ kinds: [NostrConnect, 24134], '#p': [getPublicKey(this.secretKey)] }], + { + async onevent(event: NostrEvent) { + const decryptedContent = await decrypt(clientSecretKey, event.pubkey, event.content) + const parsedContent = JSON.parse(decryptedContent) + const { id, result, error } = parsedContent + + let handler = listeners[id] + if (handler) { + if (error) handler.reject(error) + else if (result) handler.resolve(result) + delete listeners[id] + } + }, + }, + ) + 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(async (resolve, reject) => { + try { + if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one') + this.serial++ + const id = `${this.idPrefix}-${this.serial}` + + const encryptedContent = await encrypt( + this.secretKey, + this.remotePubkey, + JSON.stringify({ id, method, params }), + ) + + // the request event + const verifiedEvent: VerifiedEvent = finalizeEvent( + { + kind: method === 'create_account' ? 24134 : NostrConnect, + tags: [['p', this.remotePubkey]], + content: encryptedContent, + created_at: Math.floor(Date.now() / 1000), + }, + this.secretKey, + ) + + // setup callback listener + this.listeners[id] = { resolve, reject } + + // Build auth_url handler + // const authHandler = (response: Response) => { + // if (response.result) { + // this.emit('authChallengeSuccess', response) + // } else { + // this.emit('authChallengeError', response.error) + // } + // } + + // publish the event + await Promise.any(this.pool.publish(this.relays, verifiedEvent)) + } catch (err) { + reject(err) + } + }) + } + + /** + * Sends a ping request to the remote server. + * Requires permission/access rights to bunker. + * @returns "Pong" if successful. The promise will reject if the response is not "pong". + */ + async ping(): Promise { + let resp = await this.sendRequest('ping', []) + if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`) + } + + /** + * Connects to a remote server using the provided keys and remote public key. + * Optionally, a secret can be provided for additional authentication. + * + * @param remotePubkey - Optional the remote public key to connect to. + * @param secret - Optional secret for additional authentication. + * @throws {Error} If no keys are found or no remote public key is found. + * @returns "ack" if successful. The promise will reject if the response is not "ack". + */ + async connect(): Promise { + await this.sendRequest('connect', [getPublicKey(this.secretKey), this.connectionSecret]) + } + + /** + * Signs an event using the remote private key. + * @param event - The event to sign. + * @throws {Error} If no keys are found or no remote public key is found. + * @returns A Promise that resolves to the signed event. + */ + async signEvent(event: UnsignedEvent): Promise { + let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]) + let signed: NostrEvent = JSON.parse(resp) + if (signed.pubkey === getPublicKey(this.secretKey) && verifyEvent(signed)) { + return signed + } else { + throw new Error(`event returned from bunker is improperly signed: ${signed}`) + } + } +} + +/** + * Creates an account with the specified username, domain, and optional email. + * @param bunkerPubkey - The public key of the bunker to use for the create_account call. + * @param username - The username for the account. + * @param domain - The domain for the account. + * @param email - The optional email for the account. + * @throws Error if no keys are found, no remote public key is found, or the email is present but invalid. + * @returns A Promise that resolves to the auth_url that the client should follow to create an account. + */ +export async function createAccount( + bunker: BunkerProfile, + username: string, + domain: string, + email?: string, +): Promise { + if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email') + + let sk = generateSecretKey() + let rpc = new BunkerSigner(sk, bunker.bunkerPointer) + + let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']) + + // once we get the newly created pubkey back, we hijack this signer instance + // and turn it into the main instance for this newly created pubkey + rpc.remotePubkey = pubkey + await rpc.connect() + + return rpc +} + +/** + * Fetches info on available signers (nsecbunkers) using NIP-89 events. + * + * @returns A promise that resolves to an array of available bunker objects. + */ +export async function fetchCustodialbunkers(pool: AbstractSimplePool, relays: string[]): Promise { + const events = await pool.querySync(relays, { kinds: [Handlerinformation] }) + // filter for events that handle the connect event kind + const filteredEvents = events.filter(event => + event.tags.some(tag => tag[0] === 'k' && tag[1] === NostrConnect.toString()), + ) + + // Validate bunkers by checking their NIP-05 and pubkey + // Map to a more useful object + const validatedBunkers = await Promise.all( + filteredEvents.map(async event => { + try { + const content = JSON.parse(event.content) + const bp = await queryBunkerProfile(content.nip05) + if (bp && bp.pubkey === event.pubkey) { + 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) { + 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/package.json b/package.json index 306d762..b690505 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,11 @@ "require": "./lib/cjs/index.js", "types": "./lib/types/index.d.ts" }, + "./core": { + "import": "./lib/esm/core.js", + "require": "./lib/cjs/core.js", + "types": "./lib/types/core.d.ts" + }, "./pure": { "import": "./lib/esm/pure.js", "require": "./lib/cjs/pure.js", @@ -150,6 +155,11 @@ "require": "./lib/cjs/nip44.js", "types": "./lib/types/nip44.d.ts" }, + "./nip46": { + "import": "./lib/esm/nip46.js", + "require": "./lib/cjs/nip46.js", + "types": "./lib/types/nip46.d.ts" + }, "./nip49": { "import": "./lib/esm/nip49.js", "require": "./lib/cjs/nip49.js",