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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/README.md +1 -0
- package/docs/lws.md +84 -0
- package/package.json +1 -1
- package/src/auth/lws-cid.js +679 -0
- package/src/auth/token.js +12 -1
- package/test/lws-cid.test.js +705 -0
|
@@ -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
|
+
}
|