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/docs/lws.md +35 -24
- package/package.json +1 -1
- package/src/auth/cid-doc-fetch.js +202 -0
- package/src/auth/lws-cid.js +17 -180
- package/src/auth/nostr.js +459 -8
- package/src/idp/interactions.js +69 -7
- package/src/idp/views.js +11 -2
- package/test/nostr-cid-vm.test.js +562 -0
package/src/auth/nostr.js
CHANGED
|
@@ -7,13 +7,26 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Authorization header format: "Nostr <base64-encoded-event>"
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
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
|
package/src/idp/interactions.js
CHANGED
|
@@ -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
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
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.
|
|
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();
|