javascript-solid-server 0.0.179 → 0.0.181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth/did-nostr.js +417 -73
- package/src/auth/nostr-keys.js +112 -0
- package/src/auth/nostr.js +37 -28
- package/src/handlers/resource.js +19 -2
- package/src/idp/well-known-did-nostr.js +458 -0
- package/src/server.js +66 -0
- package/test/conneg.test.js +82 -0
- package/test/did-nostr.test.js +331 -1
- package/test/well-known-did-nostr.test.js +631 -0
package/package.json
CHANGED
package/src/auth/did-nostr.js
CHANGED
|
@@ -7,13 +7,36 @@
|
|
|
7
7
|
* 3. Verifying bidirectional link (WebID links back to did:nostr)
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { validateExternalUrl } from '../utils/ssrf.js';
|
|
11
|
+
import { extractNostrPubkeysFromProfile } from './nostr-keys.js';
|
|
12
|
+
|
|
10
13
|
// Default DID resolver endpoint
|
|
11
14
|
const DEFAULT_DID_RESOLVER = 'https://nostr.social/.well-known/did/nostr';
|
|
12
15
|
|
|
13
|
-
// Cache for resolved DIDs (pubkey -> webId
|
|
16
|
+
// Cache for resolved DIDs (pubkey -> { webId, timestamp, failureTtl? }).
|
|
17
|
+
//
|
|
18
|
+
// Bounded LRU: pubkeys come from external NIP-98 events, so an
|
|
19
|
+
// attacker can flood the resolver with unique pubkeys and grow the
|
|
20
|
+
// cache without limit if it's an unbounded Map. The Map iteration
|
|
21
|
+
// order IS insertion order, so evicting `cache.keys().next().value`
|
|
22
|
+
// drops the oldest entry — same pattern as src/auth/cid-doc-fetch.js.
|
|
23
|
+
// On every set: re-insert (delete + set) bumps the entry to "newest"
|
|
24
|
+
// so the LRU semantics are preserved across cache hits.
|
|
14
25
|
const cache = new Map();
|
|
15
26
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
16
27
|
const FAILURE_CACHE_TTL = 60 * 1000; // 1 minute for failed lookups
|
|
28
|
+
const CACHE_MAX_ENTRIES = 10_000; // bound at ~few MB worst case
|
|
29
|
+
|
|
30
|
+
function setCacheEntry(key, entry) {
|
|
31
|
+
// Re-insert to mark as MRU (Map preserves insertion order).
|
|
32
|
+
if (cache.has(key)) cache.delete(key);
|
|
33
|
+
cache.set(key, entry);
|
|
34
|
+
while (cache.size > CACHE_MAX_ENTRIES) {
|
|
35
|
+
const oldest = cache.keys().next().value;
|
|
36
|
+
if (oldest === undefined) break;
|
|
37
|
+
cache.delete(oldest);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
17
40
|
|
|
18
41
|
// Rate-limit repeated error logs (key -> { count, lastLogged })
|
|
19
42
|
const errorLogTracker = new Map();
|
|
@@ -36,65 +59,207 @@ function rateLimitedError(key, message) {
|
|
|
36
59
|
errorLogTracker.set(key, { count: 0, lastLogged: now });
|
|
37
60
|
}
|
|
38
61
|
|
|
62
|
+
// Redirect/SSRF/size limits, mirroring src/auth/cid-doc-fetch.js so
|
|
63
|
+
// both the DID-doc resolver and the WebID-backlink verifier apply
|
|
64
|
+
// the same hardening:
|
|
65
|
+
// - manual redirect handling (5 hops max)
|
|
66
|
+
// - SSRF re-validation on EVERY hop (an allowed origin could 30x
|
|
67
|
+
// to a private IP / cloud metadata; default fetch redirect would
|
|
68
|
+
// bypass the initial validateExternalUrl check)
|
|
69
|
+
// - cross-origin redirects refused (open-redirect → arbitrary host)
|
|
70
|
+
// - response size cap before reading the body
|
|
71
|
+
const MAX_REDIRECTS = 5;
|
|
72
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 5000;
|
|
73
|
+
const DEFAULT_MAX_BYTES = 1 * 1024 * 1024; // 1 MB — DID docs / WebID profiles are tiny
|
|
74
|
+
|
|
39
75
|
/**
|
|
40
|
-
* Fetch with timeout
|
|
76
|
+
* Fetch with a timeout, manual redirect following, SSRF re-validation
|
|
77
|
+
* per hop, and a body-size cap. Returns `{ url, status, headers, body }`
|
|
78
|
+
* — `body` is a string (caller decides whether to JSON-parse).
|
|
79
|
+
*
|
|
80
|
+
* Throws on validation, network, redirect, or size failures so the
|
|
81
|
+
* resolver can swallow them uniformly into a null/false return.
|
|
82
|
+
*
|
|
83
|
+
* Exported for tests so the redirect + cross-origin + cap logic can
|
|
84
|
+
* be unit-tested directly with a stubbed validator (the production
|
|
85
|
+
* validator hard-blocks loopback, which is the only thing a unit
|
|
86
|
+
* test can spin up — without injection the redirect tests can't
|
|
87
|
+
* tell the SSRF guard from the redirect guard).
|
|
88
|
+
*
|
|
89
|
+
* @param {object} [opts]
|
|
90
|
+
* @param {Function} [opts._validateUrl] - Test seam. Defaults to the
|
|
91
|
+
* real `validateExternalUrl`. Production callers MUST NOT override.
|
|
41
92
|
*/
|
|
42
|
-
async function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
export async function fetchWithRedirectGuard(initialUrl, {
|
|
94
|
+
accept,
|
|
95
|
+
timeout = DEFAULT_FETCH_TIMEOUT_MS,
|
|
96
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
97
|
+
_validateUrl = validateExternalUrl,
|
|
98
|
+
} = {}) {
|
|
99
|
+
const originalOrigin = new URL(initialUrl).origin;
|
|
100
|
+
let currentUrl = initialUrl;
|
|
101
|
+
|
|
102
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
103
|
+
const isLastAllowedHop = hop === MAX_REDIRECTS;
|
|
104
|
+
const validation = await _validateUrl(currentUrl, {
|
|
105
|
+
requireHttps: process.env.NODE_ENV === 'production',
|
|
106
|
+
blockPrivateIPs: true,
|
|
107
|
+
resolveDNS: true,
|
|
108
|
+
});
|
|
109
|
+
if (!validation.valid) {
|
|
110
|
+
throw new Error(`SSRF protection: ${validation.error}`);
|
|
111
|
+
}
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
114
|
+
let res;
|
|
115
|
+
try {
|
|
116
|
+
res = await fetch(currentUrl, {
|
|
117
|
+
headers: accept ? { Accept: accept } : {},
|
|
118
|
+
redirect: 'manual',
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
});
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
if (res.status >= 300 && res.status < 400) {
|
|
125
|
+
if (isLastAllowedHop) throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
|
|
126
|
+
const loc = res.headers.get('location');
|
|
127
|
+
if (!loc) throw new Error(`redirect ${res.status} without Location`);
|
|
128
|
+
const nextUrl = new URL(loc, currentUrl).toString();
|
|
129
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
130
|
+
if (nextOrigin !== originalOrigin) {
|
|
131
|
+
throw new Error(`cross-origin redirect refused: ${originalOrigin} → ${nextOrigin}`);
|
|
132
|
+
}
|
|
133
|
+
currentUrl = nextUrl;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// Cap the body before reading. Content-Length pre-check rejects
|
|
137
|
+
// a server that advertises an oversized response; the streaming
|
|
138
|
+
// cap rejects servers that lie about Content-Length.
|
|
139
|
+
const declared = Number(res.headers.get('content-length'));
|
|
140
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
141
|
+
throw new Error(`response too large (Content-Length=${declared} > ${maxBytes})`);
|
|
142
|
+
}
|
|
143
|
+
const reader = res.body?.getReader?.();
|
|
144
|
+
let body = '';
|
|
145
|
+
if (!reader) {
|
|
146
|
+
body = await res.text();
|
|
147
|
+
if (Buffer.byteLength(body, 'utf8') > maxBytes) {
|
|
148
|
+
throw new Error(`response too large (>${maxBytes} bytes)`);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let total = 0;
|
|
153
|
+
for (;;) {
|
|
154
|
+
const { value, done } = await reader.read();
|
|
155
|
+
if (done) break;
|
|
156
|
+
total += value.byteLength;
|
|
157
|
+
if (total > maxBytes) {
|
|
158
|
+
try { await reader.cancel(); } catch { /* noop */ }
|
|
159
|
+
throw new Error(`response too large (>${maxBytes} bytes)`);
|
|
160
|
+
}
|
|
161
|
+
chunks.push(value);
|
|
162
|
+
}
|
|
163
|
+
body = Buffer.concat(chunks).toString('utf8');
|
|
164
|
+
}
|
|
165
|
+
return { url: currentUrl, status: res.status, headers: res.headers, body };
|
|
52
166
|
}
|
|
167
|
+
throw new Error('fetch loop exited unexpectedly');
|
|
53
168
|
}
|
|
54
169
|
|
|
55
170
|
/**
|
|
56
|
-
* Resolve did:nostr pubkey to WebID via DID document
|
|
171
|
+
* Resolve did:nostr pubkey to WebID via DID document.
|
|
172
|
+
*
|
|
173
|
+
* Local users are resolved by `resolveDidNostrLocally` in the auth
|
|
174
|
+
* caller (well-known-did-nostr.js exports an in-process function) —
|
|
175
|
+
* this resolver is the cross-pod fallback that fetches an external
|
|
176
|
+
* DID doc, so all fetches run through the SSRF guard.
|
|
177
|
+
*
|
|
57
178
|
* @param {string} pubkey - 64-char hex Nostr pubkey
|
|
58
|
-
* @param {string} resolverUrl - DID resolver base URL
|
|
179
|
+
* @param {string} [resolverUrl] - DID resolver base URL (without the
|
|
180
|
+
* trailing `/<pubkey>.json`). Defaults to the configured
|
|
181
|
+
* DEFAULT_DID_RESOLVER (nostr.social).
|
|
59
182
|
* @returns {Promise<string|null>} WebID URL or null
|
|
60
183
|
*/
|
|
61
184
|
export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_RESOLVER) {
|
|
62
|
-
|
|
185
|
+
// Pubkey is attacker-controlled (it comes off a NIP-98 event)
|
|
186
|
+
// and is interpolated into the resolver URL path and cache key.
|
|
187
|
+
// Length-only validation isn't enough — characters like `/` or
|
|
188
|
+
// `..` would turn this into an arbitrary-path fetch against the
|
|
189
|
+
// resolver origin and produce confusing cache entries. Enforce
|
|
190
|
+
// the documented shape: 64 lowercase hex chars.
|
|
191
|
+
if (typeof pubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkey)) {
|
|
63
192
|
return null;
|
|
64
193
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
194
|
+
pubkey = pubkey.toLowerCase();
|
|
195
|
+
|
|
196
|
+
// Cache key includes the resolver URL because different resolvers
|
|
197
|
+
// can legitimately disagree about the same pubkey (one might have
|
|
198
|
+
// a DID doc, another not; alsoKnownAs values can differ across
|
|
199
|
+
// operator-run resolvers). Keying only on pubkey would let a hit
|
|
200
|
+
// from one resolver leak into a query against another, including
|
|
201
|
+
// a cached `null` mistakenly suppressing a real result.
|
|
202
|
+
const cacheKey = `${resolverUrl}::${pubkey.toLowerCase()}`;
|
|
68
203
|
const cached = cache.get(cacheKey);
|
|
69
204
|
if (cached) {
|
|
70
205
|
const ttl = cached.failureTtl ? FAILURE_CACHE_TTL : CACHE_TTL;
|
|
71
206
|
if (Date.now() - cached.timestamp < ttl) {
|
|
207
|
+
// Re-insert to bump to MRU. Without this, a frequently-hit
|
|
208
|
+
// entry could still be evicted as "oldest" once the cache is
|
|
209
|
+
// at the cap, defeating the LRU intent.
|
|
210
|
+
setCacheEntry(cacheKey, cached);
|
|
72
211
|
return cached.webId;
|
|
73
212
|
}
|
|
74
213
|
cache.delete(cacheKey);
|
|
75
214
|
}
|
|
76
215
|
|
|
77
216
|
try {
|
|
78
|
-
//
|
|
217
|
+
// SSRF guard runs inside fetchWithRedirectGuard on EVERY hop —
|
|
218
|
+
// not just the initial URL — so an allowed resolver origin can't
|
|
219
|
+
// 30x-redirect to a private IP / cloud metadata endpoint and
|
|
220
|
+
// bypass the check. Same policy the LWS-CID verifier applies.
|
|
79
221
|
const didUrl = `${resolverUrl}/${pubkey}.json`;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
222
|
+
// Two failure classes with different cache TTLs:
|
|
223
|
+
// - Transient: network error, SSRF/redirect refusal, non-2xx,
|
|
224
|
+
// unparseable JSON. These should re-try sooner, so cache
|
|
225
|
+
// with `failureTtl: true` (FAILURE_CACHE_TTL = 1 min).
|
|
226
|
+
// - "No linkage": successful fetch but the DID doc had no
|
|
227
|
+
// alsoKnownAs / profile.webid we could use. That's a valid
|
|
228
|
+
// answer, not a transient blip — cache with the regular
|
|
229
|
+
// CACHE_TTL (5 min) so we don't hammer the resolver.
|
|
230
|
+
let didFetch;
|
|
231
|
+
try {
|
|
232
|
+
didFetch = await fetchWithRedirectGuard(didUrl, {
|
|
233
|
+
accept: 'application/did+json, application/json',
|
|
234
|
+
});
|
|
235
|
+
} catch {
|
|
236
|
+
setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
if (didFetch.status < 200 || didFetch.status >= 300) {
|
|
240
|
+
setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
let didDoc;
|
|
244
|
+
try {
|
|
245
|
+
didDoc = JSON.parse(didFetch.body);
|
|
246
|
+
} catch {
|
|
247
|
+
setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
|
|
86
248
|
return null;
|
|
87
249
|
}
|
|
88
|
-
|
|
89
|
-
const didDoc = await didRes.json();
|
|
90
|
-
|
|
91
250
|
// Extract WebID from alsoKnownAs (array) or profile.webid or profile.sameAs
|
|
92
251
|
let webId = null;
|
|
93
252
|
|
|
94
253
|
if (Array.isArray(didDoc.alsoKnownAs) && didDoc.alsoKnownAs.length > 0) {
|
|
95
|
-
//
|
|
254
|
+
// Accept BOTH http and https here; the SSRF guard on the
|
|
255
|
+
// backlink fetch will refuse http in production via
|
|
256
|
+
// `requireHttps: NODE_ENV === 'production'`. Filtering to
|
|
257
|
+
// https-only at this layer would mean non-production
|
|
258
|
+
// resolvers can never validate against http WebIDs (test
|
|
259
|
+
// pods, dev fixtures), even when the SSRF layer would have
|
|
260
|
+
// permitted them.
|
|
96
261
|
webId = didDoc.alsoKnownAs.find(aka =>
|
|
97
|
-
typeof aka === 'string' && aka
|
|
262
|
+
typeof aka === 'string' && /^https?:\/\//.test(aka));
|
|
98
263
|
}
|
|
99
264
|
|
|
100
265
|
// Fallback to profile fields
|
|
@@ -103,81 +268,247 @@ export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_R
|
|
|
103
268
|
}
|
|
104
269
|
|
|
105
270
|
if (!webId) {
|
|
106
|
-
|
|
271
|
+
setCacheEntry(cacheKey, { webId: null, timestamp: Date.now() });
|
|
107
272
|
return null;
|
|
108
273
|
}
|
|
109
274
|
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
275
|
+
// Always verify the WebID actually claims this pubkey. The
|
|
276
|
+
// earlier same-origin shortcut was unsafe on multi-tenant
|
|
277
|
+
// pods: same-origin doesn't equal same-control. Mallory who
|
|
278
|
+
// owns `<host>/.well-known/did/nostr/<MallorysPubkey>.json`
|
|
279
|
+
// could publish a DID doc with `alsoKnownAs` pointing at
|
|
280
|
+
// Alice's WebID on the same host, and "same origin" would
|
|
281
|
+
// accept it. The verifier checks the Alice-side profile for
|
|
282
|
+
// a verificationMethod that actually claims this pubkey, so
|
|
283
|
+
// the binding can't be forged from outside Alice's profile.
|
|
284
|
+
let verified;
|
|
285
|
+
try {
|
|
286
|
+
verified = await verifyWebIdBacklink(webId, pubkey);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (err instanceof TransientBacklinkError) {
|
|
289
|
+
// Backlink fetch flapped (network / SSRF / redirect / 5xx).
|
|
290
|
+
// Don't pin a 5-minute null — retry sooner.
|
|
291
|
+
setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
113
296
|
if (verified) {
|
|
114
|
-
|
|
297
|
+
setCacheEntry(cacheKey, { webId, timestamp: Date.now() });
|
|
115
298
|
return webId;
|
|
116
299
|
}
|
|
117
|
-
|
|
118
|
-
|
|
300
|
+
// Verified absence: the WebID profile responded successfully but
|
|
301
|
+
// didn't claim the pubkey. Steady-state answer; cache the full
|
|
302
|
+
// CACHE_TTL so we don't hammer the resolver chain.
|
|
303
|
+
setCacheEntry(cacheKey, { webId: null, timestamp: Date.now() });
|
|
119
304
|
return null;
|
|
120
305
|
|
|
121
306
|
} catch (err) {
|
|
122
307
|
// Cache failures with short TTL to avoid hammering a down service
|
|
123
|
-
|
|
308
|
+
setCacheEntry(cacheKey, { webId: null, timestamp: Date.now(), failureTtl: true });
|
|
124
309
|
rateLimitedError(`did:${pubkey.substring(0, 8)}`, `DID resolution error for ${pubkey}: ${err.message}`);
|
|
125
310
|
return null;
|
|
126
311
|
}
|
|
127
312
|
}
|
|
128
313
|
|
|
314
|
+
/** Sentinel error class: backlink fetch failed transiently (network,
|
|
315
|
+
* SSRF refusal, redirect cap, timeout, etc.). Caller should cache
|
|
316
|
+
* with the short failureTtl so a flapping WebID host doesn't pin a
|
|
317
|
+
* null answer for the full 5-minute steady-state TTL. */
|
|
318
|
+
class TransientBacklinkError extends Error {
|
|
319
|
+
constructor(message) { super(message); this.name = 'TransientBacklinkError'; }
|
|
320
|
+
}
|
|
321
|
+
|
|
129
322
|
/**
|
|
130
|
-
* Verify WebID profile links back to did:nostr
|
|
323
|
+
* Verify WebID profile links back to did:nostr.
|
|
324
|
+
*
|
|
325
|
+
* Returns:
|
|
326
|
+
* - `true` — linkage found (CID-VM or owl:sameAs)
|
|
327
|
+
* - `false` — fetched and parsed, but no linkage (verified
|
|
328
|
+
* absence — caller caches with the steady-state TTL)
|
|
329
|
+
* - throws `TransientBacklinkError` — fetch/parse failed
|
|
330
|
+
* (caller catches and caches with the short failureTtl)
|
|
331
|
+
*
|
|
131
332
|
* @param {string} webId - WebID URL
|
|
132
333
|
* @param {string} pubkey - Nostr pubkey
|
|
133
334
|
* @returns {Promise<boolean>}
|
|
134
335
|
*/
|
|
135
336
|
async function verifyWebIdBacklink(webId, pubkey) {
|
|
337
|
+
const expectedDid = `did:nostr:${pubkey.toLowerCase()}`;
|
|
338
|
+
// The WebID came out of an externally-fetched DID doc, so it's
|
|
339
|
+
// untrusted until verified. fetchWithRedirectGuard re-runs the
|
|
340
|
+
// SSRF check on every redirect hop and refuses cross-origin
|
|
341
|
+
// redirects, so a forged DID doc can't bounce us through an
|
|
342
|
+
// open-redirect into a private IP.
|
|
343
|
+
let backlinkRes;
|
|
136
344
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// Fetch WebID profile
|
|
140
|
-
const res = await fetchWithTimeout(webId, {
|
|
141
|
-
headers: { 'Accept': 'application/ld+json, application/json, text/html' }
|
|
345
|
+
backlinkRes = await fetchWithRedirectGuard(webId, {
|
|
346
|
+
accept: 'application/ld+json, application/json, text/html',
|
|
142
347
|
});
|
|
348
|
+
} catch (err) {
|
|
349
|
+
// Network/SSRF/redirect/size/timeout — transient.
|
|
350
|
+
throw new TransientBacklinkError(`fetch failed: ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
// Status classification:
|
|
353
|
+
// - 5xx: transient ("try again later")
|
|
354
|
+
// - 408 (request timeout) and 429 (too many requests): also
|
|
355
|
+
// transient — the host couldn't / wouldn't answer right now,
|
|
356
|
+
// not "the linkage is permanently absent"
|
|
357
|
+
// - other 4xx (404, 410, etc.): verified absence — the host
|
|
358
|
+
// answered authoritatively that this resource doesn't exist
|
|
359
|
+
// or is gone, cache as steady-state
|
|
360
|
+
// - 3xx (would only reach here as redirect that resolved):
|
|
361
|
+
// also verified absence (no redirect-to-content arrived)
|
|
362
|
+
if (
|
|
363
|
+
(backlinkRes.status >= 500 && backlinkRes.status < 600) ||
|
|
364
|
+
backlinkRes.status === 408 ||
|
|
365
|
+
backlinkRes.status === 429
|
|
366
|
+
) {
|
|
367
|
+
throw new TransientBacklinkError(`HTTP ${backlinkRes.status}`);
|
|
368
|
+
}
|
|
369
|
+
if (backlinkRes.status < 200 || backlinkRes.status >= 300) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
const contentType = (backlinkRes.headers.get('content-type') || '');
|
|
373
|
+
const text = backlinkRes.body;
|
|
374
|
+
|
|
375
|
+
// Two acceptable linkage shapes (either is sufficient):
|
|
376
|
+
// 1. CID v1: a verificationMethod containing this Nostr pubkey
|
|
377
|
+
// that is referenced from `authentication`. This is what
|
|
378
|
+
// JSS profiles ship and what the LWS10-CID resource-side
|
|
379
|
+
// verifier checks. Stronger than sameAs because the user
|
|
380
|
+
// is asserting the key, not merely an identity equivalence.
|
|
381
|
+
// 2. owl:sameAs / schema:sameAs to did:nostr:<pubkey>. Older
|
|
382
|
+
// shape; still accepted for compatibility.
|
|
383
|
+
// Pass `backlinkRes.url` (the FINAL post-redirect URL) as the
|
|
384
|
+
// base for absolutizing relative IDs in the profile. Profiles
|
|
385
|
+
// with a relative subject (`"@id": "#me"`) and absolute VM IDs
|
|
386
|
+
// can't otherwise be absolutized correctly by checkCidVmBacklink.
|
|
387
|
+
const checkProfile = (jsonLd) =>
|
|
388
|
+
checkCidVmBacklink(jsonLd, pubkey, backlinkRes.url) ||
|
|
389
|
+
checkSameAsLink(jsonLd, expectedDid);
|
|
390
|
+
|
|
391
|
+
// Handle HTML with JSON-LD data island
|
|
392
|
+
if (contentType.includes('text/html')) {
|
|
393
|
+
const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
|
|
394
|
+
if (jsonLdMatch) {
|
|
395
|
+
try {
|
|
396
|
+
return checkProfile(JSON.parse(jsonLdMatch[1]));
|
|
397
|
+
} catch {
|
|
398
|
+
// Parsed bytes but the JSON-LD island was malformed —
|
|
399
|
+
// verified absence (the host responded; the linkage is
|
|
400
|
+
// genuinely not there in a usable form).
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
143
406
|
|
|
144
|
-
|
|
407
|
+
// Handle JSON-LD directly
|
|
408
|
+
if (contentType.includes('json')) {
|
|
409
|
+
try {
|
|
410
|
+
return checkProfile(JSON.parse(text));
|
|
411
|
+
} catch {
|
|
145
412
|
return false;
|
|
146
413
|
}
|
|
414
|
+
}
|
|
147
415
|
|
|
148
|
-
|
|
149
|
-
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
150
418
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Does the WebID profile contain a CID v1 `verificationMethod` for
|
|
421
|
+
* the given Nostr pubkey, referenced from `authentication`, with a
|
|
422
|
+
* `controller` consistent with the profile's expected controller set?
|
|
423
|
+
*
|
|
424
|
+
* Mirrors the resource-side verifier's three checks:
|
|
425
|
+
* (1) profile contains a Nostr-shaped VM matching `pubkey` (with
|
|
426
|
+
* BIP-340 even-y validation handled inside
|
|
427
|
+
* extractNostrPubkeysFromProfile)
|
|
428
|
+
* (2) the VM is referenced from `authentication` — a key in
|
|
429
|
+
* `verificationMethod` alone is NOT an auth binding
|
|
430
|
+
* (3) the VM's `controller` is in the profile's expected
|
|
431
|
+
* controller set (the profile-level `controller` field, or
|
|
432
|
+
* the profile subject as the CID-v1 self-control fallback)
|
|
433
|
+
*
|
|
434
|
+
* @param {object} jsonLd - parsed WebID profile
|
|
435
|
+
* @param {string} pubkey - target Nostr x-only pubkey hex
|
|
436
|
+
* @param {string} [docUrl] - URL the profile was fetched from. Used
|
|
437
|
+
* as the base for absolutizing relative IDs when the profile's
|
|
438
|
+
* subject `@id` is itself relative (e.g. `"@id": "#me"`). Without
|
|
439
|
+
* this fallback, mixed-shape profiles (relative subject + absolute
|
|
440
|
+
* VM IDs) would absolutize against an empty base and the
|
|
441
|
+
* authentication-membership check would silently fail.
|
|
442
|
+
*/
|
|
443
|
+
function checkCidVmBacklink(jsonLd, pubkey, docUrl) {
|
|
444
|
+
const target = pubkey.toLowerCase();
|
|
445
|
+
const vms = extractNostrPubkeysFromProfile(jsonLd);
|
|
446
|
+
if (vms.length === 0) return false;
|
|
447
|
+
|
|
448
|
+
// Compute the URL base used for absolutizing relative IDs.
|
|
449
|
+
// Preference order:
|
|
450
|
+
// 1. profile['@id']/id when it's an absolute URL
|
|
451
|
+
// 2. the document URL the profile was fetched from
|
|
452
|
+
// 3. empty string (last resort — falls back to raw IDs)
|
|
453
|
+
const subjectRaw = jsonLd?.['@id'] || jsonLd?.id || '';
|
|
454
|
+
const stripHash = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return ''; } };
|
|
455
|
+
let base = stripHash(subjectRaw);
|
|
456
|
+
if (!base && docUrl) base = stripHash(docUrl);
|
|
457
|
+
// The absolute subject — used for the CID-v1 self-control
|
|
458
|
+
// fallback (controller defaults to the profile subject if no
|
|
459
|
+
// explicit controller is declared).
|
|
460
|
+
const profileSubject = base ? (() => {
|
|
461
|
+
try { const u = new URL(subjectRaw, base); return u.toString(); }
|
|
462
|
+
catch { return base; }
|
|
463
|
+
})() : '';
|
|
464
|
+
|
|
465
|
+
const absolutize = (s) => {
|
|
466
|
+
try { return new URL(s, base).toString(); }
|
|
467
|
+
catch { return s; }
|
|
468
|
+
};
|
|
469
|
+
const collectIds = (val) => {
|
|
470
|
+
const out = [];
|
|
471
|
+
const list = Array.isArray(val) ? val : (val ? [val] : []);
|
|
472
|
+
for (const ent of list) {
|
|
473
|
+
let id;
|
|
474
|
+
if (typeof ent === 'string') id = ent;
|
|
475
|
+
else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id;
|
|
476
|
+
if (id) out.push(absolutize(id));
|
|
163
477
|
}
|
|
478
|
+
return out;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// Authentication-referenced IDs.
|
|
482
|
+
const authIds = new Set(collectIds(jsonLd?.authentication));
|
|
483
|
+
|
|
484
|
+
// Expected controller set. Match the resource-side verifier:
|
|
485
|
+
// declared controllers if any, otherwise fall back to the
|
|
486
|
+
// profile subject (CID v1 self-control).
|
|
487
|
+
const expectedControllers = new Set(collectIds(jsonLd?.controller));
|
|
488
|
+
if (expectedControllers.size === 0 && profileSubject) {
|
|
489
|
+
expectedControllers.add(profileSubject);
|
|
490
|
+
}
|
|
164
491
|
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
492
|
+
for (const { pubkey: vmPubkey, vm } of vms) {
|
|
493
|
+
if (vmPubkey !== target) continue;
|
|
494
|
+
const vmIdRaw = vm.id || vm['@id'];
|
|
495
|
+
if (typeof vmIdRaw !== 'string') continue;
|
|
496
|
+
const vmId = absolutize(vmIdRaw);
|
|
497
|
+
if (!authIds.has(vmId)) continue;
|
|
498
|
+
// Check (3): VM MUST declare an explicit `controller`, AND
|
|
499
|
+
// that controller must be in expectedControllers. Match the
|
|
500
|
+
// resource-side verifier (src/auth/nostr.js + the well-known
|
|
501
|
+
// indexer) — neither falls back to "origin match means
|
|
502
|
+
// controller match" for a controller-less VM. A VM with no
|
|
503
|
+
// controller is ambiguous and should not authenticate; if
|
|
504
|
+
// the user wanted self-control, they can declare it.
|
|
505
|
+
const vmCtrls = collectIds(vm.controller);
|
|
506
|
+
if (vmCtrls.length === 0) continue;
|
|
507
|
+
for (const c of vmCtrls) {
|
|
508
|
+
if (expectedControllers.has(c)) return true;
|
|
173
509
|
}
|
|
174
|
-
|
|
175
|
-
return false;
|
|
176
|
-
|
|
177
|
-
} catch (err) {
|
|
178
|
-
rateLimitedError(`backlink:${webId}`, `WebID backlink verification error for ${webId}: ${err.message}`);
|
|
179
|
-
return false;
|
|
180
510
|
}
|
|
511
|
+
return false;
|
|
181
512
|
}
|
|
182
513
|
|
|
183
514
|
/**
|
|
@@ -230,3 +561,16 @@ function checkSameAsLink(jsonLd, expectedDid) {
|
|
|
230
561
|
export function clearCache() {
|
|
231
562
|
cache.clear();
|
|
232
563
|
}
|
|
564
|
+
|
|
565
|
+
/** @internal — exposed for tests; current cache size after evictions. */
|
|
566
|
+
export function _cacheSizeForTests() {
|
|
567
|
+
return cache.size;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** @internal — exposed for tests; thin wrapper over checkCidVmBacklink. */
|
|
571
|
+
export function _checkCidVmBacklinkForTests(profile, pubkey, docUrl) {
|
|
572
|
+
return checkCidVmBacklink(profile, pubkey, docUrl);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** @internal — exposed for tests; LRU max for assertions. */
|
|
576
|
+
export const _CACHE_MAX_FOR_TESTS = CACHE_MAX_ENTRIES;
|