javascript-solid-server 0.0.177 → 0.0.179

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'; // shared JSON-LD controller helper
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,404 @@ 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
+ * Confirm that a verified Nostr pubkey is declared as a CID
508
+ * verificationMethod referenced from `authentication` in the given
509
+ * WebID's profile. Used by the Schnorr-login IdP path (#403): once
510
+ * the signature is verified and the user has typed their username,
511
+ * the IdP layer can derive the candidate WebID and ask this whether
512
+ * the verified pubkey actually belongs to that WebID.
513
+ *
514
+ * Mirrors the same controller-consistency / subject-identity /
515
+ * authentication-membership checks as the resource-side path
516
+ * (tryResolveViaCidVerificationMethod) so the two paths apply the
517
+ * same key-binding semantics.
518
+ *
519
+ * Returns true on match, false on any failure (fetch, VM not in
520
+ * authentication, subject mismatch, controller mismatch, bad input).
521
+ *
522
+ * @param {string} webId - canonical fragment-bearing WebID URI (e.g.
523
+ * `https://alice.example.com/profile/card.jsonld#me`). The profile's
524
+ * own `@id` must match this exactly after absolutization.
525
+ * @param {string} pubkeyHex - 32-byte x-only Nostr pubkey hex
526
+ * @returns {Promise<boolean>}
527
+ */
528
+ export async function verifyNostrPubkeyAgainstWebId(webId, pubkeyHex) {
529
+ if (typeof webId !== 'string' || !webId) return false;
530
+ if (typeof pubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkeyHex)) return false;
531
+ const docUrl = stripHash(webId);
532
+ let profile;
533
+ try {
534
+ profile = await fetchCidDocument(docUrl);
535
+ } catch {
536
+ return false;
537
+ }
538
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return false;
539
+
540
+ // Confirm the profile actually identifies itself as the WebID we're
541
+ // asking about — otherwise a profile hosted at the WebID's URL could
542
+ // declare a different fragment as its subject and trick us. Both
543
+ // sides absolutized so a relative @id (e.g. "#me") resolves against
544
+ // docUrl and a webId without fragment (which the docstring no longer
545
+ // permits, but be defensive) doesn't accidentally match a fragment
546
+ // form.
547
+ const subject = absolutize(profile['@id'] || profile.id, docUrl);
548
+ const expectedSubject = absolutize(webId, docUrl);
549
+ if (!subject || subject !== expectedSubject) return false;
550
+
551
+ const vm = findNostrVmInProfile(profile, pubkeyHex.toLowerCase(), docUrl);
552
+ if (!vm) return false;
553
+ if (!isInProofPurpose(profile, 'authentication', vm.id, docUrl)) return false;
554
+
555
+ // Controller consistency: the VM's `controller` MUST be in the
556
+ // profile's expected controller set (declared `controller`, with
557
+ // @id fallback). Without this, a profile with a Nostr-keyed VM
558
+ // controlled by some unrelated identity would pass — a binding the
559
+ // actual subject never asserted. Mirrors the resource-path check.
560
+ const expectedCtrls = normalizeControllers(profile.controller ?? profile['@id'] ?? profile.id, docUrl);
561
+ if (expectedCtrls.length === 0) return false;
562
+ const vmCtrls = normalizeControllers(vm.controller, docUrl);
563
+ if (!vmCtrls.some((c) => expectedCtrls.includes(c))) return false;
564
+
565
+ return true;
566
+ }
567
+
568
+ /**
569
+ * Find a verificationMethod whose key material matches the Nostr
570
+ * x-only pubkey hex. Two encodings supported:
571
+ * - f-form Multikey: publicKeyMultibase = "f" + "e701" + parity + xonly
572
+ * - JsonWebKey: publicKeyJwk.x = base64url(xonly) (kty:EC, crv:secp256k1)
573
+ *
574
+ * Returns the entry (object form) on match, normalized so .id is the
575
+ * absolute IRI. Returns null on no match.
576
+ */
577
+ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) {
578
+ const target = pubkeyHex.toLowerCase();
579
+ const targetB64u = hexToBase64url(target);
580
+ const vms = asArray(profile.verificationMethod);
581
+ for (const vm of vms) {
582
+ if (!vm || typeof vm !== 'object') continue;
583
+ const vmId = vm.id || vm['@id'];
584
+ if (typeof vmId !== 'string') continue;
585
+
586
+ if (typeof vm.publicKeyMultibase === 'string') {
587
+ const xonly = decodeFFormSecp256k1(vm.publicKeyMultibase);
588
+ if (xonly === target) return { ...vm, id: absolutize(vmId, baseUrl) };
589
+ }
590
+ if (vm.publicKeyJwk && typeof vm.publicKeyJwk === 'object') {
591
+ const jwk = vm.publicKeyJwk;
592
+ if (jwk.kty === 'EC' && (jwk.crv === 'secp256k1' || jwk.crv === 'P-256K')) {
593
+ if (jwkMatchesNostrPubkey(jwk, target, targetB64u)) {
594
+ return { ...vm, id: absolutize(vmId, baseUrl) };
595
+ }
596
+ }
597
+ }
598
+ }
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * Decode an f-form Multikey for secp256k1-pub back into the 32-byte
604
+ * x-only pubkey hex. Returns null if the input isn't this shape.
605
+ */
606
+ function decodeFFormSecp256k1(mb) {
607
+ if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
608
+ const hex = mb.slice(1).toLowerCase();
609
+ if (!/^[0-9a-f]+$/.test(hex)) return null;
610
+ if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
611
+ const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
612
+ // Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
613
+ if (rest.length !== 66) return null;
614
+ const parity = rest.slice(0, 2);
615
+ if (parity !== '02' && parity !== '03') return null;
616
+ return rest.slice(2);
617
+ }
618
+
619
+ function hexToBase64url(hex) {
620
+ return Buffer.from(hex, 'hex').toString('base64')
621
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
622
+ }
623
+
624
+ /**
625
+ * Does the JWK encode the given Nostr x-only pubkey?
626
+ *
627
+ * EC keys are (x, y) pairs — two distinct valid points share the same
628
+ * x with opposite y parities. Matching on x alone would let an
629
+ * attacker craft a JWK with the target x and a wrong y, which we'd
630
+ * then accept as the user's Nostr key. So we also derive the
631
+ * BIP-340-canonical y (even-parity) for the target x and require the
632
+ * JWK's y to match.
633
+ *
634
+ * Returns false if the JWK's coordinates aren't on-curve, can't be
635
+ * decoded, or don't match the BIP-340 canonical point for `targetHex`.
636
+ */
637
+ function jwkMatchesNostrPubkey(jwk, targetHex, targetB64u) {
638
+ if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return false;
639
+ if (jwk.x !== targetB64u) return false;
640
+ // Decompress the BIP-340 even-y point for the target x. Then compare
641
+ // the JWK's declared y against this canonical y.
642
+ let canonicalY;
643
+ try {
644
+ // Compressed SEC1 point, even-y prefix (0x02) || x.
645
+ const compressed = '02' + targetHex;
646
+ const point = secp256k1.ProjectivePoint.fromHex(compressed);
647
+ const affine = point.toAffine();
648
+ canonicalY = affine.y.toString(16).padStart(64, '0');
649
+ } catch {
650
+ return false;
651
+ }
652
+ let jwkYHex;
653
+ try {
654
+ jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
655
+ .toString('hex').toLowerCase();
656
+ } catch {
657
+ return false;
658
+ }
659
+ return jwkYHex === canonicalY;
660
+ }
661
+
662
+ function isInProofPurpose(profile, predicate, vmId, baseUrl) {
663
+ const entries = asArray(profile[predicate]);
664
+ if (entries.length === 0) return false;
665
+ for (const ent of entries) {
666
+ if (typeof ent === 'string') {
667
+ if (absolutize(ent, baseUrl) === vmId) return true;
668
+ } else if (ent && typeof ent === 'object') {
669
+ const id = ent['@id'] ?? ent.id;
670
+ if (id && absolutize(id, baseUrl) === vmId) return true;
671
+ }
672
+ }
673
+ return false;
674
+ }
675
+
676
+ function asArray(v) {
677
+ if (v === undefined || v === null) return [];
678
+ return Array.isArray(v) ? v : [v];
679
+ }
680
+
681
+ function absolutize(u, base) {
682
+ if (!u) return u;
683
+ try { return new URL(u, base).toString(); } catch { return u; }
684
+ }
685
+
686
+ function stripHash(u) {
687
+ if (typeof u !== 'string') return u;
688
+ try {
689
+ const url = new URL(u);
690
+ url.hash = '';
691
+ return url.toString();
692
+ } catch {
693
+ return u.split('#')[0];
694
+ }
695
+ }
696
+
246
697
  /**
247
698
  * Get Nostr pubkey from request if authenticated via NIP-98
248
699
  * @param {object} request - Fastify request object
@@ -3,12 +3,12 @@
3
3
  * Handles the user-facing parts of the authentication flow
4
4
  */
5
5
 
6
- import { authenticate, findById, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js';
6
+ import { authenticate, findById, findByUsername, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js';
7
7
  import { loginPage, consentPage, errorPage, registerPage, passkeyPromptPage } from './views.js';
8
8
  import * as storage from '../storage/filesystem.js';
9
9
  import { createPodStructure } from '../handlers/container.js';
10
10
  import { validateInvite } from './invites.js';
11
- import { verifyNostrAuth } from '../auth/nostr.js';
11
+ import { verifyNostrAuth, getNostrPubkey, verifyNostrPubkeyAgainstWebId } from '../auth/nostr.js';
12
12
 
13
13
  // Security: Maximum body size for IdP form submissions (1MB)
14
14
  const MAX_BODY_SIZE = 1024 * 1024;
@@ -658,6 +658,41 @@ export async function handlePasskeySkip(request, reply, provider) {
658
658
  }
659
659
  }
660
660
 
661
+ /**
662
+ * Pull the optional `username` field out of a schnorr-login POST.
663
+ *
664
+ * JSS registers a wildcard parseAs:'buffer' content-type parser
665
+ * (src/server.js), so request.body for application/x-www-form-urlencoded
666
+ * arrives as a Buffer that needs string-decode + URLSearchParams. JSON
667
+ * and already-parsed object bodies are also accepted for flexibility.
668
+ *
669
+ * Returns either:
670
+ * - { tooLarge: true } if the body exceeds MAX_BODY_SIZE (matching
671
+ * handleLogin / handleRegisterPost — caller emits 413).
672
+ * - { username: string } otherwise, possibly empty.
673
+ */
674
+ function parseUsernameField(request) {
675
+ const body = request.body;
676
+ if (!body) return { username: '' };
677
+ const ct = (request.headers?.['content-type'] || '').toLowerCase();
678
+ if (Buffer.isBuffer(body) && body.length > MAX_BODY_SIZE) return { tooLarge: true };
679
+ if (typeof body === 'string' && body.length > MAX_BODY_SIZE) return { tooLarge: true };
680
+
681
+ let bag = {};
682
+ if (Buffer.isBuffer(body) || typeof body === 'string') {
683
+ const s = Buffer.isBuffer(body) ? body.toString() : body;
684
+ if (ct.includes('application/json')) {
685
+ try { bag = JSON.parse(s); } catch { bag = {}; }
686
+ } else {
687
+ try { bag = Object.fromEntries(new URLSearchParams(s).entries()); }
688
+ catch { bag = {}; }
689
+ }
690
+ } else if (typeof body === 'object') {
691
+ bag = body;
692
+ }
693
+ return { username: (bag.username || '').toString().trim() };
694
+ }
695
+
661
696
  /**
662
697
  * Handle POST /idp/interaction/:uid/schnorr-login
663
698
  * Authenticates user via Schnorr signature (NIP-98)
@@ -689,16 +724,43 @@ export async function handleSchnorrLogin(request, reply, provider) {
689
724
  const identity = authResult.webId;
690
725
  request.log.info({ identity, uid }, 'Schnorr auth verified');
691
726
 
692
- // Try to find an existing account linked to this identity
727
+ // Try to find an existing account linked to this identity. The
728
+ // primary path: identity is already a WebID (e.g. resolved via the
729
+ // existing did:nostr DID-doc resolver) and an account exists for it.
693
730
  let account = await findByWebId(identity);
694
731
 
695
732
  if (!account) {
696
- // No account linked to this did:nostr
697
- // For now, return error - user needs to link their did:nostr to an account
698
- // Future: could auto-create account or prompt for linking
733
+ // Fallback: if the user typed a username on the login form, check
734
+ // whether the verified Nostr pubkey is declared as a CID
735
+ // verificationMethod referenced from `authentication` in that
736
+ // user's WebID profile (#400's IdP-side parallel — #403). The
737
+ // signature has already been verified above, so this is just
738
+ // "does this verified pubkey belong to the typed user".
739
+ const parsed = parseUsernameField(request);
740
+ if (parsed.tooLarge) {
741
+ return reply.code(413).type('application/json').send({
742
+ success: false,
743
+ error: 'Request body exceeds maximum size.',
744
+ });
745
+ }
746
+ const typedUsername = parsed.username;
747
+ if (typedUsername) {
748
+ const candidate = await findByUsername(typedUsername);
749
+ if (candidate?.webId) {
750
+ const pubkey = await getNostrPubkey(request);
751
+ if (pubkey && await verifyNostrPubkeyAgainstWebId(candidate.webId, pubkey)) {
752
+ account = candidate;
753
+ request.log.info({ accountId: account.id, webId: candidate.webId, uid },
754
+ 'Schnorr login resolved via typed username + profile VM');
755
+ }
756
+ }
757
+ }
758
+ }
759
+
760
+ if (!account) {
699
761
  return reply.code(403).type('application/json').send({
700
762
  success: false,
701
- error: 'No account linked to this identity. Please register or link your Schnorr key to an existing account.'
763
+ error: 'No account linked to this identity. Type your username and add a Schnorr verificationMethod to your WebID profile (or link via did:nostr DID document).'
702
764
  });
703
765
  }
704
766
 
package/src/idp/views.js CHANGED
@@ -381,12 +381,21 @@ export function loginPage(uid, clientId, error = null, passkeyEnabled = true, sc
381
381
  // Sign with NIP-07 extension
382
382
  const signedEvent = await window.nostr.signEvent(event);
383
383
 
384
+ // Read the typed username so the server can resolve which
385
+ // account this Nostr key belongs to, in case the existing
386
+ // did:nostr DID-doc resolver doesn't have a binding yet.
387
+ // The signature is verified BEFORE the username is consulted —
388
+ // typing someone else's username doesn't grant access.
389
+ const typedUsername = (document.getElementById('username')?.value || '').trim();
390
+
384
391
  // Send to server
385
392
  const response = await fetch(authUrl, {
386
393
  method: 'POST',
387
394
  headers: {
388
- 'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent))
389
- }
395
+ 'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent)),
396
+ 'Content-Type': 'application/x-www-form-urlencoded'
397
+ },
398
+ body: typedUsername ? 'username=' + encodeURIComponent(typedUsername) : ''
390
399
  });
391
400
 
392
401
  const result = await response.json();