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.
- package/package.json +1 -1
- package/src/auth/did-nostr.js +417 -73
- package/src/auth/nostr-keys.js +112 -0
- package/src/auth/nostr.js +37 -28
- package/src/handlers/resource.js +19 -2
- package/src/idp/well-known-did-nostr.js +458 -0
- package/src/server.js +66 -0
- package/test/conneg.test.js +82 -0
- package/test/did-nostr.test.js +331 -1
- package/test/well-known-did-nostr.test.js +631 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the well-known did:nostr HTTP-resolution
|
|
3
|
+
* endpoint (#407): JSS publishes DID docs at
|
|
4
|
+
* `/.well-known/did/nostr/<pubkey>.json` for any local account whose
|
|
5
|
+
* profile carries that pubkey as a CID verificationMethod, so JSS's
|
|
6
|
+
* own resolver (and external clients like nostr.social, nostr.rocks)
|
|
7
|
+
* can resolve same-pod identities without a third-party round-trip.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import fs from 'fs-extra';
|
|
14
|
+
import { createServer as createNetServer } from 'net';
|
|
15
|
+
import { generateSecretKey, getPublicKey } from '../src/nostr/event.js';
|
|
16
|
+
import { createServer } from '../src/server.js';
|
|
17
|
+
import { _resetIndexForTests, profilePathFromWebId } from '../src/idp/well-known-did-nostr.js';
|
|
18
|
+
import { extractNostrPubkeysFromProfile } from '../src/auth/nostr.js';
|
|
19
|
+
|
|
20
|
+
const TEST_HOST = '127.0.0.1';
|
|
21
|
+
// Dedicated per-suite directory so we don't clobber a developer's
|
|
22
|
+
// local `./data` (which is also JSS's default data root) and don't
|
|
23
|
+
// race with other suites that use `./data` via the shared helper.
|
|
24
|
+
const TEST_DATA_DIR = './test-data-well-known-did-nostr';
|
|
25
|
+
|
|
26
|
+
/** Pick an OS-assigned port up front so idpIssuer can include it. */
|
|
27
|
+
async function getAvailablePort() {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const srv = createNetServer();
|
|
30
|
+
srv.on('error', reject);
|
|
31
|
+
srv.listen(0, TEST_HOST, () => {
|
|
32
|
+
const port = srv.address().port;
|
|
33
|
+
srv.close(() => resolve(port));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fformMultikey(xOnlyHex, parity = '02') {
|
|
39
|
+
return 'f' + 'e701' + parity + xOnlyHex.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function patchProfileWithMultikey(podName, pubkey) {
|
|
43
|
+
const profilePath = path.join(TEST_DATA_DIR, podName, 'profile', 'card.jsonld');
|
|
44
|
+
const profile = await fs.readJson(profilePath);
|
|
45
|
+
const VM_ID = `${profile['@id'].replace('#me', '')}#nostr-key-1`;
|
|
46
|
+
profile.verificationMethod = [{
|
|
47
|
+
id: VM_ID,
|
|
48
|
+
type: 'Multikey',
|
|
49
|
+
controller: profile['@id'],
|
|
50
|
+
publicKeyMultibase: fformMultikey(pubkey),
|
|
51
|
+
}];
|
|
52
|
+
profile.authentication = [VM_ID];
|
|
53
|
+
await fs.writeJson(profilePath, profile, { spaces: 2 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('GET /.well-known/did/nostr/:pubkey (#407)', () => {
|
|
57
|
+
let server;
|
|
58
|
+
let baseUrl;
|
|
59
|
+
let alicePk;
|
|
60
|
+
// Capture the original DATA_ROOT before the suite mutates it, so
|
|
61
|
+
// the after() hook can restore it. Other tests in the repo follow
|
|
62
|
+
// this save/restore pattern (e.g. idp-change-password.test.js) to
|
|
63
|
+
// avoid cross-test environment leakage.
|
|
64
|
+
const originalDataRoot = process.env.DATA_ROOT;
|
|
65
|
+
|
|
66
|
+
before(async () => {
|
|
67
|
+
// IdP must be enabled — pod creation only writes an account
|
|
68
|
+
// record (the index this endpoint reads from) when the IdP is
|
|
69
|
+
// running. Pods without IdP are out of scope for this MVP.
|
|
70
|
+
//
|
|
71
|
+
// Match the pattern in test/idp.test.js: pick an available port
|
|
72
|
+
// BEFORE listen so we can pass the real baseUrl as idpIssuer.
|
|
73
|
+
// (oidc-provider behavior depends on the issuer being accurate;
|
|
74
|
+
// a static `http://127.0.0.1` with no port would mismatch.)
|
|
75
|
+
await fs.remove(TEST_DATA_DIR);
|
|
76
|
+
await fs.ensureDir(TEST_DATA_DIR);
|
|
77
|
+
const port = await getAvailablePort();
|
|
78
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
79
|
+
server = createServer({
|
|
80
|
+
logger: false,
|
|
81
|
+
root: TEST_DATA_DIR,
|
|
82
|
+
idp: true,
|
|
83
|
+
idpIssuer: baseUrl,
|
|
84
|
+
forceCloseConnections: true,
|
|
85
|
+
});
|
|
86
|
+
await server.listen({ port, host: TEST_HOST });
|
|
87
|
+
process.env.DATA_ROOT = path.resolve(TEST_DATA_DIR);
|
|
88
|
+
// IdP-enabled pod creation requires email + password (so the
|
|
89
|
+
// account record is written to _webid_index.json).
|
|
90
|
+
const r = await fetch(`${baseUrl}/.pods`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
name: 'alice',
|
|
95
|
+
email: 'alice@example.com',
|
|
96
|
+
password: 'wellknown-test-password',
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
if (!r.ok) throw new Error(`pod create failed: ${r.status} ${await r.text()}`);
|
|
100
|
+
const sk = generateSecretKey();
|
|
101
|
+
alicePk = getPublicKey(sk);
|
|
102
|
+
await patchProfileWithMultikey('alice', alicePk);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
after(async () => {
|
|
106
|
+
await server.close();
|
|
107
|
+
await fs.remove(TEST_DATA_DIR);
|
|
108
|
+
if (originalDataRoot === undefined) {
|
|
109
|
+
delete process.env.DATA_ROOT;
|
|
110
|
+
} else {
|
|
111
|
+
process.env.DATA_ROOT = originalDataRoot;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
_resetIndexForTests();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns a CID-shaped DID doc for a local account with the matching VM', async () => {
|
|
120
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`);
|
|
121
|
+
assert.strictEqual(r.status, 200);
|
|
122
|
+
assert.match(r.headers.get('content-type') || '', /did\+json/);
|
|
123
|
+
assert.ok(r.headers.get('cache-control'));
|
|
124
|
+
assert.ok(r.headers.get('nostr-timestamp'));
|
|
125
|
+
assert.ok(r.headers.get('last-modified'));
|
|
126
|
+
|
|
127
|
+
const doc = await r.json();
|
|
128
|
+
assert.deepStrictEqual(doc['@context'], ['https://w3id.org/did', 'https://w3id.org/nostr/context']);
|
|
129
|
+
assert.strictEqual(doc.id, `did:nostr:${alicePk}`);
|
|
130
|
+
assert.strictEqual(doc.type, 'DIDNostr');
|
|
131
|
+
assert.ok(Array.isArray(doc.alsoKnownAs));
|
|
132
|
+
assert.match(doc.alsoKnownAs[0], /\/alice\/profile\/card\.jsonld#me$/);
|
|
133
|
+
assert.strictEqual(doc.verificationMethod[0].type, 'Multikey');
|
|
134
|
+
assert.strictEqual(doc.verificationMethod[0].publicKeyMultibase, fformMultikey(alicePk));
|
|
135
|
+
assert.strictEqual(doc.authentication[0], `did:nostr:${alicePk}#key1`);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('accepts the .jsonld suffix (alias)', async () => {
|
|
139
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.jsonld`);
|
|
140
|
+
assert.strictEqual(r.status, 200);
|
|
141
|
+
assert.match(r.headers.get('content-type') || '', /did\+ld\+json/);
|
|
142
|
+
const doc = await r.json();
|
|
143
|
+
assert.strictEqual(doc.id, `did:nostr:${alicePk}`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('accepts the bare pubkey (no extension)', async () => {
|
|
147
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}`);
|
|
148
|
+
assert.strictEqual(r.status, 200);
|
|
149
|
+
const doc = await r.json();
|
|
150
|
+
assert.strictEqual(doc.id, `did:nostr:${alicePk}`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns 404 for a pubkey no local account claims', async () => {
|
|
154
|
+
const otherPk = getPublicKey(generateSecretKey());
|
|
155
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${otherPk}.json`);
|
|
156
|
+
assert.strictEqual(r.status, 404);
|
|
157
|
+
// Per-status header policy: 404 still sets Nostr-Timestamp (so
|
|
158
|
+
// clients can correlate the resolver clock with the negative
|
|
159
|
+
// answer) and a short cache so newly added keys surface fast.
|
|
160
|
+
assert.ok(r.headers.get('nostr-timestamp'));
|
|
161
|
+
assert.match(r.headers.get('cache-control') || '', /max-age=60/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns 400 for a non-hex pubkey', async () => {
|
|
165
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/not-a-real-pubkey.json`);
|
|
166
|
+
assert.strictEqual(r.status, 400);
|
|
167
|
+
// 400 sets Nostr-Timestamp but never caches (request was malformed).
|
|
168
|
+
assert.ok(r.headers.get('nostr-timestamp'));
|
|
169
|
+
assert.match(r.headers.get('cache-control') || '', /no-store/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('returns 400 for a wrong-length hex pubkey', async () => {
|
|
173
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/abcdef.json`);
|
|
174
|
+
assert.strictEqual(r.status, 400);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('responds to HEAD with the same headers as GET (no body)', async () => {
|
|
178
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`, { method: 'HEAD' });
|
|
179
|
+
assert.strictEqual(r.status, 200);
|
|
180
|
+
assert.match(r.headers.get('content-type') || '', /did\+json/);
|
|
181
|
+
assert.ok(r.headers.get('cache-control'));
|
|
182
|
+
assert.ok(r.headers.get('last-modified'));
|
|
183
|
+
// HEAD bodies must be empty.
|
|
184
|
+
const text = await r.text();
|
|
185
|
+
assert.strictEqual(text, '');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('rejects writes (PUT/POST/PATCH/DELETE) with 405 Method Not Allowed', async () => {
|
|
189
|
+
// Without these explicit handlers, the wildcard write routes
|
|
190
|
+
// would accept unauthenticated writes under /.well-known/* (the
|
|
191
|
+
// namespace bypasses the WAC preHandler).
|
|
192
|
+
for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) {
|
|
193
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${alicePk}.json`, {
|
|
194
|
+
method,
|
|
195
|
+
headers: { 'Content-Type': 'application/json' },
|
|
196
|
+
body: method === 'DELETE' ? undefined : '{}',
|
|
197
|
+
});
|
|
198
|
+
assert.strictEqual(r.status, 405, `${method} should be 405`);
|
|
199
|
+
assert.match(r.headers.get('allow') || '', /GET/);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('OPTIONS advertises only safe methods (Allow consistent with 405) AND sets CORS headers', async () => {
|
|
204
|
+
// The wildcard `OPTIONS /*` advertises GET/HEAD/PUT/DELETE/PATCH/POST,
|
|
205
|
+
// which is wrong for the read-only well-known namespace and
|
|
206
|
+
// confusing to CORS preflights. Explicit OPTIONS handlers must
|
|
207
|
+
// return the same Allow set as the 405 responses AND the full
|
|
208
|
+
// CORS header set so browser preflights work cross-origin.
|
|
209
|
+
for (const subpath of ['', '/', '/x', '/a/b']) {
|
|
210
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr${subpath}`, {
|
|
211
|
+
method: 'OPTIONS',
|
|
212
|
+
headers: { Origin: 'https://other.example' },
|
|
213
|
+
});
|
|
214
|
+
assert.strictEqual(r.status, 204, `OPTIONS ${subpath} should be 204`);
|
|
215
|
+
const allow = (r.headers.get('allow') || '').toUpperCase();
|
|
216
|
+
assert.match(allow, /GET/, `Allow should include GET (got "${allow}")`);
|
|
217
|
+
assert.match(allow, /HEAD/);
|
|
218
|
+
assert.doesNotMatch(allow, /\bPUT\b/, `Allow should not advertise PUT (got "${allow}")`);
|
|
219
|
+
assert.doesNotMatch(allow, /\bPOST\b/);
|
|
220
|
+
assert.doesNotMatch(allow, /\bDELETE\b/);
|
|
221
|
+
assert.doesNotMatch(allow, /\bPATCH\b/);
|
|
222
|
+
// CORS preflights need these. Without them, browsers refuse
|
|
223
|
+
// to follow up with the actual request.
|
|
224
|
+
const acAllowMethods = (r.headers.get('access-control-allow-methods') || '').toUpperCase();
|
|
225
|
+
assert.match(acAllowMethods, /GET/, `ACAM missing GET (got "${acAllowMethods}")`);
|
|
226
|
+
assert.match(acAllowMethods, /HEAD/);
|
|
227
|
+
assert.match(acAllowMethods, /OPTIONS/);
|
|
228
|
+
assert.doesNotMatch(acAllowMethods, /\bPUT\b/, `ACAM should not advertise PUT`);
|
|
229
|
+
assert.ok(r.headers.get('access-control-allow-origin'), 'ACAO must be set');
|
|
230
|
+
assert.ok(r.headers.get('access-control-allow-headers'), 'ACAH must be set');
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('blocks writes to multi-segment paths under the namespace', async () => {
|
|
235
|
+
// The single-segment `:pubkeyAndExt` route only matches one
|
|
236
|
+
// path component — `PUT /.well-known/did/nostr/a/b` would
|
|
237
|
+
// otherwise fall through to the wildcard `PUT /*` and accept
|
|
238
|
+
// an unauthenticated write since `/.well-known/*` bypasses
|
|
239
|
+
// WAC. The wildcard 405 handler closes that.
|
|
240
|
+
for (const subpath of ['', '/', '/a/b', '/foo/bar/baz.json']) {
|
|
241
|
+
const url = `${baseUrl}/.well-known/did/nostr${subpath}`;
|
|
242
|
+
for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) {
|
|
243
|
+
const r = await fetch(url, {
|
|
244
|
+
method,
|
|
245
|
+
headers: { 'Content-Type': 'application/json' },
|
|
246
|
+
body: method === 'DELETE' ? undefined : '{}',
|
|
247
|
+
});
|
|
248
|
+
assert.strictEqual(r.status, 405,
|
|
249
|
+
`${method} ${url} should be 405 (got ${r.status})`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('indexes root-level pods (profile at /profile/card.jsonld, no podName prefix)', async () => {
|
|
255
|
+
// Single-user / root-pod layout: the profile lives directly at
|
|
256
|
+
// <DATA_ROOT>/profile/card.jsonld with no podName subdirectory,
|
|
257
|
+
// even though the seeded IDP account record can have
|
|
258
|
+
// `podName: 'me'`. The indexer must derive the on-disk path
|
|
259
|
+
// from the WebID's pathname, NOT from podName, or this whole
|
|
260
|
+
// class of pods is invisible to local DID resolution.
|
|
261
|
+
const sk = generateSecretKey();
|
|
262
|
+
const rootPk = getPublicKey(sk);
|
|
263
|
+
const rootWebId = `${baseUrl}/profile/card.jsonld#me`;
|
|
264
|
+
const rootProfilePath = path.join(TEST_DATA_DIR, 'profile', 'card.jsonld');
|
|
265
|
+
const VM_ID = `${baseUrl}/profile/card.jsonld#nostr-root`;
|
|
266
|
+
await fs.ensureDir(path.dirname(rootProfilePath));
|
|
267
|
+
await fs.writeJson(rootProfilePath, {
|
|
268
|
+
'@context': 'https://www.w3.org/ns/solid/v1',
|
|
269
|
+
'@id': rootWebId,
|
|
270
|
+
verificationMethod: [{
|
|
271
|
+
id: VM_ID,
|
|
272
|
+
type: 'Multikey',
|
|
273
|
+
controller: rootWebId,
|
|
274
|
+
publicKeyMultibase: fformMultikey(rootPk),
|
|
275
|
+
}],
|
|
276
|
+
authentication: [VM_ID],
|
|
277
|
+
}, { spaces: 2 });
|
|
278
|
+
|
|
279
|
+
// Synthesize a matching account record + index entry. We bypass
|
|
280
|
+
// the IdP /pods POST flow because that creates a named pod with
|
|
281
|
+
// its own subdirectory; we want the root-pod shape specifically.
|
|
282
|
+
const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts');
|
|
283
|
+
const indexPath = path.join(accountsDir, '_webid_index.json');
|
|
284
|
+
const idx = await fs.readJson(indexPath);
|
|
285
|
+
const accountId = 'root-pod-test-account';
|
|
286
|
+
idx[rootWebId] = accountId;
|
|
287
|
+
await fs.writeJson(indexPath, idx, { spaces: 2 });
|
|
288
|
+
await fs.writeJson(path.join(accountsDir, `${accountId}.json`), {
|
|
289
|
+
id: accountId,
|
|
290
|
+
podName: 'me', // intentionally != on-disk layout
|
|
291
|
+
webId: rootWebId,
|
|
292
|
+
email: 'root@example.com',
|
|
293
|
+
// Other fields the account loader expects can be undefined for
|
|
294
|
+
// the lookup we're doing — findById just returns the JSON.
|
|
295
|
+
}, { spaces: 2 });
|
|
296
|
+
|
|
297
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${rootPk}.json`);
|
|
298
|
+
assert.strictEqual(r.status, 200);
|
|
299
|
+
const doc = await r.json();
|
|
300
|
+
assert.strictEqual(doc.id, `did:nostr:${rootPk}`);
|
|
301
|
+
assert.strictEqual(doc.alsoKnownAs[0], rootWebId);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// No `it()` here — path containment is now exercised directly
|
|
305
|
+
// by unit tests on `profilePathFromWebId` below. The previous
|
|
306
|
+
// integration-style test couldn't actually trigger the
|
|
307
|
+
// containment branch because WHATWG URL parsing strips `..`
|
|
308
|
+
// segments before path-resolution sees them, so the test
|
|
309
|
+
// returned 404 for the wrong reason (URL normalization, not
|
|
310
|
+
// containment).
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
it('handles profiles whose authentication entries are relative fragments', async () => {
|
|
314
|
+
// Profiles in the wild often use relative `#me`-style fragments
|
|
315
|
+
// for the subject. The indexer must absolutize `authentication`
|
|
316
|
+
// entries against the validated absolute subject, not re-derive
|
|
317
|
+
// the base from `profile['@id']` (which would itself be relative
|
|
318
|
+
// and produce unusable IDs).
|
|
319
|
+
//
|
|
320
|
+
// We simulate this by writing the profile with the `@id` set to
|
|
321
|
+
// a relative fragment and the authentication entry as a relative
|
|
322
|
+
// fragment too. If the absolute base is honored, `#nostr-rel`
|
|
323
|
+
// resolves to the same VM ID as the absolutized version inside
|
|
324
|
+
// `verificationMethod`, the auth-membership check passes, and
|
|
325
|
+
// the DID doc is published.
|
|
326
|
+
const sk = generateSecretKey();
|
|
327
|
+
const pk = getPublicKey(sk);
|
|
328
|
+
const profilePath = path.join(TEST_DATA_DIR, 'alice', 'profile', 'card.jsonld');
|
|
329
|
+
const profile = await fs.readJson(profilePath);
|
|
330
|
+
const absSubject = profile['@id']; // e.g. http://.../alice/profile/card.jsonld#me
|
|
331
|
+
const absSubjectNoHash = absSubject.replace('#me', '');
|
|
332
|
+
profile['@id'] = '#me'; // relative subject
|
|
333
|
+
profile.verificationMethod = [{
|
|
334
|
+
id: `${absSubjectNoHash}#nostr-rel`, // VM stays absolute
|
|
335
|
+
type: 'Multikey',
|
|
336
|
+
controller: absSubject,
|
|
337
|
+
publicKeyMultibase: fformMultikey(pk),
|
|
338
|
+
}];
|
|
339
|
+
profile.authentication = ['#nostr-rel']; // relative auth ref
|
|
340
|
+
await fs.writeJson(profilePath, profile, { spaces: 2 });
|
|
341
|
+
|
|
342
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${pk}.json`);
|
|
343
|
+
assert.strictEqual(r.status, 200);
|
|
344
|
+
const doc = await r.json();
|
|
345
|
+
assert.strictEqual(doc.id, `did:nostr:${pk}`);
|
|
346
|
+
|
|
347
|
+
// Restore the profile so the rest of the suite (and any later
|
|
348
|
+
// re-runs without isolation) sees a well-formed absolute subject.
|
|
349
|
+
profile['@id'] = absSubject;
|
|
350
|
+
profile.verificationMethod = [{
|
|
351
|
+
id: `${absSubjectNoHash}#nostr-key-1`,
|
|
352
|
+
type: 'Multikey',
|
|
353
|
+
controller: absSubject,
|
|
354
|
+
publicKeyMultibase: fformMultikey(alicePk),
|
|
355
|
+
}];
|
|
356
|
+
profile.authentication = [`${absSubjectNoHash}#nostr-key-1`];
|
|
357
|
+
await fs.writeJson(profilePath, profile, { spaces: 2 });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('logs a diagnostic when an account profile is unreadable (not silent)', async () => {
|
|
361
|
+
// Operators need to be able to debug "why isn't my pubkey
|
|
362
|
+
// publishing?" without grepping silence. Pre-fix, the
|
|
363
|
+
// rebuildPubkeyIndex catch was `catch { continue; }` and a
|
|
364
|
+
// broken profile produced a 404 with zero log output.
|
|
365
|
+
const sk = generateSecretKey();
|
|
366
|
+
const orphanPk = getPublicKey(sk);
|
|
367
|
+
const accountsDir = path.join(TEST_DATA_DIR, '.idp', 'accounts');
|
|
368
|
+
const indexPath = path.join(accountsDir, '_webid_index.json');
|
|
369
|
+
const idx = await fs.readJson(indexPath);
|
|
370
|
+
const orphanId = 'orphan-broken-profile';
|
|
371
|
+
const orphanWebId = `${baseUrl}/orphan/profile/card.jsonld#me`;
|
|
372
|
+
idx[orphanWebId] = orphanId;
|
|
373
|
+
await fs.writeJson(indexPath, idx, { spaces: 2 });
|
|
374
|
+
await fs.writeJson(path.join(accountsDir, `${orphanId}.json`), {
|
|
375
|
+
id: orphanId,
|
|
376
|
+
podName: 'orphan',
|
|
377
|
+
webId: orphanWebId,
|
|
378
|
+
}, { spaces: 2 });
|
|
379
|
+
// Write a malformed profile so JSON.parse will throw.
|
|
380
|
+
const profilePath = path.join(TEST_DATA_DIR, 'orphan', 'profile', 'card.jsonld');
|
|
381
|
+
await fs.ensureDir(path.dirname(profilePath));
|
|
382
|
+
await fs.writeFile(profilePath, '{ this is not valid json', 'utf8');
|
|
383
|
+
|
|
384
|
+
// Capture console.error.
|
|
385
|
+
const errors = [];
|
|
386
|
+
const origError = console.error;
|
|
387
|
+
console.error = (...args) => errors.push(args.map(String).join(' '));
|
|
388
|
+
try {
|
|
389
|
+
_resetIndexForTests();
|
|
390
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${orphanPk}.json`);
|
|
391
|
+
assert.strictEqual(r.status, 404);
|
|
392
|
+
} finally {
|
|
393
|
+
console.error = origError;
|
|
394
|
+
}
|
|
395
|
+
const matched = errors.find((m) => m.includes(orphanId) && m.includes('orphan/profile/card.jsonld'));
|
|
396
|
+
assert.ok(matched, `expected a log entry mentioning ${orphanId} and the profile path; got: ${errors.join('\n')}`);
|
|
397
|
+
|
|
398
|
+
// Cleanup.
|
|
399
|
+
delete idx[orphanWebId];
|
|
400
|
+
await fs.writeJson(indexPath, idx, { spaces: 2 });
|
|
401
|
+
await fs.remove(path.join(accountsDir, `${orphanId}.json`));
|
|
402
|
+
await fs.remove(path.dirname(path.dirname(profilePath)));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('does NOT publish a VM that is in verificationMethod but not in authentication', async () => {
|
|
406
|
+
// Add a key to the profile under verificationMethod but explicitly
|
|
407
|
+
// omit it from `authentication` — the user has decided this key
|
|
408
|
+
// is NOT for auth (revocation pending, assertion-only, etc.).
|
|
409
|
+
// Index must respect that intent.
|
|
410
|
+
const otherSk = generateSecretKey();
|
|
411
|
+
const otherPk = getPublicKey(otherSk);
|
|
412
|
+
const profilePath = path.join(TEST_DATA_DIR, 'alice', 'profile', 'card.jsonld');
|
|
413
|
+
const profile = await fs.readJson(profilePath);
|
|
414
|
+
const REVOKED_VM_ID = `${profile['@id'].replace('#me', '')}#nostr-revoked`;
|
|
415
|
+
profile.verificationMethod.push({
|
|
416
|
+
id: REVOKED_VM_ID,
|
|
417
|
+
type: 'Multikey',
|
|
418
|
+
controller: profile['@id'],
|
|
419
|
+
publicKeyMultibase: fformMultikey(otherPk),
|
|
420
|
+
});
|
|
421
|
+
// NOTE: NOT added to profile.authentication
|
|
422
|
+
await fs.writeJson(profilePath, profile, { spaces: 2 });
|
|
423
|
+
|
|
424
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr/${otherPk}.json`);
|
|
425
|
+
assert.strictEqual(r.status, 404);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('Non-IdP /.well-known/did/nostr write blocking', () => {
|
|
430
|
+
// Regression test for the case Copilot caught: even with IdP
|
|
431
|
+
// disabled, writes under /.well-known/did/nostr/* must be 405.
|
|
432
|
+
// /.well-known/* bypasses the WAC preHandler unconditionally,
|
|
433
|
+
// so without dedicated 405 handlers the wildcard write routes
|
|
434
|
+
// would accept unauthenticated PUT/POST and create files on
|
|
435
|
+
// disk under this namespace.
|
|
436
|
+
let server;
|
|
437
|
+
let baseUrl;
|
|
438
|
+
// createServer mutates process.env.DATA_ROOT — capture and
|
|
439
|
+
// restore so we don't leak the test value into anything that
|
|
440
|
+
// runs after this describe (mirrors the pattern in the first
|
|
441
|
+
// describe block).
|
|
442
|
+
const originalDataRoot = process.env.DATA_ROOT;
|
|
443
|
+
|
|
444
|
+
before(async () => {
|
|
445
|
+
const port = await getAvailablePort();
|
|
446
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
447
|
+
server = createServer({
|
|
448
|
+
logger: false,
|
|
449
|
+
root: TEST_DATA_DIR + '-noidp',
|
|
450
|
+
idp: false, // <-- the point of the test
|
|
451
|
+
forceCloseConnections: true,
|
|
452
|
+
});
|
|
453
|
+
await server.listen({ port, host: TEST_HOST });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
after(async () => {
|
|
457
|
+
await server.close();
|
|
458
|
+
await fs.remove(TEST_DATA_DIR + '-noidp');
|
|
459
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
460
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('returns 405 for PUT/POST/PATCH/DELETE under the namespace', async () => {
|
|
464
|
+
for (const subpath of ['', '/x', '/a/b']) {
|
|
465
|
+
for (const method of ['PUT', 'POST', 'PATCH', 'DELETE']) {
|
|
466
|
+
const r = await fetch(`${baseUrl}/.well-known/did/nostr${subpath}`, {
|
|
467
|
+
method,
|
|
468
|
+
headers: { 'Content-Type': 'application/json' },
|
|
469
|
+
body: method === 'DELETE' ? undefined : '{}',
|
|
470
|
+
});
|
|
471
|
+
assert.strictEqual(r.status, 405,
|
|
472
|
+
`${method} /.well-known/did/nostr${subpath} should be 405 in non-IdP mode (got ${r.status})`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe('extractNostrPubkeysFromProfile', () => {
|
|
479
|
+
it('finds f-form Multikey entries', () => {
|
|
480
|
+
const sk = generateSecretKey();
|
|
481
|
+
const pk = getPublicKey(sk);
|
|
482
|
+
const profile = {
|
|
483
|
+
verificationMethod: [{
|
|
484
|
+
id: '#k1',
|
|
485
|
+
type: 'Multikey',
|
|
486
|
+
publicKeyMultibase: fformMultikey(pk),
|
|
487
|
+
}],
|
|
488
|
+
};
|
|
489
|
+
const found = extractNostrPubkeysFromProfile(profile);
|
|
490
|
+
assert.strictEqual(found.length, 1);
|
|
491
|
+
assert.strictEqual(found[0].pubkey, pk);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('finds JsonWebKey entries when y matches the BIP-340 canonical point', async () => {
|
|
495
|
+
const { secp256k1 } = await import('@noble/curves/secp256k1');
|
|
496
|
+
const sk = generateSecretKey();
|
|
497
|
+
const pk = getPublicKey(sk);
|
|
498
|
+
// x-coord is the hex pubkey base64url-encoded.
|
|
499
|
+
const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
|
|
500
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
501
|
+
const x = b64u(pk);
|
|
502
|
+
// Compute the canonical (even-y) y for this x — same logic as
|
|
503
|
+
// the verifier in src/auth/nostr.js.
|
|
504
|
+
const point = secp256k1.ProjectivePoint.fromHex('02' + pk);
|
|
505
|
+
const yHex = point.toAffine().y.toString(16).padStart(64, '0');
|
|
506
|
+
const y = b64u(yHex);
|
|
507
|
+
const profile = {
|
|
508
|
+
verificationMethod: [{
|
|
509
|
+
id: '#k1',
|
|
510
|
+
type: 'JsonWebKey',
|
|
511
|
+
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x, y },
|
|
512
|
+
}],
|
|
513
|
+
};
|
|
514
|
+
const found = extractNostrPubkeysFromProfile(profile);
|
|
515
|
+
assert.strictEqual(found.length, 1);
|
|
516
|
+
assert.strictEqual(found[0].pubkey, pk);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('rejects JsonWebKey entries with mismatched y (not the BIP-340 canonical point)', () => {
|
|
520
|
+
const sk = generateSecretKey();
|
|
521
|
+
const pk = getPublicKey(sk);
|
|
522
|
+
const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
|
|
523
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
524
|
+
const profile = {
|
|
525
|
+
verificationMethod: [{
|
|
526
|
+
id: '#k1',
|
|
527
|
+
type: 'JsonWebKey',
|
|
528
|
+
// Right x, but a y that's clearly not on-curve. Indexer must
|
|
529
|
+
// refuse, otherwise it could publish a key the verifier will
|
|
530
|
+
// reject (401 on advertised pubkey).
|
|
531
|
+
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: b64u(pk), y: b64u('00'.repeat(32)) },
|
|
532
|
+
}],
|
|
533
|
+
};
|
|
534
|
+
assert.deepStrictEqual(extractNostrPubkeysFromProfile(profile), []);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('rejects JsonWebKey entries missing y', () => {
|
|
538
|
+
const sk = generateSecretKey();
|
|
539
|
+
const pk = getPublicKey(sk);
|
|
540
|
+
const x = Buffer.from(pk, 'hex').toString('base64')
|
|
541
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
542
|
+
const profile = {
|
|
543
|
+
verificationMethod: [{ id: '#k1', type: 'JsonWebKey',
|
|
544
|
+
publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x } }],
|
|
545
|
+
};
|
|
546
|
+
assert.deepStrictEqual(extractNostrPubkeysFromProfile(profile), []);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('returns empty for profiles without Nostr-shaped VMs', () => {
|
|
550
|
+
assert.deepStrictEqual(extractNostrPubkeysFromProfile({}), []);
|
|
551
|
+
assert.deepStrictEqual(extractNostrPubkeysFromProfile({ verificationMethod: [] }), []);
|
|
552
|
+
assert.deepStrictEqual(extractNostrPubkeysFromProfile({
|
|
553
|
+
verificationMethod: [{ type: 'Ed25519VerificationKey2020' }],
|
|
554
|
+
}), []);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('returns empty for malformed input', () => {
|
|
558
|
+
assert.deepStrictEqual(extractNostrPubkeysFromProfile(null), []);
|
|
559
|
+
assert.deepStrictEqual(extractNostrPubkeysFromProfile('not an object'), []);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe('profilePathFromWebId — DATA_ROOT containment', () => {
|
|
564
|
+
// Pure unit tests; no server. Exercises the containment branch
|
|
565
|
+
// directly with raw inputs that bypass URL parsing's `..`
|
|
566
|
+
// normalization, since that's the layer that would matter if a
|
|
567
|
+
// future caller ever bypassed `new URL()`.
|
|
568
|
+
const DATA_ROOT = '/srv/jss/data';
|
|
569
|
+
|
|
570
|
+
it('resolves a normal pathname under dataRoot', () => {
|
|
571
|
+
const p = profilePathFromWebId(DATA_ROOT, 'http://example/alice/profile/card.jsonld#me');
|
|
572
|
+
assert.strictEqual(p, '/srv/jss/data/alice/profile/card.jsonld');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('resolves a root-pod pathname under dataRoot', () => {
|
|
576
|
+
const p = profilePathFromWebId(DATA_ROOT, 'http://example/profile/card.jsonld#me');
|
|
577
|
+
assert.strictEqual(p, '/srv/jss/data/profile/card.jsonld');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('rejects unparseable webIds', () => {
|
|
581
|
+
assert.strictEqual(profilePathFromWebId(DATA_ROOT, 'not a url'), null);
|
|
582
|
+
assert.strictEqual(profilePathFromWebId(DATA_ROOT, null), null);
|
|
583
|
+
assert.strictEqual(profilePathFromWebId(DATA_ROOT, 42), null);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('does NOT escape dataRoot for `..` traversal in the URL pathname', () => {
|
|
587
|
+
// WHATWG URL parsing already strips this — confirm the result
|
|
588
|
+
// stays inside dataRoot regardless.
|
|
589
|
+
const p = profilePathFromWebId(DATA_ROOT, 'http://example/../../../etc/passwd');
|
|
590
|
+
assert.ok(p === null || p.startsWith('/srv/jss/data'),
|
|
591
|
+
`expected containment, got ${p}`);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('keeps a relative dataRoot + plausible webId inside the absolute dataRoot', () => {
|
|
595
|
+
// Sanity test for the relative-dataRoot case. With dataRoot
|
|
596
|
+
// `./inner` and a normal-looking webId pathname, the resolved
|
|
597
|
+
// path lives at `<cwd>/inner/some/profile/card.jsonld` —
|
|
598
|
+
// INSIDE the resolved-absolute innerRoot. (URL normalization
|
|
599
|
+
// already strips `..` segments before path-resolution sees
|
|
600
|
+
// them, so a "real" outside-dataRoot result isn't reachable
|
|
601
|
+
// through URL-parsed webIds in practice. The containment
|
|
602
|
+
// check stays as defense-in-depth for any future caller that
|
|
603
|
+
// bypasses URL parsing.)
|
|
604
|
+
const innerRoot = './nonexistent-inner-root';
|
|
605
|
+
const p = profilePathFromWebId(innerRoot, 'http://example/some/profile/card.jsonld');
|
|
606
|
+
assert.ok(p && p.startsWith(path.resolve(innerRoot)),
|
|
607
|
+
`expected ${p} to be under ${path.resolve(innerRoot)}`);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('every URL-parseable webId with `..` segments still resolves inside dataRoot', () => {
|
|
611
|
+
// The production path is unreachable via URL-parsed input —
|
|
612
|
+
// WHATWG URL parsing strips `..` before our path-resolution
|
|
613
|
+
// sees it. This test asserts the resulting INVARIANT (every
|
|
614
|
+
// URL-parseable webId stays inside dataRoot) across a few
|
|
615
|
+
// traversal-shaped inputs, so any future regression where
|
|
616
|
+
// someone bypasses URL parsing or breaks the leading-slash
|
|
617
|
+
// strip would surface here.
|
|
618
|
+
for (const evil of [
|
|
619
|
+
'http://h/../../../etc/passwd',
|
|
620
|
+
'http://h//../etc/passwd',
|
|
621
|
+
'http://h/.%2e/etc/passwd',
|
|
622
|
+
'http://h/foo/../../../etc/passwd',
|
|
623
|
+
]) {
|
|
624
|
+
const p = profilePathFromWebId(DATA_ROOT, evil);
|
|
625
|
+
assert.ok(
|
|
626
|
+
p === null || p.startsWith(DATA_ROOT + path.sep) || p === DATA_ROOT,
|
|
627
|
+
`${evil} → ${p} escaped DATA_ROOT`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
});
|