You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

498 lines
17 KiB

import { LRUCache } from 'lru-cache'
import { nip19 } from 'nostr-tools'
import {
clearSitesProxyUnavailableThisSession,
isSitesProxyUnavailableThisSession,
markSitesProxyUnavailableFromHttpStatus
} from '@/lib/optional-proxy-session'
import { buildViteProxySitesFetchUrl } from '@/lib/vite-proxy-url'
import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey, userIdToPubkey } from './pubkey'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger'
type TVerifyNip05Result = {
isVerified: boolean
nip05Name: string
nip05Domain: string
relays?: string[]
}
/** Bumps when verification rules change so LRU does not serve stale false negatives. */
const VERIFY_CACHE_SCHEMA = 7
/** Bumps when well-known fetch/parse rules change so stale empty cache entries are not reused. */
const WELL_KNOWN_CACHE_SCHEMA = 5
type WellKnownCacheEntry = { json: Record<string, unknown> | null; schema: number }
/** Per-domain `nostr.json` (or negative `null`) so feeds do not re-fetch every NIP-05 on the same host. */
const wellKnownJsonByDomain = new LRUCache<string, WellKnownCacheEntry>({ max: 512 })
const wellKnownDomainInFlight = new Map<string, Promise<Record<string, unknown> | null>>()
function normalizeNip05Domain(domain: string): string {
return domain.trim().toLowerCase().replace(/\.$/, '')
}
function asNip05LookupString(value: unknown): string {
if (typeof value === 'string') return value
if (value == null) return ''
if (Array.isArray(value)) {
for (const x of value) {
if (typeof x === 'string' && x.trim()) return x
}
return ''
}
return String(value)
}
/**
* Split `local@domain` on the **first** `@` only (NIP-05 local part must not contain `@`).
* `split('@')` breaks for domains like `user@sub.example.com` → wrong domain.
*/
export function splitNip05Identifier(nip05Str: string): { name: string; domain: string } | null {
const s = nip05Str.trim()
const at = s.indexOf('@')
if (at <= 0 || at >= s.length - 1) return null
const name = s.slice(0, at).trim()
const domain = s.slice(at + 1).trim().replace(/\.$/, '')
if (!name || !domain) return null
return { name, domain: domain.toLowerCase() }
}
/** Normalize a `names` entry: hex (any case) or `npub1…` → lowercase hex, else null. */
function pubkeyHexFromWellKnownNamesValue(v: unknown): string | null {
if (typeof v !== 'string') return null
let t = v.trim()
if (t.startsWith('0x') || t.startsWith('0X')) t = t.slice(2).trim()
if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase()
if (t.startsWith('npub1')) {
try {
const { type, data } = nip19.decode(t)
if (type === 'npub' && typeof data === 'string' && isValidPubkey(data)) return data.toLowerCase()
} catch {
return null
}
}
return null
}
function npubFromHexPubkey(hex: string): string | null {
try {
return nip19.npubEncode(normalizeHexPubkey(hex))
} catch {
return null
}
}
export type TNip05NamePubkeyEntry = { name: string; pubkey: string }
/**
* Parse one `names` row. Supports:
* - standard: `username` → hex or npub
* - inverted: hex or npub key → `username` label
*/
export function parseNip05NamePubkeyEntry(key: string, value: unknown): TNip05NamePubkeyEntry | null {
const hexFromValue = pubkeyHexFromWellKnownNamesValue(value)
if (hexFromValue) {
return { name: key, pubkey: hexFromValue }
}
const hexFromKey = pubkeyHexFromWellKnownNamesValue(key)
if (!hexFromKey) return null
const label = asNip05LookupString(value).trim()
if (!label || pubkeyHexFromWellKnownNamesValue(label)) return null
return { name: label, pubkey: hexFromKey }
}
function resolveNamesEntry(
names: Record<string, unknown>,
nip05Name: string,
userPubkeyHex?: string
): TNip05NamePubkeyEntry | null {
const want = nip05Name.trim()
if (!want) return null
const direct = getNamesEntryRaw(names, want)
if (direct != null) {
const hex = pubkeyHexFromWellKnownNamesValue(direct)
if (hex) return { name: want, pubkey: hex }
}
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && entry.name.toLowerCase() === want.toLowerCase()) return entry
}
if (!userPubkeyHex || !isValidPubkey(userPubkeyHex)) return null
const user = normalizeHexPubkey(userPubkeyHex)
const userNpub = npubFromHexPubkey(user)
if (userNpub) {
for (const [key, v] of Object.entries(names)) {
if (key.toLowerCase() !== userNpub.toLowerCase()) continue
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && hexPubkeysEqual(entry.pubkey, user)) return entry
const hex = pubkeyHexFromWellKnownNamesValue(v)
if (hex && hexPubkeysEqual(hex, user)) return { name: want, pubkey: hex }
}
}
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && hexPubkeysEqual(entry.pubkey, user)) return entry
}
return null
}
function nip05LocalNameForPubkeyFromNames(
names: Record<string, unknown> | undefined,
userPubkeyHex: string
): string | undefined {
if (!names || typeof names !== 'object') return undefined
const user = normalizeHexPubkey(userPubkeyHex)
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && hexPubkeysEqual(entry.pubkey, user)) return entry.name
}
return undefined
}
function getNamesEntryRaw(names: Record<string, unknown>, nip05Name: string): string | undefined {
if (!nip05Name || typeof names !== 'object' || names == null) return undefined
const asTrimmedString = (x: unknown): string | undefined =>
typeof x === 'string' && x.trim() ? x.trim() : undefined
const direct = asTrimmedString(names[nip05Name])
if (direct !== undefined) return direct
const want = nip05Name.toLowerCase()
for (const k of Object.keys(names)) {
if (k.toLowerCase() === want) return asTrimmedString(names[k])
}
return undefined
}
function asRelayUrlList(value: unknown): string[] | undefined {
if (Array.isArray(value)) {
const urls = value.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
return urls.length > 0 ? urls : []
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const urls = Object.values(value as Record<string, unknown>).filter(
(x): x is string => typeof x === 'string' && x.trim().length > 0
)
return urls.length > 0 ? urls : []
}
return undefined
}
function pickRelayListForPubkey(
relays: Record<string, unknown> | undefined,
userPubkeyHex: string,
listedRaw: string,
nip05LocalName: string,
names?: Record<string, unknown>
): unknown {
if (!relays || typeof relays !== 'object') return undefined
const user = normalizeHexPubkey(userPubkeyHex)
const keysToTry = new Set<string>()
keysToTry.add(user)
keysToTry.add(userPubkeyHex.trim())
keysToTry.add(listedRaw.trim())
const userNpub = npubFromHexPubkey(user)
if (userNpub) keysToTry.add(userNpub)
const listedHex = pubkeyHexFromWellKnownNamesValue(listedRaw)
if (listedHex) {
keysToTry.add(listedHex)
const listedNpub = npubFromHexPubkey(listedHex)
if (listedNpub) keysToTry.add(listedNpub)
}
for (const k of keysToTry) {
if (!k) continue
const urls = asRelayUrlList(relays[k])
if (urls !== undefined) return urls
}
for (const k of Object.keys(relays)) {
if (pubkeyHexFromWellKnownNamesValue(k) === user) {
const urls = asRelayUrlList(relays[k])
if (urls !== undefined) return urls
}
}
const local =
nip05LocalName.trim() || nip05LocalNameForPubkeyFromNames(names, userPubkeyHex) || ''
if (local) {
const want = local.toLowerCase()
for (const k of Object.keys(relays)) {
if (k.toLowerCase() === want) {
const urls = asRelayUrlList(relays[k])
if (urls !== undefined) return urls
}
}
}
return undefined
}
const verifyNip05ResultCache = new LRUCache<string, TVerifyNip05Result>({
max: 1000,
fetchMethod: (key) => {
const parsed = JSON.parse(key) as { s?: number; nip05?: unknown; pubkey?: unknown }
const nip05 = asNip05LookupString(parsed.nip05).trim()
const pubkey = typeof parsed.pubkey === 'string' ? parsed.pubkey.trim() : ''
return _verifyNip05(nip05, pubkey)
}
})
async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const nip05Str = asNip05LookupString(nip05).trim()
const split = splitNip05Identifier(nip05Str)
const nip05Name = split?.name ?? ''
const nip05Domain = split?.domain ?? ''
const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain }
const userHex = normalizePubkeyForNip05Lookup(pubkey)
if (!split || !userHex) return result
const json = await fetchWellKnownNostrJson(nip05Domain, nip05Name)
if (!json) return result
const names = json.names as Record<string, unknown> | undefined
if (!names || typeof names !== 'object') return result
const resolved = resolveNamesEntry(names, nip05Name, userHex)
if (!resolved || !hexPubkeysEqual(resolved.pubkey, userHex)) return result
const relays = json.relays as Record<string, unknown> | undefined
const relayList = pickRelayListForPubkey(
relays,
userHex,
resolved.pubkey,
resolved.name,
names
)
return {
...result,
isVerified: true,
relays: Array.isArray(relayList) ? (relayList as string[]) : undefined
}
}
export async function verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const nip05Str = asNip05LookupString(nip05).trim()
const pubkeyNorm = normalizePubkeyForNip05Lookup(pubkey) ?? pubkey.trim()
const cached = await verifyNip05ResultCache.fetch(
JSON.stringify({ s: VERIFY_CACHE_SCHEMA, nip05: nip05Str, pubkey: pubkeyNorm })
)
if (cached) return cached
const split = splitNip05Identifier(nip05Str)
return {
isVerified: false,
nip05Name: split?.name ?? '',
nip05Domain: split?.domain ?? ''
}
}
export function getWellKnownNip05Url(domain: string, name?: string): string {
const url = new URL('/.well-known/nostr.json', `https://${domain}`)
if (name) {
url.searchParams.set('name', name)
}
return url.toString()
}
function normalizePubkeyForNip05Lookup(pubkey: string): string | null {
const hex = userIdToPubkey(pubkey.trim())
return hex && isValidPubkey(hex) ? normalizeHexPubkey(hex) : null
}
function normalizeWellKnownNamesField(raw: unknown): Record<string, unknown> | null {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, unknown>
}
if (!Array.isArray(raw)) return null
const out: Record<string, unknown> = {}
for (const item of raw) {
if (Array.isArray(item) && item.length >= 2) {
out[String(item[0])] = item[1]
continue
}
if (item && typeof item === 'object' && !Array.isArray(item)) {
const rec = item as Record<string, unknown>
if (typeof rec.name === 'string' && rec.pubkey != null) {
out[rec.name] = rec.pubkey
} else if (typeof rec.key === 'string' && rec.value != null) {
out[rec.key] = rec.value
}
}
}
return Object.keys(out).length > 0 ? out : null
}
function normalizeWellKnownDocument(data: unknown): Record<string, unknown> | null {
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
const doc = data as Record<string, unknown>
const names = normalizeWellKnownNamesField(doc.names)
if (!names) return null
return { ...doc, names }
}
function isWellKnownNostrJsonDocument(data: unknown): data is Record<string, unknown> {
return normalizeWellKnownDocument(data) != null
}
async function readWellKnownNostrJsonResponse(res: Response): Promise<Record<string, unknown> | null> {
try {
const data: unknown = await res.json()
return normalizeWellKnownDocument(data)
} catch {
return null
}
}
async function fetchWellKnownNostrJsonFromUrl(
fetchUrl: string,
opts?: { viaProxy?: boolean }
): Promise<Record<string, unknown> | null> {
try {
const res = await fetchWithTimeout(fetchUrl, {
credentials: 'omit',
headers: {
Accept: 'application/nostr+json, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1'
},
timeoutMs: 15_000,
mode: 'cors'
})
if (!res.ok) {
if (opts?.viaProxy && !res.redirected) {
markSitesProxyUnavailableFromHttpStatus(res.status)
}
return null
}
const json = await readWellKnownNostrJsonResponse(res)
if (json && opts?.viaProxy) clearSitesProxyUnavailableThisSession()
return json
} catch {
return null
}
}
async function fetchWellKnownNostrJsonDirect(targetUrl: string): Promise<Record<string, unknown> | null> {
return fetchWellKnownNostrJsonFromUrl(targetUrl)
}
/**
* Fetch `/.well-known/nostr.json` in the browser without tripping third-party CORS:
* direct first (NIP-05 hosts usually allow *), then optional `/sites` proxy, then public CORS proxy in dev.
*/
async function fetchWellKnownNostrJsonOnce(
domain: string,
nameInQuery: string | undefined
): Promise<Record<string, unknown> | null> {
const targetUrl = getWellKnownNip05Url(domain, nameInQuery)
const direct = await fetchWellKnownNostrJsonDirect(targetUrl)
if (direct) return direct
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
if (proxyServer && !isSitesProxyUnavailableThisSession()) {
const viaProxy = await fetchWellKnownNostrJsonFromUrl(
buildViteProxySitesFetchUrl(targetUrl, proxyServer),
{ viaProxy: true }
)
if (viaProxy) return viaProxy
}
if (!import.meta.env.PROD) {
return fetchWellKnownNostrJsonFromUrl(
`https://api.allorigins.win/raw?url=${encodeURIComponent(targetUrl)}`
)
}
return null
}
/** Always fetch the full domain document (never `?name=` — partial responses must not poison domain cache). */
async function fetchWellKnownFullDocument(domain: string): Promise<Record<string, unknown> | null> {
return fetchWellKnownNostrJsonOnce(domain, undefined)
}
async function getOrFetchWellKnownJsonForDomain(
domain: string
): Promise<Record<string, unknown> | null> {
const key = normalizeNip05Domain(domain)
if (!key) return null
if (wellKnownJsonByDomain.has(key)) {
const cached = wellKnownJsonByDomain.get(key)!
if (cached.schema === WELL_KNOWN_CACHE_SCHEMA && cached.json && isWellKnownNostrJsonDocument(cached.json)) {
return cached.json
}
wellKnownJsonByDomain.delete(key)
}
let inflight = wellKnownDomainInFlight.get(key)
if (!inflight) {
inflight = fetchWellKnownFullDocument(key).then((json) => {
wellKnownDomainInFlight.delete(key)
if (isWellKnownNostrJsonDocument(json)) {
wellKnownJsonByDomain.set(key, { json, schema: WELL_KNOWN_CACHE_SCHEMA })
}
return json
})
wellKnownDomainInFlight.set(key, inflight)
}
return inflight
}
/** Fetch `/.well-known/nostr.json` for a domain (full document; `name` is resolved locally). */
async function fetchWellKnownNostrJson(domain: string, _name?: string): Promise<Record<string, unknown> | null> {
return getOrFetchWellKnownJsonForDomain(domain)
}
export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> {
const entries = await fetchNip05NamePubkeysFromDomain(domain)
return entries.map((e) => e.pubkey)
}
export function parseNip05NamePubkeysFromWellKnownJson(
json: Record<string, unknown>
): Array<{ name: string; pubkey: string }> {
const normalized = normalizeWellKnownDocument(json)
if (!normalized) return []
const names = normalized.names as Record<string, unknown>
const out: Array<{ name: string; pubkey: string }> = []
const seen = new Set<string>()
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (!entry || !isValidPubkey(entry.pubkey)) continue
const dedupe = `${entry.name}:${entry.pubkey}`
if (seen.has(dedupe)) continue
seen.add(dedupe)
out.push(entry)
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
export async function fetchNip05NamePubkeysFromDomain(
domain: string
): Promise<Array<{ name: string; pubkey: string }>> {
try {
const json = await fetchWellKnownNostrJson(domain)
if (!json) return []
return parseNip05NamePubkeysFromWellKnownJson(json)
} catch (error) {
logger.error('Error fetching pubkeys from domain', { error, domain })
return []
}
}
/**
* Attempt to get relays from NIP-07 extension
* Some extensions support a getRelays() method
*/
export async function getRelaysFromNip07Extension(): Promise<string[]> {
try {
if (window.nostr && typeof window.nostr.getRelays === 'function') {
const relaysObj = await window.nostr.getRelays()
// getRelays() returns an object like { "wss://relay.url": {read: true, write: true} }
return Object.keys(relaysObj || {})
}
} catch (error) {
logger.warn('NIP-07 extension does not support getRelays()', error as Error)
}
return []
}