feat: connection initiated by the client (#364)
This commit is contained in:
162
src/components/AccountManager/NostrConnectionLogin.tsx
Normal file
162
src/components/AccountManager/NostrConnectionLogin.tsx
Normal file
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { NstartModal } from 'nstart-modal'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import AccountList from '../AccountList'
|
import AccountList from '../AccountList'
|
||||||
import BunkerLogin from './BunkerLogin'
|
import NostrConnectLogin from './NostrConnectionLogin'
|
||||||
import GenerateNewAccount from './GenerateNewAccount'
|
import GenerateNewAccount from './GenerateNewAccount'
|
||||||
import NpubLogin from './NpubLogin'
|
import NpubLogin from './NpubLogin'
|
||||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||||
@@ -22,7 +22,7 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
|||||||
{page === 'nsec' ? (
|
{page === 'nsec' ? (
|
||||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : page === 'bunker' ? (
|
) : page === 'bunker' ? (
|
||||||
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : page === 'generate' ? (
|
) : page === 'generate' ? (
|
||||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : page === 'npub' ? (
|
) : page === 'npub' ? (
|
||||||
|
|||||||
@@ -82,3 +82,5 @@ export const NIP_96_SERVICE = [
|
|||||||
'https://files.sovbit.host'
|
'https://files.sovbit.host'
|
||||||
]
|
]
|
||||||
export const DEFAULT_NIP_96_SERVICE = 'https://nostr.build'
|
export const DEFAULT_NIP_96_SERVICE = 'https://nostr.build'
|
||||||
|
|
||||||
|
export const DEFAULT_NOSTRCONNECT_RELAY = ['wss://relay.nsec.app/'];
|
||||||
@@ -24,6 +24,7 @@ import { BunkerSigner } from './bunker.signer'
|
|||||||
import { Nip07Signer } from './nip-07.signer'
|
import { Nip07Signer } from './nip-07.signer'
|
||||||
import { NpubSigner } from './npub.signer'
|
import { NpubSigner } from './npub.signer'
|
||||||
import { NsecSigner } from './nsec.signer'
|
import { NsecSigner } from './nsec.signer'
|
||||||
|
import { NostrConnectionSigner } from './nostrConnection.signer'
|
||||||
|
|
||||||
type TNostrContext = {
|
type TNostrContext = {
|
||||||
isInitialized: boolean
|
isInitialized: boolean
|
||||||
@@ -45,6 +46,7 @@ type TNostrContext = {
|
|||||||
ncryptsecLogin: (ncryptsec: string) => Promise<string>
|
ncryptsecLogin: (ncryptsec: string) => Promise<string>
|
||||||
nip07Login: () => Promise<string>
|
nip07Login: () => Promise<string>
|
||||||
bunkerLogin: (bunker: string) => Promise<string>
|
bunkerLogin: (bunker: string) => Promise<string>
|
||||||
|
nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string>
|
||||||
npubLogin(npub: string): Promise<string>
|
npubLogin(npub: string): Promise<string>
|
||||||
removeAccount: (account: TAccountPointer) => void
|
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<string | null> => {
|
const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
|
||||||
let account = storage.findAccount(act)
|
let account = storage.findAccount(act)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -655,6 +671,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
ncryptsecLogin,
|
ncryptsecLogin,
|
||||||
nip07Login,
|
nip07Login,
|
||||||
bunkerLogin,
|
bunkerLogin,
|
||||||
|
nostrConnectionLogin,
|
||||||
npubLogin,
|
npubLogin,
|
||||||
removeAccount,
|
removeAccount,
|
||||||
publish,
|
publish,
|
||||||
|
|||||||
485
src/providers/NostrProvider/nip46.ts
Normal file
485
src/providers/NostrProvider/nip46.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
73
src/providers/NostrProvider/nostrConnection.signer.ts
Normal file
73
src/providers/NostrProvider/nostrConnection.signer.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user