javascript-solid-server 0.0.179 → 0.0.181

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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shared Nostr-key encoding helpers.
3
+ *
4
+ * Lives in its own module so both the NIP-98 verifier
5
+ * (`src/auth/nostr.js`) and the well-known DID-doc publisher
6
+ * (`src/idp/well-known-did-nostr.js`) can use it without forming a
7
+ * circular import.
8
+ */
9
+
10
+ import { secp256k1 } from '@noble/curves/secp256k1';
11
+
12
+ /** Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex. */
13
+ const MULTICODEC_SECP256K1_PUB_HEX = 'e701';
14
+
15
+ /**
16
+ * Validate a secp256k1 JWK as a Nostr key and return its x-only
17
+ * pubkey hex. Returns `null` if the JWK isn't a Nostr-shaped key
18
+ * or its `y` doesn't match the BIP-340 canonical (even-y) point
19
+ * for the declared `x`.
20
+ *
21
+ * Why y matters: every secp256k1 x has TWO valid points (positive
22
+ * and negative y). Nostr uses x-only pubkeys, which by BIP-340
23
+ * convention always pick the even-y point. A profile that declares
24
+ * a JWK with the right x but the wrong y is NOT the user's Nostr
25
+ * key — accepting it would let an attacker plant a JWK at someone
26
+ * else's WebID and have the indexer publish it as theirs.
27
+ *
28
+ * The verifier in src/auth/nostr.js (jwkMatchesNostrPubkey) does
29
+ * the same check. Keeping the indexer in sync prevents the
30
+ * "indexed but verifier rejects" inconsistency that would surface
31
+ * as a 401 on a key the well-known endpoint had advertised.
32
+ */
33
+ export function pubkeyFromValidatedJwk(jwk) {
34
+ if (!jwk || typeof jwk !== 'object') return null;
35
+ if (jwk.kty !== 'EC') return null;
36
+ if (jwk.crv !== 'secp256k1' && jwk.crv !== 'P-256K') return null;
37
+ if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return null;
38
+ let xHex;
39
+ try {
40
+ xHex = Buffer.from(jwk.x.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
41
+ .toString('hex').toLowerCase();
42
+ } catch { return null; }
43
+ if (!/^[0-9a-f]{64}$/.test(xHex)) return null;
44
+ let canonicalY;
45
+ try {
46
+ // Compressed SEC1 encoding for the EVEN-y point at this x.
47
+ const point = secp256k1.ProjectivePoint.fromHex('02' + xHex);
48
+ canonicalY = point.toAffine().y.toString(16).padStart(64, '0');
49
+ } catch { return null; }
50
+ let jwkYHex;
51
+ try {
52
+ jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
53
+ .toString('hex').toLowerCase();
54
+ } catch { return null; }
55
+ if (jwkYHex !== canonicalY) return null;
56
+ return xHex;
57
+ }
58
+
59
+ /**
60
+ * Decode an f-form Multikey for secp256k1-pub back into the 32-byte
61
+ * x-only pubkey hex. Returns null if the input isn't this shape.
62
+ *
63
+ * The f-form recipe (per CCG community#254 / did:nostr): multibase
64
+ * `f` (base16-lower) + multicodec `e701` + parity byte (`02`/`03`)
65
+ * + 32-byte xonly pubkey.
66
+ */
67
+ export function decodeFFormSecp256k1(mb) {
68
+ if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
69
+ const hex = mb.slice(1).toLowerCase();
70
+ if (!/^[0-9a-f]+$/.test(hex)) return null;
71
+ if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
72
+ const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
73
+ // Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
74
+ if (rest.length !== 66) return null;
75
+ const parity = rest.slice(0, 2);
76
+ if (parity !== '02' && parity !== '03') return null;
77
+ return rest.slice(2);
78
+ }
79
+
80
+ /**
81
+ * Enumerate every Nostr pubkey declared in a profile's
82
+ * `verificationMethod` entries. Matches both encodings:
83
+ * - f-form Multikey (`publicKeyMultibase`)
84
+ * - JsonWebKey (`kty: EC, crv: secp256k1`) — derives x as the pubkey
85
+ *
86
+ * Returns `[ { pubkey, vm } ]` — the VM is returned alongside so
87
+ * callers can do further checks (`controller`, `authentication`
88
+ * membership, etc.) without re-parsing.
89
+ */
90
+ export function extractNostrPubkeysFromProfile(profile) {
91
+ if (!profile || typeof profile !== 'object') return [];
92
+ const out = [];
93
+ const raw = profile.verificationMethod;
94
+ const vms = raw === undefined || raw === null ? []
95
+ : Array.isArray(raw) ? raw : [raw];
96
+ for (const vm of vms) {
97
+ if (!vm || typeof vm !== 'object') continue;
98
+ if (typeof vm.publicKeyMultibase === 'string') {
99
+ const xonly = decodeFFormSecp256k1(vm.publicKeyMultibase);
100
+ if (xonly) out.push({ pubkey: xonly, vm });
101
+ } else if (vm.publicKeyJwk && typeof vm.publicKeyJwk === 'object') {
102
+ // Require y to match the BIP-340 canonical point — the same
103
+ // check the NIP-98 verifier applies. Without this, the indexer
104
+ // could publish a JWK that the verifier will then reject,
105
+ // surfacing as a 401 on a key the well-known endpoint had
106
+ // advertised as authentic.
107
+ const xonly = pubkeyFromValidatedJwk(vm.publicKeyJwk);
108
+ if (xonly) out.push({ pubkey: xonly, vm });
109
+ }
110
+ }
111
+ return out;
112
+ }
package/src/auth/nostr.js CHANGED
@@ -14,10 +14,14 @@
14
14
  * Match by f-form Multikey or by JsonWebKey x/y coordinates. If
15
15
  * found, authenticate as the WebID. (#399 — pairs with the
16
16
  * LWS10-CID verifier.)
17
- * 2. Resolve via the existing did:nostr DID-document path
17
+ * 2. (IdP-only) Look up the pubkey in the local in-process index
18
+ * built from `<DATA_ROOT>/.idp/accounts/_webid_index.json`.
19
+ * No HTTP, no SSRF surface — direct function call. Catches
20
+ * same-pod users without a third-party round-trip. (#407)
21
+ * 3. Resolve via the external did:nostr DID-document path
18
22
  * (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
23
+ * Used for cross-pod identities; SSRF + redirect hardened.
24
+ * 4. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the
21
25
  * agent identity (the original behavior).
22
26
  */
23
27
 
@@ -25,8 +29,14 @@ import { verifyEvent, getEventHash } from '../nostr/event.js';
25
29
  import { secp256k1 } from '@noble/curves/secp256k1';
26
30
  import crypto from 'crypto';
27
31
  import { resolveDidNostrToWebId } from './did-nostr.js';
32
+ // resolveDidNostrLocally is loaded lazily (inside the idpEnabled
33
+ // branch) so non-IdP deployments don't pay the IdP/accounts module
34
+ // startup cost (bcryptjs, oidc-provider helpers, etc.) just by
35
+ // importing the NIP-98 verifier.
28
36
  import { fetchCidDocument } from './cid-doc-fetch.js';
29
37
  import { normalizeControllers } from './lws-cid.js'; // shared JSON-LD controller helper
38
+ import { decodeFFormSecp256k1, extractNostrPubkeysFromProfile } from './nostr-keys.js'; // re-exported for back-compat
39
+ export { extractNostrPubkeysFromProfile };
30
40
 
31
41
  // NIP-98 event kind (references RFC 7235)
32
42
  const HTTP_AUTH_KIND = 27235;
@@ -34,11 +44,6 @@ const HTTP_AUTH_KIND = 27235;
34
44
  // Timestamp tolerance in seconds
35
45
  const TIMESTAMP_TOLERANCE = 60;
36
46
 
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
47
  // Profile-fetch body-size cap. Matches the LWS-CID verifier; both
43
48
  // callers go through the shared fetchCidDocument helper.
44
49
  const MAX_PROFILE_BYTES = 256 * 1024;
@@ -282,9 +287,30 @@ export async function verifyNostrAuth(request) {
282
287
  return { webId: vmWebId, error: null };
283
288
  }
284
289
 
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.
290
+ // Second lookup: in-process local DID resolution (#407). Fast path
291
+ // direct function call into the local account index, no HTTP
292
+ // fetch, no SSRF surface from request-controlled headers. Catches
293
+ // any user who's published a Nostr Multikey VM into their profile
294
+ // on this same pod.
295
+ //
296
+ // Gated on idpEnabled because the index reads from
297
+ // <DATA_ROOT>/.idp/accounts which only exists when the IdP layer
298
+ // is in use. On non-IdP deployments the local resolver has nothing
299
+ // to find and would just spin disk on every request.
300
+ if (request.idpEnabled) {
301
+ // Dynamic import: only load the IdP-accounts stack when IdP is
302
+ // actually enabled. Cached after first load (ESM module caching).
303
+ const { resolveDidNostrLocally } = await import('../idp/well-known-did-nostr.js');
304
+ const localWebId = await resolveDidNostrLocally(event.pubkey);
305
+ if (localWebId) {
306
+ return { webId: localWebId, error: null };
307
+ }
308
+ }
309
+
310
+ // Third lookup: external did:nostr DID-document resolver. Fetches
311
+ // a DID doc from the configured external resolver (nostr.social) and
312
+ // checks bidirectional alsoKnownAs ↔ WebID linking. Used only for
313
+ // cross-pod identities (the local case is handled above).
288
314
  const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
289
315
  if (resolvedWebId) {
290
316
  return { webId: resolvedWebId, error: null };
@@ -599,23 +625,6 @@ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) {
599
625
  return null;
600
626
  }
601
627
 
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
628
  function hexToBase64url(hex) {
620
629
  return Buffer.from(hex, 'hex').toString('base64')
621
630
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -28,6 +28,14 @@ const LIVE_RELOAD_SCRIPT = `<script>(function(){var ws=new WebSocket((location.p
28
28
  // where a cached data variant was served on top-level navigation (#315).
29
29
  const RDF_CACHE_CONTROL = 'private, no-cache, must-revalidate';
30
30
 
31
+ // Detects when the request's Accept header explicitly names a JSON
32
+ // media type. Used by the container/index.html branches of GET and HEAD
33
+ // to decide whether to surface the embedded JSON-LD data island —
34
+ // without this guard, selectContentType's `*/*` arm would divert plain
35
+ // browser requests into the RDF branch (#409). Hoisted so GET and HEAD
36
+ // can't drift apart silently.
37
+ const EXPLICIT_JSON_RE = /\b(application\/ld\+json|application\/json)\b/i;
38
+
31
39
  /**
32
40
  * Inject live reload script into HTML content
33
41
  */
@@ -161,7 +169,16 @@ export async function handleGet(request, reply) {
161
169
  const wantsTurtle = negotiated === RDF_TYPES.TURTLE
162
170
  || negotiated === RDF_TYPES.N3
163
171
  || negotiated === 'application/n-triples';
164
- const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;
172
+ // Only treat as JSON-LD when Accept *explicitly* asks for JSON.
173
+ // selectContentType doesn't recognize text/html or
174
+ // application/xhtml+xml, so for a browser Accept like
175
+ // `text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8`
176
+ // it walks past those unsupported types and lands on `*/*`, which
177
+ // returns JSON-LD — diverting plain browser GETs into the RDF
178
+ // branch and serving the embedded data island instead of the
179
+ // index.html body. Mirrors the HEAD-handler logic below (#409).
180
+ const explicitJson = EXPLICIT_JSON_RE.test(acceptHeader);
181
+ const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD && explicitJson;
165
182
 
166
183
  if (wantsTurtle || wantsJsonLd) {
167
184
  // Extract JSON-LD from HTML data island
@@ -601,7 +618,7 @@ export async function handleHead(request, reply) {
601
618
  // For an index.html container, only override to JSON-LD if the
602
619
  // Accept header explicitly asked for JSON; otherwise fall back
603
620
  // to text/html so HEAD matches the index.html that GET serves.
604
- const explicitJson = /\b(application\/ld\+json|application\/json)\b/i.test(acceptHeader);
621
+ const explicitJson = EXPLICIT_JSON_RE.test(acceptHeader);
605
622
  contentType = (indexExists && !explicitJson) ? 'text/html' : 'application/ld+json';
606
623
  } else {
607
624
  contentType = indexExists ? 'text/html' : 'application/ld+json';