diff --git a/packages/app/src/Hooks/useLoginHandler.tsx b/packages/app/src/Hooks/useLoginHandler.tsx index d3a826e9..1908a14c 100644 --- a/packages/app/src/Hooks/useLoginHandler.tsx +++ b/packages/app/src/Hooks/useLoginHandler.tsx @@ -1,4 +1,4 @@ -import { fetchNip05Pubkey, unwrap } from "@snort/shared"; +import { fetchNostrAddress, unwrap } from "@snort/shared"; import { KeyStorage, Nip46Signer } from "@snort/system"; import { useIntl } from "react-intl"; @@ -51,11 +51,38 @@ export default function useLoginHandler() { LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey); } else if (key.match(EmailRegex)) { const [name, domain] = key.split("@"); - const hexKey = await fetchNip05Pubkey(name, domain); - if (!hexKey) { + const json = await fetchNostrAddress(name, domain); + if (!json) { throw new Error("Invalid nostr address"); } - LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey); + const match = Object.keys(json.names).find(n => { + return n.toLowerCase() === name.toLowerCase(); + }); + if (!match) { + throw new Error("Invalid nostr address"); + } + const pubkey = json.names[match]; + + if (json.nip46) { + const bunkerRelays = json.nip46[json.names["_"]]; + const nip46 = new Nip46Signer(`bunker://${pubkey}?relay=${encodeURIComponent(bunkerRelays[0])}`); + nip46.on("oauth", url => { + window.open(url, CONFIG.appNameCapitalized, "width=600,height=800,popup=yes"); + }); + await nip46.init(); + + const loginPubkey = await nip46.getPubKey(); + LoginStore.loginWithPubkey( + loginPubkey, + LoginSessionType.Nip46, + undefined, + nip46.relays, + await pin(unwrap(nip46.privateKey)), + ); + nip46.close(); + } else { + LoginStore.loginWithPubkey(pubkey, LoginSessionType.PublicKey); + } } else if (key.startsWith("bunker://")) { const nip46 = new Nip46Signer(key); await nip46.init(); diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index aa075bc7..37b18466 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -160,10 +160,13 @@ export function bech32ToText(str: string) { return new TextDecoder().decode(Uint8Array.from(buf)); } +export interface NostrJson { + names: Record; + relays?: Record>; + nip46?: Record>; +} + export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2_000): Promise { - interface NostrJson { - names: Record; - } if (!name || !domain) { return undefined; } @@ -182,6 +185,21 @@ export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2 return undefined; } +export async function fetchNostrAddress(name: string, domain: string, timeout = 2_000): Promise { + if (!name || !domain) { + return undefined; + } + try { + const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`, { + signal: AbortSignal.timeout(timeout), + }); + return await res.json() as NostrJson; + } catch { + // ignored + } + return undefined; +} + export function removeUndefined(v: Array) { return v.filter(a => a != undefined).map(a => unwrap(a)); } @@ -208,7 +226,7 @@ export function normalizeReaction(content: string) { } } -export class OfflineError extends Error {} +export class OfflineError extends Error { } export function throwIfOffline() { if (isOffline()) { diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 2d70b7ed..40417eff 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -8,6 +8,7 @@ "module": "ESNext", "strict": true, "declaration": true, + "declarationMap": true, "inlineSourceMap": true, "outDir": "dist", "skipLibCheck": true diff --git a/packages/system/src/impl/nip46.ts b/packages/system/src/impl/nip46.ts index 1134c357..dc77f478 100644 --- a/packages/system/src/impl/nip46.ts +++ b/packages/system/src/impl/nip46.ts @@ -8,6 +8,7 @@ import { EventSigner, PrivateKeySigner } from "../signer"; import { NostrEvent } from "../nostr"; import { EventBuilder } from "../event-builder"; import EventKind from "../event-kind"; +import EventEmitter from "eventemitter3"; const NIP46_KIND = 24_133; @@ -31,11 +32,15 @@ interface Nip46Response { } interface QueueObj { - resolve: (o: any) => void; + resolve: (o: Nip46Response) => void; reject: (e: Error) => void; } -export class Nip46Signer implements EventSigner { +interface Nip46Events { + oauth: (url: string) => void; +} + +export class Nip46Signer extends EventEmitter implements EventSigner { #conn?: Connection; #relay: string; #localPubkey: string; @@ -47,10 +52,16 @@ export class Nip46Signer implements EventSigner { #proto: string; #didInit: boolean = false; + /** + * Start NIP-46 connection + * @param config bunker/nostrconnect://{npub/hex-pubkey}?relay={websocket-url}#{token-hex} + * @param insideSigner + */ constructor(config: string, insideSigner?: EventSigner) { + super(); const u = new URL(config); this.#proto = u.protocol; - this.#localPubkey = u.pathname.substring(2); + this.#localPubkey = u.hostname || u.pathname.substring(2); if (u.hash.length > 1) { this.#token = u.hash.substring(1); @@ -98,16 +109,35 @@ export class Nip46Signer implements EventSigner { "#p": [this.#localPubkey], }, ], - () => {}, + () => { }, ); if (isBunker) { - await this.#connect(unwrap(this.#remotePubkey)); - resolve(); + const rsp = await this.#connect(unwrap(this.#remotePubkey)); + if (rsp.result === "auth_url") { + // re-insert the command into queue for result of oAuth flow + this.#commandQueue.set(rsp.id, { + resolve: async (o: Nip46Response) => { + if (o.result === "ack") { + resolve(); + } else { + reject(o.error); + } + }, + reject, + }); + this.emit("oauth", rsp.error); + } else if (rsp.result === "ack") { + resolve(); + } else { + reject(rsp.error); + } } else { this.#commandQueue.set("connect", { reject, - resolve, + resolve: () => { + resolve(); + }, }); } }); @@ -127,21 +157,24 @@ export class Nip46Signer implements EventSigner { } async describe() { - return await this.#rpc>("describe", []); + const rsp = await this.#rpc("describe", []); + return rsp.result as Array; } async getPubKey() { - return await this.#rpc("get_public_key", []); + const rsp = await this.#rpc("get_public_key", []); + return rsp.result as string; } async nip4Encrypt(content: string, otherKey: string) { - return await this.#rpc("nip04_encrypt", [otherKey, content]); + const rsp = await this.#rpc("nip04_encrypt", [otherKey, content]); + return rsp.result as string; } async nip4Decrypt(content: string, otherKey: string) { - const payload = await this.#rpc("nip04_decrypt", [otherKey, content]); + const rsp = await this.#rpc("nip04_decrypt", [otherKey, content]); try { - return JSON.parse(payload)[0]; + return JSON.parse(rsp.result)[0]; } catch { return ""; } @@ -156,8 +189,8 @@ export class Nip46Signer implements EventSigner { } async sign(ev: NostrEvent) { - const evStr = await this.#rpc("sign_event", [JSON.stringify(ev)]); - return JSON.parse(evStr); + const rsp = await this.#rpc("sign_event", [JSON.stringify(ev)]); + return JSON.parse(rsp.result as string); } async #disconnect() { @@ -169,7 +202,7 @@ export class Nip46Signer implements EventSigner { if (this.#token) { connectParams.push(this.#token); } - return await this.#rpc("connect", connectParams); + return await this.#rpc("connect", connectParams); } async #onReply(e: NostrEvent) { @@ -199,11 +232,11 @@ export class Nip46Signer implements EventSigner { throw new Error("No pending command found"); } - pending.resolve(reply); + pending.resolve(reply as Nip46Response); this.#commandQueue.delete(reply.id); } - async #rpc(method: string, params: Array) { + async #rpc(method: string, params: Array) { if (!this.#didInit) { await this.init(); } @@ -216,10 +249,10 @@ export class Nip46Signer implements EventSigner { } as Nip46Request; this.#sendCommand(payload, unwrap(this.#remotePubkey)); - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { this.#commandQueue.set(payload.id, { resolve: async (o: Nip46Response) => { - resolve(o.result as T); + resolve(o); }, reject, }); diff --git a/packages/system/tsconfig.json b/packages/system/tsconfig.json index 5240a30a..5a30c452 100644 --- a/packages/system/tsconfig.json +++ b/packages/system/tsconfig.json @@ -8,6 +8,7 @@ "module": "ESNext", "strict": true, "declaration": true, + "declarationMap": true, "inlineSourceMap": true, "outDir": "dist", "skipLibCheck": true