javascript-solid-server 0.0.177 → 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/src/auth/nostr.js CHANGED
@@ -7,13 +7,26 @@
7
7
  *
8
8
  * Authorization header format: "Nostr <base64-encoded-event>"
9
9
  *
10
- * The authenticated identity is returned as a did:nostr URI:
11
- * did:nostr:<64-char-hex-pubkey>
10
+ * Identity resolution chain (after a successful Schnorr verification):
11
+ *
12
+ * 1. Look up the Nostr pubkey in the resource owner's WebID profile
13
+ * as a CID v1 verificationMethod referenced from `authentication`.
14
+ * Match by f-form Multikey or by JsonWebKey x/y coordinates. If
15
+ * found, authenticate as the WebID. (#399 — pairs with the
16
+ * LWS10-CID verifier.)
17
+ * 2. Resolve via the existing did:nostr DID-document path
18
+ * (nostr.social `.well-known` + bidirectional alsoKnownAs).
19
+ * If found, authenticate as the WebID it points to.
20
+ * 3. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the
21
+ * agent identity (the original behavior).
12
22
  */
13
23
 
14
24
  import { verifyEvent, getEventHash } from '../nostr/event.js';
25
+ import { secp256k1 } from '@noble/curves/secp256k1';
15
26
  import crypto from 'crypto';
16
27
  import { resolveDidNostrToWebId } from './did-nostr.js';
28
+ import { fetchCidDocument } from './cid-doc-fetch.js';
29
+ import { normalizeControllers } from './lws-cid.js';
17
30
 
18
31
  // NIP-98 event kind (references RFC 7235)
19
32
  const HTTP_AUTH_KIND = 27235;
@@ -21,6 +34,15 @@ const HTTP_AUTH_KIND = 27235;
21
34
  // Timestamp tolerance in seconds
22
35
  const TIMESTAMP_TOLERANCE = 60;
23
36
 
