javascript-solid-server 0.0.176 → 0.0.177

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.
@@ -0,0 +1,679 @@
1
+ /**
2
+ * LWS 1.0 Authentication Suite — Self-Signed Identity using Controlled Identifiers
3
+ *
4
+ * Implements the verifier side of the LWS10-CID FPWD (2026-04-23):
5
+ * https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/
6
+ *
7
+ * The credential is a JWT (RFC7515 / RFC7519) signed with a JWS algorithm.
8
+ * The verifier:
9
+ *
10
+ * 1. Reads `kid` from the JWT header. Per LWS10-CID, `kid` references a
11
+ * verificationMethod inside the subject's controlled identifier
12
+ * document — for a Solid pod, that document IS the WebID profile.
13
+ * 2. Validates the FPWD §4 constraints: `sub === iss === client_id`
14
+ * (all the same WebID URI), `aud` includes the target server, `exp`
15
+ * not past, `iat` recent.
16
+ * 3. Fetches the WebID profile, locates the verificationMethod by `kid`.
17
+ * 4. Decodes its `publicKeyJwk`.
18
+ * 5. Verifies the JWT signature per RFC7515 §5.2.
19
+ * 6. Confirms the VM's `controller` matches the profile's declared
20
+ * controller (with fallback to @id) — same self-control rule the
21
+ * doctor's lws-cid validator uses on the client side.
22
+ * 7. Returns the WebID as the authenticated identity.
23
+ *
24
+ * Design choices:
25
+ *
26
+ * - Detection is unambiguous: LWS-CID JWTs have a `kid` whose value is a
27
+ * URL with a fragment (the VM's `id`). IDP-issued JWTs (the existing
28
+ * `verifyJwtFromIdp` path) use opaque fingerprints. We route on shape.
29
+ *
30
+ * - secp256k1 / ES256K is the focus algorithm — same key Nostr users
31
+ * already have, signed as ECDSA for spec conformance. ES256 / EdDSA /
32
+ * RS256 also accepted; jose handles those natively.
33
+ */
34
+
35
+ import * as jose from 'jose';
36
+ import { secp256k1 } from '@noble/curves/secp256k1';
37
+ import { sha256 } from '@noble/hashes/sha256';
38
+ import { validateExternalUrl } from '../utils/ssrf.js';
39
+
40
+ // JWS algorithms we accept. ES256K (RFC8812) is the primary target —
41
+ // secp256k1, the same curve as Nostr — but we support the common JWS
42
+ // algorithms too so other CID-document key shapes work out of the box.
43
+ const ACCEPTED_ALGS = new Set(['ES256K', 'ES256', 'ES384', 'EdDSA', 'RS256']);
44
+
45
+ // Maximum age for the iat (issued-at) claim, in seconds. JWT-as-HTTP-auth
46
+ // tokens are expected to be freshly minted; rejecting stale ones limits
47
+ // replay damage.
48
+ const MAX_IAT_AGE = 600; // 10 minutes
49
+
50
+ // Maximum allowed token lifetime (exp − iat). Auth tokens are short-lived
51
+ // by design; arbitrarily long-lived JWTs widen the replay window if a
52
+ // signed token leaks.
53
+ const MAX_LIFETIME = 3600; // 1 hour
54
+
55
+ // Clock skew tolerance for exp/nbf checks (seconds).
56
+ const CLOCK_SKEW = 60;
57
+
58
+ // Profile fetch cache. Auth is on the hot path; refetching the CID
59
+ // document on every request is unacceptable for both latency and
60
+ // reliability. Mirrors the pattern in did-nostr.js, but bounded — an
61
+ // attacker can otherwise grow the cache without limit by sending tokens
62
+ // with many distinct `sub` URLs.
63
+ const profileCache = new Map(); // url -> { profile, timestamp, failureTtl?, error? }
64
+ const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes for hits
65
+ const PROFILE_FAILURE_TTL = 60 * 1000; // 1 minute for misses
66
+ const PROFILE_CACHE_MAX = 1000; // simple LRU bound
67
+
68
+ // Manual-redirect cap so a chain can't loop or grind.
69
+ const MAX_REDIRECTS = 5;
70
+
71
+ // Max profile body size — guards against DoS via giant JSON bodies on
72
+ // untrusted URLs. CID documents are tiny in practice (~1-5 KB).
73
+ const MAX_PROFILE_BYTES = 256 * 1024; // 256 KB
74
+
75
+ /** @internal — exposed for tests */
76
+ export function _clearProfileCacheForTests() {
77
+ profileCache.clear();
78
+ }
79
+
80
+ /**
81
+ * Cheap detector — does this request carry an LWS-CID JWT?
82
+ *
83
+ * Routes a Bearer JWT to verifyLwsCidAuth only when it shows the
84
+ * specific LWS-CID shape:
85
+ * - Authorization: Bearer <token>
86
+ * - token is a 3-part JWT
87
+ * - header.alg is one of our accepted JWS algorithms
88
+ * - header.kid is an http(s) URL with a fragment (which is what an
89
+ * LWS-CID verificationMethod id always looks like)
90
+ *
91
+ * Other JWS algs / non-URL kids fall through to the existing
92
+ * IdP / simple-token paths in token.js — so this detector is
93
+ * conservative on purpose.
94
+ *
95
+ * @param {object} request
96
+ * @returns {boolean}
97
+ */
98
+ export function hasLwsCidAuth(request) {
99
+ const auth = request.headers?.authorization;
100
+ if (!auth || typeof auth !== 'string' || !auth.startsWith('Bearer ')) return false;
101
+ const token = auth.slice(7).trim();
102
+ const parts = token.split('.');
103
+ if (parts.length !== 3) return false;
104
+ try {
105
+ const header = JSON.parse(b64uDecode(parts[0]).toString('utf8'));
106
+ if (!header) return false;
107
+ if (typeof header.alg !== 'string' || !ACCEPTED_ALGS.has(header.alg)) return false;
108
+ if (typeof header.kid !== 'string') return false;
109
+ const u = new URL(header.kid);
110
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
111
+ return Boolean(u.hash); // LWS-CID kid is always a fragment URI
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Verify an LWS-CID JWT and return the authenticated WebID.
119
+ *
120
+ * @param {object} request
121
+ * @returns {Promise<{webId: string|null, error: string|null}>}
122
+ */
123
+ export async function verifyLwsCidAuth(request) {
124
+ const auth = request.headers?.authorization;
125
+ if (!auth || !auth.startsWith('Bearer ')) {
126
+ return { webId: null, error: 'missing Bearer token' };
127
+ }
128
+ const token = auth.slice(7).trim();
129
+
130
+ // Decode header + payload without verifying so we can pick the key.
131
+ let header, payload;
132
+ try {
133
+ header = jose.decodeProtectedHeader(token);
134
+ payload = jose.decodeJwt(token);
135
+ } catch (err) {
136
+ return { webId: null, error: `malformed JWT: ${err.message}` };
137
+ }
138
+
139
+ if (typeof header.alg !== 'string' || header.alg.length === 0) {
140
+ return { webId: null, error: 'JWT header missing alg' };
141
+ }
142
+ if (header.alg === 'none') {
143
+ return { webId: null, error: 'JWT MUST NOT use "none" as the signing algorithm' };
144
+ }
145
+ if (!ACCEPTED_ALGS.has(header.alg)) {
146
+ return { webId: null, error: `unsupported alg: ${header.alg}` };
147
+ }
148
+ if (typeof header.kid !== 'string' || !header.kid) {
149
+ return { webId: null, error: 'missing kid' };
150
+ }
151
+ let kidUrl;
152
+ try {
153
+ kidUrl = new URL(header.kid);
154
+ } catch {
155
+ return { webId: null, error: 'kid is not a URL' };
156
+ }
157
+ if (!kidUrl.hash) {
158
+ return { webId: null, error: 'kid must be a fragment URI within a CID document' };
159
+ }
160
+ // Normalize kid via the URL parser's canonicalization (case-folded
161
+ // scheme/host, default-port stripped, percent-encoded path) so all
162
+ // downstream comparisons against absolutized VM ids and proof-purpose
163
+ // refs operate on canonical strings. Otherwise a semantically
164
+ // equivalent but non-canonical kid in the JWT header would fail to
165
+ // match the profile's id values.
166
+ const kid = kidUrl.toString();
167
+
168
+ // Auth credentials over plaintext HTTP are a non-starter — fail loud
169
+ // and early with a clear error rather than letting the request limp
170
+ // along to a generic "could not fetch / SSRF protection" failure
171
+ // downstream. (The SSRF guard still has its own production check as
172
+ // defense-in-depth.)
173
+ if (kidUrl.protocol !== 'https:') {
174
+ return { webId: null, error: 'kid must use https' };
175
+ }
176
+
177
+ // FPWD §4: sub === iss === client_id, all the same WebID URI.
178
+ // Compare canonicalized forms so semantically equal but textually
179
+ // different URIs (different case, default ports written out, etc.)
180
+ // pass equality. The canonical form is also what we hand back to
181
+ // callers — WAC and other downstream consumers do string equality on
182
+ // the WebID, so handing them a non-canonical form would fail to
183
+ // match ACL agent entries.
184
+ const { sub, iss, client_id, aud, exp, iat, nbf } = payload;
185
+ if (!sub || !iss || !client_id) {
186
+ return { webId: null, error: 'JWT missing sub/iss/client_id' };
187
+ }
188
+ let webId, canonicalIss, canonicalClientId;
189
+ try {
190
+ webId = new URL(sub).toString();
191
+ canonicalIss = new URL(iss).toString();
192
+ canonicalClientId = new URL(client_id).toString();
193
+ } catch (err) {
194
+ return { webId: null, error: `invalid sub/iss/client_id URI: ${err.message}` };
195
+ }
196
+ if (webId !== canonicalIss || webId !== canonicalClientId) {
197
+ return { webId: null, error: 'sub, iss, and client_id MUST all use the same URI value' };
198
+ }
199
+
200
+ // The kid's document URL must match the WebID's document URL — the VM
201
+ // lives inside the subject's CID document.
202
+ const kidDoc = stripHash(kid);
203
+ const webIdDoc = stripHash(webId);
204
+ if (kidDoc !== webIdDoc) {
205
+ return {
206
+ webId: null,
207
+ error: `kid (${kid}) is not in the subject's CID document (${webIdDoc})`,
208
+ };
209
+ }
210
+
211
+ // Time-claim validation. The ES256K branch below skips jose.jwtVerify
212
+ // and relies on these checks alone, so each claim's TYPE matters as
213
+ // much as its value — `exp: "9999999999"` (string) must NOT be
214
+ // silently accepted as a number.
215
+ //
216
+ // Per FPWD §4, both iat and exp are MUST; nbf is optional but enforced
217
+ // when present. We additionally cap the lifetime (exp − iat) to bound
218
+ // the replay window if a signed token leaks.
219
+ if (typeof exp !== 'number') {
220
+ return { webId: null, error: 'JWT exp claim is required and must be a number' };
221
+ }
222
+ if (typeof iat !== 'number') {
223
+ return { webId: null, error: 'JWT iat claim is required and must be a number' };
224
+ }
225
+ if (nbf !== undefined && typeof nbf !== 'number') {
226
+ return { webId: null, error: 'JWT nbf claim must be a number' };
227
+ }
228
+ const now = Math.floor(Date.now() / 1000);
229
+ if (now > exp + CLOCK_SKEW) {
230
+ return { webId: null, error: 'JWT expired' };
231
+ }
232
+ if (typeof nbf === 'number' && now + CLOCK_SKEW < nbf) {
233
+ return { webId: null, error: 'JWT not yet valid (nbf in the future)' };
234
+ }
235
+ if (now - iat > MAX_IAT_AGE + CLOCK_SKEW) {
236
+ return { webId: null, error: 'JWT iat too old' };
237
+ }
238
+ if (iat - now > CLOCK_SKEW) {
239
+ return { webId: null, error: 'JWT iat is in the future' };
240
+ }
241
+ if (exp - iat > MAX_LIFETIME) {
242
+ return {
243
+ webId: null,
244
+ error: `JWT lifetime exceeds maximum (${exp - iat}s > ${MAX_LIFETIME}s)`,
245
+ };
246
+ }
247
+ if (exp <= iat) {
248
+ return { webId: null, error: 'JWT exp must be after iat' };
249
+ }
250
+
251
+ // Audience check — `aud` is required (FPWD §4: "the aud claim MUST
252
+ // include the target authorization server"), and the request's
253
+ // origin must appear in it.
254
+ const reqOrigin = getRequestOrigin(request);
255
+ const audList = aud === undefined ? [] : Array.isArray(aud) ? aud : [aud];
256
+ if (audList.length === 0) {
257
+ return { webId: null, error: 'JWT aud claim is required' };
258
+ }
259
+ if (!reqOrigin) {
260
+ // We can't determine our own origin, so we can't verify aud. Per
261
+ // FPWD, aud MUST include the target server — failing closed is
262
+ // safer than silently accepting any aud value.
263
+ return {
264
+ webId: null,
265
+ error: 'cannot determine server origin to verify aud',
266
+ };
267
+ }
268
+ const audMatch = audList.some((a) => normalizeOrigin(a) === reqOrigin);
269
+ if (!audMatch) {
270
+ return {
271
+ webId: null,
272
+ error: `aud does not include this server's origin (${reqOrigin})`,
273
+ };
274
+ }
275
+
276
+ // Fetch the CID document (= WebID profile) and locate the VM by kid.
277
+ let profile;
278
+ try {
279
+ profile = await fetchProfile(webIdDoc);
280
+ } catch (err) {
281
+ return { webId: null, error: `could not fetch CID document: ${err.message}` };
282
+ }
283
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
284
+ return { webId: null, error: 'CID document is not a JSON object' };
285
+ }
286
+
287
+ // Subject-identity check. The CID document we just fetched MUST
288
+ // actually identify itself as the JWT's `sub`. Without this, a doc
289
+ // hosted at the same URL could declare itself to be a different
290
+ // WebID fragment, but reuse a verificationMethod controlled by
291
+ // another node — and we'd authenticate as `sub` based on the wrong
292
+ // VM's signature.
293
+ const profileSubject = absolutize(profile['@id'] ?? profile.id, webIdDoc);
294
+ if (!profileSubject) {
295
+ return {
296
+ webId: null,
297
+ error: 'CID document declares no subject (@id / id)',
298
+ };
299
+ }
300
+ if (profileSubject !== webId) {
301
+ return {
302
+ webId: null,
303
+ error: `CID document subject (${profileSubject}) does not match JWT sub (${webId})`,
304
+ };
305
+ }
306
+
307
+ const vm = findVerificationMethod(profile, kid, webIdDoc);
308
+ if (!vm) {
309
+ return {
310
+ webId: null,
311
+ error: `no verificationMethod with id ${kid} in CID document`,
312
+ };
313
+ }
314
+
315
+ // VM must be referenced by `authentication` to be usable as an auth
316
+ // credential. (CID 1.0 §3.3)
317
+ if (!isInProofPurpose(profile, 'authentication', kid, webIdDoc)) {
318
+ return {
319
+ webId: null,
320
+ error: `verificationMethod ${kid} is not listed in authentication`,
321
+ };
322
+ }
323
+
324
+ // Confirm the VM's controller agrees with the profile's controller
325
+ // (or with @id on fallback). Self-controlled is the common case. A
326
+ // profile with no controller / @id / id at all is malformed — fail
327
+ // closed rather than letting the VM controller check pass vacuously.
328
+ const expectedCtrls = normalizeControllers(profile.controller ?? profile['@id'] ?? profile.id, webIdDoc);
329
+ if (expectedCtrls.length === 0) {
330
+ return {
331
+ webId: null,
332
+ error: 'CID document has no controller or @id — controller check cannot proceed',
333
+ };
334
+ }
335
+ const vmCtrls = normalizeControllers(vm.controller, webIdDoc);
336
+ const matched = vmCtrls.some((c) => expectedCtrls.includes(c));
337
+ if (!matched) {
338
+ return {
339
+ webId: null,
340
+ error: 'verificationMethod controller does not match profile controller',
341
+ };
342
+ }
343
+
344
+ // Decode the JWK and verify the signature.
345
+ if (!vm.publicKeyJwk || typeof vm.publicKeyJwk !== 'object') {
346
+ return {
347
+ webId: null,
348
+ error: 'verificationMethod has no publicKeyJwk (Multikey-only VMs not yet handled here)',
349
+ };
350
+ }
351
+ const jwk = vm.publicKeyJwk;
352
+
353
+ try {
354
+ if (header.alg === 'ES256K') {
355
+ // jose's Web Crypto path doesn't support secp256k1 in all
356
+ // environments. Verify with @noble/curves directly — same primitive
357
+ // we already use for Schnorr in the Nostr path.
358
+ await verifyEs256kJwt(token, jwk);
359
+ } else {
360
+ const key = await jose.importJWK(jwk, header.alg);
361
+ await jose.jwtVerify(token, key, {
362
+ algorithms: [header.alg],
363
+ clockTolerance: CLOCK_SKEW,
364
+ });
365
+ }
366
+ } catch (err) {
367
+ return { webId: null, error: `signature verification failed: ${err.message}` };
368
+ }
369
+
370
+ return { webId, error: null };
371
+ }
372
+
373
+ // ---------------------------------------------------------------------
374
+ // helpers
375
+ // ---------------------------------------------------------------------
376
+
377
+ function b64uDecode(s) {
378
+ return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
379
+ }
380
+
381
+ function stripHash(u) {
382
+ if (typeof u !== 'string') return u;
383
+ try {
384
+ const url = new URL(u);
385
+ url.hash = '';
386
+ return url.toString();
387
+ } catch {
388
+ return u.split('#')[0];
389
+ }
390
+ }
391
+
392
+ function getRequestOrigin(request) {
393
+ // Behind a reverse proxy, the front-end forwarded headers are the
394
+ // authoritative source. Match the convention used in src/ap/* and
395
+ // similar code: x-forwarded-* take precedence, fall back to fastify's
396
+ // protocol/hostname.
397
+ //
398
+ // Multi-proxy chains may produce comma-separated lists (e.g.
399
+ // `x-forwarded-host: a.example, b.internal`); the leftmost value is
400
+ // the original client-facing front-end, which is what we want.
401
+ //
402
+ // The result is run through normalizeOrigin so default ports and
403
+ // case folding match the audList comparison side.
404
+ const headers = request.headers || {};
405
+ const proto = firstHeaderValue(headers['x-forwarded-proto']) || request.protocol || 'https';
406
+ const host = firstHeaderValue(headers['x-forwarded-host']) || headers.host || request.hostname;
407
+ if (!host) return null;
408
+ return normalizeOrigin(`${proto}://${host}`);
409
+ }
410
+
411
+ function firstHeaderValue(v) {
412
+ if (!v) return null;
413
+ // Fastify can yield a string, an array, or undefined.
414
+ const s = Array.isArray(v) ? v[0] : v;
415
+ if (typeof s !== 'string') return null;
416
+ const first = s.split(',')[0].trim();
417
+ return first || null;
418
+ }
419
+
420
+ function normalizeOrigin(s) {
421
+ if (typeof s !== 'string') return null;
422
+ try {
423
+ const u = new URL(s);
424
+ return `${u.protocol}//${u.host}`;
425
+ } catch {
426
+ return s;
427
+ }
428
+ }
429
+
430
+ async function fetchProfile(docUrl) {
431
+ // Cache hit (or recent failure) — return immediately. On hit we
432
+ // delete-then-reset so this entry moves to the tail of the Map's
433
+ // insertion order, giving us LRU eviction without an extra structure.
434
+ const cached = profileCache.get(docUrl);
435
+ if (cached) {
436
+ const ttl = cached.failureTtl ? PROFILE_FAILURE_TTL : PROFILE_CACHE_TTL;
437
+ if (Date.now() - cached.timestamp < ttl) {
438
+ profileCache.delete(docUrl);
439
+ profileCache.set(docUrl, cached);
440
+ if (cached.failureTtl) throw new Error(cached.error);
441
+ return cached.profile;
442
+ }
443
+ profileCache.delete(docUrl);
444
+ }
445
+
446
+ try {
447
+ const profile = await fetchProfileNoCache(docUrl);
448
+ setCached(docUrl, { profile, timestamp: Date.now() });
449
+ return profile;
450
+ } catch (err) {
451
+ setCached(docUrl, {
452
+ timestamp: Date.now(),
453
+ failureTtl: true,
454
+ error: err.message,
455
+ });
456
+ throw err;
457
+ }
458
+ }
459
+
460
+ /** Insert into the bounded LRU; evict the oldest entry past the cap. */
461
+ function setCached(url, entry) {
462
+ profileCache.set(url, entry);
463
+ while (profileCache.size > PROFILE_CACHE_MAX) {
464
+ // Map iterates in insertion order; first key is the oldest.
465
+ const oldest = profileCache.keys().next().value;
466
+ if (oldest === undefined) break;
467
+ profileCache.delete(oldest);
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Fetch the CID document with SSRF protection.
473
+ *
474
+ * docUrl comes from JWT claims (sub, kid) BEFORE the signature is
475
+ * verified, so it's untrusted. We:
476
+ * 1. Validate it through the existing SSRF guard (blocks loopback,
477
+ * private IPs, http (in production), DNS that resolves to private
478
+ * addresses).
479
+ * 2. Disable automatic redirects and re-validate every Location to
480
+ * defeat redirect-based bypasses (mirrors the cors-proxy pattern).
481
+ * Cross-origin redirects are refused — otherwise a target
482
+ * attacker-controlled host could serve a substitute CID document
483
+ * for the WebID's origin.
484
+ * 3. Cap redirects so a chain can't loop.
485
+ * 4. Cap response body size so a giant payload can't OOM us.
486
+ * 5. Always send a fresh Accept and a small read-side timeout.
487
+ */
488
+ async function fetchProfileNoCache(docUrl) {
489
+ const originalOrigin = new URL(docUrl).origin;
490
+ let currentUrl = docUrl;
491
+
492
+ // Hop 0 is the original request; up to MAX_REDIRECTS subsequent
493
+ // redirects are followed, after which we throw.
494
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
495
+ const isLastAllowedHop = hop === MAX_REDIRECTS;
496
+ const validation = await validateExternalUrl(currentUrl, {
497
+ // Allow http on dev only — production deploys should always be https.
498
+ requireHttps: process.env.NODE_ENV === 'production',
499
+ blockPrivateIPs: true,
500
+ resolveDNS: true,
501
+ });
502
+ if (!validation.valid) {
503
+ throw new Error(`SSRF protection: ${validation.error}`);
504
+ }
505
+
506
+ const controller = new AbortController();
507
+ const timer = setTimeout(() => controller.abort(), 5000);
508
+ let res;
509
+ try {
510
+ res = await fetch(currentUrl, {
511
+ // Prefer JSON-LD but accept plain JSON too — some WebID hosts
512
+ // serve `application/json` for `card.jsonld`. The body is JSON
513
+ // either way; we don't perform JSON-LD-specific processing here.
514
+ headers: { Accept: 'application/ld+json, application/json;q=0.9' },
515
+ redirect: 'manual',
516
+ signal: controller.signal,
517
+ });
518
+ } finally {
519
+ clearTimeout(timer);
520
+ }
521
+
522
+ // Manual redirect handling — re-validate every Location and require
523
+ // same-origin so a redirect can't substitute an attacker-controlled
524
+ // CID document for the WebID's origin.
525
+ if (res.status >= 300 && res.status < 400) {
526
+ if (isLastAllowedHop) {
527
+ throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
528
+ }
529
+ const loc = res.headers.get('location');
530
+ if (!loc) throw new Error(`redirect ${res.status} without Location`);
531
+ const nextUrl = new URL(loc, currentUrl).toString();
532
+ const nextOrigin = new URL(nextUrl).origin;
533
+ if (nextOrigin !== originalOrigin) {
534
+ throw new Error(
535
+ `cross-origin redirect refused: ${originalOrigin} → ${nextOrigin}`,
536
+ );
537
+ }
538
+ currentUrl = nextUrl;
539
+ continue;
540
+ }
541
+
542
+ if (!res.ok) {
543
+ throw new Error(`HTTP ${res.status}`);
544
+ }
545
+
546
+ // Size guard. Two layers: trust Content-Length when present, then
547
+ // also enforce as we read so a streaming response can't lie.
548
+ const declared = Number(res.headers.get('content-length'));
549
+ if (Number.isFinite(declared) && declared > MAX_PROFILE_BYTES) {
550
+ throw new Error(
551
+ `CID document too large (Content-Length=${declared} > ${MAX_PROFILE_BYTES})`,
552
+ );
553
+ }
554
+ const text = await readBodyWithCap(res, MAX_PROFILE_BYTES);
555
+ return JSON.parse(text);
556
+ }
557
+ // Loop exited without returning or redirecting — defensive fallback.
558
+ throw new Error('profile fetch loop exited unexpectedly');
559
+ }
560
+
561
+ /**
562
+ * Read response body with a hard byte cap. Aborts the stream as soon as
563
+ * the cap is exceeded so we don't buffer the entire untrusted payload.
564
+ */
565
+ async function readBodyWithCap(res, maxBytes) {
566
+ const reader = res.body?.getReader?.();
567
+ if (!reader) {
568
+ // No streaming reader (older runtimes / mocked responses) — fall
569
+ // back to .text() but enforce the cap after the fact.
570
+ const text = await res.text();
571
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) {
572
+ throw new Error(`CID document too large (>${maxBytes} bytes)`);
573
+ }
574
+ return text;
575
+ }
576
+ const chunks = [];
577
+ let total = 0;
578
+ for (;;) {
579
+ const { value, done } = await reader.read();
580
+ if (done) break;
581
+ total += value.byteLength;
582
+ if (total > maxBytes) {
583
+ try { await reader.cancel(); } catch { /* noop */ }
584
+ throw new Error(`CID document too large (>${maxBytes} bytes)`);
585
+ }
586
+ chunks.push(value);
587
+ }
588
+ return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString('utf8');
589
+ }
590
+
591
+ function findVerificationMethod(profile, kid, baseUrl) {
592
+ const vms = asArray(profile.verificationMethod);
593
+ for (const vm of vms) {
594
+ if (!vm || typeof vm !== 'object') continue;
595
+ const vmId = vm.id || vm['@id'];
596
+ if (!vmId) continue;
597
+ if (absolutize(vmId, baseUrl) === kid) return vm;
598
+ }
599
+ return null;
600
+ }
601
+
602
+ function isInProofPurpose(profile, predicate, kid, baseUrl) {
603
+ const entries = asArray(profile[predicate]);
604
+ if (entries.length === 0) return false;
605
+ for (const ent of entries) {
606
+ if (typeof ent === 'string') {
607
+ if (absolutize(ent, baseUrl) === kid) return true;
608
+ } else if (ent && typeof ent === 'object') {
609
+ const id = ent['@id'] ?? ent.id;
610
+ if (id && absolutize(id, baseUrl) === kid) return true;
611
+ }
612
+ }
613
+ return false;
614
+ }
615
+
616
+ function normalizeControllers(value, baseUrl) {
617
+ if (value === undefined || value === null) return [];
618
+ const list = Array.isArray(value) ? value : [value];
619
+ const out = [];
620
+ for (const v of list) {
621
+ let iri;
622
+ if (typeof v === 'string') iri = v;
623
+ else if (v && typeof v === 'object') iri = v['@id'] ?? v.id;
624
+ if (typeof iri !== 'string' || iri.length === 0) continue;
625
+ out.push(absolutize(iri, baseUrl));
626
+ }
627
+ return out;
628
+ }
629
+
630
+ function asArray(v) {
631
+ if (v === undefined || v === null) return [];
632
+ return Array.isArray(v) ? v : [v];
633
+ }
634
+
635
+ function absolutize(u, base) {
636
+ if (!u) return u;
637
+ try {
638
+ return new URL(u, base).toString();
639
+ } catch {
640
+ return u;
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Verify a JWT signed with ES256K (ECDSA over secp256k1) using
646
+ * @noble/curves. Returns on success, throws on failure.
647
+ *
648
+ * Web Crypto / jose lack uniform secp256k1 support across Node versions,
649
+ * and this primitive is already in tree (used by NIP-98 / Nostr). The
650
+ * curve (secp256k1) is the same one Nostr keys live on, so a Nostr
651
+ * private key can sign here without any new key material.
652
+ */
653
+ async function verifyEs256kJwt(token, jwk) {
654
+ if (jwk.kty !== 'EC' || (jwk.crv !== 'secp256k1' && jwk.crv !== 'P-256K')) {
655
+ throw new Error(`ES256K requires kty:EC and crv:secp256k1 (or legacy crv:P-256K), got kty:${jwk.kty} crv:${jwk.crv}`);
656
+ }
657
+ if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
658
+ throw new Error('JWK missing x/y coordinates');
659
+ }
660
+ // Build the uncompressed SEC1 public point: 0x04 || x || y
661
+ const x = b64uDecode(jwk.x);
662
+ const y = b64uDecode(jwk.y);
663
+ if (x.length !== 32 || y.length !== 32) {
664
+ throw new Error(`secp256k1 coordinates must be 32 bytes; got x=${x.length} y=${y.length}`);
665
+ }
666
+ const pub = Buffer.concat([Buffer.from([0x04]), x, y]);
667
+
668
+ const [headerB64, payloadB64, sigB64] = token.split('.');
669
+ const signingInput = Buffer.from(`${headerB64}.${payloadB64}`, 'utf8');
670
+ const sigRaw = b64uDecode(sigB64);
671
+ if (sigRaw.length !== 64) {
672
+ throw new Error(`ES256K signature must be 64 bytes (r||s); got ${sigRaw.length}`);
673
+ }
674
+
675
+ const msgHash = sha256(signingInput);
676
+ const sig = secp256k1.Signature.fromCompact(sigRaw);
677
+ const ok = secp256k1.verify(sig, msgHash, pub);
678
+ if (!ok) throw new Error('signature is not valid');
679
+ }