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/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 +397 -8
- package/test/nostr-cid-vm.test.js +509 -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';
|
|
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,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
|