javascript-solid-server 0.0.176 → 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/.claude/scheduled_tasks.lock +1 -0
- package/README.md +1 -0
- package/docs/lws.md +84 -0
- package/package.json +1 -1
- package/src/auth/cid-doc-fetch.js +202 -0
- package/src/auth/lws-cid.js +516 -0
- package/src/auth/nostr.js +397 -8
- package/src/auth/token.js +12 -1
- package/test/lws-cid.test.js +705 -0
- package/test/nostr-cid-vm.test.js +509 -0
|
@@ -0,0 +1,509 @@
|
|
|
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 } 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
|
+
it('still rejects an invalid signature regardless of the profile', async () => {
|
|
496
|
+
const url = `https://${POD_HOST}/private/data.ttl`;
|
|
497
|
+
const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
|
|
498
|
+
// Tamper: re-encode the event with a flipped signature byte.
|
|
499
|
+
const decoded = JSON.parse(Buffer.from(authHeader.slice(6), 'base64').toString());
|
|
500
|
+
decoded.sig = decoded.sig.slice(0, -2) + (decoded.sig.endsWith('00') ? 'ff' : '00');
|
|
501
|
+
const tampered = `Nostr ${Buffer.from(JSON.stringify(decoded)).toString('base64')}`;
|
|
502
|
+
const req = makeRequest({ url });
|
|
503
|
+
req.headers.authorization = tampered;
|
|
504
|
+
|
|
505
|
+
const r = await verifyNostrAuth(req);
|
|
506
|
+
assert.strictEqual(r.webId, null);
|
|
507
|
+
assert.match(r.error, /signature/);
|
|
508
|
+
});
|
|
509
|
+
});
|