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.
@@ -0,0 +1,562 @@
1
+ /**
2
+ * NIP-98 → WebID via verificationMethod lookup (#399)
3
+ *
4
+ * Covers `verifyNostrAuth`'s new tryResolveViaCidVerificationMethod
5
+ * step: when a Nostr-signed request hits a pod whose owner's WebID
6
+ * profile declares the request's signing pubkey as a CID-v1
7
+ * verificationMethod (in `authentication`), authenticate as the
8
+ * WebID rather than as `did:nostr:<pubkey>`.
9
+ *
10
+ * Stubs global.fetch so we hand-craft the profile document. Real
11
+ * Schnorr signatures are produced via the in-tree nostr/event
12
+ * primitives.
13
+ */
14
+
15
+ import { describe, it, before, beforeEach, after } from 'node:test';
16
+ import assert from 'node:assert';
17
+ import { secp256k1 } from '@noble/curves/secp256k1';
18
+ import { generateSecretKey, getPublicKey, finalizeEvent } from '../src/nostr/event.js';
19
+ import { verifyNostrAuth, verifyNostrPubkeyAgainstWebId } from '../src/auth/nostr.js';
20
+ import { _clearProfileCacheForTests } from '../src/auth/cid-doc-fetch.js';
21
+
22
+ /** Compute the BIP-340 even-y JWK coordinates for an x-only Nostr pubkey. */
23
+ function evenYJwk(xOnlyHex) {
24
+ const point = secp256k1.ProjectivePoint.fromHex('02' + xOnlyHex);
25
+ const aff = point.toAffine();
26
+ const xHex = aff.x.toString(16).padStart(64, '0');
27
+ const yHex = aff.y.toString(16).padStart(64, '0');
28
+ const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
29
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
30
+ return { kty: 'EC', crv: 'secp256k1', alg: 'ES256K', x: b64u(xHex), y: b64u(yHex) };
31
+ }
32
+
33
+ // --- helpers ---------------------------------------------------------
34
+
35
+ function nip98Authorization({ method, url, secretKey, createdAt }) {
36
+ const event = finalizeEvent({
37
+ kind: 27235,
38
+ created_at: createdAt ?? Math.floor(Date.now() / 1000),
39
+ tags: [['u', url], ['method', method.toUpperCase()]],
40
+ content: '',
41
+ }, secretKey);
42
+ const token = Buffer.from(JSON.stringify(event)).toString('base64');
43
+ return { authHeader: `Nostr ${token}`, event };
44
+ }
45
+
46
+ function makeRequest({ method = 'GET', url, host = 'alice.example.com', mode = 'subdomain', extra = {} } = {}) {
47
+ const fullUrl = url ?? `https://${host}/private/data.ttl`;
48
+ const path = new URL(fullUrl).pathname;
49
+ const base = {
50
+ method,
51
+ url: path,
52
+ protocol: 'https',
53
+ hostname: host,
54
+ headers: { authorization: '', host, ...extra.headers },
55
+ };
56
+ if (mode === 'subdomain') {
57
+ return {
58
+ ...base,
59
+ subdomainsEnabled: true,
60
+ baseDomain: 'example.com',
61
+ podName: host.split('.')[0] === 'example' ? null : host.split('.')[0],
62
+ };
63
+ }
64
+ // Path mode (JSS default): no subdomains, pod is first URL segment.
65
+ return { ...base, subdomainsEnabled: false };
66
+ }
67
+
68
+ // f-form Multikey for the CCG-compromise Nostr recipe:
69
+ // "f" + "e701" + parity (default 02) + 32-byte xonly hex.
70
+ function nostrPubkeyToFformMultikey(pubHex, parity = '02') {
71
+ return `f` + 'e701' + parity + pubHex.toLowerCase();
72
+ }
73
+
74
+ // --- profile fixtures ------------------------------------------------
75
+
76
+ const POD_HOST = 'alice.example.com';
77
+ const WEBID = `https://${POD_HOST}/profile/card.jsonld#me`;
78
+ const DOC_URL = `https://${POD_HOST}/profile/card.jsonld`;
79
+
80
+ // Path-mode equivalents (JSS's default deployment shape).
81
+ const PATH_HOST = 'example.com';
82
+ const PATH_PODNAME = 'alice';
83
+ const PATH_WEBID = `https://${PATH_HOST}/${PATH_PODNAME}/profile/card.jsonld#me`;
84
+ const PATH_DOC_URL = `https://${PATH_HOST}/${PATH_PODNAME}/profile/card.jsonld`;
85
+
86
+ function buildProfile({ pubkey, vmId = `${DOC_URL}#nostr-key-1`, withAuth = true, jwk = null, webId = WEBID } = {}) {
87
+ const vm = jwk
88
+ ? { id: vmId, type: 'JsonWebKey', controller: webId, publicKeyJwk: jwk }
89
+ : { id: vmId, type: 'Multikey', controller: webId,
90
+ publicKeyMultibase: nostrPubkeyToFformMultikey(pubkey) };
91
+ return {
92
+ '@context': {
93
+ cid: 'https://www.w3.org/ns/cid/v1#',
94
+ controller: { '@id': 'cid:controller', '@type': '@id' },
95
+ verificationMethod: { '@id': 'cid:verificationMethod', '@container': '@set' },
96
+ authentication: { '@id': 'cid:authentication', '@type': '@id', '@container': '@set' },
97
+ publicKeyMultibase: { '@id': 'cid:publicKeyMultibase' },
98
+ publicKeyJwk: { '@id': 'cid:publicKeyJwk', '@type': '@json' },
99
+ },
100
+ '@id': webId,
101
+ controller: webId,
102
+ verificationMethod: [vm],
103
+ ...(withAuth ? { authentication: [vmId] } : {}),
104
+ };
105
+ }
106
+
107
+ // --- fetch stub ------------------------------------------------------
108
+
109
+ const realFetch = global.fetch;
110
+ let nextProfile = null;
111
+ let nextStatus = 200;
112
+ let urlResponses = new Map();
113
+ let pathProfile = null;
114
+
115
+ function installFetchStub() {
116
+ global.fetch = async (url) => {
117
+ const u = String(url);
118
+ if (urlResponses.has(u)) {
119
+ const { status = 200, headers = {}, body = '' } = urlResponses.get(u);
120
+ return new Response(body, { status, headers });
121
+ }
122
+ if (u === DOC_URL) {
123
+ return new Response(JSON.stringify(nextProfile), {
124
+ status: nextStatus,
125
+ headers: { 'content-type': 'application/ld+json' },
126
+ });
127
+ }
128
+ if (u === PATH_DOC_URL) {
129
+ return new Response(JSON.stringify(pathProfile), {
130
+ status: pathProfile ? 200 : 404,
131
+ headers: { 'content-type': 'application/ld+json' },
132
+ });
133
+ }
134
+ // Anything else (the did-nostr.js DID-doc resolver fallback) — 404
135
+ // so the secondary lookup short-circuits.
136
+ return new Response('not found', { status: 404 });
137
+ };
138
+ }
139
+ function restoreFetch() { global.fetch = realFetch; }
140
+
141
+ // --- tests -----------------------------------------------------------
142
+
143
+ describe('NIP-98 + CID verificationMethod lookup (#399)', () => {
144
+ let sk, pk;
145
+
146
+ before(() => {
147
+ installFetchStub();
148
+ });
149
+ after(() => {
150
+ restoreFetch();
151
+ });
152
+
153
+ beforeEach(() => {
154
+ sk = generateSecretKey();
155
+ pk = getPublicKey(sk);
156
+ nextStatus = 200;
157
+ nextProfile = buildProfile({ pubkey: pk });
158
+ pathProfile = null;
159
+ urlResponses = new Map();
160
+ // Cache lives in cid-doc-fetch.js now and is shared with lws-cid.js;
161
+ // must clear so a previous test's profile doesn't satisfy this one.
162
+ _clearProfileCacheForTests();
163
+ });
164
+
165
+ it('upgrades did:nostr → WebID when the pubkey is in the profile as f-form Multikey VM', async () => {
166
+ const url = `https://${POD_HOST}/private/data.ttl`;
167
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
168
+ const req = makeRequest({ url });
169
+ req.headers.authorization = authHeader;
170
+
171
+ const r = await verifyNostrAuth(req);
172
+ assert.strictEqual(r.error, null);
173
+ assert.strictEqual(r.webId, WEBID);
174
+ });
175
+
176
+ it('upgrades did:nostr → WebID when the pubkey is in the profile as JsonWebKey VM', async () => {
177
+ // Construct a real even-y JWK so the verifier's full-point match
178
+ // succeeds (matching by x alone would be unsafe — see #400 pass 3).
179
+ nextProfile = buildProfile({ pubkey: pk, jwk: evenYJwk(pk) });
180
+ const url = `https://${POD_HOST}/private/data.ttl`;
181
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
182
+ const req = makeRequest({ url });
183
+ req.headers.authorization = authHeader;
184
+
185
+ const r = await verifyNostrAuth(req);
186
+ assert.strictEqual(r.error, null);
187
+ assert.strictEqual(r.webId, WEBID);
188
+ });
189
+
190
+ it("rejects when CID document's subject differs from computed owner WebID", async () => {
191
+ // Profile sits at the expected docUrl but declares a DIFFERENT
192
+ // @id. Without the subject check, this would let an attacker
193
+ // host a card.jsonld whose @id is "...#bob" + a Nostr VM under
194
+ // bob's name, and trick us into authenticating as bob when the
195
+ // request URL says alice.
196
+ nextProfile = {
197
+ ...buildProfile({ pubkey: pk }),
198
+ '@id': `${DOC_URL}#bob`,
199
+ controller: `${DOC_URL}#bob`,
200
+ };
201
+ const url = `https://${POD_HOST}/private/data.ttl`;
202
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
203
+ const req = makeRequest({ url });
204
+ req.headers.authorization = authHeader;
205
+
206
+ const r = await verifyNostrAuth(req);
207
+ // Falls back to did:nostr — VM lookup refused due to subject mismatch.
208
+ assert.strictEqual(r.webId, `did:nostr:${pk}`);
209
+ });
210
+
211
+ it('rejects a JWK with the right x but wrong y (curve-point integrity)', async () => {
212
+ const goodJwk = evenYJwk(pk);
213
+ // Flip the y to invalid — same x, different y → not on canonical
214
+ // BIP-340 point, so should NOT match the Nostr key.
215
+ const badJwk = { ...goodJwk, y: goodJwk.y.slice(0, -1) + (goodJwk.y.endsWith('A') ? 'B' : 'A') };
216
+ nextProfile = buildProfile({ pubkey: pk, jwk: badJwk });
217
+ const url = `https://${POD_HOST}/private/data.ttl`;
218
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
219
+ const req = makeRequest({ url });
220
+ req.headers.authorization = authHeader;
221
+
222
+ const r = await verifyNostrAuth(req);
223
+ assert.strictEqual(r.webId, `did:nostr:${pk}`); // fell through to did:nostr
224
+ });
225
+
226
+ it('falls back to did:nostr when the profile has no matching VM', async () => {
227
+ const otherSk = generateSecretKey();
228
+ const otherPk = getPublicKey(otherSk);
229
+ nextProfile = buildProfile({ pubkey: otherPk }); // VM has a different key
230
+
231
+ const url = `https://${POD_HOST}/private/data.ttl`;
232
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
233
+ const req = makeRequest({ url });
234
+ req.headers.authorization = authHeader;
235
+
236
+ const r = await verifyNostrAuth(req);
237
+ assert.strictEqual(r.error, null);
238
+ assert.strictEqual(r.webId, `did:nostr:${pk}`);
239
+ });
240
+
241
+ it('falls back to did:nostr when the matching VM is NOT in authentication', async () => {
242
+ nextProfile = buildProfile({ pubkey: pk, withAuth: false });
243
+
244
+ const url = `https://${POD_HOST}/private/data.ttl`;
245
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
246
+ const req = makeRequest({ url });
247
+ req.headers.authorization = authHeader;
248
+
249
+ const r = await verifyNostrAuth(req);
250
+ assert.strictEqual(r.error, null);
251
+ assert.strictEqual(r.webId, `did:nostr:${pk}`);
252
+ });
253
+
254
+ it('falls back to did:nostr when the profile fetch fails', async () => {
255
+ nextStatus = 404;
256
+ nextProfile = null;
257
+
258
+ const url = `https://${POD_HOST}/private/data.ttl`;
259
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
260
+ const req = makeRequest({ url });
261
+ req.headers.authorization = authHeader;
262
+
263
+ const r = await verifyNostrAuth(req);
264
+ assert.strictEqual(r.error, null);
265
+ assert.strictEqual(r.webId, `did:nostr:${pk}`);
266
+ });
267
+
268
+ it('upgrades did:nostr → WebID in single-user mode', async () => {
269
+ // Single-user: one pod at the host root, WebID at /profile/card.jsonld#me.
270
+ const SINGLE_HOST = 'pod.example.com';
271
+ const SINGLE_DOC = `https://${SINGLE_HOST}/profile/card.jsonld`;
272
+ const SINGLE_WEBID = `${SINGLE_DOC}#me`;
273
+ urlResponses.set(SINGLE_DOC, {
274
+ status: 200,
275
+ headers: { 'content-type': 'application/ld+json' },
276
+ body: JSON.stringify(buildProfile({
277
+ pubkey: pk,
278
+ vmId: `${SINGLE_DOC}#nostr-key-1`,
279
+ webId: SINGLE_WEBID,
280
+ })),
281
+ });
282
+ const url = `https://${SINGLE_HOST}/private/data.ttl`;
283
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
284
+ const req = makeRequest({ url, host: SINGLE_HOST, mode: 'path' });
285
+ req.singleUser = true;
286
+ req.subdomainsEnabled = false;
287
+ req.headers.authorization = authHeader;
288
+
289
+ const r = await verifyNostrAuth(req);
290
+ assert.strictEqual(r.error, null);
291
+ assert.strictEqual(r.webId, SINGLE_WEBID);
292
+ });
293
+
294
+ it('upgrades did:nostr → WebID in single-user mode with a named pod', async () => {
295
+ // singleUser=true + singleUserName='alice' mounts the pod at
296
+ // /alice/, with WebID at /alice/profile/card.jsonld#me.
297
+ const NAMED_HOST = 'pod.example.com';
298
+ const NAMED_DOC = `https://${NAMED_HOST}/alice/profile/card.jsonld`;
299
+ const NAMED_WEBID = `${NAMED_DOC}#me`;
300
+ urlResponses.set(NAMED_DOC, {
301
+ status: 200,
302
+ headers: { 'content-type': 'application/ld+json' },
303
+ body: JSON.stringify(buildProfile({
304
+ pubkey: pk,
305
+ vmId: `${NAMED_DOC}#nostr-key-1`,
306
+ webId: NAMED_WEBID,
307
+ })),
308
+ });
309
+ const url = `https://${NAMED_HOST}/alice/private/data.ttl`;
310
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
311
+ const req = makeRequest({ url, host: NAMED_HOST, mode: 'path' });
312
+ req.singleUser = true;
313
+ req.singleUserName = 'alice';
314
+ req.subdomainsEnabled = false;
315
+ req.headers.authorization = authHeader;
316
+
317
+ const r = await verifyNostrAuth(req);
318
+ assert.strictEqual(r.error, null);
319
+ assert.strictEqual(r.webId, NAMED_WEBID);
320
+ });
321
+
322
+ it('handles IPv6 literal host without crashing the host parser', async () => {
323
+ // host.split(':')[0] would mangle '[::1]:3000' to '['. The
324
+ // URL-aware parser must give a usable hostname instead of
325
+ // crashing. Sign with the IPv6 host so the existing NIP-98
326
+ // URL-match check passes — this test is about getPodOwnerWebId
327
+ // not crashing, not about URL matching.
328
+ const v6Host = '[2001:db8::1]:8443';
329
+ const url = `https://${v6Host}/private/data.ttl`;
330
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
331
+ const req = makeRequest({ url, host: v6Host, mode: 'path' });
332
+ req.headers.authorization = authHeader;
333
+ req.subdomainsEnabled = false;
334
+
335
+ const r = await verifyNostrAuth(req);
336
+ // No crash. The IPv6 path-mode WebID is malformed (a known
337
+ // limitation matching JSS pod creation, see in-source comment),
338
+ // so this falls back to did:nostr — that's the explicit
339
+ // acceptable outcome.
340
+ assert.strictEqual(r.error, null);
341
+ assert.strictEqual(r.webId, `did:nostr:${pk}`);
342
+ });
343
+
344
+ it('handles host:port without breaking baseDomain match', async () => {
345
+ // Subdomain-enabled deployment, request landed on base domain
346
+ // with a port. The base-domain comparison must work when the
347
+ // host carries a port — without port stripping, hostNoPort !==
348
+ // baseDomain and the path-mode-on-base branch never matches.
349
+ const PORT_HOST = 'example.com:8080';
350
+ const SUB_HOST = 'alice.example.com';
351
+ const SUB_DOC = `https://${SUB_HOST}/profile/card.jsonld`;
352
+ const SUB_WEBID = `${SUB_DOC}#me`;
353
+ urlResponses.set(SUB_DOC, {
354
+ status: 200,
355
+ headers: { 'content-type': 'application/ld+json' },
356
+ body: JSON.stringify(buildProfile({
357
+ pubkey: pk,
358
+ vmId: `${SUB_DOC}#nostr-key-1`,
359
+ webId: SUB_WEBID,
360
+ })),
361
+ });
362
+ // Sign with the same host:port the request will carry, so the
363
+ // existing NIP-98 URL-match check passes — this test is about the
364
+ // base-domain comparison in WebID derivation, not URL matching.
365
+ const url = `https://${PORT_HOST}/alice/private/data.ttl`;
366
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
367
+ const req = makeRequest({ url, host: PORT_HOST, mode: 'subdomain' });
368
+ // Hit the base domain (no podName).
369
+ req.podName = null;
370
+ req.headers.authorization = authHeader;
371
+
372
+ const r = await verifyNostrAuth(req);
373
+ assert.strictEqual(r.error, null);
374
+ assert.strictEqual(r.webId, SUB_WEBID);
375
+ });
376
+
377
+ it('upgrades did:nostr → WebID in path mode even when host carries a port', async () => {
378
+ // Reverse proxies forward Host with port (e.g. example.com:8080).
379
+ // The computed ownerWebId must match what JSS stores at pod
380
+ // creation, which uses request.hostname (port-stripped). Otherwise
381
+ // the subject-identity check would reject valid requests.
382
+ pathProfile = buildProfile({
383
+ pubkey: pk,
384
+ vmId: `${PATH_DOC_URL}#nostr-key-1`,
385
+ webId: PATH_WEBID,
386
+ });
387
+ // Sign with the port-included URL so the existing NIP-98 URL-match
388
+ // check passes — this test is about WebID derivation, not URL matching.
389
+ const PORT_HOST = `${PATH_HOST}:8080`;
390
+ const url = `https://${PORT_HOST}/${PATH_PODNAME}/private/data.ttl`;
391
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
392
+ const req = makeRequest({ url, host: PORT_HOST, mode: 'path' });
393
+ req.headers.authorization = authHeader;
394
+
395
+ const r = await verifyNostrAuth(req);
396
+ assert.strictEqual(r.error, null);
397
+ assert.strictEqual(r.webId, PATH_WEBID); // canonical, port-stripped
398
+ });
399
+
400
+ it('upgrades did:nostr → WebID in path mode (subdomains disabled, JSS default)', async () => {
401
+ // JSS's default deployment shape: pod is the first URL segment
402
+ // and the WebID lives under that path.
403
+ pathProfile = buildProfile({
404
+ pubkey: pk,
405
+ vmId: `${PATH_DOC_URL}#nostr-key-1`,
406
+ webId: PATH_WEBID,
407
+ });
408
+ const url = `https://${PATH_HOST}/${PATH_PODNAME}/private/data.ttl`;
409
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
410
+ const req = makeRequest({ url, host: PATH_HOST, mode: 'path' });
411
+ req.headers.authorization = authHeader;
412
+
413
+ const r = await verifyNostrAuth(req);
414
+ assert.strictEqual(r.error, null);
415
+ assert.strictEqual(r.webId, PATH_WEBID);
416
+ });
417
+
418
+ it('refuses cross-origin redirect during profile fetch (falls back to did:nostr)', async () => {
419
+ // Pod-owner profile URL 302s to an attacker-controlled host. The
420
+ // redirect must be refused regardless of where it points; the VM
421
+ // lookup gets nothing and we fall through to did:nostr.
422
+ urlResponses.set(DOC_URL, {
423
+ status: 302,
424
+ headers: { location: 'https://attacker.example/profile/card.jsonld' },
425
+ });
426
+ const url = `https://${POD_HOST}/private/data.ttl`;
427
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
428
+ const req = makeRequest({ url });
429
+ req.headers.authorization = authHeader;
430
+
431
+ const r = await verifyNostrAuth(req);
432
+ assert.strictEqual(r.error, null);
433
+ assert.strictEqual(r.webId, `did:nostr:${pk}`);
434
+ });
435
+
436
+ it('refuses oversized profile bodies (falls back to did:nostr)', async () => {
437
+ const huge = 'x'.repeat(300 * 1024);
438
+ urlResponses.set(DOC_URL, {
439
+ status: 200,
440
+ headers: { 'content-type': 'application/ld+json' },
441
+ body: JSON.stringify({ junk: huge }),
442
+ });
443
+ const url = `https://${POD_HOST}/private/data.ttl`;
444
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
445
+ const req = makeRequest({ url });
446
+ req.headers.authorization = authHeader;
447
+
448
+ const r = await verifyNostrAuth(req);
449
+ assert.strictEqual(r.webId, `did:nostr:${pk}`);
450
+ });
451
+
452
+ it('rejects malformed Host header (URL-injection defense)', async () => {
453
+ // Host carries `@` which would steer the computed owner WebID at
454
+ // attacker.com if we naively passed it through new URL().
455
+ const url = `https://${POD_HOST}/private/data.ttl`;
456
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
457
+ const req = makeRequest({ url });
458
+ req.headers.authorization = authHeader;
459
+ req.headers.host = `${POD_HOST}@attacker.example`;
460
+
461
+ const r = await verifyNostrAuth(req);
462
+ // The URL match runs first and rejects on the malformed host.
463
+ assert.match(r.error, /invalid characters/);
464
+ });
465
+
466
+ it('lowercases x-forwarded-proto so HTTPS still matches profile @id', async () => {
467
+ // Some proxies send `X-Forwarded-Proto: HTTPS` (uppercase). The
468
+ // computed WebID must use the lowercase form so the subject-identity
469
+ // check still matches a profile @id that uses lowercase `https://`.
470
+ const url = `https://${POD_HOST}/private/data.ttl`;
471
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
472
+ const req = makeRequest({ url });
473
+ req.headers.authorization = authHeader;
474
+ req.headers['x-forwarded-proto'] = 'HTTPS';
475
+
476
+ const r = await verifyNostrAuth(req);
477
+ assert.strictEqual(r.error, null);
478
+ assert.strictEqual(r.webId, WEBID);
479
+ });
480
+
481
+ it('handles array-valued forwarded headers without throwing', async () => {
482
+ // Fastify can yield x-forwarded-* as an array when duplicated.
483
+ const url = `https://${POD_HOST}/private/data.ttl`;
484
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
485
+ const req = makeRequest({ url });
486
+ req.headers.authorization = authHeader;
487
+ req.headers['x-forwarded-proto'] = ['https', 'http'];
488
+ req.headers['x-forwarded-host'] = [POD_HOST, 'internal.lan'];
489
+
490
+ const r = await verifyNostrAuth(req);
491
+ assert.strictEqual(r.error, null);
492
+ assert.strictEqual(r.webId, WEBID);
493
+ });
494
+
495
+ // --- IdP Schnorr-login helper (#403) ---------------------------------
496
+
497
+ describe('verifyNostrPubkeyAgainstWebId', () => {
498
+ it('returns true when the pubkey is a Multikey VM in authentication', async () => {
499
+ _clearProfileCacheForTests();
500
+ nextProfile = buildProfile({ pubkey: pk });
501
+ const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
502
+ assert.strictEqual(ok, true);
503
+ });
504
+
505
+ it('returns false when the pubkey is in verificationMethod but NOT in authentication', async () => {
506
+ _clearProfileCacheForTests();
507
+ nextProfile = buildProfile({ pubkey: pk, withAuth: false });
508
+ const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
509
+ assert.strictEqual(ok, false);
510
+ });
511
+
512
+ it('returns false when the profile has no matching VM', async () => {
513
+ _clearProfileCacheForTests();
514
+ const otherPk = getPublicKey(generateSecretKey());
515
+ nextProfile = buildProfile({ pubkey: otherPk });
516
+ const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
517
+ assert.strictEqual(ok, false);
518
+ });
519
+
520
+ it("returns false when the profile's @id differs from the asked WebID", async () => {
521
+ _clearProfileCacheForTests();
522
+ nextProfile = { ...buildProfile({ pubkey: pk }), '@id': `${DOC_URL}#bob` };
523
+ const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
524
+ assert.strictEqual(ok, false);
525
+ });
526
+
527
+ it('returns false on bad input', async () => {
528
+ assert.strictEqual(await verifyNostrPubkeyAgainstWebId('', pk), false);
529
+ assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, 'not-hex'), false);
530
+ assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, ''), false);
531
+ });
532
+
533
+ it('returns false when VM controller is unrelated to profile controller', async () => {
534
+ _clearProfileCacheForTests();
535
+ // VM with right Multikey but its controller points at a different
536
+ // identity — the profile's outer controller is the WebID, but the
537
+ // VM claims to be controlled by `https://attacker.example/#me`.
538
+ // This is the "key bound by an unrelated controller" attack the
539
+ // controller-consistency check defends against.
540
+ const profile = buildProfile({ pubkey: pk });
541
+ profile.verificationMethod[0].controller = 'https://attacker.example/profile/card.jsonld#me';
542
+ nextProfile = profile;
543
+ const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
544
+ assert.strictEqual(ok, false);
545
+ });
546
+ });
547
+
548
+ it('still rejects an invalid signature regardless of the profile', async () => {
549
+ const url = `https://${POD_HOST}/private/data.ttl`;
550
+ const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
551
+ // Tamper: re-encode the event with a flipped signature byte.
552
+ const decoded = JSON.parse(Buffer.from(authHeader.slice(6), 'base64').toString());
553
+ decoded.sig = decoded.sig.slice(0, -2) + (decoded.sig.endsWith('00') ? 'ff' : '00');
554
+ const tampered = `Nostr ${Buffer.from(JSON.stringify(decoded)).toString('base64')}`;
555
+ const req = makeRequest({ url });
556
+ req.headers.authorization = tampered;
557
+
558
+ const r = await verifyNostrAuth(req);
559
+ assert.strictEqual(r.webId, null);
560
+ assert.match(r.error, /signature/);
561
+ });
562
+ });