javascript-solid-server 0.0.176 → 0.0.178
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/cid-doc-fetch.js +202 -0
- package/src/auth/lws-cid.js +516 -0
- package/src/auth/nostr.js +397 -8
- package/src/auth/token.js +12 -1
- package/test/lws-cid.test.js +705 -0
- package/test/nostr-cid-vm.test.js +509 -0
|
@@ -0,0 +1,516 @@
|
|
|
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 { fetchCidDocument, _clearProfileCacheForTests } from './cid-doc-fetch.js';
|
|
39
|
+
|
|
40
|
+
// Re-export for the existing test suite, which already calls this.
|
|
41
|
+
export { _clearProfileCacheForTests };
|
|
42
|
+
|
|
43
|
+
// JWS algorithms we accept. ES256K (RFC8812) is the primary target —
|
|
44
|
+
// secp256k1, the same curve as Nostr — but we support the common JWS
|
|
45
|
+
// algorithms too so other CID-document key shapes work out of the box.
|
|
46
|
+
const ACCEPTED_ALGS = new Set(['ES256K', 'ES256', 'ES384', 'EdDSA', 'RS256']);
|
|
47
|
+
|
|
48
|
+
// Maximum age for the iat (issued-at) claim, in seconds. JWT-as-HTTP-auth
|
|
49
|
+
// tokens are expected to be freshly minted; rejecting stale ones limits
|
|
50
|
+
// replay damage.
|
|
51
|
+
const MAX_IAT_AGE = 600; // 10 minutes
|
|
52
|
+
|
|
53
|
+
// Maximum allowed token lifetime (exp − iat). Auth tokens are short-lived
|
|
54
|
+
// by design; arbitrarily long-lived JWTs widen the replay window if a
|
|
55
|
+
// signed token leaks.
|
|
56
|
+
const MAX_LIFETIME = 3600; // 1 hour
|
|
57
|
+
|
|
58
|
+
// Clock skew tolerance for exp/nbf checks (seconds).
|
|
59
|
+
const CLOCK_SKEW = 60;
|
|
60
|
+
|
|
61
|
+
// Max profile body size — passed to the shared fetcher. CID documents
|
|
62
|
+
// are tiny in practice (~1-5 KB); 256 KB leaves plenty of headroom
|
|
63
|
+
// while bounding any DoS attempt. The cache itself lives in
|
|
64
|
+
// cid-doc-fetch.js so both this module and the NIP-98 path benefit.
|
|
65
|
+
const MAX_PROFILE_BYTES = 256 * 1024;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cheap detector — does this request carry an LWS-CID JWT?
|
|
69
|
+
*
|
|
70
|
+
* Routes a Bearer JWT to verifyLwsCidAuth only when it shows the
|
|
71
|
+
* specific LWS-CID shape:
|
|
72
|
+
* - Authorization: Bearer <token>
|
|
73
|
+
* - token is a 3-part JWT
|
|
74
|
+
* - header.alg is one of our accepted JWS algorithms
|
|
75
|
+
* - header.kid is an http(s) URL with a fragment (which is what an
|
|
76
|
+
* LWS-CID verificationMethod id always looks like)
|
|
77
|
+
*
|
|
78
|
+
* Other JWS algs / non-URL kids fall through to the existing
|
|
79
|
+
* IdP / simple-token paths in token.js — so this detector is
|
|
80
|
+
* conservative on purpose.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} request
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function hasLwsCidAuth(request) {
|
|
86
|
+
const auth = request.headers?.authorization;
|
|
87
|
+
if (!auth || typeof auth !== 'string' || !auth.startsWith('Bearer ')) return false;
|
|
88
|
+
const token = auth.slice(7).trim();
|
|
89
|
+
const parts = token.split('.');
|
|
90
|
+
if (parts.length !== 3) return false;
|
|
91
|
+
try {
|
|
92
|
+
const header = JSON.parse(b64uDecode(parts[0]).toString('utf8'));
|
|
93
|
+
if (!header) return false;
|
|
94
|
+
if (typeof header.alg !== 'string' || !ACCEPTED_ALGS.has(header.alg)) return false;
|
|
95
|
+
if (typeof header.kid !== 'string') return false;
|
|
96
|
+
const u = new URL(header.kid);
|
|
97
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
|
|
98
|
+
return Boolean(u.hash); // LWS-CID kid is always a fragment URI
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verify an LWS-CID JWT and return the authenticated WebID.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} request
|
|
108
|
+
* @returns {Promise<{webId: string|null, error: string|null}>}
|
|
109
|
+
*/
|
|
110
|
+
export async function verifyLwsCidAuth(request) {
|
|
111
|
+
const auth = request.headers?.authorization;
|
|
112
|
+
if (!auth || !auth.startsWith('Bearer ')) {
|
|
113
|
+
return { webId: null, error: 'missing Bearer token' };
|
|
114
|
+
}
|
|
115
|
+
const token = auth.slice(7).trim();
|
|
116
|
+
|
|
117
|
+
// Decode header + payload without verifying so we can pick the key.
|
|
118
|
+
let header, payload;
|
|
119
|
+
try {
|
|
120
|
+
header = jose.decodeProtectedHeader(token);
|
|
121
|
+
payload = jose.decodeJwt(token);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { webId: null, error: `malformed JWT: ${err.message}` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof header.alg !== 'string' || header.alg.length === 0) {
|
|
127
|
+
return { webId: null, error: 'JWT header missing alg' };
|
|
128
|
+
}
|
|
129
|
+
if (header.alg === 'none') {
|
|
130
|
+
return { webId: null, error: 'JWT MUST NOT use "none" as the signing algorithm' };
|
|
131
|
+
}
|
|
132
|
+
if (!ACCEPTED_ALGS.has(header.alg)) {
|
|
133
|
+
return { webId: null, error: `unsupported alg: ${header.alg}` };
|
|
134
|
+
}
|
|
135
|
+
if (typeof header.kid !== 'string' || !header.kid) {
|
|
136
|
+
return { webId: null, error: 'missing kid' };
|
|
137
|
+
}
|
|
138
|
+
let kidUrl;
|
|
139
|
+
try {
|
|
140
|
+
kidUrl = new URL(header.kid);
|
|
141
|
+
} catch {
|
|
142
|
+
return { webId: null, error: 'kid is not a URL' };
|
|
143
|
+
}
|
|
144
|
+
if (!kidUrl.hash) {
|
|
145
|
+
return { webId: null, error: 'kid must be a fragment URI within a CID document' };
|
|
146
|
+
}
|
|
147
|
+
// Normalize kid via the URL parser's canonicalization (case-folded
|
|
148
|
+
// scheme/host, default-port stripped, percent-encoded path) so all
|
|
149
|
+
// downstream comparisons against absolutized VM ids and proof-purpose
|
|
150
|
+
// refs operate on canonical strings. Otherwise a semantically
|
|
151
|
+
// equivalent but non-canonical kid in the JWT header would fail to
|
|
152
|
+
// match the profile's id values.
|
|
153
|
+
const kid = kidUrl.toString();
|
|
154
|
+
|
|
155
|
+
// Auth credentials over plaintext HTTP are a non-starter — fail loud
|
|
156
|
+
// and early with a clear error rather than letting the request limp
|
|
157
|
+
// along to a generic "could not fetch / SSRF protection" failure
|
|
158
|
+
// downstream. (The SSRF guard still has its own production check as
|
|
159
|
+
// defense-in-depth.)
|
|
160
|
+
if (kidUrl.protocol !== 'https:') {
|
|
161
|
+
return { webId: null, error: 'kid must use https' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// FPWD §4: sub === iss === client_id, all the same WebID URI.
|
|
165
|
+
// Compare canonicalized forms so semantically equal but textually
|
|
166
|
+
// different URIs (different case, default ports written out, etc.)
|
|
167
|
+
// pass equality. The canonical form is also what we hand back to
|
|
168
|
+
// callers — WAC and other downstream consumers do string equality on
|
|
169
|
+
// the WebID, so handing them a non-canonical form would fail to
|
|
170
|
+
// match ACL agent entries.
|
|
171
|
+
const { sub, iss, client_id, aud, exp, iat, nbf } = payload;
|
|
172
|
+
if (!sub || !iss || !client_id) {
|
|
173
|
+
return { webId: null, error: 'JWT missing sub/iss/client_id' };
|
|
174
|
+
}
|
|
175
|
+
let webId, canonicalIss, canonicalClientId;
|
|
176
|
+
try {
|
|
177
|
+
webId = new URL(sub).toString();
|
|
178
|
+
canonicalIss = new URL(iss).toString();
|
|
179
|
+
canonicalClientId = new URL(client_id).toString();
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return { webId: null, error: `invalid sub/iss/client_id URI: ${err.message}` };
|
|
182
|
+
}
|
|
183
|
+
if (webId !== canonicalIss || webId !== canonicalClientId) {
|
|
184
|
+
return { webId: null, error: 'sub, iss, and client_id MUST all use the same URI value' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The kid's document URL must match the WebID's document URL — the VM
|
|
188
|
+
// lives inside the subject's CID document.
|
|
189
|
+
const kidDoc = stripHash(kid);
|
|
190
|
+
const webIdDoc = stripHash(webId);
|
|
191
|
+
if (kidDoc !== webIdDoc) {
|
|
192
|
+
return {
|
|
193
|
+
webId: null,
|
|
194
|
+
error: `kid (${kid}) is not in the subject's CID document (${webIdDoc})`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Time-claim validation. The ES256K branch below skips jose.jwtVerify
|
|
199
|
+
// and relies on these checks alone, so each claim's TYPE matters as
|
|
200
|
+
// much as its value — `exp: "9999999999"` (string) must NOT be
|
|
201
|
+
// silently accepted as a number.
|
|
202
|
+
//
|
|
203
|
+
// Per FPWD §4, both iat and exp are MUST; nbf is optional but enforced
|
|
204
|
+
// when present. We additionally cap the lifetime (exp − iat) to bound
|
|
205
|
+
// the replay window if a signed token leaks.
|
|
206
|
+
if (typeof exp !== 'number') {
|
|
207
|
+
return { webId: null, error: 'JWT exp claim is required and must be a number' };
|
|
208
|
+
}
|
|
209
|
+
if (typeof iat !== 'number') {
|
|
210
|
+
return { webId: null, error: 'JWT iat claim is required and must be a number' };
|
|
211
|
+
}
|
|
212
|
+
if (nbf !== undefined && typeof nbf !== 'number') {
|
|
213
|
+
return { webId: null, error: 'JWT nbf claim must be a number' };
|
|
214
|
+
}
|
|
215
|
+
const now = Math.floor(Date.now() / 1000);
|
|
216
|
+
if (now > exp + CLOCK_SKEW) {
|
|
217
|
+
return { webId: null, error: 'JWT expired' };
|
|
218
|
+
}
|
|
219
|
+
if (typeof nbf === 'number' && now + CLOCK_SKEW < nbf) {
|
|
220
|
+
return { webId: null, error: 'JWT not yet valid (nbf in the future)' };
|
|
221
|
+
}
|
|
222
|
+
if (now - iat > MAX_IAT_AGE + CLOCK_SKEW) {
|
|
223
|
+
return { webId: null, error: 'JWT iat too old' };
|
|
224
|
+
}
|
|
225
|
+
if (iat - now > CLOCK_SKEW) {
|
|
226
|
+
return { webId: null, error: 'JWT iat is in the future' };
|
|
227
|
+
}
|
|
228
|
+
if (exp - iat > MAX_LIFETIME) {
|
|
229
|
+
return {
|
|
230
|
+
webId: null,
|
|
231
|
+
error: `JWT lifetime exceeds maximum (${exp - iat}s > ${MAX_LIFETIME}s)`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (exp <= iat) {
|
|
235
|
+
return { webId: null, error: 'JWT exp must be after iat' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Audience check — `aud` is required (FPWD §4: "the aud claim MUST
|
|
239
|
+
// include the target authorization server"), and the request's
|
|
240
|
+
// origin must appear in it.
|
|
241
|
+
const reqOrigin = getRequestOrigin(request);
|
|
242
|
+
const audList = aud === undefined ? [] : Array.isArray(aud) ? aud : [aud];
|
|
243
|
+
if (audList.length === 0) {
|
|
244
|
+
return { webId: null, error: 'JWT aud claim is required' };
|
|
245
|
+
}
|
|
246
|
+
if (!reqOrigin) {
|
|
247
|
+
// We can't determine our own origin, so we can't verify aud. Per
|
|
248
|
+
// FPWD, aud MUST include the target server — failing closed is
|
|
249
|
+
// safer than silently accepting any aud value.
|
|
250
|
+
return {
|
|
251
|
+
webId: null,
|
|
252
|
+
error: 'cannot determine server origin to verify aud',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const audMatch = audList.some((a) => normalizeOrigin(a) === reqOrigin);
|
|
256
|
+
if (!audMatch) {
|
|
257
|
+
return {
|
|
258
|
+
webId: null,
|
|
259
|
+
error: `aud does not include this server's origin (${reqOrigin})`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Fetch the CID document (= WebID profile) and locate the VM by kid.
|
|
264
|
+
let profile;
|
|
265
|
+
try {
|
|
266
|
+
profile = await fetchProfile(webIdDoc);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return { webId: null, error: `could not fetch CID document: ${err.message}` };
|
|
269
|
+
}
|
|
270
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
271
|
+
return { webId: null, error: 'CID document is not a JSON object' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Subject-identity check. The CID document we just fetched MUST
|
|
275
|
+
// actually identify itself as the JWT's `sub`. Without this, a doc
|
|
276
|
+
// hosted at the same URL could declare itself to be a different
|
|
277
|
+
// WebID fragment, but reuse a verificationMethod controlled by
|
|
278
|
+
// another node — and we'd authenticate as `sub` based on the wrong
|
|
279
|
+
// VM's signature.
|
|
280
|
+
const profileSubject = absolutize(profile['@id'] ?? profile.id, webIdDoc);
|
|
281
|
+
if (!profileSubject) {
|
|
282
|
+
return {
|
|
283
|
+
webId: null,
|
|
284
|
+
error: 'CID document declares no subject (@id / id)',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (profileSubject !== webId) {
|
|
288
|
+
return {
|
|
289
|
+
webId: null,
|
|
290
|
+
error: `CID document subject (${profileSubject}) does not match JWT sub (${webId})`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const vm = findVerificationMethod(profile, kid, webIdDoc);
|
|
295
|
+
if (!vm) {
|
|
296
|
+
return {
|
|
297
|
+
webId: null,
|
|
298
|
+
error: `no verificationMethod with id ${kid} in CID document`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// VM must be referenced by `authentication` to be usable as an auth
|
|
303
|
+
// credential. (CID 1.0 §3.3)
|
|
304
|
+
if (!isInProofPurpose(profile, 'authentication', kid, webIdDoc)) {
|
|
305
|
+
return {
|
|
306
|
+
webId: null,
|
|
307
|
+
error: `verificationMethod ${kid} is not listed in authentication`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Confirm the VM's controller agrees with the profile's controller
|
|
312
|
+
// (or with @id on fallback). Self-controlled is the common case. A
|
|
313
|
+
// profile with no controller / @id / id at all is malformed — fail
|
|
314
|
+
// closed rather than letting the VM controller check pass vacuously.
|
|
315
|
+
const expectedCtrls = normalizeControllers(profile.controller ?? profile['@id'] ?? profile.id, webIdDoc);
|
|
316
|
+
if (expectedCtrls.length === 0) {
|
|
317
|
+
return {
|
|
318
|
+
webId: null,
|
|
319
|
+
error: 'CID document has no controller or @id — controller check cannot proceed',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const vmCtrls = normalizeControllers(vm.controller, webIdDoc);
|
|
323
|
+
const matched = vmCtrls.some((c) => expectedCtrls.includes(c));
|
|
324
|
+
if (!matched) {
|
|
325
|
+
return {
|
|
326
|
+
webId: null,
|
|
327
|
+
error: 'verificationMethod controller does not match profile controller',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Decode the JWK and verify the signature.
|
|
332
|
+
if (!vm.publicKeyJwk || typeof vm.publicKeyJwk !== 'object') {
|
|
333
|
+
return {
|
|
334
|
+
webId: null,
|
|
335
|
+
error: 'verificationMethod has no publicKeyJwk (Multikey-only VMs not yet handled here)',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const jwk = vm.publicKeyJwk;
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
if (header.alg === 'ES256K') {
|
|
342
|
+
// jose's Web Crypto path doesn't support secp256k1 in all
|
|
343
|
+
// environments. Verify with @noble/curves directly — same primitive
|
|
344
|
+
// we already use for Schnorr in the Nostr path.
|
|
345
|
+
await verifyEs256kJwt(token, jwk);
|
|
346
|
+
} else {
|
|
347
|
+
const key = await jose.importJWK(jwk, header.alg);
|
|
348
|
+
await jose.jwtVerify(token, key, {
|
|
349
|
+
algorithms: [header.alg],
|
|
350
|
+
clockTolerance: CLOCK_SKEW,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
return { webId: null, error: `signature verification failed: ${err.message}` };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { webId, error: null };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------
|
|
361
|
+
// helpers
|
|
362
|
+
// ---------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
function b64uDecode(s) {
|
|
365
|
+
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function stripHash(u) {
|
|
369
|
+
if (typeof u !== 'string') return u;
|
|
370
|
+
try {
|
|
371
|
+
const url = new URL(u);
|
|
372
|
+
url.hash = '';
|
|
373
|
+
return url.toString();
|
|
374
|
+
} catch {
|
|
375
|
+
return u.split('#')[0];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function getRequestOrigin(request) {
|
|
380
|
+
// Behind a reverse proxy, the front-end forwarded headers are the
|
|
381
|
+
// authoritative source. Match the convention used in src/ap/* and
|
|
382
|
+
// similar code: x-forwarded-* take precedence, fall back to fastify's
|
|
383
|
+
// protocol/hostname.
|
|
384
|
+
//
|
|
385
|
+
// Multi-proxy chains may produce comma-separated lists (e.g.
|
|
386
|
+
// `x-forwarded-host: a.example, b.internal`); the leftmost value is
|
|
387
|
+
// the original client-facing front-end, which is what we want.
|
|
388
|
+
//
|
|
389
|
+
// The result is run through normalizeOrigin so default ports and
|
|
390
|
+
// case folding match the audList comparison side.
|
|
391
|
+
const headers = request.headers || {};
|
|
392
|
+
const proto = firstHeaderValue(headers['x-forwarded-proto']) || request.protocol || 'https';
|
|
393
|
+
const host = firstHeaderValue(headers['x-forwarded-host']) || headers.host || request.hostname;
|
|
394
|
+
if (!host) return null;
|
|
395
|
+
return normalizeOrigin(`${proto}://${host}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function firstHeaderValue(v) {
|
|
399
|
+
if (!v) return null;
|
|
400
|
+
// Fastify can yield a string, an array, or undefined.
|
|
401
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
402
|
+
if (typeof s !== 'string') return null;
|
|
403
|
+
const first = s.split(',')[0].trim();
|
|
404
|
+
return first || null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function normalizeOrigin(s) {
|
|
408
|
+
if (typeof s !== 'string') return null;
|
|
409
|
+
try {
|
|
410
|
+
const u = new URL(s);
|
|
411
|
+
return `${u.protocol}//${u.host}`;
|
|
412
|
+
} catch {
|
|
413
|
+
return s;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Fetch the CID document. Delegates to the shared `fetchCidDocument`
|
|
419
|
+
* helper which handles SSRF / redirects / body cap AND a bounded TTL
|
|
420
|
+
* cache (see src/auth/cid-doc-fetch.js).
|
|
421
|
+
*/
|
|
422
|
+
async function fetchProfile(docUrl) {
|
|
423
|
+
return fetchCidDocument(docUrl, { maxBytes: MAX_PROFILE_BYTES });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function findVerificationMethod(profile, kid, baseUrl) {
|
|
427
|
+
const vms = asArray(profile.verificationMethod);
|
|
428
|
+
for (const vm of vms) {
|
|
429
|
+
if (!vm || typeof vm !== 'object') continue;
|
|
430
|
+
const vmId = vm.id || vm['@id'];
|
|
431
|
+
if (!vmId) continue;
|
|
432
|
+
if (absolutize(vmId, baseUrl) === kid) return vm;
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isInProofPurpose(profile, predicate, kid, baseUrl) {
|
|
438
|
+
const entries = asArray(profile[predicate]);
|
|
439
|
+
if (entries.length === 0) return false;
|
|
440
|
+
for (const ent of entries) {
|
|
441
|
+
if (typeof ent === 'string') {
|
|
442
|
+
if (absolutize(ent, baseUrl) === kid) return true;
|
|
443
|
+
} else if (ent && typeof ent === 'object') {
|
|
444
|
+
const id = ent['@id'] ?? ent.id;
|
|
445
|
+
if (id && absolutize(id, baseUrl) === kid) return true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Exported so the NIP-98 → WebID path (src/auth/nostr.js) can perform
|
|
452
|
+
// the same controller consistency check the LWS-CID verifier uses.
|
|
453
|
+
export function normalizeControllers(value, baseUrl) {
|
|
454
|
+
if (value === undefined || value === null) return [];
|
|
455
|
+
const list = Array.isArray(value) ? value : [value];
|
|
456
|
+
const out = [];
|
|
457
|
+
for (const v of list) {
|
|
458
|
+
let iri;
|
|
459
|
+
if (typeof v === 'string') iri = v;
|
|
460
|
+
else if (v && typeof v === 'object') iri = v['@id'] ?? v.id;
|
|
461
|
+
if (typeof iri !== 'string' || iri.length === 0) continue;
|
|
462
|
+
out.push(absolutize(iri, baseUrl));
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function asArray(v) {
|
|
468
|
+
if (v === undefined || v === null) return [];
|
|
469
|
+
return Array.isArray(v) ? v : [v];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function absolutize(u, base) {
|
|
473
|
+
if (!u) return u;
|
|
474
|
+
try {
|
|
475
|
+
return new URL(u, base).toString();
|
|
476
|
+
} catch {
|
|
477
|
+
return u;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Verify a JWT signed with ES256K (ECDSA over secp256k1) using
|
|
483
|
+
* @noble/curves. Returns on success, throws on failure.
|
|
484
|
+
*
|
|
485
|
+
* Web Crypto / jose lack uniform secp256k1 support across Node versions,
|
|
486
|
+
* and this primitive is already in tree (used by NIP-98 / Nostr). The
|
|
487
|
+
* curve (secp256k1) is the same one Nostr keys live on, so a Nostr
|
|
488
|
+
* private key can sign here without any new key material.
|
|
489
|
+
*/
|
|
490
|
+
async function verifyEs256kJwt(token, jwk) {
|
|
491
|
+
if (jwk.kty !== 'EC' || (jwk.crv !== 'secp256k1' && jwk.crv !== 'P-256K')) {
|
|
492
|
+
throw new Error(`ES256K requires kty:EC and crv:secp256k1 (or legacy crv:P-256K), got kty:${jwk.kty} crv:${jwk.crv}`);
|
|
493
|
+
}
|
|
494
|
+
if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
|
|
495
|
+
throw new Error('JWK missing x/y coordinates');
|
|
496
|
+
}
|
|
497
|
+
// Build the uncompressed SEC1 public point: 0x04 || x || y
|
|
498
|
+
const x = b64uDecode(jwk.x);
|
|
499
|
+
const y = b64uDecode(jwk.y);
|
|
500
|
+
if (x.length !== 32 || y.length !== 32) {
|
|
501
|
+
throw new Error(`secp256k1 coordinates must be 32 bytes; got x=${x.length} y=${y.length}`);
|
|
502
|
+
}
|
|
503
|
+
const pub = Buffer.concat([Buffer.from([0x04]), x, y]);
|
|
504
|
+
|
|
505
|
+
const [headerB64, payloadB64, sigB64] = token.split('.');
|
|
506
|
+
const signingInput = Buffer.from(`${headerB64}.${payloadB64}`, 'utf8');
|
|
507
|
+
const sigRaw = b64uDecode(sigB64);
|
|
508
|
+
if (sigRaw.length !== 64) {
|
|
509
|
+
throw new Error(`ES256K signature must be 64 bytes (r||s); got ${sigRaw.length}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const msgHash = sha256(signingInput);
|
|
513
|
+
const sig = secp256k1.Signature.fromCompact(sigRaw);
|
|
514
|
+
const ok = secp256k1.verify(sig, msgHash, pub);
|
|
515
|
+
if (!ok) throw new Error('signature is not valid');
|
|
516
|
+
}
|