javascript-solid-server 0.0.179 → 0.0.180
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/idp/well-known-did-nostr.js +458 -0
- package/src/server.js +66 -0
- package/test/did-nostr.test.js +331 -1
- package/test/well-known-did-nostr.test.js +631 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Nostr-key encoding helpers.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so both the NIP-98 verifier
|
|
5
|
+
* (`src/auth/nostr.js`) and the well-known DID-doc publisher
|
|
6
|
+
* (`src/idp/well-known-did-nostr.js`) can use it without forming a
|
|
7
|
+
* circular import.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
11
|
+
|
|
12
|
+
/** Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex. */
|
|
13
|
+
const MULTICODEC_SECP256K1_PUB_HEX = 'e701';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate a secp256k1 JWK as a Nostr key and return its x-only
|
|
17
|
+
* pubkey hex. Returns `null` if the JWK isn't a Nostr-shaped key
|
|
18
|
+
* or its `y` doesn't match the BIP-340 canonical (even-y) point
|
|
19
|
+
* for the declared `x`.
|
|
20
|
+
*
|
|
21
|
+
* Why y matters: every secp256k1 x has TWO valid points (positive
|
|
22
|
+
* and negative y). Nostr uses x-only pubkeys, which by BIP-340
|
|
23
|
+
* convention always pick the even-y point. A profile that declares
|
|
24
|
+
* a JWK with the right x but the wrong y is NOT the user's Nostr
|
|
25
|
+
* key — accepting it would let an attacker plant a JWK at someone
|
|
26
|
+
* else's WebID and have the indexer publish it as theirs.
|
|
27
|
+
*
|
|
28
|
+
* The verifier in src/auth/nostr.js (jwkMatchesNostrPubkey) does
|
|
29
|
+
* the same check. Keeping the indexer in sync prevents the
|
|
30
|
+
* "indexed but verifier rejects" inconsistency that would surface
|
|
31
|
+
* as a 401 on a key the well-known endpoint had advertised.
|
|
32
|
+
*/
|
|
33
|
+
export function pubkeyFromValidatedJwk(jwk) {
|
|
34
|
+
if (!jwk || typeof jwk !== 'object') return null;
|
|
35
|
+
if (jwk.kty !== 'EC') return null;
|
|
36
|
+
if (jwk.crv !== 'secp256k1' && jwk.crv !== 'P-256K') return null;
|
|
37
|
+
if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return null;
|
|
38
|
+
let xHex;
|
|
39
|
+
try {
|
|
40
|
+
xHex = Buffer.from(jwk.x.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
|
|
41
|
+
.toString('hex').toLowerCase();
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
if (!/^[0-9a-f]{64}$/.test(xHex)) return null;
|
|
44
|
+
let canonicalY;
|
|
45
|
+
try {
|
|
46
|
+
// Compressed SEC1 encoding for the EVEN-y point at this x.
|
|
47
|
+
const point = secp256k1.ProjectivePoint.fromHex('02' + xHex);
|
|
48
|
+
canonicalY = point.toAffine().y.toString(16).padStart(64, '0');
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
let jwkYHex;
|
|
51
|
+
try {
|
|
52
|
+
jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
|
|
53
|
+
.toString('hex').toLowerCase();
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
if (jwkYHex !== canonicalY) return null;
|
|
56
|
+
return xHex;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decode an f-form Multikey for secp256k1-pub back into the 32-byte
|
|
61
|
+
* x-only pubkey hex. Returns null if the input isn't this shape.
|
|
62
|
+
*
|
|
63
|
+
* The f-form recipe (per CCG community#254 / did:nostr): multibase
|
|
64
|
+
* `f` (base16-lower) + multicodec `e701` + parity byte (`02`/`03`)
|
|
65
|
+
* + 32-byte xonly pubkey.
|
|
66
|
+
*/
|
|
67
|
+
export function decodeFFormSecp256k1(mb) {
|
|
68
|
+
if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
|
|
69
|
+
const hex = mb.slice(1).toLowerCase();
|
|
70
|
+
if (!/^[0-9a-f]+$/.test(hex)) return null;
|
|
71
|
+
if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
|
|
72
|
+
const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
|
|
73
|
+
// Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
|
|
74
|
+
if (rest.length !== 66) return null;
|
|
75
|
+
const parity = rest.slice(0, 2);
|
|
76
|
+
if (parity !== '02' && parity !== '03') return null;
|
|
77
|
+
return rest.slice(2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Enumerate every Nostr pubkey declared in a profile's
|
|
82
|
+
* `verificationMethod` entries. Matches both encodings:
|
|
83
|
+
* - f-form Multikey (`publicKeyMultibase`)
|
|
84
|
+
* - JsonWebKey (`kty: EC, crv: secp256k1`) — derives x as the pubkey
|
|
85
|
+
*
|
|
86
|
+
* Returns `[ { pubkey, vm } ]` — the VM is returned alongside so
|
|
87
|
+
* callers can do further checks (`controller`, `authentication`
|
|
88
|
+
* membership, etc.) without re-parsing.
|
|
89
|
+
*/
|
|
90
|
+
export function extractNostrPubkeysFromProfile(profile) {
|
|
91
|
+
if (!profile || typeof profile !== 'object') return [];
|
|
92
|
+
const out = [];
|
|
93
|
+
const raw = profile.verificationMethod;
|
|
94
|
+
const vms = raw === undefined || raw === null ? []
|
|
95
|
+
: Array.isArray(raw) ? raw : [raw];
|
|
96
|
+
for (const vm of vms) {
|
|
97
|
+
if (!vm || typeof vm !== 'object') continue;
|
|
98
|
+
if (typeof vm.publicKeyMultibase === 'string') {
|
|
99
|
+
const xonly = decodeFFormSecp256k1(vm.publicKeyMultibase);
|
|
100
|
+
if (xonly) out.push({ pubkey: xonly, vm });
|
|
101
|
+
} else if (vm.publicKeyJwk && typeof vm.publicKeyJwk === 'object') {
|
|
102
|
+
// Require y to match the BIP-340 canonical point — the same
|
|
103
|
+
// check the NIP-98 verifier applies. Without this, the indexer
|
|
104
|
+
// could publish a JWK that the verifier will then reject,
|
|
105
|
+
// surfacing as a 401 on a key the well-known endpoint had
|
|
106
|
+
// advertised as authentic.
|
|
107
|
+
const xonly = pubkeyFromValidatedJwk(vm.publicKeyJwk);
|
|
108
|
+
if (xonly) out.push({ pubkey: xonly, vm });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/src/auth/nostr.js
CHANGED
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
* Match by f-form Multikey or by JsonWebKey x/y coordinates. If
|
|
15
15
|
* found, authenticate as the WebID. (#399 — pairs with the
|
|
16
16
|
* LWS10-CID verifier.)
|
|
17
|
-
* 2.
|
|
17
|
+
* 2. (IdP-only) Look up the pubkey in the local in-process index
|
|
18
|
+
* built from `<DATA_ROOT>/.idp/accounts/_webid_index.json`.
|
|
19
|
+
* No HTTP, no SSRF surface — direct function call. Catches
|
|
20
|
+
* same-pod users without a third-party round-trip. (#407)
|
|
21
|
+
* 3. Resolve via the external did:nostr DID-document path
|
|
18
22
|
* (nostr.social `.well-known` + bidirectional alsoKnownAs).
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* Used for cross-pod identities; SSRF + redirect hardened.
|
|
24
|
+
* 4. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the
|
|
21
25
|
* agent identity (the original behavior).
|
|
22
26
|
*/
|
|
23
27
|
|
|
@@ -25,8 +29,14 @@ import { verifyEvent, getEventHash } from '../nostr/event.js';
|
|
|
25
29
|
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
26
30
|
import crypto from 'crypto';
|
|
27
31
|
import { resolveDidNostrToWebId } from './did-nostr.js';
|
|
32
|
+
// resolveDidNostrLocally is loaded lazily (inside the idpEnabled
|
|
33
|
+
// branch) so non-IdP deployments don't pay the IdP/accounts module
|
|
34
|
+
// startup cost (bcryptjs, oidc-provider helpers, etc.) just by
|
|
35
|
+
// importing the NIP-98 verifier.
|
|
28
36
|
import { fetchCidDocument } from './cid-doc-fetch.js';
|
|
29
37
|
import { normalizeControllers } from './lws-cid.js'; // shared JSON-LD controller helper
|
|
38
|
+
import { decodeFFormSecp256k1, extractNostrPubkeysFromProfile } from './nostr-keys.js'; // re-exported for back-compat
|
|
39
|
+
export { extractNostrPubkeysFromProfile };
|
|
30
40
|
|
|
31
41
|
// NIP-98 event kind (references RFC 7235)
|
|
32
42
|
const HTTP_AUTH_KIND = 27235;
|
|
@@ -34,11 +44,6 @@ const HTTP_AUTH_KIND = 27235;
|
|
|
34
44
|
// Timestamp tolerance in seconds
|
|
35
45
|
const TIMESTAMP_TOLERANCE = 60;
|
|
36
46
|
|
|
37
|
-
// Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex.
|
|
38
|
-
// Used to decode f-form Multikey verificationMethod values back into
|
|
39
|
-
// the 32-byte x-only Nostr pubkey.
|
|
40
|
-
const MULTICODEC_SECP256K1_PUB_HEX = 'e701';
|
|
41
|
-
|
|
42
47
|
// Profile-fetch body-size cap. Matches the LWS-CID verifier; both
|
|
43
48
|
// callers go through the shared fetchCidDocument helper.
|
|
44
49
|
const MAX_PROFILE_BYTES = 256 * 1024;
|
|
@@ -282,9 +287,30 @@ export async function verifyNostrAuth(request) {
|
|
|
282
287
|
return { webId: vmWebId, error: null };
|
|
283
288
|
}
|
|
284
289
|
|
|
285
|
-
// Second lookup:
|
|
286
|
-
//
|
|
287
|
-
//
|
|
290
|
+
// Second lookup: in-process local DID resolution (#407). Fast path
|
|
291
|
+
// — direct function call into the local account index, no HTTP
|
|
292
|
+
// fetch, no SSRF surface from request-controlled headers. Catches
|
|
293
|
+
// any user who's published a Nostr Multikey VM into their profile
|
|
294
|
+
// on this same pod.
|
|
295
|
+
//
|
|
296
|
+
// Gated on idpEnabled because the index reads from
|
|
297
|
+
// <DATA_ROOT>/.idp/accounts which only exists when the IdP layer
|
|
298
|
+
// is in use. On non-IdP deployments the local resolver has nothing
|
|
299
|
+
// to find and would just spin disk on every request.
|
|
300
|
+
if (request.idpEnabled) {
|
|
301
|
+
// Dynamic import: only load the IdP-accounts stack when IdP is
|
|
302
|
+
// actually enabled. Cached after first load (ESM module caching).
|
|
303
|
+
const { resolveDidNostrLocally } = await import('../idp/well-known-did-nostr.js');
|
|
304
|
+
const localWebId = await resolveDidNostrLocally(event.pubkey);
|
|
305
|
+
if (localWebId) {
|
|
306
|
+
return { webId: localWebId, error: null };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Third lookup: external did:nostr DID-document resolver. Fetches
|
|
311
|
+
// a DID doc from the configured external resolver (nostr.social) and
|
|
312
|
+
// checks bidirectional alsoKnownAs ↔ WebID linking. Used only for
|
|
313
|
+
// cross-pod identities (the local case is handled above).
|
|
288
314
|
const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
|
|
289
315
|
if (resolvedWebId) {
|
|
290
316
|
return { webId: resolvedWebId, error: null };
|
|
@@ -599,23 +625,6 @@ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) {
|
|
|
599
625
|
return null;
|
|
600
626
|
}
|
|
601
627
|
|
|
602
|
-
/**
|
|
603
|
-
* Decode an f-form Multikey for secp256k1-pub back into the 32-byte
|
|
604
|
-
* x-only pubkey hex. Returns null if the input isn't this shape.
|
|
605
|
-
*/
|
|
606
|
-
function decodeFFormSecp256k1(mb) {
|
|
607
|
-
if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
|
|
608
|
-
const hex = mb.slice(1).toLowerCase();
|
|
609
|
-
if (!/^[0-9a-f]+$/.test(hex)) return null;
|
|
610
|
-
if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
|
|
611
|
-
const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
|
|
612
|
-
// Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
|
|
613
|
-
if (rest.length !== 66) return null;
|
|
614
|
-
const parity = rest.slice(0, 2);
|
|
615
|
-
if (parity !== '02' && parity !== '03') return null;
|
|
616
|
-
return rest.slice(2);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
628
|
function hexToBase64url(hex) {
|
|
620
629
|
return Buffer.from(hex, 'hex').toString('base64')
|
|
621
630
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* did:nostr HTTP resolution endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Implements the well-known path from the did:nostr spec:
|
|
5
|
+
*
|
|
6
|
+
* GET /.well-known/did/nostr/<pubkey>.json
|
|
7
|
+
* GET /.well-known/did/nostr/<pubkey>.jsonld
|
|
8
|
+
* GET /.well-known/did/nostr/<pubkey>
|
|
9
|
+
*
|
|
10
|
+
* For any local account whose WebID profile declares this Nostr pubkey
|
|
11
|
+
* as a CID `verificationMethod` referenced from `authentication`, JSS
|
|
12
|
+
* generates a DID document on the fly with `alsoKnownAs: [<webId>]`.
|
|
13
|
+
* Other resolvers (nostr.social, nostr.rocks, JSS's own
|
|
14
|
+
* `src/auth/did-nostr.js`) can then fetch the DID doc from this pod
|
|
15
|
+
* and follow the WebID linkage — making the pod its own
|
|
16
|
+
* authoritative DID resolver for its accounts.
|
|
17
|
+
*
|
|
18
|
+
* Closes the "type your username" UX hack on the IdP login page
|
|
19
|
+
* (#403 / #405): the existing did-nostr resolver finds local users
|
|
20
|
+
* via this endpoint without any user-typed hint.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import fs from 'fs-extra';
|
|
25
|
+
import { findById } from './accounts.js';
|
|
26
|
+
import { extractNostrPubkeysFromProfile } from '../auth/nostr-keys.js';
|
|
27
|
+
|
|
28
|
+
// In-memory pubkey → resolved-account-record index. Built lazily
|
|
29
|
+
// from disk; rebuilt when the TTL expires. Real production wants
|
|
30
|
+
// a write-path hook on LDP PUT/PATCH so updates are immediate;
|
|
31
|
+
// that's filed as a follow-up.
|
|
32
|
+
//
|
|
33
|
+
// Each entry stores `{ accountId, webId, mtimeMs }` so the hot
|
|
34
|
+
// path (NIP-98 auth via resolveDidNostrLocally + every DID-doc
|
|
35
|
+
// request) can answer without re-reading the account JSON from
|
|
36
|
+
// disk. accountId is kept for log diagnostics; webId is what
|
|
37
|
+
// the resolver actually needs.
|
|
38
|
+
let pubkeyIndex = null; // Map<pubkeyHex, { accountId, webId, mtimeMs }>
|
|
39
|
+
let indexBuiltAt = 0;
|
|
40
|
+
let rebuildInFlight = null; // Promise — in-flight rebuild dedup
|
|
41
|
+
const INDEX_TTL_MS = 5 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
// Rate-limit "profile unreadable" log spam. A single broken profile
|
|
44
|
+
// shouldn't flood logs every 5 minutes (every TTL rebuild) — but the
|
|
45
|
+
// first occurrence per rebuild cycle MUST be logged so operators can
|
|
46
|
+
// debug "why isn't my pubkey publishing?" without grepping silence.
|
|
47
|
+
const PROFILE_LOG_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
48
|
+
const profileLogTracker = new Map(); // accountId -> last logged ms
|
|
49
|
+
function logProfileFailure(accountId, profilePath, err) {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const last = profileLogTracker.get(accountId) || 0;
|
|
52
|
+
if (now - last < PROFILE_LOG_INTERVAL_MS) return;
|
|
53
|
+
profileLogTracker.set(accountId, now);
|
|
54
|
+
// Trim the tracker so it can't grow without bound.
|
|
55
|
+
if (profileLogTracker.size > 10_000) {
|
|
56
|
+
const oldest = profileLogTracker.keys().next().value;
|
|
57
|
+
if (oldest !== undefined) profileLogTracker.delete(oldest);
|
|
58
|
+
}
|
|
59
|
+
console.error(
|
|
60
|
+
`well-known-did-nostr: skipping account ${accountId} ` +
|
|
61
|
+
`(profile=${profilePath}): ${err.code || err.name || 'error'} ${err.message}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// Size cap on per-account profile reads. WebID profiles are tiny —
|
|
65
|
+
// 64 KB is generous and matches the bound the LDP layer would impose
|
|
66
|
+
// for any sane profile. A user shouldn't be able to make the indexer
|
|
67
|
+
// allocate megabytes by writing a giant profile, especially since
|
|
68
|
+
// rebuilds can be triggered by attacker-driven NIP-98 traffic once
|
|
69
|
+
// the TTL expires.
|
|
70
|
+
const MAX_PROFILE_BYTES = 64 * 1024;
|
|
71
|
+
|
|
72
|
+
/** @internal — exposed for tests */
|
|
73
|
+
export function _resetIndexForTests() {
|
|
74
|
+
pubkeyIndex = null;
|
|
75
|
+
indexBuiltAt = 0;
|
|
76
|
+
rebuildInFlight = null;
|
|
77
|
+
profileLogTracker.clear();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Match the layout in src/idp/accounts.js — accounts live under
|
|
81
|
+
// <DATA_ROOT>/.idp/accounts. Computed lazily so DATA_ROOT changes
|
|
82
|
+
// (test setup, env override) are picked up.
|
|
83
|
+
function getAccountsDir() {
|
|
84
|
+
const dataRoot = process.env.DATA_ROOT || './data';
|
|
85
|
+
return path.join(dataRoot, '.idp', 'accounts');
|
|
86
|
+
}
|
|
87
|
+
function getWebIdIndexPath() {
|
|
88
|
+
return path.join(getAccountsDir(), '_webid_index.json');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read a JSON file. Returns null in two cases (with different
|
|
93
|
+
* semantics, kept the same return shape for caller simplicity):
|
|
94
|
+
*
|
|
95
|
+
* - ENOENT — silently null. The index file legitimately doesn't
|
|
96
|
+
* exist on a fresh deployment with no accounts yet.
|
|
97
|
+
* - Any other error (parse error, permission denied, etc.) — null
|
|
98
|
+
* PLUS a loud console.error so operational issues surface in logs
|
|
99
|
+
* instead of silently disabling DID-doc publishing.
|
|
100
|
+
*/
|
|
101
|
+
async function readJsonOrEmpty(file) {
|
|
102
|
+
try {
|
|
103
|
+
return await fs.readJson(file);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err.code === 'ENOENT') return null;
|
|
106
|
+
console.error(`well-known-did-nostr: failed to read ${file}: ${err.message}`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function rebuildPubkeyIndex() {
|
|
112
|
+
const idx = new Map();
|
|
113
|
+
const dataRoot = process.env.DATA_ROOT || './data';
|
|
114
|
+
const webIdIndex = await readJsonOrEmpty(getWebIdIndexPath());
|
|
115
|
+
if (!webIdIndex) {
|
|
116
|
+
pubkeyIndex = idx;
|
|
117
|
+
indexBuiltAt = Date.now();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Track pubkeys that appear under more than one account so we can
|
|
121
|
+
// EXCLUDE them rather than silently picking one. An ambiguous binding
|
|
122
|
+
// would make resolution depend on insertion order and be hard to
|
|
123
|
+
// diagnose; better to refuse and log loudly.
|
|
124
|
+
const seenAccounts = new Map(); // pubkey -> Set<accountId>
|
|
125
|
+
for (const [, accountId] of Object.entries(webIdIndex)) {
|
|
126
|
+
// Wrap each account read so one corrupt/unreadable account file
|
|
127
|
+
// can't take down resolution for everyone — the index would just
|
|
128
|
+
// skip that account and a single 500 wouldn't cascade across the
|
|
129
|
+
// whole pod's NIP-98 traffic.
|
|
130
|
+
let account;
|
|
131
|
+
try {
|
|
132
|
+
account = await findById(accountId);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(
|
|
135
|
+
`well-known-did-nostr: skipping account ${accountId} ` +
|
|
136
|
+
`(read failed: ${err.message})`,
|
|
137
|
+
);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!account?.webId) continue;
|
|
141
|
+
const profilePath = profilePathFromWebId(dataRoot, account.webId, accountId);
|
|
142
|
+
if (!profilePath) continue;
|
|
143
|
+
let profile;
|
|
144
|
+
let mtimeMs = 0;
|
|
145
|
+
try {
|
|
146
|
+
const stat = await fs.stat(profilePath);
|
|
147
|
+
mtimeMs = stat.mtimeMs;
|
|
148
|
+
// Size cap to bound per-rebuild memory/CPU. A user can write
|
|
149
|
+
// their own profile, and TTL-expired rebuilds can be triggered
|
|
150
|
+
// by attacker-driven NIP-98 traffic — without this an
|
|
151
|
+
// adversarially-large profile could pin the event loop on
|
|
152
|
+
// JSON.parse during the rebuild loop.
|
|
153
|
+
if (stat.size > MAX_PROFILE_BYTES) {
|
|
154
|
+
console.error(
|
|
155
|
+
`well-known-did-nostr: skipping account ${accountId} ` +
|
|
156
|
+
`— profile size ${stat.size} > ${MAX_PROFILE_BYTES} bytes`,
|
|
157
|
+
);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const text = await fs.readFile(profilePath, 'utf8');
|
|
161
|
+
profile = JSON.parse(text);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// Log so operators can debug "why isn't my pubkey publishing?".
|
|
164
|
+
// Rate-limited per account so a single perpetually-broken
|
|
165
|
+
// profile can't flood logs every TTL cycle.
|
|
166
|
+
logProfileFailure(accountId, profilePath, err);
|
|
167
|
+
continue; // unreadable / malformed — skip
|
|
168
|
+
}
|
|
169
|
+
// CID semantics — match the resource-side checks:
|
|
170
|
+
// (1) profile's @id MUST match the account's webId (no fragment-
|
|
171
|
+
// swapping attack via a stored profile that claims to be
|
|
172
|
+
// someone else)
|
|
173
|
+
// (2) VM's controller MUST be in the profile's expected controller
|
|
174
|
+
// set (declared `controller`, with @id fallback)
|
|
175
|
+
// (3) VM MUST be referenced from `authentication` — a key in
|
|
176
|
+
// verificationMethod alone (no auth membership) shouldn't be
|
|
177
|
+
// published as authentic
|
|
178
|
+
const profileSubject = absolutize(profile?.['@id'] || profile?.id, stripHashIfAny(account.webId));
|
|
179
|
+
if (!profileSubject || profileSubject !== account.webId) continue;
|
|
180
|
+
const expectedControllers = collectControllerIds(profile, profileSubject);
|
|
181
|
+
if (expectedControllers.size === 0) continue;
|
|
182
|
+
// Pass the already-validated absolute subject as the base. Without
|
|
183
|
+
// this, profiles with a relative subject (e.g. `"@id": "#me"`)
|
|
184
|
+
// would absolutize their `authentication` entries against an
|
|
185
|
+
// unusable base, leaving the IDs relative — and then the
|
|
186
|
+
// `authIds.has(vmId)` check below would never match even when the
|
|
187
|
+
// VM is actually authenticated.
|
|
188
|
+
const authIds = collectAuthenticationIds(profile, stripHashIfAny(profileSubject));
|
|
189
|
+
|
|
190
|
+
for (const { pubkey, vm } of extractNostrPubkeysFromProfile(profile)) {
|
|
191
|
+
const vmId = absolutize(vm.id || vm['@id'], stripHashIfAny(profileSubject));
|
|
192
|
+
if (!vmId || !authIds.has(vmId)) continue;
|
|
193
|
+
const vmCtrls = collectControllerIds({ controller: vm.controller }, profileSubject);
|
|
194
|
+
let controllerOk = false;
|
|
195
|
+
for (const c of vmCtrls) {
|
|
196
|
+
if (expectedControllers.has(c)) { controllerOk = true; break; }
|
|
197
|
+
}
|
|
198
|
+
if (!controllerOk) continue;
|
|
199
|
+
|
|
200
|
+
// Duplicate-pubkey detection: track every account that claims
|
|
201
|
+
// it; resolve at the end of the scan.
|
|
202
|
+
if (!seenAccounts.has(pubkey)) seenAccounts.set(pubkey, new Set());
|
|
203
|
+
seenAccounts.get(pubkey).add(accountId);
|
|
204
|
+
// Cache the resolved webId in the index so the lookup hot
|
|
205
|
+
// path doesn't have to re-read the account JSON.
|
|
206
|
+
if (!idx.has(pubkey)) idx.set(pubkey, { accountId, webId: account.webId, mtimeMs });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Drop ambiguous pubkeys and warn loudly.
|
|
210
|
+
for (const [pubkey, accountIds] of seenAccounts) {
|
|
211
|
+
if (accountIds.size > 1) {
|
|
212
|
+
console.error(
|
|
213
|
+
`well-known-did-nostr: pubkey ${pubkey} claimed by ` +
|
|
214
|
+
`${accountIds.size} accounts (${[...accountIds].join(', ')}) — ` +
|
|
215
|
+
`omitting from index to avoid ambiguous resolution`,
|
|
216
|
+
);
|
|
217
|
+
idx.delete(pubkey);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
pubkeyIndex = idx;
|
|
221
|
+
indexBuiltAt = Date.now();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Derive the on-disk profile path from a WebID (and validate
|
|
226
|
+
* containment in DATA_ROOT). Returns the absolute filesystem path
|
|
227
|
+
* or `null` if the WebID is unparseable / would escape dataRoot.
|
|
228
|
+
*
|
|
229
|
+
* Why a separate function: WHATWG URL parsing already strips most
|
|
230
|
+
* `..` traversal at the URL layer, but the path-resolve containment
|
|
231
|
+
* check is defense-in-depth for any future caller that bypasses
|
|
232
|
+
* URL parsing (string manipulation, alternate parser, etc.). Lives
|
|
233
|
+
* in its own function so the containment branch is unit-testable
|
|
234
|
+
* with raw inputs that DON'T go through `new URL()`.
|
|
235
|
+
*
|
|
236
|
+
* @internal exported for tests
|
|
237
|
+
*/
|
|
238
|
+
export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') {
|
|
239
|
+
if (typeof webId !== 'string') return null;
|
|
240
|
+
let pathname;
|
|
241
|
+
try {
|
|
242
|
+
pathname = new URL(webId).pathname;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
// Strip leading `/` so it's treated as a relative segment, then
|
|
247
|
+
// resolve and assert the result is at-or-under dataRootAbs. An
|
|
248
|
+
// account record whose webId path resolves outside dataRoot is
|
|
249
|
+
// never indexed.
|
|
250
|
+
const relPath = pathname.replace(/^\/+/, '');
|
|
251
|
+
const dataRootAbs = path.resolve(dataRoot);
|
|
252
|
+
const resolved = path.resolve(dataRootAbs, relPath);
|
|
253
|
+
if (resolved !== dataRootAbs && !resolved.startsWith(dataRootAbs + path.sep)) {
|
|
254
|
+
console.error(
|
|
255
|
+
`well-known-did-nostr: account ${accountId} webId ${webId} ` +
|
|
256
|
+
`resolves outside dataRoot (${resolved}) — skipping`,
|
|
257
|
+
);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return resolved;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function collectControllerIds(source, baseUrl) {
|
|
264
|
+
const out = new Set();
|
|
265
|
+
const c = source?.controller;
|
|
266
|
+
const list = Array.isArray(c) ? c : (c ? [c] : []);
|
|
267
|
+
for (const ent of list) {
|
|
268
|
+
let id;
|
|
269
|
+
if (typeof ent === 'string') id = ent;
|
|
270
|
+
else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id;
|
|
271
|
+
if (id) out.add(absolutize(id, baseUrl));
|
|
272
|
+
}
|
|
273
|
+
// Fallback to @id when no explicit controller (CID v1 self-control).
|
|
274
|
+
if (out.size === 0 && source && (source['@id'] || source.id)) {
|
|
275
|
+
out.add(absolutize(source['@id'] || source.id, baseUrl));
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve a profile's `authentication` entries to a Set of absolute
|
|
282
|
+
* IDs. Caller MUST pass an already-absolute base URL — re-deriving
|
|
283
|
+
* the base from `profile['@id']` here would fail when the profile
|
|
284
|
+
* subject is relative (e.g. `"@id": "#me"`), leaving the resulting
|
|
285
|
+
* IDs relative and silently breaking the auth-membership check.
|
|
286
|
+
*/
|
|
287
|
+
function collectAuthenticationIds(profile, baseUrl) {
|
|
288
|
+
const out = new Set();
|
|
289
|
+
const auth = profile?.authentication;
|
|
290
|
+
const list = Array.isArray(auth) ? auth : (auth ? [auth] : []);
|
|
291
|
+
for (const ent of list) {
|
|
292
|
+
let id;
|
|
293
|
+
if (typeof ent === 'string') id = ent;
|
|
294
|
+
else if (ent && typeof ent === 'object') id = ent['@id'] || ent.id;
|
|
295
|
+
if (id) out.add(absolutize(id, baseUrl));
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function absolutize(u, base) {
|
|
301
|
+
if (!u) return u;
|
|
302
|
+
try { return new URL(u, base).toString(); } catch { return u; }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function stripHashIfAny(u) {
|
|
306
|
+
if (typeof u !== 'string') return u;
|
|
307
|
+
try { const url = new URL(u); url.hash = ''; return url.toString(); }
|
|
308
|
+
catch { return u; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function findAccountByNostrPubkey(pubkeyHex) {
|
|
312
|
+
const lower = pubkeyHex.toLowerCase();
|
|
313
|
+
if (!pubkeyIndex || (Date.now() - indexBuiltAt) > INDEX_TTL_MS) {
|
|
314
|
+
// Dedup concurrent rebuilds: under a burst of requests that all
|
|
315
|
+
// arrive after the TTL expires, only ONE rebuild runs and every
|
|
316
|
+
// other caller awaits its promise. Without this, N concurrent
|
|
317
|
+
// requests would each do a full disk scan + parse pass, with
|
|
318
|
+
// N-1 of them throwing away their result.
|
|
319
|
+
if (!rebuildInFlight) {
|
|
320
|
+
rebuildInFlight = rebuildPubkeyIndex().finally(() => {
|
|
321
|
+
rebuildInFlight = null;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
await rebuildInFlight;
|
|
325
|
+
}
|
|
326
|
+
const entry = pubkeyIndex.get(lower);
|
|
327
|
+
if (!entry) return null;
|
|
328
|
+
// The webId is now stored on the index entry — no per-request
|
|
329
|
+
// findById disk read needed. NIP-98 auth (via
|
|
330
|
+
// resolveDidNostrLocally) and DID-doc generation hit this on
|
|
331
|
+
// every request, so dropping the I/O matters.
|
|
332
|
+
return { account: { webId: entry.webId }, mtimeMs: entry.mtimeMs };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* In-process local DID resolution: given a Nostr pubkey, return the
|
|
337
|
+
* matching account's WebID without any network fetch. Lets the
|
|
338
|
+
* verifyNostrAuth resolver chain prefer local users via direct
|
|
339
|
+
* function call instead of a same-host HTTP loop, removing both the
|
|
340
|
+
* latency and the SSRF surface that came with feeding request-
|
|
341
|
+
* controlled host headers into a `fetch()`.
|
|
342
|
+
*
|
|
343
|
+
* Returns null for non-local pubkeys (caller falls back to the
|
|
344
|
+
* external HTTP resolver, with SSRF protection).
|
|
345
|
+
*/
|
|
346
|
+
export async function resolveDidNostrLocally(pubkeyHex) {
|
|
347
|
+
if (typeof pubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkeyHex)) return null;
|
|
348
|
+
const found = await findAccountByNostrPubkey(pubkeyHex.toLowerCase());
|
|
349
|
+
return found?.account?.webId || null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Build a CID-shaped DID document for a Nostr pubkey + account pair.
|
|
354
|
+
*
|
|
355
|
+
* Uses the spec example's vocabulary (Multikey + publicKeyMultibase)
|
|
356
|
+
* for max interop with our own resolver and the W3C VC track. The
|
|
357
|
+
* Multikey value is computed deterministically from the pubkey via
|
|
358
|
+
* the f-form recipe (multibase `f` + multicodec `e701` + parity byte
|
|
359
|
+
* `02` + 32-byte xonly hex) — the same shape the doctor's B.2 emits.
|
|
360
|
+
*/
|
|
361
|
+
function buildDidDocument({ pubkey, webId }) {
|
|
362
|
+
const did = `did:nostr:${pubkey.toLowerCase()}`;
|
|
363
|
+
const multikey = `f` + `e701` + `02` + pubkey.toLowerCase();
|
|
364
|
+
const vmId = `${did}#key1`;
|
|
365
|
+
return {
|
|
366
|
+
'@context': ['https://w3id.org/did', 'https://w3id.org/nostr/context'],
|
|
367
|
+
'id': did,
|
|
368
|
+
'type': 'DIDNostr',
|
|
369
|
+
'alsoKnownAs': [webId],
|
|
370
|
+
'verificationMethod': [{
|
|
371
|
+
'id': vmId,
|
|
372
|
+
'type': 'Multikey',
|
|
373
|
+
'controller': did,
|
|
374
|
+
'publicKeyMultibase': multikey,
|
|
375
|
+
}],
|
|
376
|
+
'authentication': [vmId],
|
|
377
|
+
'assertionMethod': [vmId],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Fastify handler for GET /.well-known/did/nostr/:pubkeyAndExt
|
|
383
|
+
*
|
|
384
|
+
* The :pubkeyAndExt parameter accepts `<pubkey>`, `<pubkey>.json`, or
|
|
385
|
+
* `<pubkey>.jsonld`; the body is the same DID doc either way. The
|
|
386
|
+
* spec specifies `.json` as the canonical path, so that's the
|
|
387
|
+
* primary; the others are friendly aliases.
|
|
388
|
+
*
|
|
389
|
+
* The data root is read from `process.env.DATA_ROOT` (matching
|
|
390
|
+
* `accounts.js`). We don't accept a parameter for it because the
|
|
391
|
+
* account-index path is derived from the same env elsewhere — taking
|
|
392
|
+
* a parameter would create two sources of truth and be misleading.
|
|
393
|
+
*/
|
|
394
|
+
export function buildWellKnownDidNostrHandler() {
|
|
395
|
+
return async function handleWellKnownDidNostr(request, reply) {
|
|
396
|
+
const raw = String(request.params.pubkeyAndExt || '');
|
|
397
|
+
const ext = raw.endsWith('.jsonld') ? '.jsonld'
|
|
398
|
+
: raw.endsWith('.json') ? '.json'
|
|
399
|
+
: '';
|
|
400
|
+
const pubkey = (ext ? raw.slice(0, -ext.length) : raw).toLowerCase();
|
|
401
|
+
// Per-status header policy (so success and failure responses are
|
|
402
|
+
// both predictable to clients/CDNs):
|
|
403
|
+
// 200 Cache-Control: max-age=3600 — DID doc seldom changes
|
|
404
|
+
// 404 Cache-Control: max-age=60 — short TTL so a newly added
|
|
405
|
+
// key surfaces quickly
|
|
406
|
+
// 400 Cache-Control: no-store — request was malformed; never cache
|
|
407
|
+
// Nostr-Timestamp is set on EVERY response (including errors) per
|
|
408
|
+
// the did:nostr spec recommendation that clients can correlate the
|
|
409
|
+
// resolver's clock with the answer they got. Last-Modified only
|
|
410
|
+
// makes sense for 200 (it tracks the underlying profile mtime);
|
|
411
|
+
// for errors we omit it because there's no underlying resource.
|
|
412
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
413
|
+
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
|
|
414
|
+
return reply.code(400)
|
|
415
|
+
.header('Content-Type', 'application/json')
|
|
416
|
+
.header('Cache-Control', 'no-store')
|
|
417
|
+
.header('Nostr-Timestamp', String(nowEpoch))
|
|
418
|
+
.send({ error: 'pubkey must be 64 hex chars' });
|
|
419
|
+
}
|
|
420
|
+
const found = await findAccountByNostrPubkey(pubkey);
|
|
421
|
+
if (!found?.account) {
|
|
422
|
+
return reply.code(404)
|
|
423
|
+
.header('Cache-Control', 'max-age=60')
|
|
424
|
+
.header('Content-Type', 'application/json')
|
|
425
|
+
.header('Nostr-Timestamp', String(nowEpoch))
|
|
426
|
+
.send({ error: 'no local account claims this pubkey' });
|
|
427
|
+
}
|
|
428
|
+
const { account, mtimeMs } = found;
|
|
429
|
+
if (!account.webId) {
|
|
430
|
+
// Defensive — every account has a webId, but if one slips through,
|
|
431
|
+
// the DID doc would be useless without alsoKnownAs.
|
|
432
|
+
return reply.code(404)
|
|
433
|
+
.header('Cache-Control', 'max-age=60')
|
|
434
|
+
.header('Content-Type', 'application/json')
|
|
435
|
+
.header('Nostr-Timestamp', String(nowEpoch))
|
|
436
|
+
.send({ error: 'account has no webId' });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const didDoc = buildDidDocument({ pubkey, webId: account.webId });
|
|
440
|
+
const contentType = ext === '.jsonld'
|
|
441
|
+
? 'application/did+ld+json; charset=utf-8'
|
|
442
|
+
: 'application/did+json; charset=utf-8';
|
|
443
|
+
// Two distinct timestamp semantics, two distinct headers:
|
|
444
|
+
// - Nostr-Timestamp: the resolver's clock at answer time (uniform
|
|
445
|
+
// across 200/404/400 — clients use it to correlate the resolver
|
|
446
|
+
// clock with their own, regardless of cache hits).
|
|
447
|
+
// - Last-Modified: when the underlying mapping (the user's
|
|
448
|
+
// profile file) actually changed — only meaningful for 200,
|
|
449
|
+
// so clients/CDNs can do conditional GET against the source.
|
|
450
|
+
const lastModifiedDate = mtimeMs > 0 ? new Date(mtimeMs) : new Date(indexBuiltAt);
|
|
451
|
+
return reply
|
|
452
|
+
.header('Content-Type', contentType)
|
|
453
|
+
.header('Cache-Control', 'max-age=3600')
|
|
454
|
+
.header('Nostr-Timestamp', String(nowEpoch))
|
|
455
|
+
.header('Last-Modified', lastModifiedDate.toUTCString())
|
|
456
|
+
.send(didDoc);
|
|
457
|
+
};
|
|
458
|
+
}
|