6 changed files with 741 additions and 2 deletions
@ -0,0 +1,162 @@
@@ -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> |
||||
</> |
||||
) |
||||
} |
||||
@ -0,0 +1,485 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,73 @@
@@ -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) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue