-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathdid-nostr.test.js
More file actions
509 lines (454 loc) · 19.2 KB
/
did-nostr.test.js
File metadata and controls
509 lines (454 loc) · 19.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
/**
* Tests for did:nostr to WebID resolution
*/
import { describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert';
import { generateSecretKey, getPublicKey, finalizeEvent } from '../src/nostr/event.js';
import {
startTestServer,
stopTestServer,
request,
createTestPod,
getBaseUrl,
assertStatus
} from './helpers.js';
// Import the module under test
import {
resolveDidNostrToWebId,
clearCache,
_cacheSizeForTests,
_CACHE_MAX_FOR_TESTS,
} from '../src/auth/did-nostr.js';
describe('DID:nostr Resolution', () => {
describe('Unit Tests', () => {
before(() => {
clearCache();
});
it('should return null for invalid pubkey', async () => {
const result = await resolveDidNostrToWebId('invalid');
assert.strictEqual(result, null);
});
it('should return null for empty pubkey', async () => {
const result = await resolveDidNostrToWebId('');
assert.strictEqual(result, null);
});
it('should return null for null pubkey', async () => {
const result = await resolveDidNostrToWebId(null);
assert.strictEqual(result, null);
});
it('should return null for pubkey with wrong length', async () => {
const result = await resolveDidNostrToWebId('abcd1234');
assert.strictEqual(result, null);
});
it('should handle non-existent DID gracefully', async () => {
// Use a random pubkey that won't exist
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
// This will hit nostr.social and get 404
const result = await resolveDidNostrToWebId(pubkey);
assert.strictEqual(result, null);
});
});
describe('checkSameAsLink Function', () => {
// We need to test the internal checkSameAsLink function
// Since it's not exported, we test it indirectly through WebID verification
it('should recognize owl:sameAs string value', async () => {
// This test verifies the format we expect in WebID profiles
const profile = {
'@id': '#me',
'owl:sameAs': 'did:nostr:abcd1234'
};
// The profile should have the correct structure
assert.strictEqual(profile['owl:sameAs'], 'did:nostr:abcd1234');
});
it('should recognize sameAs as @id object', async () => {
const profile = {
'@id': '#me',
'owl:sameAs': { '@id': 'did:nostr:abcd1234' }
};
assert.strictEqual(profile['owl:sameAs']['@id'], 'did:nostr:abcd1234');
});
});
describe('Nostr Auth with DID Resolution', () => {
before(async () => {
await startTestServer();
});
after(async () => {
await stopTestServer();
clearCache();
});
it('should create a pod for DID testing', async () => {
const result = await createTestPod('nostrtest');
assert.ok(result.webId, 'Should have webId');
assert.ok(result.token, 'Should have token');
});
it('should accept valid NIP-98 auth header', async () => {
// Generate a Nostr keypair
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
// Create the pod for this pubkey
const podName = pubkey.substring(0, 16);
await createTestPod(podName);
// Create a NIP-98 event
const baseUrl = getBaseUrl();
const event = finalizeEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', `${baseUrl}/${podName}/public/`],
['method', 'GET']
],
content: ''
}, sk);
// Encode as base64
const token = Buffer.from(JSON.stringify(event)).toString('base64');
// Make request with Nostr auth
const res = await fetch(`${baseUrl}/${podName}/public/`, {
headers: {
'Authorization': `Nostr ${token}`
}
});
// Should succeed (200) - the Nostr auth should work
// Even without DID resolution, did:nostr:<pubkey> is accepted
assertStatus(res, 200);
});
it('should return did:nostr when no WebID linked', async () => {
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
// Try to resolve - should return null since no alsoKnownAs
const result = await resolveDidNostrToWebId(pubkey);
assert.strictEqual(result, null, 'Should return null when no WebID linked');
});
});
describe('Same-origin shortcut removed — backlink always verified', () => {
// The previous same-origin shortcut returned a WebID without
// checking that the WebID profile actually claimed the pubkey.
// On a multi-tenant origin where one user controls
// /.well-known/did/nostr/<theirPubkey>.json and another user
// controls /<other>/profile/card, the attacker could publish
// a DID doc with `alsoKnownAs` pointing at the OTHER user's
// WebID and the resolver would accept it.
//
// We can't drive the live resolver here because
// validateExternalUrl unconditionally blocks loopback (the
// only thing a unit test can bind to), so we exercise the
// CID-VM backlink check directly via the exposed test seam
// with in-memory profiles. The full multi-tenant flow is
// observable in production once a public host is involved.
let attackPubkey;
let legitPubkey;
let legitX;
let legitY;
before(async () => {
const { secp256k1 } = await import('@noble/curves/secp256k1');
// Use real on-curve keys; we need a valid point to match the
// verifier's BIP-340 even-y derivation.
legitPubkey = getPublicKey(generateSecretKey());
attackPubkey = getPublicKey(generateSecretKey());
const point = secp256k1.ProjectivePoint.fromHex('02' + legitPubkey);
const yHex = point.toAffine().y.toString(16).padStart(64, '0');
const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
legitX = b64u(legitPubkey);
legitY = b64u(yHex);
clearCache();
});
it('checkCidVmBacklink: accepts a profile with matching VM in authentication', async () => {
const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
const subj = `http://example.test/profile/card#me`;
const profile = {
'@id': subj,
verificationMethod: [{
id: `${subj.replace('#me','')}#k`,
type: 'JsonWebKey',
controller: subj,
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
}],
authentication: [`${subj.replace('#me','')}#k`],
};
assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), true);
});
it('checkCidVmBacklink: rejects a profile without the VM', async () => {
const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
const profile = {
'@id': 'http://example.test/profile/card#me',
verificationMethod: [],
authentication: [],
};
// attackPubkey (all-a) isn't in the profile — multi-tenant
// attack scenario.
assert.strictEqual(_checkCidVmBacklinkForTests(profile, attackPubkey), false);
});
it('checkCidVmBacklink: rejects a VM in verificationMethod but NOT in authentication', async () => {
const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
const subj = `http://example.test/profile/card#me`;
const profile = {
'@id': subj,
verificationMethod: [{
id: `${subj.replace('#me','')}#k`,
type: 'JsonWebKey',
controller: subj,
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
}],
authentication: [], // <-- not declared for auth
};
assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false);
});
it('checkCidVmBacklink: handles relative @id when docUrl is supplied', async () => {
const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
const docUrl = 'http://example.test/profile/card.jsonld';
// Relative subject AND absolute VM IDs (a common mixed shape).
// Without the docUrl fallback, base would be empty and the
// authentication-membership check would silently fail.
const profile = {
'@id': '#me',
verificationMethod: [{
id: `${docUrl}#k`,
type: 'JsonWebKey',
controller: `${docUrl}#me`,
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
}],
authentication: [`${docUrl}#k`],
};
assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey, docUrl), true);
});
it('checkCidVmBacklink: rejects when VM controller is not in expected set', async () => {
const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
const subj = 'http://example.test/profile/card#me';
// VM controller points at a totally different origin — would
// be a planted-key attack. Resource-side verifier rejects this;
// CID-VM backlink must agree.
const profile = {
'@id': subj,
// No top-level controller — defaults to subject.
verificationMethod: [{
id: `${subj.replace('#me','')}#k`,
type: 'JsonWebKey',
controller: 'http://attacker.example/me',
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
}],
authentication: [`${subj.replace('#me','')}#k`],
};
assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false);
});
it('checkCidVmBacklink: rejects a VM with NO explicit controller (matches resource-side strictness)', async () => {
// Earlier passes had a permissive branch that accepted a
// controller-less VM if the VM ID and the profile subject
// shared an origin. That made backlink looser than the
// resource-side verifier, opening a binding-rule mismatch
// (DID resolution would say "yes" for keys the LWS10-CID
// verifier would later reject). Both layers now require
// an explicit controller.
const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
const subj = 'http://example.test/profile/card#me';
const profile = {
'@id': subj,
verificationMethod: [{
id: `${subj.replace('#me','')}#k`,
type: 'JsonWebKey',
// controller intentionally absent
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
}],
authentication: [`${subj.replace('#me','')}#k`],
};
assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false);
});
});
describe('Pubkey input validation', () => {
// Pubkey is attacker-controlled (NIP-98 event) and is
// interpolated into the resolver URL path and cache key.
// Length-only validation lets a malicious pubkey containing
// `/` or other chars rewrite the URL path on the resolver
// origin and pollute the cache.
it('rejects non-hex pubkeys without making any request', async () => {
// 64-character pubkey containing a path separator —
// length-only validation would let this through and
// cause an arbitrary-path fetch on the resolver origin.
const evil = 'a'.repeat(31) + '/' + 'b'.repeat(32);
assert.strictEqual(evil.length, 64);
const out = await resolveDidNostrToWebId(evil, 'http://nonexistent.invalid:1');
assert.strictEqual(out, null);
});
it('rejects too-short and non-string pubkeys', async () => {
assert.strictEqual(await resolveDidNostrToWebId('abc'), null);
assert.strictEqual(await resolveDidNostrToWebId(null), null);
assert.strictEqual(await resolveDidNostrToWebId(42), null);
assert.strictEqual(await resolveDidNostrToWebId('a'.repeat(63)), null);
assert.strictEqual(await resolveDidNostrToWebId('a'.repeat(65)), null);
});
});
describe('Cache key includes resolverUrl', () => {
// Cross-resolver leakage: different resolvers can legitimately
// disagree about the same pubkey (one might have a DID doc,
// another not). Keying the cache only on pubkey would let a
// hit from one resolver suppress a real lookup against another.
before(() => clearCache());
it('does NOT share cache entries across resolvers', async () => {
// Both calls hit unresolvable hosts → both cache as
// failureTtl. Crucially, they cache under DIFFERENT keys, so
// the cache size grows by 2 (not 1).
const pk = 'a'.repeat(64);
const sizeBefore = _cacheSizeForTests();
await resolveDidNostrToWebId(pk, 'http://nonexistent-a.invalid:1');
await resolveDidNostrToWebId(pk, 'http://nonexistent-b.invalid:1');
const sizeAfter = _cacheSizeForTests();
assert.strictEqual(sizeAfter - sizeBefore, 2,
'each resolver+pubkey pair should cache independently');
});
});
describe('Cache bounding', () => {
// The cache is keyed by attacker-controlled NIP-98 pubkeys.
// Without an LRU cap a stream of unique pubkeys would grow
// memory without limit. Drive the cap directly via the
// SSRF / unknown-resolver path (every lookup gets cached as
// a transient failure) and assert the size never exceeds
// CACHE_MAX_ENTRIES.
before(() => clearCache());
it('cache stays at-or-under CACHE_MAX_ENTRIES after a burst of misses', async () => {
// Use an unreachable resolver so every lookup fast-fails
// and gets cached as `failureTtl: true`. The LRU code is
// mechanical (set + while size > cap → delete oldest),
// so a small N is enough to assert the invariant
// `size <= cap`.
assert.ok(_CACHE_MAX_FOR_TESTS >= 1, 'cap must be positive');
const N = 5;
const before = _cacheSizeForTests();
for (let i = 0; i < N; i++) {
const pk = i.toString(16).padStart(64, '0');
// Force a network failure → cached as failureTtl
await resolveDidNostrToWebId(pk, 'http://nonexistent.invalid:1');
}
const after = _cacheSizeForTests();
assert.ok(after - before <= N, 'cache shouldn\'t grow more than N');
assert.ok(after <= _CACHE_MAX_FOR_TESTS,
`cache size ${after} > cap ${_CACHE_MAX_FOR_TESTS}`);
});
});
describe('fetchWithRedirectGuard SSRF / redirect hardening', () => {
// The production resolver wraps fetchWithRedirectGuard with
// validateExternalUrl as a hard SSRF gate, which by design
// rejects loopback (`127.0.0.1`) — the only thing a unit test
// can bind to. So testing the resolver end-to-end against a
// local server makes the redirect/cap logic invisible: every
// request fails on the SSRF guard before fetch is even called.
//
// Solution: import fetchWithRedirectGuard directly and inject
// a permissive `_validateUrl` stub. That isolates the redirect
// hop counter, cross-origin check, and size cap from the SSRF
// gate so we can actually observe each one.
let http;
let server;
let port;
let hopMode = 'cross-origin';
let fetchWithRedirectGuard;
const allowAll = async () => ({ valid: true });
before(async () => {
http = await import('node:http');
({ fetchWithRedirectGuard } = await import('../src/auth/did-nostr.js'));
server = http.createServer((req, res) => {
if (hopMode === 'cross-origin') {
res.writeHead(302, { Location: 'http://other.example:1/foo.json' });
res.end();
return;
}
if (hopMode === 'loop') {
// Each hop appends `/r` to the path; the cap fires before
// we ever return a non-3xx.
res.writeHead(302, { Location: req.url + '/r' });
res.end();
return;
}
if (hopMode === 'oversize') {
res.writeHead(200, { 'Content-Type': 'application/json' });
// Stream a body larger than the 1 KB cap we'll pass.
res.end('"' + 'x'.repeat(2000) + '"');
return;
}
if (hopMode === 'ok') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"ok":true}');
return;
}
res.writeHead(404).end();
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
port = server.address().port;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
});
it('refuses cross-origin redirects', async () => {
hopMode = 'cross-origin';
await assert.rejects(
() => fetchWithRedirectGuard(`http://127.0.0.1:${port}/foo.json`, { _validateUrl: allowAll }),
/cross-origin redirect refused/,
);
});
it('refuses redirect chains exceeding the hop cap', async () => {
hopMode = 'loop';
await assert.rejects(
() => fetchWithRedirectGuard(`http://127.0.0.1:${port}/start`, { _validateUrl: allowAll }),
/too many redirects/,
);
});
it('refuses oversized response bodies', async () => {
hopMode = 'oversize';
await assert.rejects(
() => fetchWithRedirectGuard(`http://127.0.0.1:${port}/big`, {
_validateUrl: allowAll,
maxBytes: 1000,
}),
/response too large/,
);
});
it('re-runs SSRF validation on every hop', async () => {
hopMode = 'loop';
let calls = 0;
const counting = async (url) => {
calls++;
return { valid: true };
};
await assert.rejects(
() => fetchWithRedirectGuard(`http://127.0.0.1:${port}/start`, { _validateUrl: counting }),
/too many redirects/,
);
// 1 initial + MAX_REDIRECTS (5) hops = 6 calls if we re-validate
// on every hop. < 6 means the per-hop check is missing.
assert.ok(calls >= 6, `expected ≥6 validator calls, got ${calls}`);
});
it('returns the response body on success', async () => {
hopMode = 'ok';
const r = await fetchWithRedirectGuard(`http://127.0.0.1:${port}/ok`, { _validateUrl: allowAll });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.body, '{"ok":true}');
});
});
describe('Real DID Document Fetch', () => {
before(() => {
clearCache();
});
it('should fetch DID document from nostr.social', async () => {
// Use a known pubkey that exists on nostr.social
// fiatjaf's pubkey
const pubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
// This should not throw, just return null if no WebID linked
const result = await resolveDidNostrToWebId(pubkey);
// fiatjaf likely doesn't have a WebID linked, so expect null
// But the fetch itself should work without error
assert.strictEqual(result, null, 'Should return null when no bidirectional link');
});
it('should cache DID resolution results', async () => {
const pubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
// First call
const start1 = Date.now();
await resolveDidNostrToWebId(pubkey);
const time1 = Date.now() - start1;
// Second call should be cached (much faster)
const start2 = Date.now();
await resolveDidNostrToWebId(pubkey);
const time2 = Date.now() - start2;
// Cached call should be < 5ms typically
assert.ok(time2 < time1 || time2 < 10, `Cached call should be fast. First: ${time1}ms, Second: ${time2}ms`);
});
});
});