javascript-solid-server 0.0.177 → 0.0.179
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/lws.md +35 -24
- package/package.json +1 -1
- package/src/auth/cid-doc-fetch.js +202 -0
- package/src/auth/lws-cid.js +17 -180
- package/src/auth/nostr.js +459 -8
- package/src/idp/interactions.js +69 -7
- package/src/idp/views.js +11 -2
- package/test/nostr-cid-vm.test.js +562 -0
package/docs/lws.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# LWS / Controlled Identifiers (CID v1)
|
|
2
2
|
|
|
3
|
-
JSS
|
|
3
|
+
JSS is aligned end-to-end with the W3C [Linked Web Storage 1.0 Authentication Suite](https://www.w3.org/news/2026/first-public-working-drafts-for-the-linked-web-storage-lws-1-0-authentication-suite/) (FPWDs published 2026-04-23) and its substrate, [W3C Controlled Identifiers v1.0](https://www.w3.org/TR/cid-1.0/) — pod profiles are CID-shaped, users add keys via the [doctor](https://jss.live/doctor/), and the server accepts strict LWS10-CID JWTs as an HTTP auth method alongside the existing Solid-OIDC and NIP-98 paths.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Convergence tracker: [#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386). FPWD-alignment audit: [#319](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/319).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Compatibility, by level
|
|
8
8
|
|
|
9
9
|
| | What it means | Status |
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| **1. Profile shape** | A WebID profile that's structurally a W3C Controlled Identifier document — right `@context`, right vocabulary, parseable as a CID document by any LWS-aware tool | ✅ **Yes** (since v0.0.174, [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388)) |
|
|
12
|
-
| **2. Profile carries keys** | The CID document actually declares `verificationMethod` entries an LWS verifier can look up by `kid` |
|
|
13
|
-
| **3. Server accepts LWS-CID JWTs** | An incoming request with an LWS-CID self-signed JWT (`sub`/`iss`/`client_id` triple-equality, `kid` lookup against the WebID's `verificationMethod`, signature check) |
|
|
12
|
+
| **2. Profile carries keys** | The CID document actually declares `verificationMethod` entries an LWS verifier can look up by `kid` | ✅ Browser-side via the [doctor](https://jss.live/doctor/) — [B.2](https://github.com/JavaScriptSolidServer/doctor/pull/2) for Nostr/Multikey, [B.3](https://github.com/JavaScriptSolidServer/doctor/pull/4) for ES256K/JsonWebKey. The doctor authenticates as the WebID owner via Solid-OIDC and PATCHes the VM into the profile. |
|
|
13
|
+
| **3. Server accepts LWS-CID JWTs** | An incoming request with an LWS-CID self-signed JWT (`sub`/`iss`/`client_id` triple-equality, `kid` lookup against the WebID's `verificationMethod`, signature check) | ✅ Shipped in v0.0.177 ([#398](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/398)). Strict FPWD §4 — ES256K is the focus algorithm; ES256, ES384, EdDSA, RS256 also accepted. |
|
|
14
|
+
| **Bonus: NIP-98 → WebID** | A Schnorr-signed NIP-98 request authenticates as the WebID (not `did:nostr:`) when the pubkey is declared as a CID `verificationMethod` referenced from `authentication` | ✅ Shipped in v0.0.178 ([#400](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/400)). No client-side change — the doctor's B.2 output is enough to light it up. |
|
|
14
15
|
|
|
15
16
|
## What Phase A actually does
|
|
16
17
|
|
|
@@ -37,35 +38,42 @@ A freshly-created pod's `profile/card.jsonld` looks like this (excerpt — the e
|
|
|
37
38
|
}
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
##
|
|
41
|
+
## Adding keys (Phase B — via the doctor)
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
The [doctor](https://jss.live/doctor/) is a separate browser-side tool that signs in to your pod via Solid-OIDC and writes verificationMethod entries to your profile. After the round-trip your profile has:
|
|
43
44
|
|
|
44
45
|
```jsonld
|
|
45
46
|
"verificationMethod": [
|
|
46
|
-
{ "id": "...#nostr-1", "type": "Multikey", "controller": "...#me",
|
|
47
|
-
"publicKeyMultibase": "fe70102
|
|
48
|
-
{ "id": "...#
|
|
49
|
-
"
|
|
50
|
-
{ "id": "...#passkey-1", "type": "JsonWebKey", "controller": "...#me",
|
|
51
|
-
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } }
|
|
47
|
+
{ "id": "...#nostr-key-1", "type": "Multikey", "controller": "...#me",
|
|
48
|
+
"publicKeyMultibase": "fe70102…" },
|
|
49
|
+
{ "id": "...#lws-key-1", "type": "JsonWebKey", "controller": "...#me",
|
|
50
|
+
"publicKeyJwk": { "kty": "EC", "crv": "secp256k1", "alg": "ES256K", "x": "…", "y": "…" } }
|
|
52
51
|
],
|
|
53
|
-
"authentication": ["...#nostr-1", "...#
|
|
52
|
+
"authentication": ["...#nostr-key-1", "...#lws-key-1"]
|
|
54
53
|
```
|
|
55
54
|
|
|
55
|
+
The Multikey entry handles did:nostr binding + NIP-98 lookup; the JsonWebKey entry handles strict LWS10-CID JWT auth. Both can be the same secp256k1 key — different signature schemes (Schnorr vs ECDSA), same private key.
|
|
56
|
+
|
|
56
57
|
Because Phase A already declared the context terms, this is a pure data-layer PATCH — no `@context` rewrite needed.
|
|
57
58
|
|
|
58
|
-
##
|
|
59
|
+
## Server-side verifier (Phase 3 — `src/auth/lws-cid.js`)
|
|
60
|
+
|
|
61
|
+
When an incoming request carries an LWS-CID JWT (detected by an `Authorization: Bearer <jwt>` whose JWT-header `kid` is an http(s) URL with a fragment), JSS:
|
|
62
|
+
|
|
63
|
+
1. Confirms `sub === iss === client_id` (canonicalized via URL parsing) — that URI is the WebID being claimed
|
|
64
|
+
2. Validates `aud` includes the server origin, `exp` not past, `iat` recent, lifetime ≤ 1 hour
|
|
65
|
+
3. Fetches the WebID profile through the shared SSRF guard — manual redirects with same-origin enforcement, 256 KB body cap, bounded LRU cache
|
|
66
|
+
4. Confirms the profile's `@id` equals the JWT's `sub` (closes a profile-substitution attack)
|
|
67
|
+
5. Looks up `kid` in `verificationMethod`; the entry must be referenced from `authentication` and its `controller` must match the profile's outer `controller`
|
|
68
|
+
6. Verifies the JWT signature per RFC7515 §5.2. ES256K via `@noble/curves` (already in tree from NIP-98); ES256, ES384, EdDSA, RS256 via `jose`
|
|
69
|
+
|
|
70
|
+
The verifier joins the existing auth methods (Solid-OIDC, NIP-98, Bearer-JWT-from-IDP, WebID-TLS) — preference order is OIDC → LWS-CID → NIP-98 → Bearer fallback (per [#306](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/306)).
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
## NIP-98 → WebID upgrade (`src/auth/nostr.js`)
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
2. Dereference the WebID, parse it as a CID document
|
|
64
|
-
3. Look up `kid` in the document's `verificationMethod` array
|
|
65
|
-
4. Confirm the method is in `authentication`
|
|
66
|
-
5. Verify the JWT signature with that public key
|
|
74
|
+
Built on top of the LWS-CID infrastructure ([#400](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/400)): when a NIP-98 request's signing pubkey is declared as a CID `verificationMethod` (and the VM is in `authentication`) on the resource owner's WebID profile, the request authenticates as the WebID instead of `did:nostr:<pubkey>`. Match is by f-form Multikey or by JsonWebKey full-point (x AND y, BIP-340 even-y). Profile fetch uses the same SSRF guard / cache as the LWS-CID verifier. No client-side change — Nostr clients sign as today.
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
So: anyone who's used the doctor's B.2 to add a Nostr Multikey VM gets WebID-based NIP-98 sign-in for free.
|
|
69
77
|
|
|
70
78
|
## Spec references
|
|
71
79
|
|
|
@@ -76,9 +84,12 @@ The verifier joins the existing auth methods (OIDC, NIP-98, etc.) — preference
|
|
|
76
84
|
|
|
77
85
|
## Related
|
|
78
86
|
|
|
79
|
-
- [`docs/authentication.md`](authentication.md) —
|
|
87
|
+
- [`docs/authentication.md`](authentication.md) — full JSS auth surface (OIDC, NIP-98, LWS-CID, passkey, etc.)
|
|
80
88
|
- [`docs/nostr.md`](nostr.md) — Nostr relay + did:nostr resolution
|
|
89
|
+
- [doctor](https://github.com/JavaScriptSolidServer/doctor) — the browser-side diagnostic + add-keys app
|
|
81
90
|
- [#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386) — convergence tracker
|
|
82
|
-
- [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388) — Phase A
|
|
91
|
+
- [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388) — Phase A (profile shape)
|
|
92
|
+
- [#398](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/398) — Phase 3 (LWS-CID JWT verifier)
|
|
93
|
+
- [#400](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/400) — NIP-98 → WebID via VM lookup
|
|
83
94
|
- [#389](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/389) — `@context` array form support (turtle conneg)
|
|
84
95
|
- [#390](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/390) — `@type:'@json'` literal handling (turtle conneg)
|
package/package.json
CHANGED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CID-document / WebID-profile fetcher.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the LWS10-CID JWT verifier (`src/auth/lws-cid.js`) and
|
|
5
|
+
* the NIP-98 → WebID VM-lookup path (`src/auth/nostr.js`). Centralized
|
|
6
|
+
* so future SSRF / redirect / DoS hardening only needs to land in one
|
|
7
|
+
* place.
|
|
8
|
+
*
|
|
9
|
+
* Defenses (mirrored from the original lws-cid implementation):
|
|
10
|
+
*
|
|
11
|
+
* - URL validated through `validateExternalUrl` (loopback, private
|
|
12
|
+
* IPs, http-in-prod blocked) on the original URL AND every
|
|
13
|
+
* redirect Location.
|
|
14
|
+
* - Manual redirect handling — no automatic following — capped at
|
|
15
|
+
* MAX_REDIRECTS hops.
|
|
16
|
+
* - Cross-origin redirects refused (an open redirect on the WebID's
|
|
17
|
+
* host can't substitute an attacker-controlled CID document).
|
|
18
|
+
* - Body size cap enforced via Content-Length up front AND a
|
|
19
|
+
* streaming reader cap (cancel on overage), so untrusted hosts
|
|
20
|
+
* can't OOM us with a large payload.
|
|
21
|
+
* - 5-second timeout per request via AbortController.
|
|
22
|
+
*
|
|
23
|
+
* Throws on any failure; callers convert to whatever they want
|
|
24
|
+
* (LWS-CID surfaces the error string, the NIP-98 path treats
|
|
25
|
+
* throw-as-null and falls back to the existing did:nostr resolver).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { validateExternalUrl } from '../utils/ssrf.js';
|
|
29
|
+
|
|
30
|
+
// Default body cap (256 KB) — CID documents are tiny in practice.
|
|
31
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
32
|
+
const MAX_REDIRECTS = 5;
|
|
33
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
34
|
+
|
|
35
|
+
// Auth is on the hot path; refetching the CID document on every
|
|
36
|
+
// request is unacceptable for both latency and reliability (and would
|
|
37
|
+
// amplify self-traffic when the profile is hosted on this same
|
|
38
|
+
// server). Bounded LRU — an attacker could otherwise grow the cache
|
|
39
|
+
// without limit by sending tokens / requests with many distinct
|
|
40
|
+
// document URLs. Mirrors the pattern in did-nostr.js.
|
|
41
|
+
const profileCache = new Map(); // url -> { profile, timestamp, failureTtl?, error? }
|
|
42
|
+
const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes for hits
|
|
43
|
+
const PROFILE_FAILURE_TTL = 60 * 1000; // 1 minute for misses
|
|
44
|
+
const PROFILE_CACHE_MAX = 1000;
|
|
45
|
+
|
|
46
|
+
/** @internal — exposed for tests */
|
|
47
|
+
export function _clearProfileCacheForTests() {
|
|
48
|
+
profileCache.clear();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch and parse a JSON CID document with SSRF, redirect, and
|
|
53
|
+
* body-size protections — and a bounded TTL cache so auth-path callers
|
|
54
|
+
* don't refetch on every request.
|
|
55
|
+
*
|
|
56
|
+
* Content-Type expectation: the response must declare a JSON-bearing
|
|
57
|
+
* Content-Type (matching `/json/`, e.g. `application/ld+json`,
|
|
58
|
+
* `application/json`, `application/json+ld`). A missing or non-JSON
|
|
59
|
+
* Content-Type rejects with a clear error rather than silently
|
|
60
|
+
* attempting JSON.parse — this caught real misconfigurations during
|
|
61
|
+
* #398 review where Turtle / HTML error pages were being served at
|
|
62
|
+
* profile URLs. Deployments serving WebID profiles should make sure
|
|
63
|
+
* their host returns the correct Content-Type for the CID document.
|
|
64
|
+
*
|
|
65
|
+
* Cache contract: keyed by `docUrl` only. All callers MUST pass
|
|
66
|
+
* consistent `opts` (in practice today everyone passes
|
|
67
|
+
* maxBytes = 256 KB). If a future caller needs a stricter limit, the
|
|
68
|
+
* cache key needs to incorporate it (or that caller needs its own
|
|
69
|
+
* cache) — otherwise a permissive entry would be returned to a strict
|
|
70
|
+
* caller and the cap wouldn't be re-enforced.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} docUrl - URL to fetch (untrusted — comes from JWT
|
|
73
|
+
* claims or is derived from a request).
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {number} [opts.maxBytes=DEFAULT_MAX_BYTES]
|
|
76
|
+
* @returns {Promise<object>} parsed JSON
|
|
77
|
+
* @throws on any validation, network, redirect, size, or parse failure
|
|
78
|
+
*/
|
|
79
|
+
export async function fetchCidDocument(docUrl, opts = {}) {
|
|
80
|
+
// Cache hit (or recent failure) — return immediately. On hit we
|
|
81
|
+
// delete-then-set so this entry moves to the tail of the Map's
|
|
82
|
+
// insertion order, giving LRU eviction without a separate structure.
|
|
83
|
+
const cached = profileCache.get(docUrl);
|
|
84
|
+
if (cached) {
|
|
85
|
+
const ttl = cached.failureTtl ? PROFILE_FAILURE_TTL : PROFILE_CACHE_TTL;
|
|
86
|
+
if (Date.now() - cached.timestamp < ttl) {
|
|
87
|
+
profileCache.delete(docUrl);
|
|
88
|
+
profileCache.set(docUrl, cached);
|
|
89
|
+
if (cached.failureTtl) throw new Error(cached.error);
|
|
90
|
+
return cached.profile;
|
|
91
|
+
}
|
|
92
|
+
profileCache.delete(docUrl);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const profile = await fetchCidDocumentNoCache(docUrl, opts);
|
|
97
|
+
setCached(docUrl, { profile, timestamp: Date.now() });
|
|
98
|
+
return profile;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setCached(docUrl, {
|
|
101
|
+
timestamp: Date.now(),
|
|
102
|
+
failureTtl: true,
|
|
103
|
+
error: err.message,
|
|
104
|
+
});
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Insert into the bounded LRU; evict the oldest entry past the cap. */
|
|
110
|
+
function setCached(url, entry) {
|
|
111
|
+
profileCache.set(url, entry);
|
|
112
|
+
while (profileCache.size > PROFILE_CACHE_MAX) {
|
|
113
|
+
const oldest = profileCache.keys().next().value;
|
|
114
|
+
if (oldest === undefined) break;
|
|
115
|
+
profileCache.delete(oldest);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchCidDocumentNoCache(docUrl, opts = {}) {
|
|
120
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
121
|
+
const originalOrigin = new URL(docUrl).origin;
|
|
122
|
+
let currentUrl = docUrl;
|
|
123
|
+
|
|
124
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
125
|
+
const isLastAllowedHop = hop === MAX_REDIRECTS;
|
|
126
|
+
const validation = await validateExternalUrl(currentUrl, {
|
|
127
|
+
requireHttps: process.env.NODE_ENV === 'production',
|
|
128
|
+
blockPrivateIPs: true,
|
|
129
|
+
resolveDNS: true,
|
|
130
|
+
});
|
|
131
|
+
if (!validation.valid) {
|
|
132
|
+
throw new Error(`SSRF protection: ${validation.error}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
137
|
+
let res;
|
|
138
|
+
try {
|
|
139
|
+
res = await fetch(currentUrl, {
|
|
140
|
+
headers: { Accept: 'application/ld+json, application/json;q=0.9' },
|
|
141
|
+
redirect: 'manual',
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
});
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (res.status >= 300 && res.status < 400) {
|
|
149
|
+
if (isLastAllowedHop) {
|
|
150
|
+
throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
|
|
151
|
+
}
|
|
152
|
+
const loc = res.headers.get('location');
|
|
153
|
+
if (!loc) throw new Error(`redirect ${res.status} without Location`);
|
|
154
|
+
const nextUrl = new URL(loc, currentUrl).toString();
|
|
155
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
156
|
+
if (nextOrigin !== originalOrigin) {
|
|
157
|
+
throw new Error(`cross-origin redirect refused: ${originalOrigin} → ${nextOrigin}`);
|
|
158
|
+
}
|
|
159
|
+
currentUrl = nextUrl;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
164
|
+
|
|
165
|
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
166
|
+
if (!ct.includes('json')) {
|
|
167
|
+
throw new Error(`unexpected content-type: ${ct || '(none)'}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const declared = Number(res.headers.get('content-length'));
|
|
171
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
172
|
+
throw new Error(`CID document too large (Content-Length=${declared} > ${maxBytes})`);
|
|
173
|
+
}
|
|
174
|
+
const text = await readBodyWithCap(res, maxBytes);
|
|
175
|
+
return JSON.parse(text);
|
|
176
|
+
}
|
|
177
|
+
throw new Error('profile fetch loop exited unexpectedly');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function readBodyWithCap(res, maxBytes) {
|
|
181
|
+
const reader = res.body?.getReader?.();
|
|
182
|
+
if (!reader) {
|
|
183
|
+
const text = await res.text();
|
|
184
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
|
|
185
|
+
throw new Error(`CID document too large (>${maxBytes} bytes)`);
|
|
186
|
+
}
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
const chunks = [];
|
|
190
|
+
let total = 0;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const { value, done } = await reader.read();
|
|
193
|
+
if (done) break;
|
|
194
|
+
total += value.byteLength;
|
|
195
|
+
if (total > maxBytes) {
|
|
196
|
+
try { await reader.cancel(); } catch { /* noop */ }
|
|
197
|
+
throw new Error(`CID document too large (>${maxBytes} bytes)`);
|
|
198
|
+
}
|
|
199
|
+
chunks.push(value);
|
|
200
|
+
}
|
|
201
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString('utf8');
|
|
202
|
+
}
|
package/src/auth/lws-cid.js
CHANGED
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
import * as jose from 'jose';
|
|
36
36
|
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
37
37
|
import { sha256 } from '@noble/hashes/sha256';
|
|
38
|
-
import {
|
|
38
|
+
import { fetchCidDocument, _clearProfileCacheForTests } from './cid-doc-fetch.js';
|
|
39
|
+
|
|
40
|
+
// Re-export for the existing test suite, which already calls this.
|
|
41
|
+
export { _clearProfileCacheForTests };
|
|
39
42
|
|
|
40
43
|
// JWS algorithms we accept. ES256K (RFC8812) is the primary target —
|
|
41
44
|
// secp256k1, the same curve as Nostr — but we support the common JWS
|
|
@@ -55,27 +58,11 @@ const MAX_LIFETIME = 3600; // 1 hour
|
|
|
55
58
|
// Clock skew tolerance for exp/nbf checks (seconds).
|
|
56
59
|
const CLOCK_SKEW = 60;
|
|
57
60
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
const profileCache = new Map(); // url -> { profile, timestamp, failureTtl?, error? }
|
|
64
|
-
const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes for hits
|
|
65
|
-
const PROFILE_FAILURE_TTL = 60 * 1000; // 1 minute for misses
|
|
66
|
-
const PROFILE_CACHE_MAX = 1000; // simple LRU bound
|
|
67
|
-
|
|
68
|
-
// Manual-redirect cap so a chain can't loop or grind.
|
|
69
|
-
const MAX_REDIRECTS = 5;
|
|
70
|
-
|
|
71
|
-
// Max profile body size — guards against DoS via giant JSON bodies on
|
|
72
|
-
// untrusted URLs. CID documents are tiny in practice (~1-5 KB).
|
|
73
|
-
const MAX_PROFILE_BYTES = 256 * 1024; // 256 KB
|
|
74
|
-
|
|
75
|
-
/** @internal — exposed for tests */
|
|
76
|
-
export function _clearProfileCacheForTests() {
|
|
77
|
-
profileCache.clear();
|
|
78
|
-
}
|
|
61
|
+
// Max profile body size — passed to the shared fetcher. CID documents
|
|
62
|
+
// are tiny in practice (~1-5 KB); 256 KB leaves plenty of headroom
|
|
63
|
+
// while bounding any DoS attempt. The cache itself lives in
|
|
64
|
+
// cid-doc-fetch.js so both this module and the NIP-98 path benefit.
|
|
65
|
+
const MAX_PROFILE_BYTES = 256 * 1024;
|
|
79
66
|
|
|
80
67
|
/**
|
|
81
68
|
* Cheap detector — does this request carry an LWS-CID JWT?
|
|
@@ -427,165 +414,13 @@ function normalizeOrigin(s) {
|
|
|
427
414
|
}
|
|
428
415
|
}
|
|
429
416
|
|
|
430
|
-
async function fetchProfile(docUrl) {
|
|
431
|
-
// Cache hit (or recent failure) — return immediately. On hit we
|
|
432
|
-
// delete-then-reset so this entry moves to the tail of the Map's
|
|
433
|
-
// insertion order, giving us LRU eviction without an extra structure.
|
|
434
|
-
const cached = profileCache.get(docUrl);
|
|
435
|
-
if (cached) {
|
|
436
|
-
const ttl = cached.failureTtl ? PROFILE_FAILURE_TTL : PROFILE_CACHE_TTL;
|
|
437
|
-
if (Date.now() - cached.timestamp < ttl) {
|
|
438
|
-
profileCache.delete(docUrl);
|
|
439
|
-
profileCache.set(docUrl, cached);
|
|
440
|
-
if (cached.failureTtl) throw new Error(cached.error);
|
|
441
|
-
return cached.profile;
|
|
442
|
-
}
|
|
443
|
-
profileCache.delete(docUrl);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
try {
|
|
447
|
-
const profile = await fetchProfileNoCache(docUrl);
|
|
448
|
-
setCached(docUrl, { profile, timestamp: Date.now() });
|
|
449
|
-
return profile;
|
|
450
|
-
} catch (err) {
|
|
451
|
-
setCached(docUrl, {
|
|
452
|
-
timestamp: Date.now(),
|
|
453
|
-
failureTtl: true,
|
|
454
|
-
error: err.message,
|
|
455
|
-
});
|
|
456
|
-
throw err;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** Insert into the bounded LRU; evict the oldest entry past the cap. */
|
|
461
|
-
function setCached(url, entry) {
|
|
462
|
-
profileCache.set(url, entry);
|
|
463
|
-
while (profileCache.size > PROFILE_CACHE_MAX) {
|
|
464
|
-
// Map iterates in insertion order; first key is the oldest.
|
|
465
|
-
const oldest = profileCache.keys().next().value;
|
|
466
|
-
if (oldest === undefined) break;
|
|
467
|
-
profileCache.delete(oldest);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Fetch the CID document with SSRF protection.
|
|
473
|
-
*
|
|
474
|
-
* docUrl comes from JWT claims (sub, kid) BEFORE the signature is
|
|
475
|
-
* verified, so it's untrusted. We:
|
|
476
|
-
* 1. Validate it through the existing SSRF guard (blocks loopback,
|
|
477
|
-
* private IPs, http (in production), DNS that resolves to private
|
|
478
|
-
* addresses).
|
|
479
|
-
* 2. Disable automatic redirects and re-validate every Location to
|
|
480
|
-
* defeat redirect-based bypasses (mirrors the cors-proxy pattern).
|
|
481
|
-
* Cross-origin redirects are refused — otherwise a target
|
|
482
|
-
* attacker-controlled host could serve a substitute CID document
|
|
483
|
-
* for the WebID's origin.
|
|
484
|
-
* 3. Cap redirects so a chain can't loop.
|
|
485
|
-
* 4. Cap response body size so a giant payload can't OOM us.
|
|
486
|
-
* 5. Always send a fresh Accept and a small read-side timeout.
|
|
487
|
-
*/
|
|
488
|
-
async function fetchProfileNoCache(docUrl) {
|
|
489
|
-
const originalOrigin = new URL(docUrl).origin;
|
|
490
|
-
let currentUrl = docUrl;
|
|
491
|
-
|
|
492
|
-
// Hop 0 is the original request; up to MAX_REDIRECTS subsequent
|
|
493
|
-
// redirects are followed, after which we throw.
|
|
494
|
-
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
495
|
-
const isLastAllowedHop = hop === MAX_REDIRECTS;
|
|
496
|
-
const validation = await validateExternalUrl(currentUrl, {
|
|
497
|
-
// Allow http on dev only — production deploys should always be https.
|
|
498
|
-
requireHttps: process.env.NODE_ENV === 'production',
|
|
499
|
-
blockPrivateIPs: true,
|
|
500
|
-
resolveDNS: true,
|
|
501
|
-
});
|
|
502
|
-
if (!validation.valid) {
|
|
503
|
-
throw new Error(`SSRF protection: ${validation.error}`);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const controller = new AbortController();
|
|
507
|
-
const timer = setTimeout(() => controller.abort(), 5000);
|
|
508
|
-
let res;
|
|
509
|
-
try {
|
|
510
|
-
res = await fetch(currentUrl, {
|
|
511
|
-
// Prefer JSON-LD but accept plain JSON too — some WebID hosts
|
|
512
|
-
// serve `application/json` for `card.jsonld`. The body is JSON
|
|
513
|
-
// either way; we don't perform JSON-LD-specific processing here.
|
|
514
|
-
headers: { Accept: 'application/ld+json, application/json;q=0.9' },
|
|
515
|
-
redirect: 'manual',
|
|
516
|
-
signal: controller.signal,
|
|
517
|
-
});
|
|
518
|
-
} finally {
|
|
519
|
-
clearTimeout(timer);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Manual redirect handling — re-validate every Location and require
|
|
523
|
-
// same-origin so a redirect can't substitute an attacker-controlled
|
|
524
|
-
// CID document for the WebID's origin.
|
|
525
|
-
if (res.status >= 300 && res.status < 400) {
|
|
526
|
-
if (isLastAllowedHop) {
|
|
527
|
-
throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
|
|
528
|
-
}
|
|
529
|
-
const loc = res.headers.get('location');
|
|
530
|
-
if (!loc) throw new Error(`redirect ${res.status} without Location`);
|
|
531
|
-
const nextUrl = new URL(loc, currentUrl).toString();
|
|
532
|
-
const nextOrigin = new URL(nextUrl).origin;
|
|
533
|
-
if (nextOrigin !== originalOrigin) {
|
|
534
|
-
throw new Error(
|
|
535
|
-
`cross-origin redirect refused: ${originalOrigin} → ${nextOrigin}`,
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
currentUrl = nextUrl;
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (!res.ok) {
|
|
543
|
-
throw new Error(`HTTP ${res.status}`);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Size guard. Two layers: trust Content-Length when present, then
|
|
547
|
-
// also enforce as we read so a streaming response can't lie.
|
|
548
|
-
const declared = Number(res.headers.get('content-length'));
|
|
549
|
-
if (Number.isFinite(declared) && declared > MAX_PROFILE_BYTES) {
|
|
550
|
-
throw new Error(
|
|
551
|
-
`CID document too large (Content-Length=${declared} > ${MAX_PROFILE_BYTES})`,
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
const text = await readBodyWithCap(res, MAX_PROFILE_BYTES);
|
|
555
|
-
return JSON.parse(text);
|
|
556
|
-
}
|
|
557
|
-
// Loop exited without returning or redirecting — defensive fallback.
|
|
558
|
-
throw new Error('profile fetch loop exited unexpectedly');
|
|
559
|
-
}
|
|
560
|
-
|
|
561
417
|
/**
|
|
562
|
-
*
|
|
563
|
-
*
|
|
418
|
+
* Fetch the CID document. Delegates to the shared `fetchCidDocument`
|
|
419
|
+
* helper which handles SSRF / redirects / body cap AND a bounded TTL
|
|
420
|
+
* cache (see src/auth/cid-doc-fetch.js).
|
|
564
421
|
*/
|
|
565
|
-
async function
|
|
566
|
-
|
|
567
|
-
if (!reader) {
|
|
568
|
-
// No streaming reader (older runtimes / mocked responses) — fall
|
|
569
|
-
// back to .text() but enforce the cap after the fact.
|
|
570
|
-
const text = await res.text();
|
|
571
|
-
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
|
|
572
|
-
throw new Error(`CID document too large (>${maxBytes} bytes)`);
|
|
573
|
-
}
|
|
574
|
-
return text;
|
|
575
|
-
}
|
|
576
|
-
const chunks = [];
|
|
577
|
-
let total = 0;
|
|
578
|
-
for (;;) {
|
|
579
|
-
const { value, done } = await reader.read();
|
|
580
|
-
if (done) break;
|
|
581
|
-
total += value.byteLength;
|
|
582
|
-
if (total > maxBytes) {
|
|
583
|
-
try { await reader.cancel(); } catch { /* noop */ }
|
|
584
|
-
throw new Error(`CID document too large (>${maxBytes} bytes)`);
|
|
585
|
-
}
|
|
586
|
-
chunks.push(value);
|
|
587
|
-
}
|
|
588
|
-
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString('utf8');
|
|
422
|
+
async function fetchProfile(docUrl) {
|
|
423
|
+
return fetchCidDocument(docUrl, { maxBytes: MAX_PROFILE_BYTES });
|
|
589
424
|
}
|
|
590
425
|
|
|
591
426
|
function findVerificationMethod(profile, kid, baseUrl) {
|
|
@@ -613,7 +448,9 @@ function isInProofPurpose(profile, predicate, kid, baseUrl) {
|
|
|
613
448
|
return false;
|
|
614
449
|
}
|
|
615
450
|
|
|
616
|
-
|
|
451
|
+
// Exported so the NIP-98 → WebID path (src/auth/nostr.js) can perform
|
|
452
|
+
// the same controller consistency check the LWS-CID verifier uses.
|
|
453
|
+
export function normalizeControllers(value, baseUrl) {
|
|
617
454
|
if (value === undefined || value === null) return [];
|
|
618
455
|
const list = Array.isArray(value) ? value : [value];
|
|
619
456
|
const out = [];
|