haechi 0.9.0 → 1.1.0

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.
@@ -0,0 +1,393 @@
1
+ // Ed25519 signed-plugin primitive (the 1.0 cryptographic trust gate).
2
+ //
3
+ // This is ASYMMETRIC signing — the plugin AUTHOR holds the Ed25519 private key
4
+ // and signs offline; the OPERATOR allowlists the Ed25519 PUBLIC key as a trust
5
+ // anchor and verifies. It deliberately does NOT reuse packages/policy-bundle:
6
+ // that is symmetric HMAC keyed off the local AES key file, where the verifier
7
+ // holds the same secret that signs, so it cannot express third-party authorship.
8
+ //
9
+ // The signature binds the sha256 of the EXACT entry bytes plus kind,
10
+ // capabilities, the compatible core range, and a validity window — so signing a
11
+ // path, or omitting entrySha256/kind/capabilities, is a swap / capability-
12
+ // downgrade attack and is rejected by verifySignedPlugin.
13
+ //
14
+ // Zero new runtime dependency: node:crypto (Ed25519 is a builtin) + the core
15
+ // canonicalize() for the signed bytes so sign and verify agree byte-for-byte.
16
+
17
+ import { createHash, createPublicKey, sign as edSign, verify as edVerify, timingSafeEqual } from "node:crypto";
18
+ import { canonicalize } from "../crypto/index.mjs";
19
+
20
+ // Minimal node:-only semver satisfies for the ">=A.B.C <D.E.F" range shape.
21
+ // Inlined rather than imported from scripts/ — scripts/check-satellite-peer-ranges.mjs
22
+ // is NOT in the published `files` allowlist, so a cross-import would
23
+ // MODULE_NOT_FOUND in the haechi tarball at runtime.
24
+ function parseSemver(v) {
25
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v).trim());
26
+ if (!m) throw new Error(`unsupported version: ${JSON.stringify(v)}`);
27
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
28
+ }
29
+ function cmpSemver(a, b) {
30
+ for (let i = 0; i < 3; i += 1) {
31
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
32
+ }
33
+ return 0;
34
+ }
35
+ function semverSatisfies(version, range) {
36
+ const m = /^>=(\d+\.\d+\.\d+)\s+<(\d+\.\d+\.\d+)$/.exec(String(range).trim());
37
+ if (!m) {
38
+ throw new Error(`unsupported range shape (expected ">=A.B.C <D.E.F"): ${JSON.stringify(range)}`);
39
+ }
40
+ return cmpSemver(parseSemver(version), parseSemver(m[1])) >= 0
41
+ && cmpSemver(parseSemver(version), parseSemver(m[2])) < 0;
42
+ }
43
+
44
+ // The verifySignedPlugin refusal reasons — security-critical and ordered.
45
+ // PluginLoadError.reason is guaranteed to be a member of this set.
46
+ export const PLUGIN_LOAD_REASONS = Object.freeze([
47
+ "manifest-invalid",
48
+ "alg-not-ed25519",
49
+ "unknown-signer",
50
+ "revoked",
51
+ "tampered-entry",
52
+ "invalid-signature",
53
+ "expired-window",
54
+ "below-version-floor",
55
+ "pin-mismatch",
56
+ "capability-not-allowlisted"
57
+ ]);
58
+
59
+ const PLUGIN_LOAD_REASON_SET = new Set(PLUGIN_LOAD_REASONS);
60
+
61
+ // A typed, fail-closed error. Every refusal path throws this with a .reason in
62
+ // PLUGIN_LOAD_REASONS so the loader/audit can branch on a stable enum, never on
63
+ // a free-text message.
64
+ export class PluginLoadError extends Error {
65
+ constructor(reason, message) {
66
+ if (!PLUGIN_LOAD_REASON_SET.has(reason)) {
67
+ // A programming error inside the verifier — never surface an off-contract
68
+ // reason to a caller relying on the enum.
69
+ throw new Error(`PluginLoadError got an off-contract reason: ${reason}`);
70
+ }
71
+ super(message ?? reason);
72
+ this.name = "PluginLoadError";
73
+ this.reason = reason;
74
+ }
75
+ }
76
+
77
+ function sha256Hex(bytes) {
78
+ return createHash("sha256").update(bytes).digest("hex");
79
+ }
80
+
81
+ function toEntryBuffer(entryBytes) {
82
+ if (Buffer.isBuffer(entryBytes)) {
83
+ return entryBytes;
84
+ }
85
+ if (entryBytes instanceof Uint8Array) {
86
+ return Buffer.from(entryBytes);
87
+ }
88
+ if (typeof entryBytes === "string") {
89
+ return Buffer.from(entryBytes, "utf8");
90
+ }
91
+ throw new Error("entryBytes must be a Buffer, Uint8Array, or string");
92
+ }
93
+
94
+ // Compares two hex-encoded sha256 digests without leaking position-of-first-
95
+ // difference timing. Both are attacker-influenced/operator-supplied digests, so
96
+ // the constant-time compare is defense-in-depth (and required by the spec).
97
+ function constantTimeHexEqual(a, b) {
98
+ if (typeof a !== "string" || typeof b !== "string") {
99
+ return false;
100
+ }
101
+ const bufA = Buffer.from(a, "utf8");
102
+ const bufB = Buffer.from(b, "utf8");
103
+ if (bufA.length !== bufB.length) {
104
+ return false;
105
+ }
106
+ return timingSafeEqual(bufA, bufB);
107
+ }
108
+
109
+ // TEST/AUTHORING HELPER. Real authors sign offline with their own tooling; this
110
+ // exists so tests (and a future signing CLI) can produce a valid envelope.
111
+ //
112
+ // Returns { payload, signerKeyId, alg: "ed25519", signature } where
113
+ // payload = { pluginId, kind, version, capabilities, coreVersionRange,
114
+ // entrySha256: sha256hex(entryBytes), notBefore, notAfter }
115
+ // signature = base64( ed25519.sign(canonicalize(payload)) )
116
+ export function signPluginManifest(
117
+ { pluginId, kind, version, capabilities, coreVersionRange, entryBytes, notBefore, notAfter },
118
+ privateKey,
119
+ signerKeyId
120
+ ) {
121
+ if (!pluginId || typeof pluginId !== "string") {
122
+ throw new Error("signPluginManifest requires a non-empty pluginId");
123
+ }
124
+ if (!kind || typeof kind !== "string") {
125
+ throw new Error("signPluginManifest requires a non-empty kind");
126
+ }
127
+ if (!version || typeof version !== "string") {
128
+ throw new Error("signPluginManifest requires a non-empty version");
129
+ }
130
+ if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities)) {
131
+ throw new Error("signPluginManifest requires a capabilities object");
132
+ }
133
+ if (!coreVersionRange || typeof coreVersionRange !== "string") {
134
+ throw new Error("signPluginManifest requires a coreVersionRange string");
135
+ }
136
+ if (!signerKeyId || typeof signerKeyId !== "string") {
137
+ throw new Error("signPluginManifest requires a signerKeyId");
138
+ }
139
+ if (entryBytes === undefined || entryBytes === null) {
140
+ throw new Error("signPluginManifest requires entryBytes (the exact plugin source bytes)");
141
+ }
142
+
143
+ const entrySha256 = sha256Hex(toEntryBuffer(entryBytes));
144
+ const payload = {
145
+ pluginId,
146
+ kind,
147
+ version,
148
+ capabilities,
149
+ coreVersionRange,
150
+ entrySha256,
151
+ notBefore: notBefore ?? null,
152
+ notAfter: notAfter ?? null
153
+ };
154
+
155
+ // Ed25519: the algorithm arg to crypto.sign is NULL.
156
+ const signature = edSign(null, Buffer.from(canonicalize(payload), "utf8"), privateKey);
157
+ return {
158
+ payload,
159
+ signerKeyId,
160
+ alg: "ed25519",
161
+ signature: signature.toString("base64")
162
+ };
163
+ }
164
+
165
+ function resolveAnchorPublicKey(anchor) {
166
+ // A trust anchor may be supplied as a KeyObject, a PEM/SPKI string, or a
167
+ // { publicKey } wrapper. Resolve to a KeyObject; reject anything else.
168
+ if (anchor && typeof anchor === "object" && anchor.publicKey !== undefined && anchor.type === undefined) {
169
+ return resolveAnchorPublicKey(anchor.publicKey);
170
+ }
171
+ if (anchor && typeof anchor === "object" && anchor.asymmetricKeyType !== undefined) {
172
+ // A KeyObject already.
173
+ return anchor;
174
+ }
175
+ if (typeof anchor === "string") {
176
+ return createPublicKey(anchor);
177
+ }
178
+ // Last resort: let createPublicKey try (e.g. a JWK object / DER buffer).
179
+ return createPublicKey(anchor);
180
+ }
181
+
182
+ // Verify a signed plugin envelope against operator trust state. Returns the
183
+ // validated payload, or throws a PluginLoadError whose .reason is in
184
+ // PLUGIN_LOAD_REASONS. The CHECK ORDER is security-critical (see the design
185
+ // §2.2 / §7.3) and must not be reordered.
186
+ export function verifySignedPlugin({
187
+ signed,
188
+ entryBytes,
189
+ trustAnchors = {},
190
+ revoked = {},
191
+ pin = null,
192
+ versionFloor = {},
193
+ allowCapabilities = [],
194
+ coreVersion = null,
195
+ now = Date.now()
196
+ } = {}) {
197
+ // (0) Structural validity of the envelope itself.
198
+ if (!signed || typeof signed !== "object") {
199
+ throw new PluginLoadError("manifest-invalid", "signed envelope must be an object");
200
+ }
201
+ const { payload, signerKeyId, alg, signature } = signed;
202
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
203
+ throw new PluginLoadError("manifest-invalid", "signed.payload must be an object");
204
+ }
205
+ if (typeof signerKeyId !== "string" || signerKeyId.length === 0) {
206
+ throw new PluginLoadError("manifest-invalid", "signed.signerKeyId must be a non-empty string");
207
+ }
208
+ if (typeof signature !== "string" || signature.length === 0) {
209
+ throw new PluginLoadError("manifest-invalid", "signed.signature must be a non-empty base64 string");
210
+ }
211
+ for (const field of ["pluginId", "kind", "version", "coreVersionRange", "entrySha256"]) {
212
+ if (typeof payload[field] !== "string" || payload[field].length === 0) {
213
+ throw new PluginLoadError("manifest-invalid", `signed.payload.${field} must be a non-empty string`);
214
+ }
215
+ }
216
+ if (!payload.capabilities || typeof payload.capabilities !== "object" || Array.isArray(payload.capabilities)) {
217
+ throw new PluginLoadError("manifest-invalid", "signed.payload.capabilities must be an object");
218
+ }
219
+
220
+ // (a) Algorithm is pinned to ed25519 — no alg agility, no HS/RS confusion.
221
+ if (alg !== "ed25519") {
222
+ throw new PluginLoadError("alg-not-ed25519", `unsupported signature alg: ${String(alg)}`);
223
+ }
224
+
225
+ // (b) Resolve the verification key ONLY from the operator's trustAnchors
226
+ // allowlist, keyed by signed.signerKeyId. If the kid is not an allowlisted
227
+ // anchor, refuse BEFORE any verify — never select a key by the object's own
228
+ // claim against a broader keyring.
229
+ const hasAnchor = Object.prototype.hasOwnProperty.call(trustAnchors, signerKeyId);
230
+ if (!hasAnchor) {
231
+ throw new PluginLoadError("unknown-signer", `signerKeyId not in trust anchors: ${signerKeyId}`);
232
+ }
233
+ let resolvedPublicKey;
234
+ try {
235
+ resolvedPublicKey = resolveAnchorPublicKey(trustAnchors[signerKeyId]);
236
+ if (!resolvedPublicKey || resolvedPublicKey.asymmetricKeyType !== "ed25519") {
237
+ throw new Error("trust anchor is not an ed25519 public key");
238
+ }
239
+ } catch (error) {
240
+ // A malformed anchor is an operator config error, but the safe outcome is
241
+ // still to refuse the load as an unusable signer.
242
+ throw new PluginLoadError("unknown-signer", `trust anchor unusable for ${signerKeyId}: ${error.message}`);
243
+ }
244
+
245
+ // (c) Revoked signer denylist (fail-closed before the expensive verify).
246
+ const revokedSignerKeyIds = Array.isArray(revoked.signerKeyIds) ? revoked.signerKeyIds : [];
247
+ if (revokedSignerKeyIds.includes(signerKeyId)) {
248
+ throw new PluginLoadError("revoked", `signerKeyId is revoked: ${signerKeyId}`);
249
+ }
250
+
251
+ // (d) Bind to the EXACT entry bytes: recompute the hash and compare in
252
+ // constant time. A mutated entry (path unchanged) trips here BEFORE the
253
+ // signature check, so a swap is "tampered-entry", not "invalid-signature".
254
+ const entrySha256 = sha256Hex(toEntryBuffer(entryBytes));
255
+ if (!constantTimeHexEqual(payload.entrySha256, entrySha256)) {
256
+ throw new PluginLoadError("tampered-entry", "entry bytes do not match the signed entrySha256");
257
+ }
258
+ const revokedEntrySha256 = Array.isArray(revoked.entrySha256) ? revoked.entrySha256 : [];
259
+ if (revokedEntrySha256.includes(entrySha256)) {
260
+ throw new PluginLoadError("revoked", `entrySha256 is revoked: ${entrySha256}`);
261
+ }
262
+
263
+ // (e) Ed25519 signature over the canonical payload (algorithm arg NULL).
264
+ let signatureValid = false;
265
+ try {
266
+ signatureValid = edVerify(
267
+ null,
268
+ Buffer.from(canonicalize(payload), "utf8"),
269
+ resolvedPublicKey,
270
+ Buffer.from(signature, "base64")
271
+ );
272
+ } catch {
273
+ signatureValid = false;
274
+ }
275
+ if (!signatureValid) {
276
+ throw new PluginLoadError("invalid-signature", "ed25519 signature verification failed");
277
+ }
278
+
279
+ // (f) Validity window (notBefore/notAfter). Both are epoch-ms numbers (or
280
+ // null = unbounded on that side).
281
+ const nowMs = typeof now === "number" ? now : Date.parse(now);
282
+ const notBefore = normalizeWindowBound(payload.notBefore);
283
+ const notAfter = normalizeWindowBound(payload.notAfter);
284
+ if (notBefore !== null && nowMs < notBefore) {
285
+ throw new PluginLoadError("expired-window", "current time is before notBefore");
286
+ }
287
+ if (notAfter !== null && nowMs > notAfter) {
288
+ throw new PluginLoadError("expired-window", "current time is after notAfter");
289
+ }
290
+
291
+ // (g) Per-pluginId version floor — reject rollback to an older signed artifact.
292
+ const floor = versionFloor?.[payload.pluginId];
293
+ if (floor !== undefined && floor !== null && compareVersions(payload.version, floor) < 0) {
294
+ throw new PluginLoadError("below-version-floor", `version ${payload.version} below floor ${floor}`);
295
+ }
296
+
297
+ // (h) Pin (anti malicious-update / rollback): version / entrySha256 /
298
+ // manifestSha256 must match the operator pin exactly.
299
+ if (pin && typeof pin === "object") {
300
+ if (pin.version !== undefined && pin.version !== null && pin.version !== payload.version) {
301
+ throw new PluginLoadError("pin-mismatch", "version does not match pin");
302
+ }
303
+ if (pin.entrySha256 !== undefined && pin.entrySha256 !== null
304
+ && !constantTimeHexEqual(pin.entrySha256, entrySha256)) {
305
+ throw new PluginLoadError("pin-mismatch", "entrySha256 does not match pin");
306
+ }
307
+ if (pin.manifestSha256 !== undefined && pin.manifestSha256 !== null) {
308
+ const manifestSha256 = sha256Hex(Buffer.from(canonicalize(payload), "utf8"));
309
+ if (!constantTimeHexEqual(pin.manifestSha256, manifestSha256)) {
310
+ throw new PluginLoadError("pin-mismatch", "manifestSha256 does not match pin");
311
+ }
312
+ }
313
+ }
314
+
315
+ // (i) Capability allowlist: every capability value MUST be a strict boolean
316
+ // (non-boolean truthy values like 1/"true"/{} skip the === true gate and are
317
+ // a bypass). Reject at the trust boundary before the allowlist check.
318
+ const allowSet = new Set(Array.isArray(allowCapabilities) ? allowCapabilities : []);
319
+ for (const [capability, requested] of Object.entries(payload.capabilities)) {
320
+ if (typeof requested !== "boolean") {
321
+ throw new PluginLoadError("manifest-invalid", `capability value must be a boolean, got ${typeof requested} for: ${capability}`);
322
+ }
323
+ if (requested === true && !allowSet.has(capability)) {
324
+ throw new PluginLoadError("capability-not-allowlisted", `capability not allowlisted: ${capability}`);
325
+ }
326
+ }
327
+ if (payload.kind === "authProvider" && payload.capabilities.readsCredentials !== true) {
328
+ throw new PluginLoadError("capability-not-allowlisted", "authProvider must declare readsCredentials");
329
+ }
330
+
331
+ // (j) coreVersionRange enforcement: when the caller supplies coreVersion AND
332
+ // the signed payload declares coreVersionRange, the version must satisfy the
333
+ // range. A mismatch means this plugin was not signed for this core — refuse.
334
+ if (coreVersion !== null && coreVersion !== undefined && payload.coreVersionRange) {
335
+ let inRange;
336
+ try {
337
+ inRange = semverSatisfies(String(coreVersion), payload.coreVersionRange);
338
+ } catch (err) {
339
+ throw new PluginLoadError("manifest-invalid", `coreVersionRange is not a valid range: ${err.message}`);
340
+ }
341
+ if (!inRange) {
342
+ throw new PluginLoadError("manifest-invalid", `coreVersion ${coreVersion} does not satisfy signed coreVersionRange ${payload.coreVersionRange}`);
343
+ }
344
+ }
345
+
346
+ // The validated payload — frozen so a downstream consumer cannot mutate the
347
+ // attested facts.
348
+ return Object.freeze({ ...payload });
349
+ }
350
+
351
+ function normalizeWindowBound(bound) {
352
+ if (bound === null || bound === undefined) {
353
+ return null;
354
+ }
355
+ if (typeof bound === "number") {
356
+ if (!Number.isFinite(bound)) {
357
+ throw new PluginLoadError("manifest-invalid", `validity window bound is not a finite number: ${bound}`);
358
+ }
359
+ return bound;
360
+ }
361
+ if (typeof bound === "string" && bound.length > 0) {
362
+ const parsed = Date.parse(bound);
363
+ if (Number.isNaN(parsed)) {
364
+ throw new PluginLoadError("manifest-invalid", `validity window bound is not a parseable date: ${JSON.stringify(bound)}`);
365
+ }
366
+ return parsed;
367
+ }
368
+ // Anything else (boolean, object, empty string, etc.) fails closed.
369
+ throw new PluginLoadError("manifest-invalid", `validity window bound has an unacceptable type/value: ${JSON.stringify(bound)}`);
370
+ }
371
+
372
+ // Minimal numeric-dotted version comparison (e.g. "1.2.0" vs "1.10.0"). Returns
373
+ // -1 / 0 / 1. Non-numeric segments compare lexicographically as a fallback so a
374
+ // malformed version can never silently rank as "newer".
375
+ function compareVersions(a, b) {
376
+ const pa = String(a).split(".");
377
+ const pb = String(b).split(".");
378
+ const len = Math.max(pa.length, pb.length);
379
+ for (let i = 0; i < len; i += 1) {
380
+ const sa = pa[i] ?? "0";
381
+ const sb = pb[i] ?? "0";
382
+ const na = Number(sa);
383
+ const nb = Number(sb);
384
+ if (Number.isInteger(na) && Number.isInteger(nb)) {
385
+ if (na !== nb) {
386
+ return na < nb ? -1 : 1;
387
+ }
388
+ } else if (sa !== sb) {
389
+ return sa < sb ? -1 : 1;
390
+ }
391
+ }
392
+ return 0;
393
+ }
@@ -0,0 +1,189 @@
1
+ // Core SSRF guard (Haechi 1.1 §2.3) — a node:-only, zero-dependency home for the
2
+ // address-blocklist + guarded-fetch pattern so CORE code (the process-isolated
3
+ // host-mediated key fetch) can use it. Core cannot import from a satellite, which
4
+ // is why this lives here.
5
+ //
6
+ // NOTE on the satellites: haechi-auth-jwt exports `isBlockedAddress`, and
7
+ // haechi-crypto-kms (vault.mjs) keeps a DELIBERATE satellite-local copy — a
8
+ // crypto/key-custody package must not runtime-depend on an auth (or core-ssrf)
9
+ // module's availability (see satellites/crypto-kms/ssrf-parity.test.mjs). 1.1 does
10
+ // NOT force those satellites to re-import this module (that would raise their
11
+ // `haechi` peer floor to 1.1 and republish them); instead the range logic here is
12
+ // kept byte-for-behavior identical to the satellite copies and guarded by a parity
13
+ // test (tests/ssrf.test.mjs). The drift is guarded, not (yet) eliminated.
14
+
15
+ import { isIP } from "node:net";
16
+ import { lookup as dnsLookup } from "node:dns/promises";
17
+
18
+ const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
19
+ const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
20
+
21
+ // Block literal addresses in private/loopback/link-local ranges + cloud metadata.
22
+ // Applied to both a literal host in the URL and every DNS-resolved address. This
23
+ // is the canonical copy; the satellite copies must agree (parity-tested).
24
+ export function isBlockedAddress(host) {
25
+ // A URL's .hostname keeps the brackets on an IPv6 literal ("[::1]"), and isIP
26
+ // rejects a bracketed string — strip them first so literals are classified.
27
+ const bare = String(host).replace(/^\[|\]$/g, "");
28
+ const v = isIP(bare);
29
+ if (v === 4) {
30
+ const o = bare.split(".").map(Number);
31
+ if (o[0] === 127) return true; // 127.0.0.0/8 loopback
32
+ if (o[0] === 10) return true; // 10.0.0.0/8
33
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // 172.16/12
34
+ if (o[0] === 192 && o[1] === 168) return true; // 192.168/16
35
+ if (o[0] === 169 && o[1] === 254) return true; // 169.254/16 link-local incl. metadata
36
+ if (o[0] === 0) return true; // 0.0.0.0/8
37
+ return false;
38
+ }
39
+ if (v === 6) {
40
+ const h = bare.toLowerCase();
41
+ if (h === "::1" || h === "::") return true; // loopback / unspecified
42
+ if (h.startsWith("::ffff:")) { // IPv4-mapped
43
+ const mapped = h.slice("::ffff:".length);
44
+ if (isIP(mapped) === 4) return isBlockedAddress(mapped);
45
+ }
46
+ // Range-check the first hextet: fe80::/10 link-local, fc00::/7 ULA, ff00::/8 multicast.
47
+ const firstHextet = parseInt(h.split(":")[0] || "", 16);
48
+ if (Number.isFinite(firstHextet)) {
49
+ if (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) return true; // link-local
50
+ if (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) return true; // unique local
51
+ if (firstHextet >= 0xff00 && firstHextet <= 0xffff) return true; // multicast
52
+ }
53
+ return false;
54
+ }
55
+ return false; // not a literal IP; resolved addresses are checked separately
56
+ }
57
+
58
+ function parseHttpsUrl(value, label) {
59
+ let url;
60
+ try {
61
+ url = new URL(value);
62
+ } catch {
63
+ throw new Error(`${label} must be a valid URL`);
64
+ }
65
+ if (url.protocol !== "https:") {
66
+ throw new Error(`${label} must be https`);
67
+ }
68
+ return url;
69
+ }
70
+
71
+ // HTTPS-only, SSRF-hardened fetch returning the response body TEXT (bounded):
72
+ // - https only;
73
+ // - the literal host AND every DNS-resolved address must pass isBlockedAddress
74
+ // (post-DNS re-check catches a hostname mapping to a private/metadata IP);
75
+ // - redirect:"error" (no redirect to an internal target after the check);
76
+ // - an AbortController timeout;
77
+ // - the body is bounded to maxBytes while streaming.
78
+ // The residual DNS-rebinding window (resolve-then-connect) is accepted for an
79
+ // operator-configured, single-origin URL — same stance as the bearer satellite.
80
+ export async function guardedFetch(urlString, {
81
+ fetchImpl = globalThis.fetch,
82
+ lookupImpl = dnsLookup,
83
+ timeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
84
+ maxBytes = DEFAULT_MAX_BYTES,
85
+ label = "url"
86
+ } = {}) {
87
+ const url = parseHttpsUrl(urlString, label);
88
+ if (isBlockedAddress(url.hostname)) {
89
+ throw new Error(`${label} host is a blocked (private/loopback/link-local/metadata) address`);
90
+ }
91
+ if (typeof fetchImpl !== "function") {
92
+ throw new Error("global fetch is unavailable; pass fetchImpl");
93
+ }
94
+ const records = await lookupImpl(url.hostname, { all: true });
95
+ for (const { address } of records) {
96
+ if (isBlockedAddress(address)) {
97
+ throw new Error(`${label} resolved to a blocked address`);
98
+ }
99
+ }
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
102
+ let res;
103
+ try {
104
+ res = await fetchImpl(url.href, { signal: controller.signal, redirect: "error" });
105
+ } finally {
106
+ clearTimeout(timer);
107
+ }
108
+ if (!res.ok) {
109
+ throw new Error(`${label} fetch failed: ${res.status}`);
110
+ }
111
+ const reader = res.body?.getReader?.();
112
+ if (!reader) {
113
+ const text = await res.text();
114
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
115
+ throw new Error(`${label} response exceeds the size limit`);
116
+ }
117
+ return text;
118
+ }
119
+ const chunks = [];
120
+ let total = 0;
121
+ for (;;) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ total += value.byteLength;
125
+ if (total > maxBytes) {
126
+ await reader.cancel();
127
+ throw new Error(`${label} response exceeds the size limit`);
128
+ }
129
+ chunks.push(Buffer.from(value));
130
+ }
131
+ return Buffer.concat(chunks).toString("utf8");
132
+ }
133
+
134
+ // A guarded key-material fetcher with a TTL cache + a refetch cooldown so an
135
+ // attacker's credential cannot pump the host's outbound requests (the kid-driven
136
+ // refetch is rate-limited, matching the bearer satellite). get() returns the
137
+ // cached body within ttlMs; otherwise it refetches, but no more often than
138
+ // cooldownMs (returning a stale cache during cooldown, or throwing if none).
139
+ export function createGuardedKeyFetcher({
140
+ url,
141
+ ttlMs = 300_000,
142
+ cooldownMs = 60_000,
143
+ now = () => Date.now(),
144
+ ...fetchOptions
145
+ } = {}) {
146
+ parseHttpsUrl(url, "keyMaterial.url"); // fail closed at construction on a bad URL
147
+ if (!Number.isFinite(ttlMs) || ttlMs < 0) {
148
+ throw new Error("keyMaterial.ttlMs must be a non-negative number");
149
+ }
150
+ if (!Number.isFinite(cooldownMs) || cooldownMs < 0) {
151
+ throw new Error("keyMaterial.cooldownMs must be a non-negative number");
152
+ }
153
+ let cache = null;
154
+ let fetchedAt = 0;
155
+ let lastAttemptAt = -Infinity;
156
+ let inflight = null;
157
+
158
+ return {
159
+ async get() {
160
+ const t = now();
161
+ if (cache !== null && (t - fetchedAt) < ttlMs) {
162
+ return cache;
163
+ }
164
+ if (inflight) {
165
+ return inflight;
166
+ }
167
+ // Cooldown: bound the outbound refetch rate. During cooldown serve the stale
168
+ // cache if we have one; otherwise fail closed.
169
+ if ((t - lastAttemptAt) < cooldownMs) {
170
+ if (cache !== null) {
171
+ return cache;
172
+ }
173
+ throw new Error("key material fetch is cooling down");
174
+ }
175
+ lastAttemptAt = t;
176
+ inflight = guardedFetch(url, { ...fetchOptions, label: "keyMaterial.url" })
177
+ .then((text) => {
178
+ cache = text;
179
+ fetchedAt = now();
180
+ inflight = null;
181
+ return text;
182
+ }, (error) => {
183
+ inflight = null;
184
+ throw error;
185
+ });
186
+ return inflight;
187
+ }
188
+ };
189
+ }