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.
 
 
 
 

468 lines
16 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 } 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 = 5
/** Bumps when well-known fetch/parse rules change so stale empty cache entries are not reused. */
const WELL_KNOWN_CACHE_SCHEMA = 3
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 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 v = relays[k]
if (Array.isArray(v)) return v
}
for (const k of Object.keys(relays)) {
if (pubkeyHexFromWellKnownNamesValue(k) === user) {
const v = relays[k]
if (Array.isArray(v)) return v
}
}
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 v = relays[k]
if (Array.isArray(v)) return v
}
}
}
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 }
if (!split || !pubkey || !isValidPubkey(pubkey)) return result
const userHex = normalizeHexPubkey(pubkey)
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 pubkeyStr = typeof pubkey === 'string' ? pubkey.trim() : ''
const pubkeyNorm = isValidPubkey(pubkeyStr) ? normalizeHexPubkey(pubkeyStr) : pubkeyStr
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 isWellKnownNostrJsonDocument(data: unknown): data is Record<string, unknown> {
if (!data || typeof data !== 'object' || Array.isArray(data)) return false
const names = (data as Record<string, unknown>).names
return typeof names === 'object' && names != null && !Array.isArray(names)
}
async function readWellKnownNostrJsonResponse(res: Response): Promise<Record<string, unknown> | null> {
try {
const data: unknown = await res.json()
return isWellKnownNostrJsonDocument(data) ? data : null
} catch {
return null
}
}
async function fetchWellKnownNostrJsonDirect(targetUrl: string): Promise<Record<string, unknown> | null> {
try {
const res = await fetchWithTimeout(targetUrl, {
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) return null
return readWellKnownNostrJsonResponse(res)
} catch {
return null
}
}
async function fetchWellKnownNostrJsonViaProxy(
targetUrl: string,
proxyServer: string
): Promise<Record<string, unknown> | null> {
const fetchUrl = buildViteProxySitesFetchUrl(targetUrl, proxyServer)
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 (!res.redirected) markSitesProxyUnavailableFromHttpStatus(res.status)
return null
}
const json = await readWellKnownNostrJsonResponse(res)
if (json) clearSitesProxyUnavailableThisSession()
return json
} catch {
return null
}
}
/**
* Fetch `/.well-known/nostr.json` in the browser without tripping third-party CORS:
* when `VITE_PROXY_SERVER` is set (production), use same-origin `/sites/?url=…` like OG preview.
*/
async function fetchWellKnownNostrJsonOnce(
domain: string,
nameInQuery: string | undefined
): Promise<Record<string, unknown> | null> {
const targetUrl = getWellKnownNip05Url(domain, nameInQuery)
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
const useProxy = Boolean(proxyServer && !isSitesProxyUnavailableThisSession())
if (useProxy) {
const viaProxy = await fetchWellKnownNostrJsonViaProxy(targetUrl, proxyServer!)
if (viaProxy) return viaProxy
return fetchWellKnownNostrJsonDirect(targetUrl)
}
return fetchWellKnownNostrJsonDirect(targetUrl)
}
/** Uncached network: optional `?name=` then full document. */
async function fetchWellKnownNostrJsonNetwork(
domain: string,
name?: string
): Promise<Record<string, unknown> | null> {
const trimmedName = typeof name === 'string' ? name.trim() : ''
const withQuery =
trimmedName.length > 0 ? await fetchWellKnownNostrJsonOnce(domain, trimmedName) : null
if (withQuery && typeof withQuery.names === 'object' && withQuery.names != null) {
if (resolveNamesEntry(withQuery.names as Record<string, unknown>, trimmedName) != null) {
return withQuery
}
}
const full = await fetchWellKnownNostrJsonOnce(domain, undefined)
if (full && trimmedName && typeof full.names === 'object' && full.names != null) {
if (resolveNamesEntry(full.names as Record<string, unknown>, trimmedName) != null) {
return full
}
}
return withQuery ?? full
}
async function getOrFetchWellKnownJsonForDomain(
domain: string,
nameHint?: 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 = fetchWellKnownNostrJsonNetwork(key, nameHint).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` (with optional `?name=`). Retries without `name` if the entry is missing. */
async function fetchWellKnownNostrJson(domain: string, name?: string): Promise<Record<string, unknown> | null> {
const trimmedName = typeof name === 'string' ? name.trim() : ''
const hint = trimmedName.length > 0 ? trimmedName : undefined
return getOrFetchWellKnownJsonForDomain(domain, hint)
}
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 names = json.names as Record<string, unknown> | undefined
if (!names || typeof names !== 'object') return []
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 []
}