37
+ // Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex.
38
+ // Used to decode f-form Multikey verificationMethod values back into
39
+ // the 32-byte x-only Nostr pubkey.
40
+ const MULTICODEC_SECP256K1_PUB_HEX = 'e701';
41
+
42
+ // Profile-fetch body-size cap. Matches the LWS-CID verifier; both
43
+ // callers go through the shared fetchCidDocument helper.
44
+ const MAX_PROFILE_BYTES = 256 * 1024;
45
+
24
46
  /**
25
47
  * Check if request has Nostr authentication
26
48
  * Supports both "Nostr <token>" and "Basic <base64(nostr:token)>" formats
@@ -155,9 +177,30 @@ export async function verifyNostrAuth(request) {
155
177
  return { webId: null, error: 'Event timestamp outside acceptable window (±60s)' };
156
178
  }
157
179
 
158
- // Build full URL for validation
159
- const protocol = request.protocol || 'http';
160
- const host = request.headers.host || request.hostname;
180
+ // Build full URL for validation. Behind a reverse proxy the public-
181
+ // facing URL is what the client signed, but request.headers.host /
182
+ // request.protocol carry the internal upstream values. Honor the
183
+ // forwarded headers (matching the conventions in src/ap/* and the
184
+ // LWS-CID verifier) so a NIP-98 sig for the public URL still
185
+ // matches in proxied deployments. Proto is lowercased + allowlisted
186
+ // to avoid casing-mismatches from misconfigured proxies.
187
+ const protoRaw = firstHeaderValue(request.headers['x-forwarded-proto'])
188
+ || request.protocol
189
+ || 'http';
190
+ const protoLower = protoRaw.toLowerCase();
191
+ const protocol = (protoLower === 'http' || protoLower === 'https') ? protoLower : 'http';
192
+ const host = firstHeaderValue(request.headers['x-forwarded-host'])
193
+ || firstHeaderValue(request.headers.host)
194
+ || request.hostname;
195
+ // Reject URL-meaningful characters in the host before interpolation
196
+ // — same defense as in getPodOwnerWebId. Otherwise a Host like
197
+ // `example.com@attacker.com` would produce a fullUrl that parses
198
+ // with attacker.com as the hostname, which a sophisticated client
199
+ // could try to align with a NIP-98 `u` tag for an external
200
+ // resource.
201
+ if (host && /[@/\s?#\\]/.test(host)) {
202
+ return { webId: null, error: 'Host header contains invalid characters' };
203
+ }
161
204
  const fullUrl = `${protocol}://${host}${request.url}`;
162
205
 
163
206
  // Validate URL tag matches request URL
@@ -229,9 +272,19 @@ export async function verifyNostrAuth(request) {
229
272
  return { webId: null, error: 'Invalid Schnorr signature' };
230
273
  }
231
274
 
232
- // Try to resolve did:nostr to a linked WebID
233
- // This checks if the pubkey has an alsoKnownAs pointing to a WebID
234
- // and verifies the WebID links back to did:nostr (bidirectional)
275
+ // First lookup: the resource's owner WebID profile. If the pod owner
276
+ // declared this pubkey as a verificationMethod (CID v1 / LWS-CID
277
+ // shape produced by the doctor's B.2 path), authenticate as the
278
+ // WebID. This is profile-only (no DID-doc fetch) and works for any
279
+ // user who's added a Nostr VM to their profile — see #386 / #399.
280
+ const vmWebId = await tryResolveViaCidVerificationMethod(request, event.pubkey);
281
+ if (vmWebId) {
282
+ return { webId: vmWebId, error: null };
283
+ }
284
+
285
+ // Second lookup: existing did:nostr DID-document resolver. Fetches
286
+ // an external DID doc (e.g. nostr.social/.well-known/...) and checks
287
+ // bidirectional alsoKnownAs ↔ WebID linking.
235
288
  const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
236
289
  if (resolvedWebId) {
237
290
  return { webId: resolvedWebId, error: null };
@@ -243,6 +296,342 @@ export async function verifyNostrAuth(request) {
243
296
  return { webId: didNostr, error: null };
244
297
  }
245
298
 
299
+ /**
300
+ * Attempt to upgrade a verified Nostr pubkey to a WebID by looking it
301
+ * up in the resource owner's CID document (= WebID profile).
302
+ *
303
+ * Returns the WebID if:
304
+ * - the pod-owner WebID can be derived from the request
305
+ * - the profile fetches cleanly (passes SSRF guard, JSON-LD)
306
+ * - one of its `verificationMethod` entries carries this pubkey
307
+ * (either as f-form Multikey or as a secp256k1 JsonWebKey)
308
+ * - that VM is referenced from `authentication`
309
+ *
310
+ * Returns null in any other case — the caller falls back to the
311
+ * existing DID-doc / did:nostr-identity paths.
312
+ */
313
+ async function tryResolveViaCidVerificationMethod(request, pubkeyHex) {
314
+ const ownerWebId = getPodOwnerWebId(request);
315
+ if (!ownerWebId) return null;
316
+ const docUrl = stripHash(ownerWebId);
317
+
318
+ // SSRF guard. The owner WebID is derived from server-side request
319
+ // data, so it shouldn't be attacker-controllable, but route through
320
+ // the same defense-in-depth as the LWS-CID verifier — including
321
+ // manual redirect handling with same-origin enforcement, and a
322
+ // body-size cap to deflect oversized-payload DoS.
323
+ let profile;
324
+ try {
325
+ profile = await fetchProfileSafely(docUrl);
326
+ } catch {
327
+ return null;
328
+ }
329
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return null;
330
+
331
+ // Subject-identity check (mirrors lws-cid.js). The CID document we
332
+ // just fetched MUST identify itself as the WebID we computed from
333
+ // the request — otherwise a profile hosted at the expected URL
334
+ // could declare a different `@id` and trick us into authenticating
335
+ // as that other identity using a sibling VM. We always return the
336
+ // computed ownerWebId (never the profile's declared subject) so a
337
+ // relative-IRI or mismatched `@id` can't substitute identity.
338
+ const subject = absolutize(profile['@id'] || profile.id, docUrl);
339
+ if (!subject || subject !== ownerWebId) return null;
340
+
341
+ const vm = findNostrVmInProfile(profile, pubkeyHex, docUrl);
342
+ if (!vm) return null;
343
+ if (!isInProofPurpose(profile, 'authentication', vm.id, docUrl)) return null;
344
+
345
+ // Controller consistency: the VM's `controller` MUST be in the
346
+ // profile's expected controller set (declared `controller`, with
347
+ // @id fallback). Without this, a profile with a Nostr-keyed VM
348
+ // controlled by some unrelated identity could still upgrade us to
349
+ // the WebID — a key-binding the actual subject never asserted.
350
+ // Mirrors the lws-cid.js check using the same normalizer.
351
+ const expectedCtrls = normalizeControllers(profile.controller ?? profile['@id'] ?? profile.id, docUrl);
352
+ if (expectedCtrls.length === 0) return null;
353
+ const vmCtrls = normalizeControllers(vm.controller, docUrl);
354
+ if (!vmCtrls.some((c) => expectedCtrls.includes(c))) return null;
355
+
356
+ return ownerWebId;
357
+ }
358
+
359
+ /**
360
+ * Derive the pod-owner WebID URL from a Fastify request.
361
+ *
362
+ * JSS supports four pod-addressing modes (see src/idp/interactions.js
363
+ * around the createPod path for the canonical list):
364
+ *
365
+ * - **Single-user mode** — `request.singleUser` is true. The pod
366
+ * either lives at the host root (WebID
367
+ * `https://host/profile/card.jsonld#me`) or, when
368
+ * `request.singleUserName` is set, at `/<name>/profile/card.jsonld#me`.
369
+ * - **Subdomain mode** — `subdomainsEnabled` is true, request hits a
370
+ * subdomain like `alice.example.com`. WebID is at the subdomain
371
+ * root: `https://alice.example.com/profile/card.jsonld#me`.
372
+ * - **Path mode** — the JSS default (`subdomainsEnabled` off). Pod
373
+ * is at the first URL path segment:
374
+ * `https://example.com/alice/foo` → WebID
375
+ * `https://example.com/alice/profile/card.jsonld#me`.
376
+ * - **Path-mode-on-base** — subdomains are enabled but the request
377
+ * hits the base domain with a path. The internal canonical form
378
+ * rewrites this to the subdomain shape (per buildResourceUrl).
379
+ *
380
+ * Returns null when no pod name can be derived; the caller falls back
381
+ * to the existing did:nostr DID-doc resolver / did:nostr identity.
382
+ */
383
+ function getPodOwnerWebId(request) {
384
+ const headers = request.headers || {};
385
+ // Lowercase + allowlist the protocol. Some proxies send
386
+ // `X-Forwarded-Proto: HTTPS` (or other casings); without
387
+ // normalization the constructed ownerWebId would carry that
388
+ // casing and the subject-identity check would reject the match
389
+ // against a profile @id that uses lowercase `https://`.
390
+ const protoRaw = firstHeaderValue(headers['x-forwarded-proto'])
391
+ || request.protocol
392
+ || 'https';
393
+ const protoLower = protoRaw.toLowerCase();
394
+ const proto = (protoLower === 'http' || protoLower === 'https') ? protoLower : 'https';
395
+ // The Host header / x-forwarded-host can carry a port and may be an
396
+ // IPv6 literal (`[::1]:3000`). For all WebID construction we use
397
+ // `hostNoPort` — the URL parser's port-stripped hostname (still
398
+ // bracketed for IPv6 here; we bail out on IPv6 below). This
399
+ // matches what JSS itself stores: subdomain mode derives from
400
+ // `baseDomain` (no port), and src/handlers/container.js builds
401
+ // path-mode WebIDs from `request.hostname` (port-stripped). Using
402
+ // a port-bearing host here would compute a WebID that doesn't
403
+ // match the stored profile @id, so the subject-identity check
404
+ // would reject otherwise-valid requests on non-default ports.
405
+ const hostRaw = firstHeaderValue(headers['x-forwarded-host'])
406
+ || firstHeaderValue(headers.host)
407
+ || request.hostname;
408
+ if (!hostRaw) return null;
409
+ // Reject host strings that carry URL-special characters before we
410
+ // hand them to the URL parser. Without this, a Host like
411
+ // `example.com@attacker.com` would parse as userinfo + attacker.com
412
+ // and steer the computed owner WebID at the attacker's domain.
413
+ // A well-formed Host header carries hostname[:port][[]] only —
414
+ // reject any other URL-meaningful character (`@`, `/`, `?`, `#`,
415
+ // whitespace, query/fragment delimiters).
416
+ if (/[@/\s?#\\]/.test(hostRaw)) return null;
417
+ // `request.hostname` is port-stripped per Fastify but doesn't survive
418
+ // x-forwarded-host parsing. Round-trip through URL semantics so
419
+ // IPv6 brackets and ports are handled by the parser, not split(':').
420
+ let hostNoPort;
421
+ try { hostNoPort = new URL(`${proto}://${hostRaw}`).hostname; }
422
+ catch { return null; }
423
+ // Detect IPv6 literal hosts and bail early. URL.hostname keeps
424
+ // brackets for IPv6 (verified Node v24.5.0:
425
+ // new URL('https://[2001:db8::1]:8443/x').hostname === '[2001:db8::1]'
426
+ // per WHATWG URL §host serializing rule). Continuing into the
427
+ // URL-construction branches with a bracket-less form would yield a
428
+ // malformed string like `https://2001:db8::1/...` — invalid, would
429
+ // throw at parse time and could pollute the shared CID-profile
430
+ // cache with a failure entry under that bogus key.
431
+ //
432
+ // Solid deployments on raw IPv6 literals are effectively
433
+ // non-existent; cleanly skipping the VM-lookup upgrade for them
434
+ // (caller falls back to the existing did:nostr resolver) is the
435
+ // right behavior. JSS itself has a parallel limitation in
436
+ // src/handlers/container.js — fixing IPv6 needs to happen at that
437
+ // pod-creation layer first.
438
+ const isIpv6 = hostNoPort.startsWith('[') && hostNoPort.endsWith(']');
439
+ if (isIpv6) return null;
440
+
441
+ // Single-user deployment. The pod is either at the host root or
442
+ // mounted under `/<singleUserName>/`; both shapes are supported.
443
+ // Use the port-stripped form to match what JSS itself stores in
444
+ // the profile @id at pod-creation time (src/handlers/container.js
445
+ // builds with `request.hostname`, port-stripped). Otherwise a
446
+ // request arriving on a non-default port would compute a WebID
447
+ // the subject-identity check rejects.
448
+ if (request.singleUser) {
449
+ const name = request.singleUserName;
450
+ return name
451
+ ? `${proto}://${hostNoPort}/${name}/profile/card.jsonld#me`
452
+ : `${proto}://${hostNoPort}/profile/card.jsonld#me`;
453
+ }
454
+
455
+ // Subdomain mode (request already on a pod's subdomain).
456
+ if (request.subdomainsEnabled && request.podName && request.baseDomain) {
457
+ return `${proto}://${request.podName}.${request.baseDomain}/profile/card.jsonld#me`;
458
+ }
459
+
460
+ // Subdomain-enabled deployment, request landed on the base domain
461
+ // with a path (e.g. https://example.com/alice/...). The canonical
462
+ // form is the subdomain — match the rewriting buildResourceUrl does.
463
+ if (request.subdomainsEnabled && request.baseDomain && hostNoPort === request.baseDomain) {
464
+ const m = (request.url || '').match(/^\/([^/?#]+)/);
465
+ if (m && !m[1].startsWith('.') && !m[1].includes('.')) {
466
+ return `${proto}://${m[1]}.${request.baseDomain}/profile/card.jsonld#me`;
467
+ }
468
+ return null;
469
+ }
470
+
471
+ // Path mode (JSS default): pod is the first URL segment. Match
472
+ // src/handlers/container.js which builds path-mode WebIDs from
473
+ // `request.hostname` (port-stripped), so the computed ownerWebId
474
+ // equals the @id JSS itself wrote at pod-creation time.
475
+ const m = (request.url || '').match(/^\/([^/?#]+)/);
476
+ if (m && !m[1].startsWith('.') && !m[1].includes('.')) {
477
+ return `${proto}://${hostNoPort}/${m[1]}/profile/card.jsonld#me`;
478
+ }
479
+ return null;
480
+ }
481
+
482
+ /**
483
+ * Fetch a CID document (= WebID profile). Delegates to the shared
484
+ * fetcher in src/auth/cid-doc-fetch.js so SSRF / redirect / body-cap
485
+ * defenses don't drift between this and the LWS-CID verifier.
486
+ *
487
+ * Throws on any failure; the caller treats throw-as-null.
488
+ *
489
+ * KNOWN GAP (#381): the underlying validateExternalUrl currently
490
+ * fail-opens when DNS returns no A/AAAA records. Fix belongs in the
491
+ * shared util so every caller benefits at once.
492
+ */
493
+ async function fetchProfileSafely(docUrl) {
494
+ return fetchCidDocument(docUrl, { maxBytes: MAX_PROFILE_BYTES });
495
+ }
496
+
497
+ function firstHeaderValue(v) {
498
+ if (!v) return null;
499
+ // Fastify/Node header values can be string or string[].
500
+ const s = Array.isArray(v) ? v[0] : v;
501
+ if (typeof s !== 'string') return null;
502
+ const first = s.split(',')[0].trim();
503
+ return first || null;
504
+ }
505
+
506
+ /**
507
+ * Find a verificationMethod whose key material matches the Nostr
508
+ * x-only pubkey hex. Two encodings supported:
509
+ * - f-form Multikey: publicKeyMultibase = "f" + "e701" + parity + xonly
510
+ * - JsonWebKey: publicKeyJwk.x = base64url(xonly) (kty:EC, crv:secp256k1)
511
+ *
512
+ * Returns the entry (object form) on match, normalized so .id is the
513
+ * absolute IRI. Returns null on no match.
514
+ */
515
+ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) {
516
+ const target = pubkeyHex.toLowerCase();
517
+ const targetB64u = hexToBase64url(target);
518
+ const vms = asArray(profile.verificationMethod);
519
+ for (const vm of vms) {
520
+ if (!vm || typeof vm !== 'object') continue;
521
+ const vmId = vm.id || vm['@id'];
522
+ if (typeof vmId !== 'string') continue;
523
+
524
+ if (typeof vm.publicKeyMultibase === 'string') {
525
+ const xonly = decodeFFormSecp256k1(vm.publicKeyMultibase);
526
+ if (xonly === target) return { ...vm, id: absolutize(vmId, baseUrl) };
527
+ }
528
+ if (vm.publicKeyJwk && typeof vm.publicKeyJwk === 'object') {
529
+ const jwk = vm.publicKeyJwk;
530
+ if (jwk.kty === 'EC' && (jwk.crv === 'secp256k1' || jwk.crv === 'P-256K')) {
531
+ if (jwkMatchesNostrPubkey(jwk, target, targetB64u)) {
532
+ return { ...vm, id: absolutize(vmId, baseUrl) };
533
+ }
534
+ }
535
+ }
536
+ }
537
+ return null;
538
+ }
539
+
540
+ /**
541
+ * Decode an f-form Multikey for secp256k1-pub back into the 32-byte
542
+ * x-only pubkey hex. Returns null if the input isn't this shape.
543
+ */
544
+ function decodeFFormSecp256k1(mb) {
545
+ if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
546
+ const hex = mb.slice(1).toLowerCase();
547
+ if (!/^[0-9a-f]+$/.test(hex)) return null;
548
+ if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
549
+ const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
550
+ // Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
551
+ if (rest.length !== 66) return null;
552
+ const parity = rest.slice(0, 2);
553
+ if (parity !== '02' && parity !== '03') return null;
554
+ return rest.slice(2);
555
+ }
556
+
557
+ function hexToBase64url(hex) {
558
+ return Buffer.from(hex, 'hex').toString('base64')
559
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
560
+ }
561
+
562
+ /**
563
+ * Does the JWK encode the given Nostr x-only pubkey?
564
+ *
565
+ * EC keys are (x, y) pairs — two distinct valid points share the same
566
+ * x with opposite y parities. Matching on x alone would let an
567
+ * attacker craft a JWK with the target x and a wrong y, which we'd
568
+ * then accept as the user's Nostr key. So we also derive the
569
+ * BIP-340-canonical y (even-parity) for the target x and require the
570
+ * JWK's y to match.
571
+ *
572
+ * Returns false if the JWK's coordinates aren't on-curve, can't be
573
+ * decoded, or don't match the BIP-340 canonical point for `targetHex`.
574
+ */
575
+ function jwkMatchesNostrPubkey(jwk, targetHex, targetB64u) {
576
+ if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return false;
577
+ if (jwk.x !== targetB64u) return false;
578
+ // Decompress the BIP-340 even-y point for the target x. Then compare
579
+ // the JWK's declared y against this canonical y.
580
+ let canonicalY;
581
+ try {
582
+ // Compressed SEC1 point, even-y prefix (0x02) || x.
583
+ const compressed = '02' + targetHex;
584
+ const point = secp256k1.ProjectivePoint.fromHex(compressed);
585
+ const affine = point.toAffine();
586
+ canonicalY = affine.y.toString(16).padStart(64, '0');
587
+ } catch {
588
+ return false;
589
+ }
590
+ let jwkYHex;
591
+ try {
592
+ jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
593
+ .toString('hex').toLowerCase();
594
+ } catch {
595
+ return false;
596
+ }
597
+ return jwkYHex === canonicalY;
598
+ }
599
+
600
+ function isInProofPurpose(profile, predicate, vmId, baseUrl) {
601
+ const entries = asArray(profile[predicate]);
602
+ if (entries.length === 0) return false;
603
+ for (const ent of entries) {
604
+ if (typeof ent === 'string') {
605
+ if (absolutize(ent, baseUrl) === vmId) return true;
606
+ } else if (ent && typeof ent === 'object') {
607
+ const id = ent['@id'] ?? ent.id;
608
+ if (id && absolutize(id, baseUrl) === vmId) return true;
609
+ }
610
+ }
611
+ return false;
612
+ }
613
+
614
+ function asArray(v) {
615
+ if (v === undefined || v === null) return [];
616
+ return Array.isArray(v) ? v : [v];
617
+ }
618
+
619
+ function absolutize(u, base) {
620
+ if (!u) return u;
621
+ try { return new URL(u, base).toString(); } catch { return u; }
622
+ }
623
+
624
+ function stripHash(u) {
625
+ if (typeof u !== 'string') return u;
626
+ try {
627
+ const url = new URL(u);
628
+ url.hash = '';
629
+ return url.toString();
630
+ } catch {
631
+ return u.split('#')[0];
632
+ }
633
+ }
634
+
246
635
  /**
247
636
  * Get Nostr pubkey from request if authenticated via NIP-98
248
637
  * @param {object} request - Fastify request object