diff --git a/src/auth/did-nostr.js b/src/auth/did-nostr.js index a182e400..3eaef70b 100644 --- a/src/auth/did-nostr.js +++ b/src/auth/did-nostr.js @@ -7,13 +7,36 @@ * 3. Verifying bidirectional link (WebID links back to did:nostr) */ +import { validateExternalUrl } from '../utils/ssrf.js'; +import { extractNostrPubkeysFromProfile } from './nostr-keys.js'; + // Default DID resolver endpoint const DEFAULT_DID_RESOLVER = 'https://nostr.social/.well-known/did/nostr'; -// Cache for resolved DIDs (pubkey -> webId or null) +// Cache for resolved DIDs (pubkey -> { webId, timestamp, failureTtl? }). +// +// Bounded LRU: pubkeys come from external NIP-98 events, so an +// attacker can flood the resolver with unique pubkeys and grow the +// cache without limit if it's an unbounded Map. The Map iteration +// order IS insertion order, so evicting `cache.keys().next().value` +// drops the oldest entry — same pattern as src/auth/cid-doc-fetch.js. +// On every set: re-insert (delete + set) bumps the entry to "newest" +// so the LRU semantics are preserved across cache hits. const cache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const FAILURE_CACHE_TTL = 60 * 1000; // 1 minute for failed lookups +const CACHE_MAX_ENTRIES = 10_000; // bound at ~few MB worst case + +function setCacheEntry(key, entry) { + // Re-insert to mark as MRU (Map preserves insertion order). + if (cache.has(key)) cache.delete(key); + cache.set(key, entry); + while (cache.size > CACHE_MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } +} // Rate-limit repeated error logs (key -> { count, lastLogged }) const errorLogTracker = new Map(); @@ -36,65 +59,207 @@ function rateLimitedError(key, message) { errorLogTracker.set(key, { count: 0, lastLogged: now }); } +// Redirect/SSRF/size limits, mirroring src/auth/cid-doc-fetch.js so +// both the DID-doc resolver and the WebID-backlink verifier apply +// the same hardening: +// - manual redirect handling (5 hops max) +// - SSRF re-validation on EVERY hop (an allowed origin could 30x +// to a private IP / cloud metadata; default fetch redirect would +// bypass the initial validateExternalUrl check) +// - cross-origin redirects refused (open-redirect → arbitrary host) +// - response size cap before reading the body +const MAX_REDIRECTS = 5; +const DEFAULT_FETCH_TIMEOUT_MS = 5000; +const DEFAULT_MAX_BYTES = 1 * 1024 * 1024; // 1 MB — DID docs / WebID profiles are tiny + /** - * Fetch with timeout + * Fetch with a timeout, manual redirect following, SSRF re-validation + * per hop, and a body-size cap. Returns `{ url, status, headers, body }` + * — `body` is a string (caller decides whether to JSON-parse). + * + * Throws on validation, network, redirect, or size failures so the + * resolver can swallow them uniformly into a null/false return. + * + * Exported for tests so the redirect + cross-origin + cap logic can + * be unit-tested directly with a stubbed validator (the production + * validator hard-blocks loopback, which is the only thing a unit + * test can spin up — without injection the redirect tests can't + * tell the SSRF guard from the redirect guard). + * + * @param {object} [opts] + * @param {Function} [opts._validateUrl] - Test seam. Defaults to the + * real `validateExternalUrl`. Production callers MUST NOT override. */ -async function fetchWithTimeout(url, options = {}, timeout = 5000) { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), timeout); - try { - const response = await fetch(url, { ...options, signal: controller.signal }); - clearTimeout(id); - return response; - } catch (err) { - clearTimeout(id); - throw err; +export async function fetchWithRedirectGuard(initialUrl, { + accept, + timeout = DEFAULT_FETCH_TIMEOUT_MS, + maxBytes = DEFAULT_MAX_BYTES, + _validateUrl = validateExternalUrl, +} = {}) { + const originalOrigin = new URL(initialUrl).origin; + let currentUrl = initialUrl; + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const isLastAllowedHop = hop === MAX_REDIRECTS; + const validation = await _validateUrl(currentUrl, { + requireHttps: process.env.NODE_ENV === 'production', + blockPrivateIPs: true, + resolveDNS: true, + }); + if (!validation.valid) { + throw new Error(`SSRF protection: ${validation.error}`); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + let res; + try { + res = await fetch(currentUrl, { + headers: accept ? { Accept: accept } : {}, + redirect: 'manual', + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + if (res.status >= 300 && res.status < 400) { + if (isLastAllowedHop) throw new Error(`too many redirects (>${MAX_REDIRECTS})`); + const loc = res.headers.get('location'); + if (!loc) throw new Error(`redirect ${res.status} without Location`); + const nextUrl = new URL(loc, currentUrl).toString(); + const nextOrigin = new URL(nextUrl).origin; + if (nextOrigin !== originalOrigin) { + throw new Error(`cross-origin redirect refused: ${originalOrigin} → ${nextOrigin}`); + } + currentUrl = nextUrl; + continue; + } + // Cap the body before reading. Content-Length pre-check rejects + // a server that advertises an oversized response; the streaming + // cap rejects servers that lie about Content-Length. + const declared = Number(res.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + throw new Error(`response too large (Content-Length=${declared} > ${maxBytes})`); + } + const reader = res.body?.getReader?.(); + let body = ''; + if (!reader) { + body = await res.text(); + if (Buffer.byteLength(body, 'utf8') > maxBytes) { + throw new Error(`response too large (>${maxBytes} bytes)`); + } + } else { + const chunks = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + try { await reader.cancel(); } catch { /* noop */ } + throw new Error(`response too large (>${maxBytes} bytes)`); + } + chunks.push(value); + } + body = Buffer.concat(chunks).toString('utf8'); + } + return { url: currentUrl, status: res.status, headers: res.headers, body }; } + throw new Error('fetch loop exited unexpectedly'); } /** - * Resolve did:nostr pubkey to WebID via DID document + * Resolve did:nostr pubkey to WebID via DID document. + * + * Local users are resolved by `resolveDidNostrLocally` in the auth + * caller (well-known-did-nostr.js exports an in-process function) — + * this resolver is the cross-pod fallback that fetches an external + * DID doc, so all fetches run through the SSRF guard. + * * @param {string} pubkey - 64-char hex Nostr pubkey - * @param {string} resolverUrl - DID resolver base URL + * @param {string} [resolverUrl] - DID resolver base URL (without the + * trailing `/.json`). Defaults to the configured + * DEFAULT_DID_RESOLVER (nostr.social). * @returns {Promise} WebID URL or null */ export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_RESOLVER) { - if (!pubkey || pubkey.length !== 64) { + // Pubkey is attacker-controlled (it comes off a NIP-98 event) + // and is interpolated into the resolver URL path and cache key. + // Length-only validation isn't enough — characters like `/` or + // `..` would turn this into an arbitrary-path fetch against the + // resolver origin and produce confusing cache entries. Enforce + // the documented shape: 64 lowercase hex chars. + if (typeof pubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkey)) { return null; } - - // Check cache (lazy eviction of expired entries) - const cacheKey = pubkey.toLowerCase(); + pubkey = pubkey.toLowerCase(); + + // Cache key includes the resolver URL because different resolvers + // can legitimately disagree about the same pubkey (one might have + // a DID doc, another not; alsoKnownAs values can differ across + // operator-run resolvers). Keying only on pubkey would let a hit + // from one resolver leak into a query against another, including + // a cached `null` mistakenly suppressing a real result. + const cacheKey = `${resolverUrl}::${pubkey.toLowerCase()}`; const cached = cache.get(cacheKey); if (cached) { const ttl = cached.failureTtl ? FAILURE_CACHE_TTL : CACHE_TTL; if (Date.now() - cached.timestamp < ttl) { + // Re-insert to bump to MRU. Without this, a frequently-hit + // entry could still be evicted as "oldest" once the cache is + // at the cap, defeating the LRU intent. + setCacheEntry(cacheKey, cached); return cached.webId; } cache.delete(cacheKey); } try { - // Fetch DID document + // SSRF guard runs inside fetchWithRedirectGuard on EVERY hop — + // not just the initial URL — so an allowed resolver origin can't + // 30x-redirect to a private IP / cloud metadata endpoint and + // bypass the check. Same policy the LWS-CID verifier applies. const didUrl = `${resolverUrl}/${pubkey}.json`; - const didRes = await fetchWithTimeout(didUrl, { - headers: { 'Accept': 'application/did+json, application/json' } - }); - - if (!didRes.ok) { - cache.set(cacheKey, { webId: null, timestamp: Date.now() }); + // Two failure classes with different cache TTLs: + // - Transient: network error, SSRF/redirect refusal, non-2xx, + // unparseable JSON. These should re-try sooner, so cache + // with `failureTtl: true` (FAILURE_CACHE_TTL = 1 min). + // - "No linkage": successful fetch but the DID doc had no + // alsoKnownAs / profile.webid we could use. That's a valid + // answer, not a transient blip — cache with the regular + // CACHE_TTL (5 min) so we don't hammer the resolver. + let didFetch; + try { + didFetch = await fetchWithRedirectGuard(didUrl, { + accept: 'application/did+json, application/json', + }); + } catch { + setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true }); + return null; + } + if (didFetch.status < 200 || didFetch.status >= 300) { + setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true }); + return null; + } + let didDoc; + try { + didDoc = JSON.parse(didFetch.body); + } catch { + setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true }); return null; } - - const didDoc = await didRes.json(); - // Extract WebID from alsoKnownAs (array) or profile.webid or profile.sameAs let webId = null; if (Array.isArray(didDoc.alsoKnownAs) && didDoc.alsoKnownAs.length > 0) { - // Find first HTTP(S) URL that looks like a WebID + // Accept BOTH http and https here; the SSRF guard on the + // backlink fetch will refuse http in production via + // `requireHttps: NODE_ENV === 'production'`. Filtering to + // https-only at this layer would mean non-production + // resolvers can never validate against http WebIDs (test + // pods, dev fixtures), even when the SSRF layer would have + // permitted them. webId = didDoc.alsoKnownAs.find(aka => - typeof aka === 'string' && aka.startsWith('https://')); + typeof aka === 'string' && /^https?:\/\//.test(aka)); } // Fallback to profile fields @@ -103,81 +268,247 @@ export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_R } if (!webId) { - cache.set(cacheKey, { webId: null, timestamp: Date.now() }); + setCacheEntry(cacheKey, { webId: null, timestamp: Date.now() }); return null; } - // Verify bidirectional link - WebID must link back to did:nostr - const verified = await verifyWebIdBacklink(webId, pubkey); - + // Always verify the WebID actually claims this pubkey. The + // earlier same-origin shortcut was unsafe on multi-tenant + // pods: same-origin doesn't equal same-control. Mallory who + // owns `/.well-known/did/nostr/.json` + // could publish a DID doc with `alsoKnownAs` pointing at + // Alice's WebID on the same host, and "same origin" would + // accept it. The verifier checks the Alice-side profile for + // a verificationMethod that actually claims this pubkey, so + // the binding can't be forged from outside Alice's profile. + let verified; + try { + verified = await verifyWebIdBacklink(webId, pubkey); + } catch (err) { + if (err instanceof TransientBacklinkError) { + // Backlink fetch flapped (network / SSRF / redirect / 5xx). + // Don't pin a 5-minute null — retry sooner. + setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true }); + return null; + } + throw err; + } if (verified) { - cache.set(cacheKey, { webId, timestamp: Date.now() }); + setCacheEntry(cacheKey, { webId, timestamp: Date.now() }); return webId; } - - cache.set(cacheKey, { webId: null, timestamp: Date.now() }); + // Verified absence: the WebID profile responded successfully but + // didn't claim the pubkey. Steady-state answer; cache the full + // CACHE_TTL so we don't hammer the resolver chain. + setCacheEntry(cacheKey, { webId: null, timestamp: Date.now() }); return null; } catch (err) { // Cache failures with short TTL to avoid hammering a down service - cache.set(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true }); + setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true }); rateLimitedError(`did:${pubkey.substring(0, 8)}`, `DID resolution error for ${pubkey}: ${err.message}`); return null; } } +/** Sentinel error class: backlink fetch failed transiently (network, + * SSRF refusal, redirect cap, timeout, etc.). Caller should cache + * with the short failureTtl so a flapping WebID host doesn't pin a + * null answer for the full 5-minute steady-state TTL. */ +class TransientBacklinkError extends Error { + constructor(message) { super(message); this.name = 'TransientBacklinkError'; } +} + /** - * Verify WebID profile links back to did:nostr + * Verify WebID profile links back to did:nostr. + * + * Returns: + * - `true` — linkage found (CID-VM or owl:sameAs) + * - `false` — fetched and parsed, but no linkage (verified + * absence — caller caches with the steady-state TTL) + * - throws `TransientBacklinkError` — fetch/parse failed + * (caller catches and caches with the short failureTtl) + * * @param {string} webId - WebID URL * @param {string} pubkey - Nostr pubkey * @returns {Promise} */ async function verifyWebIdBacklink(webId, pubkey) { + const expectedDid = `did:nostr:${pubkey.toLowerCase()}`; + // The WebID came out of an externally-fetched DID doc, so it's + // untrusted until verified. fetchWithRedirectGuard re-runs the + // SSRF check on every redirect hop and refuses cross-origin + // redirects, so a forged DID doc can't bounce us through an + // open-redirect into a private IP. + let backlinkRes; try { - const expectedDid = `did:nostr:${pubkey.toLowerCase()}`; - - // Fetch WebID profile - const res = await fetchWithTimeout(webId, { - headers: { 'Accept': 'application/ld+json, application/json, text/html' } + backlinkRes = await fetchWithRedirectGuard(webId, { + accept: 'application/ld+json, application/json, text/html', }); + } catch (err) { + // Network/SSRF/redirect/size/timeout — transient. + throw new TransientBacklinkError(`fetch failed: ${err.message}`); + } + // Status classification: + // - 5xx: transient ("try again later") + // - 408 (request timeout) and 429 (too many requests): also + // transient — the host couldn't / wouldn't answer right now, + // not "the linkage is permanently absent" + // - other 4xx (404, 410, etc.): verified absence — the host + // answered authoritatively that this resource doesn't exist + // or is gone, cache as steady-state + // - 3xx (would only reach here as redirect that resolved): + // also verified absence (no redirect-to-content arrived) + if ( + (backlinkRes.status >= 500 && backlinkRes.status < 600) || + backlinkRes.status === 408 || + backlinkRes.status === 429 + ) { + throw new TransientBacklinkError(`HTTP ${backlinkRes.status}`); + } + if (backlinkRes.status < 200 || backlinkRes.status >= 300) { + return false; + } + const contentType = (backlinkRes.headers.get('content-type') || ''); + const text = backlinkRes.body; + + // Two acceptable linkage shapes (either is sufficient): + // 1. CID v1: a verificationMethod containing this Nostr pubkey + // that is referenced from `authentication`. This is what + // JSS profiles ship and what the LWS10-CID resource-side + // verifier checks. Stronger than sameAs because the user + // is asserting the key, not merely an identity equivalence. + // 2. owl:sameAs / schema:sameAs to did:nostr:. Older + // shape; still accepted for compatibility. + // Pass `backlinkRes.url` (the FINAL post-redirect URL) as the + // base for absolutizing relative IDs in the profile. Profiles + // with a relative subject (`"@id": "#me"`) and absolute VM IDs + // can't otherwise be absolutized correctly by checkCidVmBacklink. + const checkProfile = (jsonLd) => + checkCidVmBacklink(jsonLd, pubkey, backlinkRes.url) || + checkSameAsLink(jsonLd, expectedDid); + + // Handle HTML with JSON-LD data island + if (contentType.includes('text/html')) { + const jsonLdMatch = text.match(/([\s\S]*?)<\/script>/i); + if (jsonLdMatch) { + try { + return checkProfile(JSON.parse(jsonLdMatch[1])); + } catch { + // Parsed bytes but the JSON-LD island was malformed — + // verified absence (the host responded; the linkage is + // genuinely not there in a usable form). + return false; + } + } + return false; + } - if (!res.ok) { + // Handle JSON-LD directly + if (contentType.includes('json')) { + try { + return checkProfile(JSON.parse(text)); + } catch { return false; } + } - const contentType = res.headers.get('content-type') || ''; - const text = await res.text(); + return false; +} - // Handle HTML with JSON-LD data island - if (contentType.includes('text/html')) { - const jsonLdMatch = text.match(/([\s\S]*?)<\/script>/i); - if (jsonLdMatch) { - try { - const jsonLd = JSON.parse(jsonLdMatch[1]); - return checkSameAsLink(jsonLd, expectedDid); - } catch { - return false; - } - } - return false; +/** + * Does the WebID profile contain a CID v1 `verificationMethod` for + * the given Nostr pubkey, referenced from `authentication`, with a + * `controller` consistent with the profile's expected controller set? + * + * Mirrors the resource-side verifier's three checks: + * (1) profile contains a Nostr-shaped VM matching `pubkey` (with + * BIP-340 even-y validation handled inside + * extractNostrPubkeysFromProfile) + * (2) the VM is referenced from `authentication` — a key in + * `verificationMethod` alone is NOT an auth binding + * (3) the VM's `controller` is in the profile's expected + * controller set (the profile-level `controller` field, or + * the profile subject as the CID-v1 self-control fallback) + * + * @param {object} jsonLd - parsed WebID profile + * @param {string} pubkey - target Nostr x-only pubkey hex + * @param {string} [docUrl] - URL the profile was fetched from. Used + * as the base for absolutizing relative IDs when the profile's + * subject `@id` is itself relative (e.g. `"@id": "#me"`). Without + * this fallback, mixed-shape profiles (relative subject + absolute + * VM IDs) would absolutize against an empty base and the + * authentication-membership check would silently fail. + */ +function checkCidVmBacklink(jsonLd, pubkey, docUrl) { + const target = pubkey.toLowerCase(); + const vms = extractNostrPubkeysFromProfile(jsonLd); + if (vms.length === 0) return false; + + // Compute the URL base used for absolutizing relative IDs. + // Preference order: + // 1. profile['@id']/id when it's an absolute URL + // 2. the document URL the profile was fetched from + // 3. empty string (last resort — falls back to raw IDs) + const subjectRaw = jsonLd?.['@id'] || jsonLd?.id || ''; + const stripHash = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return ''; } }; + let base = stripHash(subjectRaw); + if (!base && docUrl) base = stripHash(docUrl); + // The absolute subject — used for the CID-v1 self-control + // fallback (controller defaults to the profile subject if no + // explicit controller is declared). + const profileSubject = base ? (() => { + try { const u = new URL(subjectRaw, base); return u.toString(); } + catch { return base; } + })() : ''; + + const absolutize = (s) => { + try { return new URL(s, base).toString(); } + catch { return s; } + }; + const collectIds = (val) => { + const out = []; + const list = Array.isArray(val) ? val : (val ? [val] : []); + for (const ent of list) { + let id; + if (typeof ent === 'string') id = ent; + else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id; + if (id) out.push(absolutize(id)); } + return out; + }; + + // Authentication-referenced IDs. + const authIds = new Set(collectIds(jsonLd?.authentication)); + + // Expected controller set. Match the resource-side verifier: + // declared controllers if any, otherwise fall back to the + // profile subject (CID v1 self-control). + const expectedControllers = new Set(collectIds(jsonLd?.controller)); + if (expectedControllers.size === 0 && profileSubject) { + expectedControllers.add(profileSubject); + } - // Handle JSON-LD directly - if (contentType.includes('json')) { - try { - const jsonLd = JSON.parse(text); - return checkSameAsLink(jsonLd, expectedDid); - } catch { - return false; - } + for (const { pubkey: vmPubkey, vm } of vms) { + if (vmPubkey !== target) continue; + const vmIdRaw = vm.id || vm['@id']; + if (typeof vmIdRaw !== 'string') continue; + const vmId = absolutize(vmIdRaw); + if (!authIds.has(vmId)) continue; + // Check (3): VM MUST declare an explicit `controller`, AND + // that controller must be in expectedControllers. Match the + // resource-side verifier (src/auth/nostr.js + the well-known + // indexer) — neither falls back to "origin match means + // controller match" for a controller-less VM. A VM with no + // controller is ambiguous and should not authenticate; if + // the user wanted self-control, they can declare it. + const vmCtrls = collectIds(vm.controller); + if (vmCtrls.length === 0) continue; + for (const c of vmCtrls) { + if (expectedControllers.has(c)) return true; } - - return false; - - } catch (err) { - rateLimitedError(`backlink:${webId}`, `WebID backlink verification error for ${webId}: ${err.message}`); - return false; } + return false; } /** @@ -230,3 +561,16 @@ function checkSameAsLink(jsonLd, expectedDid) { export function clearCache() { cache.clear(); } + +/** @internal — exposed for tests; current cache size after evictions. */ +export function _cacheSizeForTests() { + return cache.size; +} + +/** @internal — exposed for tests; thin wrapper over checkCidVmBacklink. */ +export function _checkCidVmBacklinkForTests(profile, pubkey, docUrl) { + return checkCidVmBacklink(profile, pubkey, docUrl); +} + +/** @internal — exposed for tests; LRU max for assertions. */ +export const _CACHE_MAX_FOR_TESTS = CACHE_MAX_ENTRIES; diff --git a/src/auth/nostr-keys.js b/src/auth/nostr-keys.js new file mode 100644 index 00000000..b902794d --- /dev/null +++ b/src/auth/nostr-keys.js @@ -0,0 +1,112 @@ +/** + * Shared Nostr-key encoding helpers. + * + * Lives in its own module so both the NIP-98 verifier + * (`src/auth/nostr.js`) and the well-known DID-doc publisher + * (`src/idp/well-known-did-nostr.js`) can use it without forming a + * circular import. + */ + +import { secp256k1 } from '@noble/curves/secp256k1'; + +/** Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex. */ +const MULTICODEC_SECP256K1_PUB_HEX = 'e701'; + +/** + * Validate a secp256k1 JWK as a Nostr key and return its x-only + * pubkey hex. Returns `null` if the JWK isn't a Nostr-shaped key + * or its `y` doesn't match the BIP-340 canonical (even-y) point + * for the declared `x`. + * + * Why y matters: every secp256k1 x has TWO valid points (positive + * and negative y). Nostr uses x-only pubkeys, which by BIP-340 + * convention always pick the even-y point. A profile that declares + * a JWK with the right x but the wrong y is NOT the user's Nostr + * key — accepting it would let an attacker plant a JWK at someone + * else's WebID and have the indexer publish it as theirs. + * + * The verifier in src/auth/nostr.js (jwkMatchesNostrPubkey) does + * the same check. Keeping the indexer in sync prevents the + * "indexed but verifier rejects" inconsistency that would surface + * as a 401 on a key the well-known endpoint had advertised. + */ +export function pubkeyFromValidatedJwk(jwk) { + if (!jwk || typeof jwk !== 'object') return null; + if (jwk.kty !== 'EC') return null; + if (jwk.crv !== 'secp256k1' && jwk.crv !== 'P-256K') return null; + if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return null; + let xHex; + try { + xHex = Buffer.from(jwk.x.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + .toString('hex').toLowerCase(); + } catch { return null; } + if (!/^[0-9a-f]{64}$/.test(xHex)) return null; + let canonicalY; + try { + // Compressed SEC1 encoding for the EVEN-y point at this x. + const point = secp256k1.ProjectivePoint.fromHex('02' + xHex); + canonicalY = point.toAffine().y.toString(16).padStart(64, '0'); + } catch { return null; } + let jwkYHex; + try { + jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + .toString('hex').toLowerCase(); + } catch { return null; } + if (jwkYHex !== canonicalY) return null; + return xHex; +} + +/** + * Decode an f-form Multikey for secp256k1-pub back into the 32-byte + * x-only pubkey hex. Returns null if the input isn't this shape. + * + * The f-form recipe (per CCG community#254 / did:nostr): multibase + * `f` (base16-lower) + multicodec `e701` + parity byte (`02`/`03`) + * + 32-byte xonly pubkey. + */ +export function decodeFFormSecp256k1(mb) { + if (typeof mb !== 'string' || !mb.startsWith('f')) return null; + const hex = mb.slice(1).toLowerCase(); + if (!/^[0-9a-f]+$/.test(hex)) return null; + if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null; + const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length); + // Expect parity byte (02/03) + 32-byte xonly = 66 hex chars. + if (rest.length !== 66) return null; + const parity = rest.slice(0, 2); + if (parity !== '02' && parity !== '03') return null; + return rest.slice(2); +} + +/** + * Enumerate every Nostr pubkey declared in a profile's + * `verificationMethod` entries. Matches both encodings: + * - f-form Multikey (`publicKeyMultibase`) + * - JsonWebKey (`kty: EC, crv: secp256k1`) — derives x as the pubkey + * + * Returns `[ { pubkey, vm } ]` — the VM is returned alongside so + * callers can do further checks (`controller`, `authentication` + * membership, etc.) without re-parsing. + */ +export function extractNostrPubkeysFromProfile(profile) { + if (!profile || typeof profile !== 'object') return []; + const out = []; + const raw = profile.verificationMethod; + const vms = raw === undefined || raw === null ? [] + : Array.isArray(raw) ? raw : [raw]; + for (const vm of vms) { + if (!vm || typeof vm !== 'object') continue; + if (typeof vm.publicKeyMultibase === 'string') { + const xonly = decodeFFormSecp256k1(vm.publicKeyMultibase); + if (xonly) out.push({ pubkey: xonly, vm }); + } else if (vm.publicKeyJwk && typeof vm.publicKeyJwk === 'object') { + // Require y to match the BIP-340 canonical point — the same + // check the NIP-98 verifier applies. Without this, the indexer + // could publish a JWK that the verifier will then reject, + // surfacing as a 401 on a key the well-known endpoint had + // advertised as authentic. + const xonly = pubkeyFromValidatedJwk(vm.publicKeyJwk); + if (xonly) out.push({ pubkey: xonly, vm }); + } + } + return out; +} diff --git a/src/auth/nostr.js b/src/auth/nostr.js index c5460f13..3769999d 100644 --- a/src/auth/nostr.js +++ b/src/auth/nostr.js @@ -14,10 +14,14 @@ * Match by f-form Multikey or by JsonWebKey x/y coordinates. If * found, authenticate as the WebID. (#399 — pairs with the * LWS10-CID verifier.) - * 2. Resolve via the existing did:nostr DID-document path + * 2. (IdP-only) Look up the pubkey in the local in-process index + * built from `/.idp/accounts/_webid_index.json`. + * No HTTP, no SSRF surface — direct function call. Catches + * same-pod users without a third-party round-trip. (#407) + * 3. Resolve via the external did:nostr DID-document path * (nostr.social `.well-known` + bidirectional alsoKnownAs). - * If found, authenticate as the WebID it points to. - * 3. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the + * Used for cross-pod identities; SSRF + redirect hardened. + * 4. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the * agent identity (the original behavior). */ @@ -25,8 +29,14 @@ import { verifyEvent, getEventHash } from '../nostr/event.js'; import { secp256k1 } from '@noble/curves/secp256k1'; import crypto from 'crypto'; import { resolveDidNostrToWebId } from './did-nostr.js'; +// resolveDidNostrLocally is loaded lazily (inside the idpEnabled +// branch) so non-IdP deployments don't pay the IdP/accounts module +// startup cost (bcryptjs, oidc-provider helpers, etc.) just by +// importing the NIP-98 verifier. import { fetchCidDocument } from './cid-doc-fetch.js'; import { normalizeControllers } from './lws-cid.js'; // shared JSON-LD controller helper +import { decodeFFormSecp256k1, extractNostrPubkeysFromProfile } from './nostr-keys.js'; // re-exported for back-compat +export { extractNostrPubkeysFromProfile }; // NIP-98 event kind (references RFC 7235) const HTTP_AUTH_KIND = 27235; @@ -34,11 +44,6 @@ const HTTP_AUTH_KIND = 27235; // Timestamp tolerance in seconds const TIMESTAMP_TOLERANCE = 60; -// Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex. -// Used to decode f-form Multikey verificationMethod values back into -// the 32-byte x-only Nostr pubkey. -const MULTICODEC_SECP256K1_PUB_HEX = 'e701'; - // Profile-fetch body-size cap. Matches the LWS-CID verifier; both // callers go through the shared fetchCidDocument helper. const MAX_PROFILE_BYTES = 256 * 1024; @@ -282,9 +287,30 @@ export async function verifyNostrAuth(request) { return { webId: vmWebId, error: null }; } - // Second lookup: existing did:nostr DID-document resolver. Fetches - // an external DID doc (e.g. nostr.social/.well-known/...) and checks - // bidirectional alsoKnownAs ↔ WebID linking. + // Second lookup: in-process local DID resolution (#407). Fast path + // — direct function call into the local account index, no HTTP + // fetch, no SSRF surface from request-controlled headers. Catches + // any user who's published a Nostr Multikey VM into their profile + // on this same pod. + // + // Gated on idpEnabled because the index reads from + // /.idp/accounts which only exists when the IdP layer + // is in use. On non-IdP deployments the local resolver has nothing + // to find and would just spin disk on every request. + if (request.idpEnabled) { + // Dynamic import: only load the IdP-accounts stack when IdP is + // actually enabled. Cached after first load (ESM module caching). + const { resolveDidNostrLocally } = await import('../idp/well-known-did-nostr.js'); + const localWebId = await resolveDidNostrLocally(event.pubkey); + if (localWebId) { + return { webId: localWebId, error: null }; + } + } + + // Third lookup: external did:nostr DID-document resolver. Fetches + // a DID doc from the configured external resolver (nostr.social) and + // checks bidirectional alsoKnownAs ↔ WebID linking. Used only for + // cross-pod identities (the local case is handled above). const resolvedWebId = await resolveDidNostrToWebId(event.pubkey); if (resolvedWebId) { return { webId: resolvedWebId, error: null }; @@ -599,23 +625,6 @@ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) { return null; } -/** - * Decode an f-form Multikey for secp256k1-pub back into the 32-byte - * x-only pubkey hex. Returns null if the input isn't this shape. - */ -function decodeFFormSecp256k1(mb) { - if (typeof mb !== 'string' || !mb.startsWith('f')) return null; - const hex = mb.slice(1).toLowerCase(); - if (!/^[0-9a-f]+$/.test(hex)) return null; - if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null; - const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length); - // Expect parity byte (02/03) + 32-byte xonly = 66 hex chars. - if (rest.length !== 66) return null; - const parity = rest.slice(0, 2); - if (parity !== '02' && parity !== '03') return null; - return rest.slice(2); -} - function hexToBase64url(hex) { return Buffer.from(hex, 'hex').toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); diff --git a/src/idp/well-known-did-nostr.js b/src/idp/well-known-did-nostr.js new file mode 100644 index 00000000..9d235669 --- /dev/null +++ b/src/idp/well-known-did-nostr.js @@ -0,0 +1,458 @@ +/** + * did:nostr HTTP resolution endpoint. + * + * Implements the well-known path from the did:nostr spec: + * + * GET /.well-known/did/nostr/.json + * GET /.well-known/did/nostr/.jsonld + * GET /.well-known/did/nostr/ + * + * For any local account whose WebID profile declares this Nostr pubkey + * as a CID `verificationMethod` referenced from `authentication`, JSS + * generates a DID document on the fly with `alsoKnownAs: []`. + * Other resolvers (nostr.social, nostr.rocks, JSS's own + * `src/auth/did-nostr.js`) can then fetch the DID doc from this pod + * and follow the WebID linkage — making the pod its own + * authoritative DID resolver for its accounts. + * + * Closes the "type your username" UX hack on the IdP login page + * (#403 / #405): the existing did-nostr resolver finds local users + * via this endpoint without any user-typed hint. + */ + +import path from 'path'; +import fs from 'fs-extra'; +import { findById } from './accounts.js'; +import { extractNostrPubkeysFromProfile } from '../auth/nostr-keys.js'; + +// In-memory pubkey → resolved-account-record index. Built lazily +// from disk; rebuilt when the TTL expires. Real production wants +// a write-path hook on LDP PUT/PATCH so updates are immediate; +// that's filed as a follow-up. +// +// Each entry stores `{ accountId, webId, mtimeMs }` so the hot +// path (NIP-98 auth via resolveDidNostrLocally + every DID-doc +// request) can answer without re-reading the account JSON from +// disk. accountId is kept for log diagnostics; webId is what +// the resolver actually needs. +let pubkeyIndex = null; // Map +let indexBuiltAt = 0; +let rebuildInFlight = null; // Promise — in-flight rebuild dedup +const INDEX_TTL_MS = 5 * 60 * 1000; + +// Rate-limit "profile unreadable" log spam. A single broken profile +// shouldn't flood logs every 5 minutes (every TTL rebuild) — but the +// first occurrence per rebuild cycle MUST be logged so operators can +// debug "why isn't my pubkey publishing?" without grepping silence. +const PROFILE_LOG_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +const profileLogTracker = new Map(); // accountId -> last logged ms +function logProfileFailure(accountId, profilePath, err) { + const now = Date.now(); + const last = profileLogTracker.get(accountId) || 0; + if (now - last < PROFILE_LOG_INTERVAL_MS) return; + profileLogTracker.set(accountId, now); + // Trim the tracker so it can't grow without bound. + if (profileLogTracker.size > 10_000) { + const oldest = profileLogTracker.keys().next().value; + if (oldest !== undefined) profileLogTracker.delete(oldest); + } + console.error( + `well-known-did-nostr: skipping account ${accountId} ` + + `(profile=${profilePath}): ${err.code || err.name || 'error'} ${err.message}`, + ); +} +// Size cap on per-account profile reads. WebID profiles are tiny — +// 64 KB is generous and matches the bound the LDP layer would impose +// for any sane profile. A user shouldn't be able to make the indexer +// allocate megabytes by writing a giant profile, especially since +// rebuilds can be triggered by attacker-driven NIP-98 traffic once +// the TTL expires. +const MAX_PROFILE_BYTES = 64 * 1024; + +/** @internal — exposed for tests */ +export function _resetIndexForTests() { + pubkeyIndex = null; + indexBuiltAt = 0; + rebuildInFlight = null; + profileLogTracker.clear(); +} + +// Match the layout in src/idp/accounts.js — accounts live under +// /.idp/accounts. Computed lazily so DATA_ROOT changes +// (test setup, env override) are picked up. +function getAccountsDir() { + const dataRoot = process.env.DATA_ROOT || './data'; + return path.join(dataRoot, '.idp', 'accounts'); +} +function getWebIdIndexPath() { + return path.join(getAccountsDir(), '_webid_index.json'); +} + +/** + * Read a JSON file. Returns null in two cases (with different + * semantics, kept the same return shape for caller simplicity): + * + * - ENOENT — silently null. The index file legitimately doesn't + * exist on a fresh deployment with no accounts yet. + * - Any other error (parse error, permission denied, etc.) — null + * PLUS a loud console.error so operational issues surface in logs + * instead of silently disabling DID-doc publishing. + */ +async function readJsonOrEmpty(file) { + try { + return await fs.readJson(file); + } catch (err) { + if (err.code === 'ENOENT') return null; + console.error(`well-known-did-nostr: failed to read ${file}: ${err.message}`); + return null; + } +} + +async function rebuildPubkeyIndex() { + const idx = new Map(); + const dataRoot = process.env.DATA_ROOT || './data'; + const webIdIndex = await readJsonOrEmpty(getWebIdIndexPath()); + if (!webIdIndex) { + pubkeyIndex = idx; + indexBuiltAt = Date.now(); + return; + } + // Track pubkeys that appear under more than one account so we can + // EXCLUDE them rather than silently picking one. An ambiguous binding + // would make resolution depend on insertion order and be hard to + // diagnose; better to refuse and log loudly. + const seenAccounts = new Map(); // pubkey -> Set + for (const [, accountId] of Object.entries(webIdIndex)) { + // Wrap each account read so one corrupt/unreadable account file + // can't take down resolution for everyone — the index would just + // skip that account and a single 500 wouldn't cascade across the + // whole pod's NIP-98 traffic. + let account; + try { + account = await findById(accountId); + } catch (err) { + console.error( + `well-known-did-nostr: skipping account ${accountId} ` + + `(read failed: ${err.message})`, + ); + continue; + } + if (!account?.webId) continue; + const profilePath = profilePathFromWebId(dataRoot, account.webId, accountId); + if (!profilePath) continue; + let profile; + let mtimeMs = 0; + try { + const stat = await fs.stat(profilePath); + mtimeMs = stat.mtimeMs; + // Size cap to bound per-rebuild memory/CPU. A user can write + // their own profile, and TTL-expired rebuilds can be triggered + // by attacker-driven NIP-98 traffic — without this an + // adversarially-large profile could pin the event loop on + // JSON.parse during the rebuild loop. + if (stat.size > MAX_PROFILE_BYTES) { + console.error( + `well-known-did-nostr: skipping account ${accountId} ` + + `— profile size ${stat.size} > ${MAX_PROFILE_BYTES} bytes`, + ); + continue; + } + const text = await fs.readFile(profilePath, 'utf8'); + profile = JSON.parse(text); + } catch (err) { + // Log so operators can debug "why isn't my pubkey publishing?". + // Rate-limited per account so a single perpetually-broken + // profile can't flood logs every TTL cycle. + logProfileFailure(accountId, profilePath, err); + continue; // unreadable / malformed — skip + } + // CID semantics — match the resource-side checks: + // (1) profile's @id MUST match the account's webId (no fragment- + // swapping attack via a stored profile that claims to be + // someone else) + // (2) VM's controller MUST be in the profile's expected controller + // set (declared `controller`, with @id fallback) + // (3) VM MUST be referenced from `authentication` — a key in + // verificationMethod alone (no auth membership) shouldn't be + // published as authentic + const profileSubject = absolutize(profile?.['@id'] || profile?.id, stripHashIfAny(account.webId)); + if (!profileSubject || profileSubject !== account.webId) continue; + const expectedControllers = collectControllerIds(profile, profileSubject); + if (expectedControllers.size === 0) continue; + // Pass the already-validated absolute subject as the base. Without + // this, profiles with a relative subject (e.g. `"@id": "#me"`) + // would absolutize their `authentication` entries against an + // unusable base, leaving the IDs relative — and then the + // `authIds.has(vmId)` check below would never match even when the + // VM is actually authenticated. + const authIds = collectAuthenticationIds(profile, stripHashIfAny(profileSubject)); + + for (const { pubkey, vm } of extractNostrPubkeysFromProfile(profile)) { + const vmId = absolutize(vm.id || vm['@id'], stripHashIfAny(profileSubject)); + if (!vmId || !authIds.has(vmId)) continue; + const vmCtrls = collectControllerIds({ controller: vm.controller }, profileSubject); + let controllerOk = false; + for (const c of vmCtrls) { + if (expectedControllers.has(c)) { controllerOk = true; break; } + } + if (!controllerOk) continue; + + // Duplicate-pubkey detection: track every account that claims + // it; resolve at the end of the scan. + if (!seenAccounts.has(pubkey)) seenAccounts.set(pubkey, new Set()); + seenAccounts.get(pubkey).add(accountId); + // Cache the resolved webId in the index so the lookup hot + // path doesn't have to re-read the account JSON. + if (!idx.has(pubkey)) idx.set(pubkey, { accountId, webId: account.webId, mtimeMs }); + } + } + // Drop ambiguous pubkeys and warn loudly. + for (const [pubkey, accountIds] of seenAccounts) { + if (accountIds.size > 1) { + console.error( + `well-known-did-nostr: pubkey ${pubkey} claimed by ` + + `${accountIds.size} accounts (${[...accountIds].join(', ')}) — ` + + `omitting from index to avoid ambiguous resolution`, + ); + idx.delete(pubkey); + } + } + pubkeyIndex = idx; + indexBuiltAt = Date.now(); +} + +/** + * Derive the on-disk profile path from a WebID (and validate + * containment in DATA_ROOT). Returns the absolute filesystem path + * or `null` if the WebID is unparseable / would escape dataRoot. + * + * Why a separate function: WHATWG URL parsing already strips most + * `..` traversal at the URL layer, but the path-resolve containment + * check is defense-in-depth for any future caller that bypasses + * URL parsing (string manipulation, alternate parser, etc.). Lives + * in its own function so the containment branch is unit-testable + * with raw inputs that DON'T go through `new URL()`. + * + * @internal exported for tests + */ +export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') { + if (typeof webId !== 'string') return null; + let pathname; + try { + pathname = new URL(webId).pathname; + } catch { + return null; + } + // Strip leading `/` so it's treated as a relative segment, then + // resolve and assert the result is at-or-under dataRootAbs. An + // account record whose webId path resolves outside dataRoot is + // never indexed. + const relPath = pathname.replace(/^\/+/, ''); + const dataRootAbs = path.resolve(dataRoot); + const resolved = path.resolve(dataRootAbs, relPath); + if (resolved !== dataRootAbs && !resolved.startsWith(dataRootAbs + path.sep)) { + console.error( + `well-known-did-nostr: account ${accountId} webId ${webId} ` + + `resolves outside dataRoot (${resolved}) — skipping`, + ); + return null; + } + return resolved; +} + +function collectControllerIds(source, baseUrl) { + const out = new Set(); + const c = source?.controller; + const list = Array.isArray(c) ? c : (c ? [c] : []); + for (const ent of list) { + let id; + if (typeof ent === 'string') id = ent; + else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id; + if (id) out.add(absolutize(id, baseUrl)); + } + // Fallback to @id when no explicit controller (CID v1 self-control). + if (out.size === 0 && source && (source['@id'] || source.id)) { + out.add(absolutize(source['@id'] || source.id, baseUrl)); + } + return out; +} + +/** + * Resolve a profile's `authentication` entries to a Set of absolute + * IDs. Caller MUST pass an already-absolute base URL — re-deriving + * the base from `profile['@id']` here would fail when the profile + * subject is relative (e.g. `"@id": "#me"`), leaving the resulting + * IDs relative and silently breaking the auth-membership check. + */ +function collectAuthenticationIds(profile, baseUrl) { + const out = new Set(); + const auth = profile?.authentication; + const list = Array.isArray(auth) ? auth : (auth ? [auth] : []); + for (const ent of list) { + let id; + if (typeof ent === 'string') id = ent; + else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id; + if (id) out.add(absolutize(id, baseUrl)); + } + return out; +} + +function absolutize(u, base) { + if (!u) return u; + try { return new URL(u, base).toString(); } catch { return u; } +} + +function stripHashIfAny(u) { + if (typeof u !== 'string') return u; + try { const url = new URL(u); url.hash = ''; return url.toString(); } + catch { return u; } +} + +async function findAccountByNostrPubkey(pubkeyHex) { + const lower = pubkeyHex.toLowerCase(); + if (!pubkeyIndex || (Date.now() - indexBuiltAt) > INDEX_TTL_MS) { + // Dedup concurrent rebuilds: under a burst of requests that all + // arrive after the TTL expires, only ONE rebuild runs and every + // other caller awaits its promise. Without this, N concurrent + // requests would each do a full disk scan + parse pass, with + // N-1 of them throwing away their result. + if (!rebuildInFlight) { + rebuildInFlight = rebuildPubkeyIndex().finally(() => { + rebuildInFlight = null; + }); + } + await rebuildInFlight; + } + const entry = pubkeyIndex.get(lower); + if (!entry) return null; + // The webId is now stored on the index entry — no per-request + // findById disk read needed. NIP-98 auth (via + // resolveDidNostrLocally) and DID-doc generation hit this on + // every request, so dropping the I/O matters. + return { account: { webId: entry.webId }, mtimeMs: entry.mtimeMs }; +} + +/** + * In-process local DID resolution: given a Nostr pubkey, return the + * matching account's WebID without any network fetch. Lets the + * verifyNostrAuth resolver chain prefer local users via direct + * function call instead of a same-host HTTP loop, removing both the + * latency and the SSRF surface that came with feeding request- + * controlled host headers into a `fetch()`. + * + * Returns null for non-local pubkeys (caller falls back to the + * external HTTP resolver, with SSRF protection). + */ +export async function resolveDidNostrLocally(pubkeyHex) { + if (typeof pubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkeyHex)) return null; + const found = await findAccountByNostrPubkey(pubkeyHex.toLowerCase()); + return found?.account?.webId || null; +} + +/** + * Build a CID-shaped DID document for a Nostr pubkey + account pair. + * + * Uses the spec example's vocabulary (Multikey + publicKeyMultibase) + * for max interop with our own resolver and the W3C VC track. The + * Multikey value is computed deterministically from the pubkey via + * the f-form recipe (multibase `f` + multicodec `e701` + parity byte + * `02` + 32-byte xonly hex) — the same shape the doctor's B.2 emits. + */ +function buildDidDocument({ pubkey, webId }) { + const did = `did:nostr:${pubkey.toLowerCase()}`; + const multikey = `f` + `e701` + `02` + pubkey.toLowerCase(); + const vmId = `${did}#key1`; + return { + '@context': ['https://w3id.org/did', 'https://w3id.org/nostr/context'], + 'id': did, + 'type': 'DIDNostr', + 'alsoKnownAs': [webId], + 'verificationMethod': [{ + 'id': vmId, + 'type': 'Multikey', + 'controller': did, + 'publicKeyMultibase': multikey, + }], + 'authentication': [vmId], + 'assertionMethod': [vmId], + }; +} + +/** + * Fastify handler for GET /.well-known/did/nostr/:pubkeyAndExt + * + * The :pubkeyAndExt parameter accepts ``, `.json`, or + * `.jsonld`; the body is the same DID doc either way. The + * spec specifies `.json` as the canonical path, so that's the + * primary; the others are friendly aliases. + * + * The data root is read from `process.env.DATA_ROOT` (matching + * `accounts.js`). We don't accept a parameter for it because the + * account-index path is derived from the same env elsewhere — taking + * a parameter would create two sources of truth and be misleading. + */ +export function buildWellKnownDidNostrHandler() { + return async function handleWellKnownDidNostr(request, reply) { + const raw = String(request.params.pubkeyAndExt || ''); + const ext = raw.endsWith('.jsonld') ? '.jsonld' + : raw.endsWith('.json') ? '.json' + : ''; + const pubkey = (ext ? raw.slice(0, -ext.length) : raw).toLowerCase(); + // Per-status header policy (so success and failure responses are + // both predictable to clients/CDNs): + // 200 Cache-Control: max-age=3600 — DID doc seldom changes + // 404 Cache-Control: max-age=60 — short TTL so a newly added + // key surfaces quickly + // 400 Cache-Control: no-store — request was malformed; never cache + // Nostr-Timestamp is set on EVERY response (including errors) per + // the did:nostr spec recommendation that clients can correlate the + // resolver's clock with the answer they got. Last-Modified only + // makes sense for 200 (it tracks the underlying profile mtime); + // for errors we omit it because there's no underlying resource. + const nowEpoch = Math.floor(Date.now() / 1000); + if (!/^[0-9a-f]{64}$/.test(pubkey)) { + return reply.code(400) + .header('Content-Type', 'application/json') + .header('Cache-Control', 'no-store') + .header('Nostr-Timestamp', String(nowEpoch)) + .send({ error: 'pubkey must be 64 hex chars' }); + } + const found = await findAccountByNostrPubkey(pubkey); + if (!found?.account) { + return reply.code(404) + .header('Cache-Control', 'max-age=60') + .header('Content-Type', 'application/json') + .header('Nostr-Timestamp', String(nowEpoch)) + .send({ error: 'no local account claims this pubkey' }); + } + const { account, mtimeMs } = found; + if (!account.webId) { + // Defensive — every account has a webId, but if one slips through, + // the DID doc would be useless without alsoKnownAs. + return reply.code(404) + .header('Cache-Control', 'max-age=60') + .header('Content-Type', 'application/json') + .header('Nostr-Timestamp', String(nowEpoch)) + .send({ error: 'account has no webId' }); + } + + const didDoc = buildDidDocument({ pubkey, webId: account.webId }); + const contentType = ext === '.jsonld' + ? 'application/did+ld+json; charset=utf-8' + : 'application/did+json; charset=utf-8'; + // Two distinct timestamp semantics, two distinct headers: + // - Nostr-Timestamp: the resolver's clock at answer time (uniform + // across 200/404/400 — clients use it to correlate the resolver + // clock with their own, regardless of cache hits). + // - Last-Modified: when the underlying mapping (the user's + // profile file) actually changed — only meaningful for 200, + // so clients/CDNs can do conditional GET against the source. + const lastModifiedDate = mtimeMs > 0 ? new Date(mtimeMs) : new Date(indexBuiltAt); + return reply + .header('Content-Type', contentType) + .header('Cache-Control', 'max-age=3600') + .header('Nostr-Timestamp', String(nowEpoch)) + .header('Last-Modified', lastModifiedDate.toUTCString()) + .send(didDoc); + }; +} diff --git a/src/server.js b/src/server.js index 93a3b5b2..f9038ffe 100644 --- a/src/server.js +++ b/src/server.js @@ -11,6 +11,10 @@ import { authorize, handleUnauthorized } from './auth/middleware.js'; import { notificationsPlugin } from './notifications/index.js'; import { startFileWatcher } from './notifications/events.js'; import { idpPlugin } from './idp/index.js'; +// well-known-did-nostr is loaded lazily inside the idpEnabled branch +// below so non-IdP deployments don't pull in the IdP accounts module +// (bcryptjs etc.) just to register Fastify routes. The same lazy-load +// pattern is used in src/auth/nostr.js for the NIP-98 verifier. import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'; import { handleCorsProxy, isCorsProxyRequest, setProxyCorsHeaders } from './handlers/cors-proxy.js'; import { AccessMode } from './wac/parser.js'; @@ -651,6 +655,68 @@ export function createServer(options = {}) { } }; + // /.well-known/did/nostr/(.json|.jsonld)? — did:nostr HTTP + // resolution for accounts on this pod (#407). Registered before the + // LDP wildcard so it actually matches; without this the + // dynamic-segment + .json suffix gets swallowed by the wildcard + // GET /* handler below and never reaches our route. + // The 405 method blocks for /.well-known/did/nostr/* must be + // registered REGARDLESS of idpEnabled. The global auth preHandler + // unconditionally skips WAC for any /.well-known/* request (that's + // the spec-mandated public namespace), so without these blocks the + // wildcard write handlers (PUT/POST/PATCH/DELETE /*) would still + // accept unauthenticated writes under this namespace on non-IdP + // deployments — anyone could PUT a file at + // /.well-known/did/nostr/whatever.json. The GET/HEAD generation + // (which actually serves DID docs) stays IdP-only since it reads + // the IdP accounts index. + const methodNotAllowed = async (request, reply) => reply.code(405) + .header('Allow', 'GET, HEAD, OPTIONS') + .send({ error: 'Method Not Allowed' }); + // OPTIONS must report the SAME `Allow` set as the 405s. Without + // an explicit handler the request falls through to the wildcard + // `OPTIONS /*` which advertises GET, HEAD, PUT, DELETE, PATCH, + // POST — wrong for this namespace and confusing to CORS + // preflights. We also set the full CORS header set (origin, + // allowed-methods restricted to read-only, allowed-headers, + // credentials, max-age) so browser preflights to this endpoint + // succeed; bare 204 with only `Allow` would fail CORS. + const optionsForReadOnlyNamespace = async (request, reply) => { + const cors = getCorsHeaders(request.headers.origin); + cors['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS'; + return reply.code(204) + .header('Allow', 'GET, HEAD, OPTIONS') + .headers(cors) + .send(); + }; + for (const pat of [ + '/.well-known/did/nostr', + '/.well-known/did/nostr/', + '/.well-known/did/nostr/:pubkeyAndExt', + '/.well-known/did/nostr/*', + ]) { + fastify.put(pat, methodNotAllowed); + fastify.post(pat, methodNotAllowed); + fastify.patch(pat, methodNotAllowed); + fastify.delete(pat, methodNotAllowed); + fastify.options(pat, optionsForReadOnlyNamespace); + } + if (idpEnabled) { + // Async plugin registration so the dynamic import lives in here, + // not at module top level. Non-IdP deployments never enter this + // branch and never pull in the IdP accounts module. + fastify.register(async (instance) => { + const { buildWellKnownDidNostrHandler } = await import('./idp/well-known-did-nostr.js'); + const wellKnownDidNostr = buildWellKnownDidNostrHandler(); + instance.get('/.well-known/did/nostr/:pubkeyAndExt', wellKnownDidNostr); + // HEAD shares the GET implementation so headers (Content-Type, + // Cache-Control, Last-Modified, etc.) match. Without this the + // request falls through to the wildcard HEAD /* below and the + // LDP layer returns 404 because there's no on-disk file. + instance.head('/.well-known/did/nostr/:pubkeyAndExt', wellKnownDidNostr); + }); + } + // LDP routes - using wildcard routing // Read operations - no rate limit (handled by bodyLimit) fastify.get('/*', handleGet); diff --git a/test/did-nostr.test.js b/test/did-nostr.test.js index 020292fa..e767cdce 100644 --- a/test/did-nostr.test.js +++ b/test/did-nostr.test.js @@ -15,7 +15,12 @@ import { } from './helpers.js'; // Import the module under test -import { resolveDidNostrToWebId, clearCache } from '../src/auth/did-nostr.js'; +import { + resolveDidNostrToWebId, + clearCache, + _cacheSizeForTests, + _CACHE_MAX_FOR_TESTS, +} from '../src/auth/did-nostr.js'; describe('DID:nostr Resolution', () => { describe('Unit Tests', () => { @@ -141,6 +146,331 @@ describe('DID:nostr Resolution', () => { }); }); + describe('Same-origin shortcut removed — backlink always verified', () => { + // The previous same-origin shortcut returned a WebID without + // checking that the WebID profile actually claimed the pubkey. + // On a multi-tenant origin where one user controls + // /.well-known/did/nostr/.json and another user + // controls //profile/card, the attacker could publish + // a DID doc with `alsoKnownAs` pointing at the OTHER user's + // WebID and the resolver would accept it. + // + // We can't drive the live resolver here because + // validateExternalUrl unconditionally blocks loopback (the + // only thing a unit test can bind to), so we exercise the + // CID-VM backlink check directly via the exposed test seam + // with in-memory profiles. The full multi-tenant flow is + // observable in production once a public host is involved. + let attackPubkey; + let legitPubkey; + let legitX; + let legitY; + + before(async () => { + const { secp256k1 } = await import('@noble/curves/secp256k1'); + // Use real on-curve keys; we need a valid point to match the + // verifier's BIP-340 even-y derivation. + legitPubkey = getPublicKey(generateSecretKey()); + attackPubkey = getPublicKey(generateSecretKey()); + const point = secp256k1.ProjectivePoint.fromHex('02' + legitPubkey); + const yHex = point.toAffine().y.toString(16).padStart(64, '0'); + const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + legitX = b64u(legitPubkey); + legitY = b64u(yHex); + clearCache(); + }); + + it('checkCidVmBacklink: accepts a profile with matching VM in authentication', async () => { + const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js'); + const subj = `http://example.test/profile/card#me`; + const profile = { + '@id': subj, + verificationMethod: [{ + id: `${subj.replace('#me','')}#k`, + type: 'JsonWebKey', + controller: subj, + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY }, + }], + authentication: [`${subj.replace('#me','')}#k`], + }; + assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), true); + }); + + it('checkCidVmBacklink: rejects a profile without the VM', async () => { + const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js'); + const profile = { + '@id': 'http://example.test/profile/card#me', + verificationMethod: [], + authentication: [], + }; + // attackPubkey (all-a) isn't in the profile — multi-tenant + // attack scenario. + assert.strictEqual(_checkCidVmBacklinkForTests(profile, attackPubkey), false); + }); + + it('checkCidVmBacklink: rejects a VM in verificationMethod but NOT in authentication', async () => { + const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js'); + const subj = `http://example.test/profile/card#me`; + const profile = { + '@id': subj, + verificationMethod: [{ + id: `${subj.replace('#me','')}#k`, + type: 'JsonWebKey', + controller: subj, + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY }, + }], + authentication: [], // <-- not declared for auth + }; + assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false); + }); + + it('checkCidVmBacklink: handles relative @id when docUrl is supplied', async () => { + const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js'); + const docUrl = 'http://example.test/profile/card.jsonld'; + // Relative subject AND absolute VM IDs (a common mixed shape). + // Without the docUrl fallback, base would be empty and the + // authentication-membership check would silently fail. + const profile = { + '@id': '#me', + verificationMethod: [{ + id: `${docUrl}#k`, + type: 'JsonWebKey', + controller: `${docUrl}#me`, + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY }, + }], + authentication: [`${docUrl}#k`], + }; + assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey, docUrl), true); + }); + + it('checkCidVmBacklink: rejects when VM controller is not in expected set', async () => { + const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js'); + const subj = 'http://example.test/profile/card#me'; + // VM controller points at a totally different origin — would + // be a planted-key attack. Resource-side verifier rejects this; + // CID-VM backlink must agree. + const profile = { + '@id': subj, + // No top-level controller — defaults to subject. + verificationMethod: [{ + id: `${subj.replace('#me','')}#k`, + type: 'JsonWebKey', + controller: 'http://attacker.example/me', + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY }, + }], + authentication: [`${subj.replace('#me','')}#k`], + }; + assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false); + }); + + it('checkCidVmBacklink: rejects a VM with NO explicit controller (matches resource-side strictness)', async () => { + // Earlier passes had a permissive branch that accepted a + // controller-less VM if the VM ID and the profile subject + // shared an origin. That made backlink looser than the + // resource-side verifier, opening a binding-rule mismatch + // (DID resolution would say "yes" for keys the LWS10-CID + // verifier would later reject). Both layers now require + // an explicit controller. + const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js'); + const subj = 'http://example.test/profile/card#me'; + const profile = { + '@id': subj, + verificationMethod: [{ + id: `${subj.replace('#me','')}#k`, + type: 'JsonWebKey', + // controller intentionally absent + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY }, + }], + authentication: [`${subj.replace('#me','')}#k`], + }; + assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false); + }); + }); + + describe('Pubkey input validation', () => { + // Pubkey is attacker-controlled (NIP-98 event) and is + // interpolated into the resolver URL path and cache key. + // Length-only validation lets a malicious pubkey containing + // `/` or other chars rewrite the URL path on the resolver + // origin and pollute the cache. + it('rejects non-hex pubkeys without making any request', async () => { + // 64-character pubkey containing a path separator — + // length-only validation would let this through and + // cause an arbitrary-path fetch on the resolver origin. + const evil = 'a'.repeat(31) + '/' + 'b'.repeat(32); + assert.strictEqual(evil.length, 64); + const out = await resolveDidNostrToWebId(evil, 'http://nonexistent.invalid:1'); + assert.strictEqual(out, null); + }); + + it('rejects too-short and non-string pubkeys', async () => { + assert.strictEqual(await resolveDidNostrToWebId('abc'), null); + assert.strictEqual(await resolveDidNostrToWebId(null), null); + assert.strictEqual(await resolveDidNostrToWebId(42), null); + assert.strictEqual(await resolveDidNostrToWebId('a'.repeat(63)), null); + assert.strictEqual(await resolveDidNostrToWebId('a'.repeat(65)), null); + }); + }); + + describe('Cache key includes resolverUrl', () => { + // Cross-resolver leakage: different resolvers can legitimately + // disagree about the same pubkey (one might have a DID doc, + // another not). Keying the cache only on pubkey would let a + // hit from one resolver suppress a real lookup against another. + before(() => clearCache()); + + it('does NOT share cache entries across resolvers', async () => { + // Both calls hit unresolvable hosts → both cache as + // failureTtl. Crucially, they cache under DIFFERENT keys, so + // the cache size grows by 2 (not 1). + const pk = 'a'.repeat(64); + const sizeBefore = _cacheSizeForTests(); + await resolveDidNostrToWebId(pk, 'http://nonexistent-a.invalid:1'); + await resolveDidNostrToWebId(pk, 'http://nonexistent-b.invalid:1'); + const sizeAfter = _cacheSizeForTests(); + assert.strictEqual(sizeAfter - sizeBefore, 2, + 'each resolver+pubkey pair should cache independently'); + }); + }); + + describe('Cache bounding', () => { + // The cache is keyed by attacker-controlled NIP-98 pubkeys. + // Without an LRU cap a stream of unique pubkeys would grow + // memory without limit. Drive the cap directly via the + // SSRF / unknown-resolver path (every lookup gets cached as + // a transient failure) and assert the size never exceeds + // CACHE_MAX_ENTRIES. + before(() => clearCache()); + + it('cache stays at-or-under CACHE_MAX_ENTRIES after a burst of misses', async () => { + // Use an unreachable resolver so every lookup fast-fails + // and gets cached as `failureTtl: true`. The LRU code is + // mechanical (set + while size > cap → delete oldest), + // so a small N is enough to assert the invariant + // `size <= cap`. + assert.ok(_CACHE_MAX_FOR_TESTS >= 1, 'cap must be positive'); + const N = 5; + const before = _cacheSizeForTests(); + for (let i = 0; i < N; i++) { + const pk = i.toString(16).padStart(64, '0'); + // Force a network failure → cached as failureTtl + await resolveDidNostrToWebId(pk, 'http://nonexistent.invalid:1'); + } + const after = _cacheSizeForTests(); + assert.ok(after - before <= N, 'cache shouldn\'t grow more than N'); + assert.ok(after <= _CACHE_MAX_FOR_TESTS, + `cache size ${after} > cap ${_CACHE_MAX_FOR_TESTS}`); + }); + }); + + describe('fetchWithRedirectGuard SSRF / redirect hardening', () => { + // The production resolver wraps fetchWithRedirectGuard with + // validateExternalUrl as a hard SSRF gate, which by design + // rejects loopback (`127.0.0.1`) — the only thing a unit test + // can bind to. So testing the resolver end-to-end against a + // local server makes the redirect/cap logic invisible: every + // request fails on the SSRF guard before fetch is even called. + // + // Solution: import fetchWithRedirectGuard directly and inject + // a permissive `_validateUrl` stub. That isolates the redirect + // hop counter, cross-origin check, and size cap from the SSRF + // gate so we can actually observe each one. + let http; + let server; + let port; + let hopMode = 'cross-origin'; + let fetchWithRedirectGuard; + const allowAll = async () => ({ valid: true }); + + before(async () => { + http = await import('node:http'); + ({ fetchWithRedirectGuard } = await import('../src/auth/did-nostr.js')); + server = http.createServer((req, res) => { + if (hopMode === 'cross-origin') { + res.writeHead(302, { Location: 'http://other.example:1/foo.json' }); + res.end(); + return; + } + if (hopMode === 'loop') { + // Each hop appends `/r` to the path; the cap fires before + // we ever return a non-3xx. + res.writeHead(302, { Location: req.url + '/r' }); + res.end(); + return; + } + if (hopMode === 'oversize') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + // Stream a body larger than the 1 KB cap we'll pass. + res.end('"' + 'x'.repeat(2000) + '"'); + return; + } + if (hopMode === 'ok') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; + }); + + after(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it('refuses cross-origin redirects', async () => { + hopMode = 'cross-origin'; + await assert.rejects( + () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/foo.json`, { _validateUrl: allowAll }), + /cross-origin redirect refused/, + ); + }); + + it('refuses redirect chains exceeding the hop cap', async () => { + hopMode = 'loop'; + await assert.rejects( + () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/start`, { _validateUrl: allowAll }), + /too many redirects/, + ); + }); + + it('refuses oversized response bodies', async () => { + hopMode = 'oversize'; + await assert.rejects( + () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/big`, { + _validateUrl: allowAll, + maxBytes: 1000, + }), + /response too large/, + ); + }); + + it('re-runs SSRF validation on every hop', async () => { + hopMode = 'loop'; + let calls = 0; + const counting = async (url) => { + calls++; + return { valid: true }; + }; + await assert.rejects( + () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/start`, { _validateUrl: counting }), + /too many redirects/, + ); + // 1 initial + MAX_REDIRECTS (5) hops = 6 calls if we re-validate + // on every hop. < 6 means the per-hop check is missing. + assert.ok(calls >= 6, `expected ≥6 validator calls, got ${calls}`); + }); + + it('returns the response body on success', async () => { + hopMode = 'ok'; + const r = await fetchWithRedirectGuard(`http://127.0.0.1:${port}/ok`, { _validateUrl: allowAll }); + assert.strictEqual(r.status, 200); + assert.strictEqual(r.body, '{"ok":true}'); + }); + }); + describe('Real DID Document Fetch', () => { before(() => { clearCache(); diff --git a/test/well-known-did-nostr.test.js b/test/well-known-did-nostr.test.js new file mode 100644 index 00000000..fed7d642 --- /dev/null +++ b/test/well-known-did-nostr.test.js @@ -0,0 +1,631 @@ +/** + * Integration tests for the well-known did:nostr HTTP-resolution + * endpoint (#407): JSS publishes DID docs at + * `/.well-known/did/nostr/.json` for any local account whose + * profile carries that pubkey as a CID verificationMethod, so JSS's + * own resolver (and external clients like nostr.social, nostr.rocks) + * can resolve same-pod identities without a third-party round-trip. + */ + +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import path from 'path'; +import fs from 'fs-extra'; +import { createServer as createNetServer } from 'net'; +import { generateSecretKey, getPublicKey } from '../src/nostr/event.js'; +import { createServer } from '../src/server.js'; +import { _resetIndexForTests, profilePathFromWebId } from '../src/idp/well-known-did-nostr.js'; +import { extractNostrPubkeysFromProfile } from '../src/auth/nostr.js'; + +const TEST_HOST = '127.0.0.1'; +// Dedicated per-suite directory so we don't clobber a developer's +// local `./data` (which is also JSS's default data root) and don't +// race with other suites that use `./data` via the shared helper. +const TEST_DATA_DIR = './test-data-well-known-did-nostr'; + +/** Pick an OS-assigned port up front so idpIssuer can include it. */ +async function getAvailablePort() { + return new Promise((resolve, reject) => { + const srv = createNetServer(); + srv.on('error', reject); + srv.listen(0, TEST_HOST, () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + }); +} + +function fformMultikey(xOnlyHex, parity = '02') { + return 'f' + 'e701' + parity + xOnlyHex.toLowerCase(); +} + +async function patchProfileWithMultikey(podName, pubkey) { + const profilePath = path.join(TEST_DATA_DIR, podName, 'profile', 'card.jsonld'); + const profile = await fs.readJson(profilePath); + const VM_ID = `${profile['@id'].replace('#me', '')}#nostr-key-1`; + profile.verificationMethod = [{ + id: VM_ID, + type: 'Multikey', + controller: profile['@id'], + publicKeyMultibase: fformMultikey(pubkey), + }]; + profile.authentication = [VM_ID]; + await fs.writeJson(profilePath, profile, { spaces: 2 }); +} + +describe('GET /.well-known/did/nostr/:pubkey (#407)', () => { + let server; + let baseUrl; + let alicePk; + // Capture the original DATA_ROOT before the suite mutates it, so + // the after() hook can restore it. Other tests in the repo follow + // this save/restore pattern (e.g. idp-change-password.test.js) to + // avoid cross-test environment leakage. + const originalDataRoot = process.env.DATA_ROOT; + + before(async () => { + // IdP must be enabled — pod creation only writes an account + // record (the index this endpoint reads from) when the IdP is + // running. Pods without IdP are out of scope for this MVP. + // + // Match the pattern in test/idp.test.js: pick an available port + // BEFORE listen so we can pass the real baseUrl as idpIssuer. + // (oidc-provider behavior depends on the issuer being accurate; + // a static `http://127.0.0.1` with no port would mismatch.) + await fs.remove(TEST_DATA_DIR); + await fs.ensureDir(TEST_DATA_DIR); + const port = await getAvailablePort(); + baseUrl = `http://${TEST_HOST}:${port}`; + server = createServer({ + logger: false, + root: TEST_DATA_DIR, + idp: true, + idpIssuer: baseUrl, + forceCloseConnections: true, + }); + await server.listen({ port, host: TEST_HOST }); + process.env.DATA_ROOT = path.resolve(TEST_DATA_DIR); + // IdP-enabled pod creation requires email + password (so the + // account record is written to _webid_index.json). + const r = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'alice', + email: 'alice@example.com', + password: 'wellknown-test-password', + }), + }); + if (!r.ok) throw new Error(`pod create failed: ${r.status} ${await r.text()}`); + const sk = generateSecretKey(); + alicePk = getPublicKey(sk); + await patchProfileWithMultikey('alice', alicePk); + }); + + after(async () => { + await server.close(); + await fs.remove(TEST_DATA_DIR); + if (originalDataRoot === undefined) { + delete process.env.DATA_ROOT; + } else { + process.env.DATA_ROOT = originalDataRoot; + } + }); + + beforeEach(() => { + _resetIndexForTests(); + }); + + it('returns a CID-shaped DID doc for a local account with the matching VM', async () => { + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`); + assert.strictEqual(r.status, 200); + assert.match(r.headers.get('content-type') || '', /did\+json/); + assert.ok(r.headers.get('cache-control')); + assert.ok(r.headers.get('nostr-timestamp')); + assert.ok(r.headers.get('last-modified')); + + const doc = await r.json(); + assert.deepStrictEqual(doc['@context'], ['https://w3id.org/did', 'https://w3id.org/nostr/context']); + assert.strictEqual(doc.id, `did:nostr:${alicePk}`); + assert.strictEqual(doc.type, 'DIDNostr'); + assert.ok(Array.isArray(doc.alsoKnownAs)); + assert.match(doc.alsoKnownAs[0], /\/alice\/profile\/card\.jsonld#me$/); + assert.strictEqual(doc.verificationMethod[0].type, 'Multikey'); + assert.strictEqual(doc.verificationMethod[0].publicKeyMultibase, fformMultikey(alicePk)); + assert.strictEqual(doc.authentication[0], `did:nostr:${alicePk}#key1`); + }); + + it('accepts the .jsonld suffix (alias)', async () => { + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.jsonld`); + assert.strictEqual(r.status, 200); + assert.match(r.headers.get('content-type') || '', /did\+ld\+json/); + const doc = await r.json(); + assert.strictEqual(doc.id, `did:nostr:${alicePk}`); + }); + + it('accepts the bare pubkey (no extension)', async () => { + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}`); + assert.strictEqual(r.status, 200); + const doc = await r.json(); + assert.strictEqual(doc.id, `did:nostr:${alicePk}`); + }); + + it('returns 404 for a pubkey no local account claims', async () => { + const otherPk = getPublicKey(generateSecretKey()); + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${otherPk}.json`); + assert.strictEqual(r.status, 404); + // Per-status header policy: 404 still sets Nostr-Timestamp (so + // clients can correlate the resolver clock with the negative + // answer) and a short cache so newly added keys surface fast. + assert.ok(r.headers.get('nostr-timestamp')); + assert.match(r.headers.get('cache-control') || '', /max-age=60/); + }); + + it('returns 400 for a non-hex pubkey', async () => { + const r = await fetch(`${baseUrl}/.well-known/did/nostr/not-a-real-pubkey.json`); + assert.strictEqual(r.status, 400); + // 400 sets Nostr-Timestamp but never caches (request was malformed). + assert.ok(r.headers.get('nostr-timestamp')); + assert.match(r.headers.get('cache-control') || '', /no-store/); + }); + + it('returns 400 for a wrong-length hex pubkey', async () => { + const r = await fetch(`${baseUrl}/.well-known/did/nostr/abcdef.json`); + assert.strictEqual(r.status, 400); + }); + + it('responds to HEAD with the same headers as GET (no body)', async () => { + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`, { method: 'HEAD' }); + assert.strictEqual(r.status, 200); + assert.match(r.headers.get('content-type') || '', /did\+json/); + assert.ok(r.headers.get('cache-control')); + assert.ok(r.headers.get('last-modified')); + // HEAD bodies must be empty. + const text = await r.text(); + assert.strictEqual(text, ''); + }); + + it('rejects writes (PUT/POST/PATCH/DELETE) with 405 Method Not Allowed', async () => { + // Without these explicit handlers, the wildcard write routes + // would accept unauthenticated writes under /.well-known/* (the + // namespace bypasses the WAC preHandler). + for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) { + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'DELETE' ? undefined : '{}', + }); + assert.strictEqual(r.status, 405, `${method} should be 405`); + assert.match(r.headers.get('allow') || '', /GET/); + } + }); + + it('OPTIONS advertises only safe methods (Allow consistent with 405) AND sets CORS headers', async () => { + // The wildcard `OPTIONS /*` advertises GET/HEAD/PUT/DELETE/PATCH/POST, + // which is wrong for the read-only well-known namespace and + // confusing to CORS preflights. Explicit OPTIONS handlers must + // return the same Allow set as the 405 responses AND the full + // CORS header set so browser preflights work cross-origin. + for (const subpath of ['', '/', '/x', '/a/b']) { + const r = await fetch(`${baseUrl}/.well-known/did/nostr${subpath}`, { + method: 'OPTIONS', + headers: { Origin: 'https://other.example' }, + }); + assert.strictEqual(r.status, 204, `OPTIONS ${subpath} should be 204`); + const allow = (r.headers.get('allow') || '').toUpperCase(); + assert.match(allow, /GET/, `Allow should include GET (got "${allow}")`); + assert.match(allow, /HEAD/); + assert.doesNotMatch(allow, /\bPUT\b/, `Allow should not advertise PUT (got "${allow}")`); + assert.doesNotMatch(allow, /\bPOST\b/); + assert.doesNotMatch(allow, /\bDELETE\b/); + assert.doesNotMatch(allow, /\bPATCH\b/); + // CORS preflights need these. Without them, browsers refuse + // to follow up with the actual request. + const acAllowMethods = (r.headers.get('access-control-allow-methods') || '').toUpperCase(); + assert.match(acAllowMethods, /GET/, `ACAM missing GET (got "${acAllowMethods}")`); + assert.match(acAllowMethods, /HEAD/); + assert.match(acAllowMethods, /OPTIONS/); + assert.doesNotMatch(acAllowMethods, /\bPUT\b/, `ACAM should not advertise PUT`); + assert.ok(r.headers.get('access-control-allow-origin'), 'ACAO must be set'); + assert.ok(r.headers.get('access-control-allow-headers'), 'ACAH must be set'); + } + }); + + it('blocks writes to multi-segment paths under the namespace', async () => { + // The single-segment `:pubkeyAndExt` route only matches one + // path component — `PUT /.well-known/did/nostr/a/b` would + // otherwise fall through to the wildcard `PUT /*` and accept + // an unauthenticated write since `/.well-known/*` bypasses + // WAC. The wildcard 405 handler closes that. + for (const subpath of ['', '/', '/a/b', '/foo/bar/baz.json']) { + const url = `${baseUrl}/.well-known/did/nostr${subpath}`; + for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) { + const r = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'DELETE' ? undefined : '{}', + }); + assert.strictEqual(r.status, 405, + `${method} ${url} should be 405 (got ${r.status})`); + } + } + }); + + it('indexes root-level pods (profile at /profile/card.jsonld, no podName prefix)', async () => { + // Single-user / root-pod layout: the profile lives directly at + // /profile/card.jsonld with no podName subdirectory, + // even though the seeded IDP account record can have + // `podName: 'me'`. The indexer must derive the on-disk path + // from the WebID's pathname, NOT from podName, or this whole + // class of pods is invisible to local DID resolution. + const sk = generateSecretKey(); + const rootPk = getPublicKey(sk); + const rootWebId = `${baseUrl}/profile/card.jsonld#me`; + const rootProfilePath = path.join(TEST_DATA_DIR, 'profile', 'card.jsonld'); + const VM_ID = `${baseUrl}/profile/card.jsonld#nostr-root`; + await fs.ensureDir(path.dirname(rootProfilePath)); + await fs.writeJson(rootProfilePath, { + '@context': 'https://www.w3.org/ns/solid/v1', + '@id': rootWebId, + verificationMethod: [{ + id: VM_ID, + type: 'Multikey', + controller: rootWebId, + publicKeyMultibase: fformMultikey(rootPk), + }], + authentication: [VM_ID], + }, { spaces: 2 }); + + // Synthesize a matching account record + index entry. We bypass + // the IdP /pods POST flow because that creates a named pod with + // its own subdirectory; we want the root-pod shape specifically. + const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts'); + const indexPath = path.join(accountsDir, '_webid_index.json'); + const idx = await fs.readJson(indexPath); + const accountId = 'root-pod-test-account'; + idx[rootWebId] = accountId; + await fs.writeJson(indexPath, idx, { spaces: 2 }); + await fs.writeJson(path.join(accountsDir, `${accountId}.json`), { + id: accountId, + podName: 'me', // intentionally != on-disk layout + webId: rootWebId, + email: 'root@example.com', + // Other fields the account loader expects can be undefined for + // the lookup we're doing — findById just returns the JSON. + }, { spaces: 2 }); + + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${rootPk}.json`); + assert.strictEqual(r.status, 200); + const doc = await r.json(); + assert.strictEqual(doc.id, `did:nostr:${rootPk}`); + assert.strictEqual(doc.alsoKnownAs[0], rootWebId); + }); + + // No `it()` here — path containment is now exercised directly + // by unit tests on `profilePathFromWebId` below. The previous + // integration-style test couldn't actually trigger the + // containment branch because WHATWG URL parsing strips `..` + // segments before path-resolution sees them, so the test + // returned 404 for the wrong reason (URL normalization, not + // containment). + + + it('handles profiles whose authentication entries are relative fragments', async () => { + // Profiles in the wild often use relative `#me`-style fragments + // for the subject. The indexer must absolutize `authentication` + // entries against the validated absolute subject, not re-derive + // the base from `profile['@id']` (which would itself be relative + // and produce unusable IDs). + // + // We simulate this by writing the profile with the `@id` set to + // a relative fragment and the authentication entry as a relative + // fragment too. If the absolute base is honored, `#nostr-rel` + // resolves to the same VM ID as the absolutized version inside + // `verificationMethod`, the auth-membership check passes, and + // the DID doc is published. + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const profilePath = path.join(TEST_DATA_DIR, 'alice', 'profile', 'card.jsonld'); + const profile = await fs.readJson(profilePath); + const absSubject = profile['@id']; // e.g. http://.../alice/profile/card.jsonld#me + const absSubjectNoHash = absSubject.replace('#me', ''); + profile['@id'] = '#me'; // relative subject + profile.verificationMethod = [{ + id: `${absSubjectNoHash}#nostr-rel`, // VM stays absolute + type: 'Multikey', + controller: absSubject, + publicKeyMultibase: fformMultikey(pk), + }]; + profile.authentication = ['#nostr-rel']; // relative auth ref + await fs.writeJson(profilePath, profile, { spaces: 2 }); + + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${pk}.json`); + assert.strictEqual(r.status, 200); + const doc = await r.json(); + assert.strictEqual(doc.id, `did:nostr:${pk}`); + + // Restore the profile so the rest of the suite (and any later + // re-runs without isolation) sees a well-formed absolute subject. + profile['@id'] = absSubject; + profile.verificationMethod = [{ + id: `${absSubjectNoHash}#nostr-key-1`, + type: 'Multikey', + controller: absSubject, + publicKeyMultibase: fformMultikey(alicePk), + }]; + profile.authentication = [`${absSubjectNoHash}#nostr-key-1`]; + await fs.writeJson(profilePath, profile, { spaces: 2 }); + }); + + it('logs a diagnostic when an account profile is unreadable (not silent)', async () => { + // Operators need to be able to debug "why isn't my pubkey + // publishing?" without grepping silence. Pre-fix, the + // rebuildPubkeyIndex catch was `catch { continue; }` and a + // broken profile produced a 404 with zero log output. + const sk = generateSecretKey(); + const orphanPk = getPublicKey(sk); + const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts'); + const indexPath = path.join(accountsDir, '_webid_index.json'); + const idx = await fs.readJson(indexPath); + const orphanId = 'orphan-broken-profile'; + const orphanWebId = `${baseUrl}/orphan/profile/card.jsonld#me`; + idx[orphanWebId] = orphanId; + await fs.writeJson(indexPath, idx, { spaces: 2 }); + await fs.writeJson(path.join(accountsDir, `${orphanId}.json`), { + id: orphanId, + podName: 'orphan', + webId: orphanWebId, + }, { spaces: 2 }); + // Write a malformed profile so JSON.parse will throw. + const profilePath = path.join(TEST_DATA_DIR, 'orphan', 'profile', 'card.jsonld'); + await fs.ensureDir(path.dirname(profilePath)); + await fs.writeFile(profilePath, '{ this is not valid json', 'utf8'); + + // Capture console.error. + const errors = []; + const origError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + try { + _resetIndexForTests(); + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${orphanPk}.json`); + assert.strictEqual(r.status, 404); + } finally { + console.error = origError; + } + const matched = errors.find((m) => m.includes(orphanId) && m.includes('orphan/profile/card.jsonld')); + assert.ok(matched, `expected a log entry mentioning ${orphanId} and the profile path; got: ${errors.join('\n')}`); + + // Cleanup. + delete idx[orphanWebId]; + await fs.writeJson(indexPath, idx, { spaces: 2 }); + await fs.remove(path.join(accountsDir, `${orphanId}.json`)); + await fs.remove(path.dirname(path.dirname(profilePath))); + }); + + it('does NOT publish a VM that is in verificationMethod but not in authentication', async () => { + // Add a key to the profile under verificationMethod but explicitly + // omit it from `authentication` — the user has decided this key + // is NOT for auth (revocation pending, assertion-only, etc.). + // Index must respect that intent. + const otherSk = generateSecretKey(); + const otherPk = getPublicKey(otherSk); + const profilePath = path.join(TEST_DATA_DIR, 'alice', 'profile', 'card.jsonld'); + const profile = await fs.readJson(profilePath); + const REVOKED_VM_ID = `${profile['@id'].replace('#me', '')}#nostr-revoked`; + profile.verificationMethod.push({ + id: REVOKED_VM_ID, + type: 'Multikey', + controller: profile['@id'], + publicKeyMultibase: fformMultikey(otherPk), + }); + // NOTE: NOT added to profile.authentication + await fs.writeJson(profilePath, profile, { spaces: 2 }); + + const r = await fetch(`${baseUrl}/.well-known/did/nostr/${otherPk}.json`); + assert.strictEqual(r.status, 404); + }); +}); + +describe('Non-IdP /.well-known/did/nostr write blocking', () => { + // Regression test for the case Copilot caught: even with IdP + // disabled, writes under /.well-known/did/nostr/* must be 405. + // /.well-known/* bypasses the WAC preHandler unconditionally, + // so without dedicated 405 handlers the wildcard write routes + // would accept unauthenticated PUT/POST and create files on + // disk under this namespace. + let server; + let baseUrl; + // createServer mutates process.env.DATA_ROOT — capture and + // restore so we don't leak the test value into anything that + // runs after this describe (mirrors the pattern in the first + // describe block). + const originalDataRoot = process.env.DATA_ROOT; + + before(async () => { + const port = await getAvailablePort(); + baseUrl = `http://${TEST_HOST}:${port}`; + server = createServer({ + logger: false, + root: TEST_DATA_DIR + '-noidp', + idp: false, // <-- the point of the test + forceCloseConnections: true, + }); + await server.listen({ port, host: TEST_HOST }); + }); + + after(async () => { + await server.close(); + await fs.remove(TEST_DATA_DIR + '-noidp'); + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + }); + + it('returns 405 for PUT/POST/PATCH/DELETE under the namespace', async () => { + for (const subpath of ['', '/x', '/a/b']) { + for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) { + const r = await fetch(`${baseUrl}/.well-known/did/nostr${subpath}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'DELETE' ? undefined : '{}', + }); + assert.strictEqual(r.status, 405, + `${method} /.well-known/did/nostr${subpath} should be 405 in non-IdP mode (got ${r.status})`); + } + } + }); +}); + +describe('extractNostrPubkeysFromProfile', () => { + it('finds f-form Multikey entries', () => { + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const profile = { + verificationMethod: [{ + id: '#k1', + type: 'Multikey', + publicKeyMultibase: fformMultikey(pk), + }], + }; + const found = extractNostrPubkeysFromProfile(profile); + assert.strictEqual(found.length, 1); + assert.strictEqual(found[0].pubkey, pk); + }); + + it('finds JsonWebKey entries when y matches the BIP-340 canonical point', async () => { + const { secp256k1 } = await import('@noble/curves/secp256k1'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + // x-coord is the hex pubkey base64url-encoded. + const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const x = b64u(pk); + // Compute the canonical (even-y) y for this x — same logic as + // the verifier in src/auth/nostr.js. + const point = secp256k1.ProjectivePoint.fromHex('02' + pk); + const yHex = point.toAffine().y.toString(16).padStart(64, '0'); + const y = b64u(yHex); + const profile = { + verificationMethod: [{ + id: '#k1', + type: 'JsonWebKey', + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x, y }, + }], + }; + const found = extractNostrPubkeysFromProfile(profile); + assert.strictEqual(found.length, 1); + assert.strictEqual(found[0].pubkey, pk); + }); + + it('rejects JsonWebKey entries with mismatched y (not the BIP-340 canonical point)', () => { + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const profile = { + verificationMethod: [{ + id: '#k1', + type: 'JsonWebKey', + // Right x, but a y that's clearly not on-curve. Indexer must + // refuse, otherwise it could publish a key the verifier will + // reject (401 on advertised pubkey). + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: b64u(pk), y: b64u('00'.repeat(32)) }, + }], + }; + assert.deepStrictEqual(extractNostrPubkeysFromProfile(profile), []); + }); + + it('rejects JsonWebKey entries missing y', () => { + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const x = Buffer.from(pk, 'hex').toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const profile = { + verificationMethod: [{ id: '#k1', type: 'JsonWebKey', + publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x } }], + }; + assert.deepStrictEqual(extractNostrPubkeysFromProfile(profile), []); + }); + + it('returns empty for profiles without Nostr-shaped VMs', () => { + assert.deepStrictEqual(extractNostrPubkeysFromProfile({}), []); + assert.deepStrictEqual(extractNostrPubkeysFromProfile({ verificationMethod: [] }), []); + assert.deepStrictEqual(extractNostrPubkeysFromProfile({ + verificationMethod: [{ type: 'Ed25519VerificationKey2020' }], + }), []); + }); + + it('returns empty for malformed input', () => { + assert.deepStrictEqual(extractNostrPubkeysFromProfile(null), []); + assert.deepStrictEqual(extractNostrPubkeysFromProfile('not an object'), []); + }); +}); + +describe('profilePathFromWebId — DATA_ROOT containment', () => { + // Pure unit tests; no server. Exercises the containment branch + // directly with raw inputs that bypass URL parsing's `..` + // normalization, since that's the layer that would matter if a + // future caller ever bypassed `new URL()`. + const DATA_ROOT = '/srv/jss/data'; + + it('resolves a normal pathname under dataRoot', () => { + const p = profilePathFromWebId(DATA_ROOT, 'http://example/alice/profile/card.jsonld#me'); + assert.strictEqual(p, '/srv/jss/data/alice/profile/card.jsonld'); + }); + + it('resolves a root-pod pathname under dataRoot', () => { + const p = profilePathFromWebId(DATA_ROOT, 'http://example/profile/card.jsonld#me'); + assert.strictEqual(p, '/srv/jss/data/profile/card.jsonld'); + }); + + it('rejects unparseable webIds', () => { + assert.strictEqual(profilePathFromWebId(DATA_ROOT, 'not a url'), null); + assert.strictEqual(profilePathFromWebId(DATA_ROOT, null), null); + assert.strictEqual(profilePathFromWebId(DATA_ROOT, 42), null); + }); + + it('does NOT escape dataRoot for `..` traversal in the URL pathname', () => { + // WHATWG URL parsing already strips this — confirm the result + // stays inside dataRoot regardless. + const p = profilePathFromWebId(DATA_ROOT, 'http://example/../../../etc/passwd'); + assert.ok(p === null || p.startsWith('/srv/jss/data'), + `expected containment, got ${p}`); + }); + + it('keeps a relative dataRoot + plausible webId inside the absolute dataRoot', () => { + // Sanity test for the relative-dataRoot case. With dataRoot + // `./inner` and a normal-looking webId pathname, the resolved + // path lives at `/inner/some/profile/card.jsonld` — + // INSIDE the resolved-absolute innerRoot. (URL normalization + // already strips `..` segments before path-resolution sees + // them, so a "real" outside-dataRoot result isn't reachable + // through URL-parsed webIds in practice. The containment + // check stays as defense-in-depth for any future caller that + // bypasses URL parsing.) + const innerRoot = './nonexistent-inner-root'; + const p = profilePathFromWebId(innerRoot, 'http://example/some/profile/card.jsonld'); + assert.ok(p && p.startsWith(path.resolve(innerRoot)), + `expected ${p} to be under ${path.resolve(innerRoot)}`); + }); + + it('every URL-parseable webId with `..` segments still resolves inside dataRoot', () => { + // The production path is unreachable via URL-parsed input — + // WHATWG URL parsing strips `..` before our path-resolution + // sees it. This test asserts the resulting INVARIANT (every + // URL-parseable webId stays inside dataRoot) across a few + // traversal-shaped inputs, so any future regression where + // someone bypasses URL parsing or breaks the leading-slash + // strip would surface here. + for (const evil of [ + 'http://h/../../../etc/passwd', + 'http://h//../etc/passwd', + 'http://h/.%2e/etc/passwd', + 'http://h/foo/../../../etc/passwd', + ]) { + const p = profilePathFromWebId(DATA_ROOT, evil); + assert.ok( + p === null || p.startsWith(DATA_ROOT + path.sep) || p === DATA_ROOT, + `${evil} → ${p} escaped DATA_ROOT`, + ); + } + }); +});