@peac/protocol 0.11.0 → 0.11.2

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/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { uuidv7 } from 'uuidv7';
2
2
  import { sign, decode, verify, sha256Hex, computeJwkThumbprint, jwkToPublicKeyBytes, base64urlDecode } from '@peac/crypto';
3
3
  export { base64urlDecode, base64urlEncode, computeJwkThumbprint, generateKeypair, jwkToPublicKeyBytes, sha256Bytes, sha256Hex, verify } from '@peac/crypto';
4
4
  import { ZodError } from 'zod';
5
- import { isValidPurposeToken, isCanonicalPurpose, isValidPurposeReason, isValidWorkflowContext, createWorkflowContextInvalidError, hasValidDagSemantics, createWorkflowDagInvalidError, WORKFLOW_EXTENSION_KEY, validateKernelConstraints, createConstraintViolationError, ReceiptClaims, createEvidenceNotJsonError, validateSubjectSnapshot, parseReceiptClaims, PEAC_RECEIPT_HEADER, PEAC_PURPOSE_HEADER, parsePurposeHeader, PEAC_PURPOSE_APPLIED_HEADER, PEAC_PURPOSE_REASON_HEADER, PEAC_ISSUER_CONFIG_MAX_BYTES, PEAC_ISSUER_CONFIG_PATH, PEAC_POLICY_MAX_BYTES, PEAC_POLICY_PATH, PEAC_POLICY_FALLBACK_PATH } from '@peac/schema';
5
+ import { isValidPurposeToken, isCanonicalPurpose, isValidPurposeReason, isValidWorkflowContext, createWorkflowContextInvalidError, hasValidDagSemantics, createWorkflowDagInvalidError, WORKFLOW_EXTENSION_KEY, validateKernelConstraints, createConstraintViolationError, ReceiptClaims, createEvidenceNotJsonError, validateSubjectSnapshot, PEAC_ISSUER_CONFIG_MAX_BYTES, PEAC_ISSUER_CONFIG_PATH, PEAC_POLICY_MAX_BYTES, PEAC_POLICY_PATH, PEAC_POLICY_FALLBACK_PATH, parseReceiptClaims, PEAC_RECEIPT_HEADER, PEAC_PURPOSE_HEADER, parsePurposeHeader, PEAC_PURPOSE_APPLIED_HEADER, PEAC_PURPOSE_REASON_HEADER } from '@peac/schema';
6
6
  import { createHash } from 'crypto';
7
7
  import { VERIFIER_LIMITS, VERIFIER_NETWORK, VERIFIER_POLICY_VERSION, VERIFICATION_REPORT_VERSION, WIRE_TYPE } from '@peac/kernel';
8
8
 
@@ -178,1234 +178,1390 @@ async function issueJws(options) {
178
178
  const result = await issue(options);
179
179
  return result.jws;
180
180
  }
181
- var jwksCache = /* @__PURE__ */ new Map();
182
- var CACHE_TTL_MS = 5 * 60 * 1e3;
183
- async function fetchJWKS(issuerUrl) {
184
- if (!issuerUrl.startsWith("https://")) {
185
- throw new Error("Issuer URL must be https://");
181
+ function parseIssuerConfig(json) {
182
+ let config;
183
+ if (typeof json === "string") {
184
+ const bytes = new TextEncoder().encode(json).length;
185
+ if (bytes > PEAC_ISSUER_CONFIG_MAX_BYTES) {
186
+ throw new Error(`Issuer config exceeds ${PEAC_ISSUER_CONFIG_MAX_BYTES} bytes (got ${bytes})`);
187
+ }
188
+ try {
189
+ config = JSON.parse(json);
190
+ } catch {
191
+ throw new Error("Issuer config is not valid JSON");
192
+ }
193
+ } else {
194
+ config = json;
195
+ }
196
+ if (typeof config !== "object" || config === null) {
197
+ throw new Error("Issuer config must be an object");
198
+ }
199
+ const obj = config;
200
+ if (typeof obj.version !== "string" || !obj.version) {
201
+ throw new Error("Missing required field: version");
202
+ }
203
+ if (typeof obj.issuer !== "string" || !obj.issuer) {
204
+ throw new Error("Missing required field: issuer");
205
+ }
206
+ if (typeof obj.jwks_uri !== "string" || !obj.jwks_uri) {
207
+ throw new Error("Missing required field: jwks_uri");
208
+ }
209
+ if (!obj.issuer.startsWith("https://")) {
210
+ throw new Error("issuer must be an HTTPS URL");
186
211
  }
187
- const discoveryUrl = `${issuerUrl}/.well-known/peac.txt`;
188
212
  try {
189
- const discoveryResp = await fetch(discoveryUrl, {
190
- headers: { Accept: "text/plain" },
191
- // Timeout after 5 seconds
192
- signal: AbortSignal.timeout(5e3)
193
- });
194
- if (!discoveryResp.ok) {
195
- throw new Error(`Discovery fetch failed: ${discoveryResp.status}`);
213
+ new URL(obj.jwks_uri);
214
+ } catch {
215
+ throw new Error("jwks_uri must be a valid URL");
216
+ }
217
+ if (obj.verify_endpoint !== void 0) {
218
+ if (typeof obj.verify_endpoint !== "string") {
219
+ throw new Error("verify_endpoint must be a string");
220
+ }
221
+ if (!obj.verify_endpoint.startsWith("https://")) {
222
+ throw new Error("verify_endpoint must be an HTTPS URL");
223
+ }
224
+ }
225
+ if (obj.receipt_versions !== void 0) {
226
+ if (!Array.isArray(obj.receipt_versions)) {
227
+ throw new Error("receipt_versions must be an array");
196
228
  }
197
- const discoveryText = await discoveryResp.text();
198
- const jwksLine = discoveryText.split("\n").find((line) => line.startsWith("jwks:"));
199
- if (!jwksLine) {
200
- throw new Error("No jwks field in discovery");
229
+ }
230
+ if (obj.algorithms !== void 0) {
231
+ if (!Array.isArray(obj.algorithms)) {
232
+ throw new Error("algorithms must be an array");
201
233
  }
202
- const jwksUrl = jwksLine.replace("jwks:", "").trim();
203
- if (!jwksUrl.startsWith("https://")) {
204
- throw new Error("JWKS URL must be https://");
234
+ }
235
+ if (obj.payment_rails !== void 0) {
236
+ if (!Array.isArray(obj.payment_rails)) {
237
+ throw new Error("payment_rails must be an array");
205
238
  }
206
- const jwksResp = await fetch(jwksUrl, {
239
+ }
240
+ return {
241
+ version: obj.version,
242
+ issuer: obj.issuer,
243
+ jwks_uri: obj.jwks_uri,
244
+ verify_endpoint: obj.verify_endpoint,
245
+ receipt_versions: obj.receipt_versions,
246
+ algorithms: obj.algorithms,
247
+ payment_rails: obj.payment_rails,
248
+ security_contact: obj.security_contact
249
+ };
250
+ }
251
+ async function fetchIssuerConfig(issuerUrl) {
252
+ if (!issuerUrl.startsWith("https://")) {
253
+ throw new Error("Issuer URL must be https://");
254
+ }
255
+ const baseUrl = issuerUrl.replace(/\/$/, "");
256
+ const configUrl = `${baseUrl}${PEAC_ISSUER_CONFIG_PATH}`;
257
+ try {
258
+ const resp = await fetch(configUrl, {
207
259
  headers: { Accept: "application/json" },
208
- signal: AbortSignal.timeout(5e3)
260
+ signal: AbortSignal.timeout(1e4)
209
261
  });
210
- if (!jwksResp.ok) {
211
- throw new Error(`JWKS fetch failed: ${jwksResp.status}`);
262
+ if (!resp.ok) {
263
+ throw new Error(`Issuer config fetch failed: ${resp.status}`);
264
+ }
265
+ const text = await resp.text();
266
+ const config = parseIssuerConfig(text);
267
+ const normalizedExpected = baseUrl.replace(/\/$/, "");
268
+ const normalizedActual = config.issuer.replace(/\/$/, "");
269
+ if (normalizedActual !== normalizedExpected) {
270
+ throw new Error(`Issuer mismatch: expected ${normalizedExpected}, got ${normalizedActual}`);
212
271
  }
213
- const jwks = await jwksResp.json();
214
- return jwks;
272
+ return config;
215
273
  } catch (err) {
216
- throw new Error(`JWKS fetch failed: ${err instanceof Error ? err.message : String(err)}`, {
217
- cause: err
218
- });
274
+ throw new Error(
275
+ `Failed to fetch issuer config from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
276
+ { cause: err }
277
+ );
219
278
  }
220
279
  }
221
- async function getJWKS(issuerUrl) {
222
- const now = Date.now();
223
- const cached = jwksCache.get(issuerUrl);
224
- if (cached && cached.expiresAt > now) {
225
- return { jwks: cached.keys, fromCache: true };
226
- }
227
- const jwks = await fetchJWKS(issuerUrl);
228
- jwksCache.set(issuerUrl, {
229
- keys: jwks,
230
- expiresAt: now + CACHE_TTL_MS
231
- });
232
- return { jwks, fromCache: false };
280
+ function isJsonContent(text, contentType) {
281
+ if (contentType?.includes("application/json")) {
282
+ return true;
283
+ }
284
+ const firstChar = text.trimStart()[0];
285
+ return firstChar === "{";
233
286
  }
234
- function jwkToPublicKey(jwk) {
235
- if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
236
- throw new Error("Only Ed25519 keys (OKP/Ed25519) are supported");
287
+ function parsePolicyManifest(text, contentType) {
288
+ const bytes = new TextEncoder().encode(text).length;
289
+ if (bytes > PEAC_POLICY_MAX_BYTES) {
290
+ throw new Error(`Policy manifest exceeds ${PEAC_POLICY_MAX_BYTES} bytes (got ${bytes})`);
237
291
  }
238
- const xBytes = Buffer.from(jwk.x, "base64url");
239
- if (xBytes.length !== 32) {
240
- throw new Error("Ed25519 public key must be 32 bytes");
292
+ let manifest;
293
+ if (isJsonContent(text, contentType)) {
294
+ try {
295
+ manifest = JSON.parse(text);
296
+ } catch {
297
+ throw new Error("Policy manifest is not valid JSON");
298
+ }
299
+ } else {
300
+ manifest = parseSimpleYaml(text);
241
301
  }
242
- return new Uint8Array(xBytes);
302
+ if (typeof manifest.version !== "string" || !manifest.version) {
303
+ throw new Error("Missing required field: version");
304
+ }
305
+ if (!manifest.version.startsWith("peac-policy/")) {
306
+ throw new Error(
307
+ `Invalid version format: "${manifest.version}". Must start with "peac-policy/" (e.g., "peac-policy/0.1")`
308
+ );
309
+ }
310
+ if (manifest.usage !== "open" && manifest.usage !== "conditional") {
311
+ throw new Error('Missing or invalid field: usage (must be "open" or "conditional")');
312
+ }
313
+ return {
314
+ version: manifest.version,
315
+ usage: manifest.usage,
316
+ purposes: manifest.purposes,
317
+ receipts: manifest.receipts,
318
+ attribution: manifest.attribution,
319
+ rate_limit: manifest.rate_limit,
320
+ daily_limit: manifest.daily_limit,
321
+ negotiate: manifest.negotiate,
322
+ contact: manifest.contact,
323
+ license: manifest.license,
324
+ price: manifest.price,
325
+ currency: manifest.currency,
326
+ payment_methods: manifest.payment_methods,
327
+ payment_endpoint: manifest.payment_endpoint
328
+ };
243
329
  }
244
- async function verifyReceipt(optionsOrJws) {
245
- const receiptJws = typeof optionsOrJws === "string" ? optionsOrJws : optionsOrJws.receiptJws;
246
- const inputSnapshot = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.subject_snapshot;
247
- const telemetry = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.telemetry;
248
- const startTime = performance.now();
249
- let jwksFetchTime;
330
+ function parseSimpleYaml(text) {
331
+ const lines = text.split("\n");
332
+ const result = {};
333
+ if (text.includes("<<:")) {
334
+ throw new Error("YAML merge keys are not allowed");
335
+ }
336
+ if (text.includes("&") || text.includes("*")) {
337
+ throw new Error("YAML anchors and aliases are not allowed");
338
+ }
339
+ if (/!\w+/.test(text)) {
340
+ throw new Error("YAML custom tags are not allowed");
341
+ }
342
+ const docSeparators = text.match(/^---$/gm);
343
+ if (docSeparators && docSeparators.length > 1) {
344
+ throw new Error("Multi-document YAML is not allowed");
345
+ }
346
+ for (const line of lines) {
347
+ const trimmed = line.trim();
348
+ if (!trimmed || trimmed.startsWith("#") || trimmed === "---") {
349
+ continue;
350
+ }
351
+ const colonIndex = trimmed.indexOf(":");
352
+ if (colonIndex === -1) continue;
353
+ const key = trimmed.slice(0, colonIndex).trim();
354
+ let value = trimmed.slice(colonIndex + 1).trim();
355
+ if (value === "") {
356
+ value = void 0;
357
+ } else if (typeof value === "string") {
358
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
359
+ value = value.slice(1, -1);
360
+ } else if (value.startsWith("[") && value.endsWith("]")) {
361
+ const inner = value.slice(1, -1);
362
+ value = inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
363
+ } else if (/^-?\d+(\.\d+)?$/.test(value)) {
364
+ value = parseFloat(value);
365
+ } else if (value === "true") {
366
+ value = true;
367
+ } else if (value === "false") {
368
+ value = false;
369
+ }
370
+ }
371
+ if (value !== void 0) {
372
+ result[key] = value;
373
+ }
374
+ }
375
+ return result;
376
+ }
377
+ async function fetchPolicyManifest(baseUrl) {
378
+ if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
379
+ throw new Error("Base URL must be https://");
380
+ }
381
+ const normalizedBase = baseUrl.replace(/\/$/, "");
382
+ const primaryUrl = `${normalizedBase}${PEAC_POLICY_PATH}`;
383
+ const fallbackUrl = `${normalizedBase}${PEAC_POLICY_FALLBACK_PATH}`;
250
384
  try {
251
- const { header, payload } = decode(receiptJws);
252
- const constraintResult = validateKernelConstraints(payload);
253
- if (!constraintResult.valid) {
254
- const v = constraintResult.violations[0];
255
- return {
256
- ok: false,
257
- reason: "constraint_violation",
258
- details: `Kernel constraint violated: ${v.constraint} (actual: ${v.actual}, limit: ${v.limit})`
259
- };
385
+ const resp = await fetch(primaryUrl, {
386
+ headers: { Accept: "text/plain, application/json" },
387
+ signal: AbortSignal.timeout(5e3)
388
+ });
389
+ if (resp.ok) {
390
+ const text = await resp.text();
391
+ const contentType = resp.headers.get("content-type") || void 0;
392
+ return parsePolicyManifest(text, contentType);
260
393
  }
261
- ReceiptClaims.parse(payload);
262
- if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) {
263
- const durationMs = performance.now() - startTime;
264
- fireTelemetryHook(telemetry?.onReceiptVerified, {
265
- receiptHash: hashReceipt(receiptJws),
266
- valid: false,
267
- reasonCode: "expired",
268
- issuer: payload.iss,
269
- kid: header.kid,
270
- durationMs
394
+ if (resp.status === 404) {
395
+ const fallbackResp = await fetch(fallbackUrl, {
396
+ headers: { Accept: "text/plain, application/json" },
397
+ signal: AbortSignal.timeout(5e3)
271
398
  });
272
- return {
273
- ok: false,
274
- reason: "expired",
275
- details: `Receipt expired at ${new Date(payload.exp * 1e3).toISOString()}`
276
- };
277
- }
278
- const jwksFetchStart = performance.now();
279
- const { jwks, fromCache } = await getJWKS(payload.iss);
280
- if (!fromCache) {
281
- jwksFetchTime = performance.now() - jwksFetchStart;
399
+ if (fallbackResp.ok) {
400
+ const text = await fallbackResp.text();
401
+ const contentType = fallbackResp.headers.get("content-type") || void 0;
402
+ return parsePolicyManifest(text, contentType);
403
+ }
404
+ throw new Error("Policy manifest not found at primary or fallback location");
282
405
  }
283
- const jwk = jwks.keys.find((k) => k.kid === header.kid);
284
- if (!jwk) {
285
- const durationMs = performance.now() - startTime;
286
- fireTelemetryHook(telemetry?.onReceiptVerified, {
287
- receiptHash: hashReceipt(receiptJws),
288
- valid: false,
289
- reasonCode: "unknown_key",
290
- issuer: payload.iss,
291
- kid: header.kid,
292
- durationMs
293
- });
294
- return {
295
- ok: false,
296
- reason: "unknown_key",
297
- details: `No key found with kid=${header.kid}`
298
- };
406
+ throw new Error(`Policy manifest fetch failed: ${resp.status}`);
407
+ } catch (err) {
408
+ throw new Error(
409
+ `Failed to fetch policy manifest from ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`,
410
+ { cause: err }
411
+ );
412
+ }
413
+ }
414
+ function parseDiscovery(text) {
415
+ const bytes = new TextEncoder().encode(text).length;
416
+ if (bytes > 2e3) {
417
+ throw new Error(`Discovery manifest exceeds 2000 bytes (got ${bytes})`);
418
+ }
419
+ const lines = text.trim().split("\n");
420
+ if (lines.length > 20) {
421
+ throw new Error(`Discovery manifest exceeds 20 lines (got ${lines.length})`);
422
+ }
423
+ const discovery = {};
424
+ for (const line of lines) {
425
+ const trimmed = line.trim();
426
+ if (!trimmed || trimmed.startsWith("#")) {
427
+ continue;
299
428
  }
300
- const publicKey = jwkToPublicKey(jwk);
301
- const result = await verify(receiptJws, publicKey);
302
- if (!result.valid) {
303
- const durationMs = performance.now() - startTime;
304
- fireTelemetryHook(telemetry?.onReceiptVerified, {
305
- receiptHash: hashReceipt(receiptJws),
306
- valid: false,
307
- reasonCode: "invalid_signature",
308
- issuer: payload.iss,
309
- kid: header.kid,
310
- durationMs
311
- });
312
- return {
313
- ok: false,
314
- reason: "invalid_signature",
315
- details: "Ed25519 signature verification failed"
316
- };
429
+ if (trimmed.includes(":")) {
430
+ const [key, ...valueParts] = trimmed.split(":");
431
+ const value = valueParts.join(":").trim();
432
+ switch (key.trim()) {
433
+ case "version":
434
+ discovery.version = value;
435
+ break;
436
+ case "issuer":
437
+ discovery.issuer = value;
438
+ break;
439
+ case "verify":
440
+ discovery.verify_endpoint = value;
441
+ break;
442
+ case "jwks":
443
+ discovery.jwks_uri = value;
444
+ break;
445
+ case "security":
446
+ discovery.security_contact = value;
447
+ break;
448
+ }
317
449
  }
318
- const validatedSnapshot = validateSubjectSnapshot(inputSnapshot);
319
- const verifyTime = performance.now() - startTime;
320
- fireTelemetryHook(telemetry?.onReceiptVerified, {
321
- receiptHash: hashReceipt(receiptJws),
322
- valid: true,
323
- issuer: payload.iss,
324
- kid: header.kid,
325
- durationMs: verifyTime
450
+ }
451
+ if (!discovery.version) throw new Error("Missing required field: version");
452
+ if (!discovery.issuer) throw new Error("Missing required field: issuer");
453
+ if (!discovery.verify_endpoint) throw new Error("Missing required field: verify");
454
+ if (!discovery.jwks_uri) throw new Error("Missing required field: jwks");
455
+ return discovery;
456
+ }
457
+ async function fetchDiscovery(issuerUrl) {
458
+ if (!issuerUrl.startsWith("https://")) {
459
+ throw new Error("Issuer URL must be https://");
460
+ }
461
+ const discoveryUrl = `${issuerUrl}/.well-known/peac.txt`;
462
+ try {
463
+ const resp = await fetch(discoveryUrl, {
464
+ headers: { Accept: "text/plain" },
465
+ signal: AbortSignal.timeout(5e3)
326
466
  });
467
+ if (!resp.ok) {
468
+ throw new Error(`Discovery fetch failed: ${resp.status}`);
469
+ }
470
+ const text = await resp.text();
471
+ return parseDiscovery(text);
472
+ } catch (err) {
473
+ throw new Error(
474
+ `Failed to fetch discovery from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
475
+ { cause: err }
476
+ );
477
+ }
478
+ }
479
+ var cachedCapabilities = null;
480
+ function getSSRFCapabilities() {
481
+ if (cachedCapabilities) {
482
+ return cachedCapabilities;
483
+ }
484
+ cachedCapabilities = detectCapabilities();
485
+ return cachedCapabilities;
486
+ }
487
+ function detectCapabilities() {
488
+ if (typeof process !== "undefined" && process.versions?.node) {
327
489
  return {
328
- ok: true,
329
- claims: payload,
330
- ...validatedSnapshot && { subject_snapshot: validatedSnapshot },
331
- perf: {
332
- verify_ms: verifyTime,
333
- ...jwksFetchTime && { jwks_fetch_ms: jwksFetchTime }
334
- }
490
+ runtime: "node",
491
+ dnsPreResolution: true,
492
+ ipBlocking: true,
493
+ networkIsolation: false,
494
+ protectionLevel: "full",
495
+ notes: [
496
+ "Full SSRF protection available via Node.js dns module",
497
+ "DNS resolution checked before HTTP connection",
498
+ "All RFC 1918 private ranges blocked"
499
+ ]
335
500
  };
336
- } catch (err) {
337
- const durationMs = performance.now() - startTime;
338
- fireTelemetryHook(telemetry?.onReceiptVerified, {
339
- receiptHash: hashReceipt(receiptJws),
340
- valid: false,
341
- reasonCode: "verification_error",
342
- durationMs
343
- });
501
+ }
502
+ if (typeof process !== "undefined" && process.versions?.bun) {
344
503
  return {
345
- ok: false,
346
- reason: "verification_error",
347
- details: err instanceof Error ? err.message : String(err)
504
+ runtime: "bun",
505
+ dnsPreResolution: true,
506
+ ipBlocking: true,
507
+ networkIsolation: false,
508
+ protectionLevel: "full",
509
+ notes: [
510
+ "Full SSRF protection available via Bun dns compatibility",
511
+ "DNS resolution checked before HTTP connection"
512
+ ]
513
+ };
514
+ }
515
+ if (typeof globalThis !== "undefined" && "Deno" in globalThis) {
516
+ return {
517
+ runtime: "deno",
518
+ dnsPreResolution: false,
519
+ ipBlocking: false,
520
+ networkIsolation: false,
521
+ protectionLevel: "partial",
522
+ notes: [
523
+ "DNS pre-resolution not available in Deno by default",
524
+ "SSRF protection limited to URL validation and response limits",
525
+ "Consider using Deno.connect with hostname resolution for enhanced protection"
526
+ ]
527
+ };
528
+ }
529
+ if (typeof globalThis !== "undefined" && typeof globalThis.caches !== "undefined" && typeof globalThis.HTMLRewriter !== "undefined") {
530
+ return {
531
+ runtime: "cloudflare-workers",
532
+ dnsPreResolution: false,
533
+ ipBlocking: false,
534
+ networkIsolation: true,
535
+ protectionLevel: "partial",
536
+ notes: [
537
+ "Cloudflare Workers provide network-level isolation",
538
+ "DNS pre-resolution not available in Workers runtime",
539
+ "CF network blocks many SSRF vectors at infrastructure level",
540
+ "SSRF protection supplemented by URL validation and response limits"
541
+ ]
542
+ };
543
+ }
544
+ const g = globalThis;
545
+ if (typeof g.window !== "undefined" || typeof g.document !== "undefined") {
546
+ return {
547
+ runtime: "browser",
548
+ dnsPreResolution: false,
549
+ ipBlocking: false,
550
+ networkIsolation: false,
551
+ protectionLevel: "minimal",
552
+ notes: [
553
+ "Browser environment detected; DNS pre-resolution not available",
554
+ "SSRF protection limited to URL scheme validation",
555
+ "Consider validating URLs server-side before browser fetch",
556
+ "Same-origin policy provides some protection against SSRF"
557
+ ]
348
558
  };
349
559
  }
560
+ return {
561
+ runtime: "edge-generic",
562
+ dnsPreResolution: false,
563
+ ipBlocking: false,
564
+ networkIsolation: false,
565
+ protectionLevel: "partial",
566
+ notes: [
567
+ "Edge runtime detected; DNS pre-resolution may not be available",
568
+ "SSRF protection limited to URL validation and response limits",
569
+ "Verify runtime provides additional network-level protections"
570
+ ]
571
+ };
350
572
  }
351
- function isCryptoError(err) {
352
- return err !== null && typeof err === "object" && "name" in err && err.name === "CryptoError" && "code" in err && typeof err.code === "string" && err.code.startsWith("CRYPTO_") && "message" in err && typeof err.message === "string";
573
+ function resetSSRFCapabilitiesCache() {
574
+ cachedCapabilities = null;
353
575
  }
354
- var FORMAT_ERROR_CODES = /* @__PURE__ */ new Set([
355
- "CRYPTO_INVALID_JWS_FORMAT",
356
- "CRYPTO_INVALID_TYP",
357
- "CRYPTO_INVALID_ALG",
358
- "CRYPTO_INVALID_KEY_LENGTH"
359
- ]);
360
- var MAX_PARSE_ISSUES = 25;
361
- function sanitizeParseIssues(issues) {
362
- if (!Array.isArray(issues)) return void 0;
363
- return issues.slice(0, MAX_PARSE_ISSUES).map((issue2) => ({
364
- path: Array.isArray(issue2?.path) ? issue2.path.join(".") : "",
365
- message: typeof issue2?.message === "string" ? issue2.message : String(issue2)
366
- }));
576
+ function parseIPv4(ip) {
577
+ const parts = ip.split(".");
578
+ if (parts.length !== 4) return null;
579
+ const octets = [];
580
+ for (const part of parts) {
581
+ const num = parseInt(part, 10);
582
+ if (isNaN(num) || num < 0 || num > 255) return null;
583
+ octets.push(num);
584
+ }
585
+ return { octets };
367
586
  }
368
- async function verifyLocal(jws, publicKey, options = {}) {
369
- const { issuer, audience, subjectUri, rid, requireExp = false, maxClockSkew = 300 } = options;
370
- const now = options.now ?? Math.floor(Date.now() / 1e3);
371
- try {
372
- const result = await verify(jws, publicKey);
373
- if (!result.valid) {
374
- return {
375
- valid: false,
376
- code: "E_INVALID_SIGNATURE",
377
- message: "Ed25519 signature verification failed"
378
- };
379
- }
380
- const constraintResult = validateKernelConstraints(result.payload);
381
- if (!constraintResult.valid) {
382
- const v = constraintResult.violations[0];
383
- return {
384
- valid: false,
385
- code: "E_CONSTRAINT_VIOLATION",
386
- message: `Kernel constraint violated: ${v.constraint} (actual: ${v.actual}, limit: ${v.limit})`
387
- };
388
- }
389
- const pr = parseReceiptClaims(result.payload);
390
- if (!pr.ok) {
391
- return {
392
- valid: false,
393
- code: "E_INVALID_FORMAT",
394
- message: `Receipt schema validation failed: ${pr.error.message}`,
395
- details: { parse_code: pr.error.code, issues: sanitizeParseIssues(pr.error.issues) }
396
- };
397
- }
398
- if (issuer !== void 0 && pr.claims.iss !== issuer) {
399
- return {
400
- valid: false,
401
- code: "E_INVALID_ISSUER",
402
- message: `Issuer mismatch: expected "${issuer}", got "${pr.claims.iss}"`
403
- };
404
- }
405
- if (audience !== void 0 && pr.claims.aud !== audience) {
406
- return {
407
- valid: false,
408
- code: "E_INVALID_AUDIENCE",
409
- message: `Audience mismatch: expected "${audience}", got "${pr.claims.aud}"`
410
- };
411
- }
412
- if (rid !== void 0 && pr.claims.rid !== rid) {
413
- return {
414
- valid: false,
415
- code: "E_INVALID_RECEIPT_ID",
416
- message: `Receipt ID mismatch: expected "${rid}", got "${pr.claims.rid}"`
417
- };
418
- }
419
- if (requireExp && pr.claims.exp === void 0) {
420
- return {
421
- valid: false,
422
- code: "E_MISSING_EXP",
423
- message: "Receipt missing required exp claim"
424
- };
587
+ function isInCIDR(ip, cidr) {
588
+ const [rangeStr, maskStr] = cidr.split("/");
589
+ const range = parseIPv4(rangeStr);
590
+ if (!range) return false;
591
+ const maskBits = parseInt(maskStr, 10);
592
+ if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
593
+ const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
594
+ const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
595
+ const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
596
+ return (ipNum & mask) === (rangeNum & mask);
597
+ }
598
+ function isIPv6Loopback(ip) {
599
+ const normalized = ip.toLowerCase().replace(/^::ffff:/, "");
600
+ return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
601
+ }
602
+ function isIPv6LinkLocal(ip) {
603
+ const normalized = ip.toLowerCase();
604
+ return normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
605
+ }
606
+ function isBlockedIP(ip) {
607
+ const ipv4Match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
608
+ const effectiveIP = ipv4Match ? ipv4Match[1] : ip;
609
+ const ipv4 = parseIPv4(effectiveIP);
610
+ if (ipv4) {
611
+ if (isInCIDR(ipv4, "10.0.0.0/8") || isInCIDR(ipv4, "172.16.0.0/12") || isInCIDR(ipv4, "192.168.0.0/16")) {
612
+ return { blocked: true, reason: "private_ip" };
425
613
  }
426
- if (pr.claims.iat > now + maxClockSkew) {
427
- return {
428
- valid: false,
429
- code: "E_NOT_YET_VALID",
430
- message: `Receipt not yet valid: issued at ${new Date(pr.claims.iat * 1e3).toISOString()}, now is ${new Date(now * 1e3).toISOString()}`
431
- };
614
+ if (isInCIDR(ipv4, "127.0.0.0/8")) {
615
+ return { blocked: true, reason: "loopback" };
432
616
  }
433
- if (pr.claims.exp !== void 0 && pr.claims.exp < now - maxClockSkew) {
434
- return {
435
- valid: false,
436
- code: "E_EXPIRED",
437
- message: `Receipt expired at ${new Date(pr.claims.exp * 1e3).toISOString()}`
438
- };
617
+ if (isInCIDR(ipv4, "169.254.0.0/16")) {
618
+ return { blocked: true, reason: "link_local" };
439
619
  }
440
- if (pr.variant === "commerce") {
441
- const claims = pr.claims;
442
- if (subjectUri !== void 0 && claims.subject?.uri !== subjectUri) {
443
- return {
444
- valid: false,
445
- code: "E_INVALID_SUBJECT",
446
- message: `Subject mismatch: expected "${subjectUri}", got "${claims.subject?.uri ?? "undefined"}"`
447
- };
620
+ return { blocked: false };
621
+ }
622
+ if (isIPv6Loopback(ip)) {
623
+ return { blocked: true, reason: "loopback" };
624
+ }
625
+ if (isIPv6LinkLocal(ip)) {
626
+ return { blocked: true, reason: "link_local" };
627
+ }
628
+ return { blocked: false };
629
+ }
630
+ async function resolveHostname(hostname) {
631
+ if (typeof process !== "undefined" && process.versions?.node) {
632
+ try {
633
+ const dns = await import('dns');
634
+ const { promisify } = await import('util');
635
+ const resolve4 = promisify(dns.resolve4);
636
+ const resolve6 = promisify(dns.resolve6);
637
+ const results = [];
638
+ let ipv4Error = null;
639
+ let ipv6Error = null;
640
+ try {
641
+ const ipv4 = await resolve4(hostname);
642
+ results.push(...ipv4);
643
+ } catch (err) {
644
+ ipv4Error = err;
448
645
  }
449
- return {
450
- valid: true,
451
- variant: "commerce",
452
- claims,
453
- kid: result.header.kid,
454
- policy_binding: "unavailable"
455
- };
456
- } else {
457
- const claims = pr.claims;
458
- if (subjectUri !== void 0 && claims.sub !== subjectUri) {
459
- return {
460
- valid: false,
461
- code: "E_INVALID_SUBJECT",
462
- message: `Subject mismatch: expected "${subjectUri}", got "${claims.sub ?? "undefined"}"`
463
- };
646
+ try {
647
+ const ipv6 = await resolve6(hostname);
648
+ results.push(...ipv6);
649
+ } catch (err) {
650
+ ipv6Error = err;
464
651
  }
465
- return {
466
- valid: true,
467
- variant: "attestation",
468
- claims,
469
- kid: result.header.kid,
470
- policy_binding: "unavailable"
471
- };
472
- }
473
- } catch (err) {
474
- if (isCryptoError(err)) {
475
- if (FORMAT_ERROR_CODES.has(err.code)) {
476
- return {
477
- valid: false,
478
- code: "E_INVALID_FORMAT",
479
- message: err.message
480
- };
652
+ if (results.length > 0) {
653
+ return { ok: true, ips: results, browser: false };
481
654
  }
482
- if (err.code === "CRYPTO_INVALID_SIGNATURE") {
655
+ if (ipv4Error && ipv6Error) {
483
656
  return {
484
- valid: false,
485
- code: "E_INVALID_SIGNATURE",
486
- message: err.message
657
+ ok: false,
658
+ message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
487
659
  };
488
660
  }
661
+ return { ok: true, ips: [], browser: false };
662
+ } catch (err) {
663
+ return {
664
+ ok: false,
665
+ message: `DNS resolution error: ${err instanceof Error ? err.message : String(err)}`
666
+ };
489
667
  }
490
- if (err !== null && typeof err === "object" && "name" in err && err.name === "SyntaxError") {
491
- const syntaxMessage = "message" in err && typeof err.message === "string" ? err.message : "Invalid JSON";
668
+ }
669
+ return { ok: true, ips: [], browser: true };
670
+ }
671
+ async function ssrfSafeFetch(url, options = {}) {
672
+ const {
673
+ timeoutMs = VERIFIER_LIMITS.fetchTimeoutMs,
674
+ maxBytes = VERIFIER_LIMITS.maxResponseBytes,
675
+ maxRedirects = 0,
676
+ allowRedirects = VERIFIER_NETWORK.allowRedirects,
677
+ allowCrossOriginRedirects = true,
678
+ // Default: allow for CDN compatibility
679
+ dnsFailureBehavior = "block",
680
+ // Default: fail-closed for security
681
+ headers = {}
682
+ } = options;
683
+ let parsedUrl;
684
+ try {
685
+ parsedUrl = new URL(url);
686
+ } catch {
687
+ return {
688
+ ok: false,
689
+ reason: "invalid_url",
690
+ message: `Invalid URL: ${url}`,
691
+ blockedUrl: url
692
+ };
693
+ }
694
+ if (parsedUrl.protocol !== "https:") {
695
+ return {
696
+ ok: false,
697
+ reason: "not_https",
698
+ message: `URL must use HTTPS: ${url}`,
699
+ blockedUrl: url
700
+ };
701
+ }
702
+ const dnsResult = await resolveHostname(parsedUrl.hostname);
703
+ if (!dnsResult.ok) {
704
+ if (dnsFailureBehavior === "block") {
492
705
  return {
493
- valid: false,
494
- code: "E_INVALID_FORMAT",
495
- message: `Invalid receipt payload: ${syntaxMessage}`
706
+ ok: false,
707
+ reason: "dns_failure",
708
+ message: `DNS resolution blocked: ${dnsResult.message}`,
709
+ blockedUrl: url
496
710
  };
497
711
  }
498
- const message = err instanceof Error ? err.message : String(err);
499
712
  return {
500
- valid: false,
501
- code: "E_INTERNAL",
502
- message: `Unexpected verification error: ${message}`
713
+ ok: false,
714
+ reason: "network_error",
715
+ message: dnsResult.message,
716
+ blockedUrl: url
503
717
  };
504
718
  }
505
- }
506
- function isCommerceResult(r) {
507
- return r.valid === true && r.variant === "commerce";
508
- }
509
- function isAttestationResult(r) {
510
- return r.valid === true && r.variant === "attestation";
511
- }
512
- function setReceiptHeader(headers, receiptJws) {
513
- headers.set(PEAC_RECEIPT_HEADER, receiptJws);
514
- }
515
- function getReceiptHeader(headers) {
516
- return headers.get(PEAC_RECEIPT_HEADER);
517
- }
518
- function setVaryHeader(headers) {
519
- const existing = headers.get("Vary");
520
- if (existing) {
521
- const varies = existing.split(",").map((v) => v.trim());
522
- if (!varies.includes(PEAC_RECEIPT_HEADER)) {
523
- headers.set("Vary", `${existing}, ${PEAC_RECEIPT_HEADER}`);
719
+ if (!dnsResult.browser) {
720
+ for (const ip of dnsResult.ips) {
721
+ const blockResult = isBlockedIP(ip);
722
+ if (blockResult.blocked) {
723
+ return {
724
+ ok: false,
725
+ reason: blockResult.reason,
726
+ message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
727
+ blockedUrl: url
728
+ };
729
+ }
524
730
  }
525
- } else {
526
- headers.set("Vary", PEAC_RECEIPT_HEADER);
527
731
  }
528
- }
529
- function getPurposeHeader(headers) {
530
- const value = headers.get(PEAC_PURPOSE_HEADER);
531
- if (!value) {
532
- return [];
533
- }
534
- return parsePurposeHeader(value);
535
- }
536
- function setPurposeAppliedHeader(headers, purpose) {
537
- headers.set(PEAC_PURPOSE_APPLIED_HEADER, purpose);
538
- }
539
- function setPurposeReasonHeader(headers, reason) {
540
- headers.set(PEAC_PURPOSE_REASON_HEADER, reason);
541
- }
542
- function setVaryPurposeHeader(headers) {
543
- const existing = headers.get("Vary");
544
- if (existing) {
545
- const varies = existing.split(",").map((v) => v.trim());
546
- if (!varies.includes(PEAC_PURPOSE_HEADER)) {
547
- headers.set("Vary", `${existing}, ${PEAC_PURPOSE_HEADER}`);
548
- }
549
- } else {
550
- headers.set("Vary", PEAC_PURPOSE_HEADER);
551
- }
552
- }
553
- function parseIssuerConfig(json) {
554
- let config;
555
- if (typeof json === "string") {
556
- const bytes = new TextEncoder().encode(json).length;
557
- if (bytes > PEAC_ISSUER_CONFIG_MAX_BYTES) {
558
- throw new Error(`Issuer config exceeds ${PEAC_ISSUER_CONFIG_MAX_BYTES} bytes (got ${bytes})`);
559
- }
560
- try {
561
- config = JSON.parse(json);
562
- } catch {
563
- throw new Error("Issuer config is not valid JSON");
564
- }
565
- } else {
566
- config = json;
567
- }
568
- if (typeof config !== "object" || config === null) {
569
- throw new Error("Issuer config must be an object");
570
- }
571
- const obj = config;
572
- if (typeof obj.version !== "string" || !obj.version) {
573
- throw new Error("Missing required field: version");
574
- }
575
- if (typeof obj.issuer !== "string" || !obj.issuer) {
576
- throw new Error("Missing required field: issuer");
577
- }
578
- if (typeof obj.jwks_uri !== "string" || !obj.jwks_uri) {
579
- throw new Error("Missing required field: jwks_uri");
580
- }
581
- if (!obj.issuer.startsWith("https://")) {
582
- throw new Error("issuer must be an HTTPS URL");
583
- }
584
- if (!obj.jwks_uri.startsWith("https://")) {
585
- throw new Error("jwks_uri must be an HTTPS URL");
586
- }
587
- if (obj.verify_endpoint !== void 0) {
588
- if (typeof obj.verify_endpoint !== "string") {
589
- throw new Error("verify_endpoint must be a string");
590
- }
591
- if (!obj.verify_endpoint.startsWith("https://")) {
592
- throw new Error("verify_endpoint must be an HTTPS URL");
593
- }
594
- }
595
- if (obj.receipt_versions !== void 0) {
596
- if (!Array.isArray(obj.receipt_versions)) {
597
- throw new Error("receipt_versions must be an array");
598
- }
599
- }
600
- if (obj.algorithms !== void 0) {
601
- if (!Array.isArray(obj.algorithms)) {
602
- throw new Error("algorithms must be an array");
603
- }
604
- }
605
- if (obj.payment_rails !== void 0) {
606
- if (!Array.isArray(obj.payment_rails)) {
607
- throw new Error("payment_rails must be an array");
608
- }
609
- }
610
- return {
611
- version: obj.version,
612
- issuer: obj.issuer,
613
- jwks_uri: obj.jwks_uri,
614
- verify_endpoint: obj.verify_endpoint,
615
- receipt_versions: obj.receipt_versions,
616
- algorithms: obj.algorithms,
617
- payment_rails: obj.payment_rails,
618
- security_contact: obj.security_contact
619
- };
620
- }
621
- async function fetchIssuerConfig(issuerUrl) {
622
- if (!issuerUrl.startsWith("https://")) {
623
- throw new Error("Issuer URL must be https://");
624
- }
625
- const baseUrl = issuerUrl.replace(/\/$/, "");
626
- const configUrl = `${baseUrl}${PEAC_ISSUER_CONFIG_PATH}`;
627
- try {
628
- const resp = await fetch(configUrl, {
629
- headers: { Accept: "application/json" },
630
- signal: AbortSignal.timeout(1e4)
631
- });
632
- if (!resp.ok) {
633
- throw new Error(`Issuer config fetch failed: ${resp.status}`);
634
- }
635
- const text = await resp.text();
636
- const config = parseIssuerConfig(text);
637
- const normalizedExpected = baseUrl.replace(/\/$/, "");
638
- const normalizedActual = config.issuer.replace(/\/$/, "");
639
- if (normalizedActual !== normalizedExpected) {
640
- throw new Error(`Issuer mismatch: expected ${normalizedExpected}, got ${normalizedActual}`);
641
- }
642
- return config;
643
- } catch (err) {
644
- throw new Error(
645
- `Failed to fetch issuer config from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
646
- { cause: err }
647
- );
648
- }
649
- }
650
- function isJsonContent(text, contentType) {
651
- if (contentType?.includes("application/json")) {
652
- return true;
653
- }
654
- const firstChar = text.trimStart()[0];
655
- return firstChar === "{";
656
- }
657
- function parsePolicyManifest(text, contentType) {
658
- const bytes = new TextEncoder().encode(text).length;
659
- if (bytes > PEAC_POLICY_MAX_BYTES) {
660
- throw new Error(`Policy manifest exceeds ${PEAC_POLICY_MAX_BYTES} bytes (got ${bytes})`);
661
- }
662
- let manifest;
663
- if (isJsonContent(text, contentType)) {
732
+ let redirectCount = 0;
733
+ let currentUrl = url;
734
+ const originalOrigin = parsedUrl.origin;
735
+ while (true) {
664
736
  try {
665
- manifest = JSON.parse(text);
666
- } catch {
667
- throw new Error("Policy manifest is not valid JSON");
668
- }
669
- } else {
670
- manifest = parseSimpleYaml(text);
671
- }
672
- if (typeof manifest.version !== "string" || !manifest.version) {
673
- throw new Error("Missing required field: version");
674
- }
675
- if (!manifest.version.startsWith("peac-policy/")) {
676
- throw new Error(
677
- `Invalid version format: "${manifest.version}". Must start with "peac-policy/" (e.g., "peac-policy/0.1")`
678
- );
679
- }
680
- if (manifest.usage !== "open" && manifest.usage !== "conditional") {
681
- throw new Error('Missing or invalid field: usage (must be "open" or "conditional")');
682
- }
683
- return {
684
- version: manifest.version,
685
- usage: manifest.usage,
686
- purposes: manifest.purposes,
687
- receipts: manifest.receipts,
688
- attribution: manifest.attribution,
689
- rate_limit: manifest.rate_limit,
690
- daily_limit: manifest.daily_limit,
691
- negotiate: manifest.negotiate,
692
- contact: manifest.contact,
693
- license: manifest.license,
694
- price: manifest.price,
695
- currency: manifest.currency,
696
- payment_methods: manifest.payment_methods,
697
- payment_endpoint: manifest.payment_endpoint
698
- };
699
- }
700
- function parseSimpleYaml(text) {
701
- const lines = text.split("\n");
702
- const result = {};
703
- if (text.includes("<<:")) {
704
- throw new Error("YAML merge keys are not allowed");
705
- }
706
- if (text.includes("&") || text.includes("*")) {
707
- throw new Error("YAML anchors and aliases are not allowed");
708
- }
709
- if (/!\w+/.test(text)) {
710
- throw new Error("YAML custom tags are not allowed");
711
- }
712
- const docSeparators = text.match(/^---$/gm);
713
- if (docSeparators && docSeparators.length > 1) {
714
- throw new Error("Multi-document YAML is not allowed");
715
- }
716
- for (const line of lines) {
717
- const trimmed = line.trim();
718
- if (!trimmed || trimmed.startsWith("#") || trimmed === "---") {
719
- continue;
720
- }
721
- const colonIndex = trimmed.indexOf(":");
722
- if (colonIndex === -1) continue;
723
- const key = trimmed.slice(0, colonIndex).trim();
724
- let value = trimmed.slice(colonIndex + 1).trim();
725
- if (value === "") {
726
- value = void 0;
727
- } else if (typeof value === "string") {
728
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
729
- value = value.slice(1, -1);
730
- } else if (value.startsWith("[") && value.endsWith("]")) {
731
- const inner = value.slice(1, -1);
732
- value = inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
733
- } else if (/^-?\d+(\.\d+)?$/.test(value)) {
734
- value = parseFloat(value);
735
- } else if (value === "true") {
736
- value = true;
737
- } else if (value === "false") {
738
- value = false;
739
- }
740
- }
741
- if (value !== void 0) {
742
- result[key] = value;
743
- }
744
- }
745
- return result;
746
- }
747
- async function fetchPolicyManifest(baseUrl) {
748
- if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
749
- throw new Error("Base URL must be https://");
750
- }
751
- const normalizedBase = baseUrl.replace(/\/$/, "");
752
- const primaryUrl = `${normalizedBase}${PEAC_POLICY_PATH}`;
753
- const fallbackUrl = `${normalizedBase}${PEAC_POLICY_FALLBACK_PATH}`;
754
- try {
755
- const resp = await fetch(primaryUrl, {
756
- headers: { Accept: "text/plain, application/json" },
757
- signal: AbortSignal.timeout(5e3)
758
- });
759
- if (resp.ok) {
760
- const text = await resp.text();
761
- const contentType = resp.headers.get("content-type") || void 0;
762
- return parsePolicyManifest(text, contentType);
763
- }
764
- if (resp.status === 404) {
765
- const fallbackResp = await fetch(fallbackUrl, {
766
- headers: { Accept: "text/plain, application/json" },
767
- signal: AbortSignal.timeout(5e3)
737
+ const controller = new AbortController();
738
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
739
+ const response = await fetch(currentUrl, {
740
+ headers: {
741
+ Accept: "application/json, text/plain",
742
+ ...headers
743
+ },
744
+ signal: controller.signal,
745
+ redirect: "manual"
746
+ // Handle redirects manually for security
768
747
  });
769
- if (fallbackResp.ok) {
770
- const text = await fallbackResp.text();
771
- const contentType = fallbackResp.headers.get("content-type") || void 0;
772
- return parsePolicyManifest(text, contentType);
748
+ clearTimeout(timeoutId);
749
+ if (response.status >= 300 && response.status < 400) {
750
+ const location = response.headers.get("location");
751
+ if (!location) {
752
+ return {
753
+ ok: false,
754
+ reason: "network_error",
755
+ message: `Redirect without Location header from ${currentUrl}`
756
+ };
757
+ }
758
+ if (!allowRedirects) {
759
+ return {
760
+ ok: false,
761
+ reason: "too_many_redirects",
762
+ message: `Redirects not allowed: ${currentUrl} -> ${location}`,
763
+ blockedUrl: location
764
+ };
765
+ }
766
+ redirectCount++;
767
+ if (redirectCount > maxRedirects) {
768
+ return {
769
+ ok: false,
770
+ reason: "too_many_redirects",
771
+ message: `Too many redirects (${redirectCount} > ${maxRedirects})`,
772
+ blockedUrl: location
773
+ };
774
+ }
775
+ let redirectUrl;
776
+ try {
777
+ redirectUrl = new URL(location, currentUrl);
778
+ } catch {
779
+ return {
780
+ ok: false,
781
+ reason: "invalid_url",
782
+ message: `Invalid redirect URL: ${location}`,
783
+ blockedUrl: location
784
+ };
785
+ }
786
+ if (redirectUrl.protocol !== "https:") {
787
+ return {
788
+ ok: false,
789
+ reason: "scheme_downgrade",
790
+ message: `HTTPS to HTTP downgrade not allowed: ${currentUrl} -> ${redirectUrl.href}`,
791
+ blockedUrl: redirectUrl.href
792
+ };
793
+ }
794
+ if (redirectUrl.origin !== originalOrigin && !allowCrossOriginRedirects) {
795
+ return {
796
+ ok: false,
797
+ reason: "cross_origin_redirect",
798
+ message: `Cross-origin redirect not allowed: ${originalOrigin} -> ${redirectUrl.origin}`,
799
+ blockedUrl: redirectUrl.href
800
+ };
801
+ }
802
+ const redirectDnsResult = await resolveHostname(redirectUrl.hostname);
803
+ if (!redirectDnsResult.ok) {
804
+ if (dnsFailureBehavior === "block") {
805
+ return {
806
+ ok: false,
807
+ reason: "dns_failure",
808
+ message: `Redirect DNS resolution blocked: ${redirectDnsResult.message}`,
809
+ blockedUrl: redirectUrl.href
810
+ };
811
+ }
812
+ return {
813
+ ok: false,
814
+ reason: "network_error",
815
+ message: redirectDnsResult.message,
816
+ blockedUrl: redirectUrl.href
817
+ };
818
+ }
819
+ if (!redirectDnsResult.browser) {
820
+ for (const ip of redirectDnsResult.ips) {
821
+ const blockResult = isBlockedIP(ip);
822
+ if (blockResult.blocked) {
823
+ return {
824
+ ok: false,
825
+ reason: blockResult.reason,
826
+ message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
827
+ blockedUrl: redirectUrl.href
828
+ };
829
+ }
830
+ }
831
+ }
832
+ currentUrl = redirectUrl.href;
833
+ continue;
773
834
  }
774
- throw new Error("Policy manifest not found at primary or fallback location");
835
+ const contentLength = response.headers.get("content-length");
836
+ if (contentLength && parseInt(contentLength, 10) > maxBytes) {
837
+ return {
838
+ ok: false,
839
+ reason: "response_too_large",
840
+ message: `Response too large: ${contentLength} bytes > ${maxBytes} max`
841
+ };
842
+ }
843
+ const reader = response.body?.getReader();
844
+ if (!reader) {
845
+ const body2 = await response.text();
846
+ if (body2.length > maxBytes) {
847
+ return {
848
+ ok: false,
849
+ reason: "response_too_large",
850
+ message: `Response too large: ${body2.length} bytes > ${maxBytes} max`
851
+ };
852
+ }
853
+ const rawBytes2 = new TextEncoder().encode(body2);
854
+ return {
855
+ ok: true,
856
+ status: response.status,
857
+ body: body2,
858
+ rawBytes: rawBytes2,
859
+ contentType: response.headers.get("content-type") ?? void 0
860
+ };
861
+ }
862
+ const chunks = [];
863
+ let totalSize = 0;
864
+ while (true) {
865
+ const { done, value } = await reader.read();
866
+ if (done) break;
867
+ totalSize += value.length;
868
+ if (totalSize > maxBytes) {
869
+ reader.cancel();
870
+ return {
871
+ ok: false,
872
+ reason: "response_too_large",
873
+ message: `Response too large: ${totalSize} bytes > ${maxBytes} max`
874
+ };
875
+ }
876
+ chunks.push(value);
877
+ }
878
+ const rawBytes = chunks.reduce((acc, chunk) => {
879
+ const result = new Uint8Array(acc.length + chunk.length);
880
+ result.set(acc);
881
+ result.set(chunk, acc.length);
882
+ return result;
883
+ }, new Uint8Array());
884
+ const body = new TextDecoder().decode(rawBytes);
885
+ return {
886
+ ok: true,
887
+ status: response.status,
888
+ body,
889
+ rawBytes,
890
+ contentType: response.headers.get("content-type") ?? void 0
891
+ };
892
+ } catch (err) {
893
+ if (err instanceof Error) {
894
+ if (err.name === "AbortError" || err.message.includes("timeout")) {
895
+ return {
896
+ ok: false,
897
+ reason: "timeout",
898
+ message: `Fetch timeout after ${timeoutMs}ms: ${currentUrl}`
899
+ };
900
+ }
901
+ }
902
+ return {
903
+ ok: false,
904
+ reason: "network_error",
905
+ message: `Network error: ${err instanceof Error ? err.message : String(err)}`
906
+ };
775
907
  }
776
- throw new Error(`Policy manifest fetch failed: ${resp.status}`);
777
- } catch (err) {
778
- throw new Error(
779
- `Failed to fetch policy manifest from ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`,
780
- { cause: err }
781
- );
782
908
  }
783
909
  }
784
- function parseDiscovery(text) {
785
- const bytes = new TextEncoder().encode(text).length;
786
- if (bytes > 2e3) {
787
- throw new Error(`Discovery manifest exceeds 2000 bytes (got ${bytes})`);
788
- }
789
- const lines = text.trim().split("\n");
790
- if (lines.length > 20) {
791
- throw new Error(`Discovery manifest exceeds 20 lines (got ${lines.length})`);
792
- }
793
- const discovery = {};
794
- for (const line of lines) {
795
- const trimmed = line.trim();
796
- if (!trimmed || trimmed.startsWith("#")) {
797
- continue;
910
+ async function fetchJWKSSafe(jwksUrl, options) {
911
+ return ssrfSafeFetch(jwksUrl, {
912
+ ...options,
913
+ maxBytes: VERIFIER_LIMITS.maxJwksBytes,
914
+ headers: {
915
+ Accept: "application/json",
916
+ ...options?.headers
798
917
  }
799
- if (trimmed.includes(":")) {
800
- const [key, ...valueParts] = trimmed.split(":");
801
- const value = valueParts.join(":").trim();
802
- switch (key.trim()) {
803
- case "version":
804
- discovery.version = value;
805
- break;
806
- case "issuer":
807
- discovery.issuer = value;
808
- break;
809
- case "verify":
810
- discovery.verify_endpoint = value;
811
- break;
812
- case "jwks":
813
- discovery.jwks_uri = value;
814
- break;
815
- case "security":
816
- discovery.security_contact = value;
817
- break;
818
- }
918
+ });
919
+ }
920
+ async function fetchPointerSafe(pointerUrl, options) {
921
+ return ssrfSafeFetch(pointerUrl, {
922
+ ...options,
923
+ maxBytes: VERIFIER_LIMITS.maxReceiptBytes,
924
+ headers: {
925
+ Accept: "application/jose, application/json",
926
+ ...options?.headers
819
927
  }
820
- }
821
- if (!discovery.version) throw new Error("Missing required field: version");
822
- if (!discovery.issuer) throw new Error("Missing required field: issuer");
823
- if (!discovery.verify_endpoint) throw new Error("Missing required field: verify");
824
- if (!discovery.jwks_uri) throw new Error("Missing required field: jwks");
825
- return discovery;
928
+ });
826
929
  }
827
- async function fetchDiscovery(issuerUrl) {
828
- if (!issuerUrl.startsWith("https://")) {
829
- throw new Error("Issuer URL must be https://");
930
+
931
+ // src/jwks-resolver.ts
932
+ var DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
933
+ var DEFAULT_MAX_CACHE_ENTRIES = 1e3;
934
+ var jwksCache = /* @__PURE__ */ new Map();
935
+ function cacheGet(key, now) {
936
+ const entry = jwksCache.get(key);
937
+ if (!entry) return void 0;
938
+ if (entry.expiresAt <= now) {
939
+ jwksCache.delete(key);
940
+ return void 0;
830
941
  }
831
- const discoveryUrl = `${issuerUrl}/.well-known/peac.txt`;
832
- try {
833
- const resp = await fetch(discoveryUrl, {
834
- headers: { Accept: "text/plain" },
835
- signal: AbortSignal.timeout(5e3)
836
- });
837
- if (!resp.ok) {
838
- throw new Error(`Discovery fetch failed: ${resp.status}`);
839
- }
840
- const text = await resp.text();
841
- return parseDiscovery(text);
842
- } catch (err) {
843
- throw new Error(
844
- `Failed to fetch discovery from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
845
- { cause: err }
846
- );
942
+ jwksCache.delete(key);
943
+ jwksCache.set(key, entry);
944
+ return entry;
945
+ }
946
+ function cacheSet(key, entry, maxEntries) {
947
+ if (jwksCache.has(key)) jwksCache.delete(key);
948
+ jwksCache.set(key, entry);
949
+ while (jwksCache.size > maxEntries) {
950
+ const oldestKey = jwksCache.keys().next().value;
951
+ if (oldestKey !== void 0) jwksCache.delete(oldestKey);
847
952
  }
848
953
  }
849
- var DEFAULT_VERIFIER_LIMITS = {
850
- max_receipt_bytes: VERIFIER_LIMITS.maxReceiptBytes,
851
- max_jwks_bytes: VERIFIER_LIMITS.maxJwksBytes,
852
- max_jwks_keys: VERIFIER_LIMITS.maxJwksKeys,
853
- max_redirects: VERIFIER_LIMITS.maxRedirects,
854
- fetch_timeout_ms: VERIFIER_LIMITS.fetchTimeoutMs,
855
- max_extension_bytes: VERIFIER_LIMITS.maxExtensionBytes
856
- };
857
- var DEFAULT_NETWORK_SECURITY = {
858
- https_only: VERIFIER_NETWORK.httpsOnly,
859
- block_private_ips: VERIFIER_NETWORK.blockPrivateIps,
860
- allow_redirects: VERIFIER_NETWORK.allowRedirects,
861
- allow_cross_origin_redirects: true,
862
- // Allow for CDN compatibility
863
- dns_failure_behavior: "block"
864
- // Fail-closed by default
865
- };
866
- function createDefaultPolicy(mode) {
867
- return {
868
- policy_version: VERIFIER_POLICY_VERSION,
869
- mode,
870
- limits: { ...DEFAULT_VERIFIER_LIMITS },
871
- network: { ...DEFAULT_NETWORK_SECURITY }
872
- };
954
+ function clearJWKSCache() {
955
+ jwksCache.clear();
873
956
  }
874
- var CHECK_IDS = [
875
- "jws.parse",
876
- "limits.receipt_bytes",
877
- "jws.protected_header",
878
- "claims.schema_unverified",
879
- "issuer.trust_policy",
880
- "issuer.discovery",
881
- "key.resolve",
882
- "jws.signature",
883
- "claims.time_window",
884
- "extensions.limits",
885
- "transport.profile_binding",
886
- "policy.binding"
887
- ];
888
- var NON_DETERMINISTIC_ARTIFACT_KEYS = [
889
- "issuer_jwks_digest"
890
- ];
891
- function createDigest(hexValue) {
892
- return {
893
- alg: "sha-256",
894
- value: hexValue.toLowerCase()
895
- };
957
+ function getJWKSCacheSize() {
958
+ return jwksCache.size;
896
959
  }
897
- function createEmptyReport(policy) {
898
- return {
899
- report_version: VERIFICATION_REPORT_VERSION,
900
- policy
901
- };
960
+ function canonicalizeIssuerOrigin(issuerUrl) {
961
+ try {
962
+ const origin = new URL(issuerUrl).origin;
963
+ if (origin === "null") {
964
+ return { ok: false, message: `Issuer URL has no valid origin: ${issuerUrl}` };
965
+ }
966
+ return { ok: true, origin };
967
+ } catch {
968
+ return { ok: false, message: `Issuer URL is not a valid URL: ${issuerUrl}` };
969
+ }
902
970
  }
903
- function ssrfErrorToReasonCode(ssrfReason, fetchType) {
904
- const prefix = fetchType === "key" ? "key_fetch" : "pointer_fetch";
905
- switch (ssrfReason) {
971
+ function mapSSRFError(reason, context) {
972
+ let code;
973
+ switch (reason) {
906
974
  case "not_https":
975
+ code = "E_VERIFY_INSECURE_SCHEME_BLOCKED";
976
+ break;
907
977
  case "private_ip":
908
978
  case "loopback":
909
979
  case "link_local":
910
- case "cross_origin_redirect":
911
- case "dns_failure":
912
- return `${prefix}_blocked`;
980
+ code = "E_VERIFY_KEY_FETCH_BLOCKED";
981
+ break;
913
982
  case "timeout":
914
- return `${prefix}_timeout`;
983
+ code = "E_VERIFY_KEY_FETCH_TIMEOUT";
984
+ break;
915
985
  case "response_too_large":
916
- return fetchType === "pointer" ? "pointer_fetch_too_large" : "jwks_too_large";
917
- case "jwks_too_many_keys":
918
- return "jwks_too_many_keys";
986
+ code = context === "jwks" ? "E_VERIFY_JWKS_TOO_LARGE" : "E_VERIFY_KEY_FETCH_FAILED";
987
+ break;
988
+ case "dns_failure":
989
+ case "network_error":
919
990
  case "too_many_redirects":
920
991
  case "scheme_downgrade":
921
- case "network_error":
992
+ case "cross_origin_redirect":
922
993
  case "invalid_url":
994
+ code = context === "issuer_config" ? "E_VERIFY_ISSUER_CONFIG_MISSING" : "E_VERIFY_KEY_FETCH_FAILED";
995
+ break;
996
+ case "jwks_too_many_keys":
997
+ code = "E_VERIFY_JWKS_TOO_MANY_KEYS";
998
+ break;
923
999
  default:
924
- return `${prefix}_failed`;
1000
+ code = "E_VERIFY_KEY_FETCH_FAILED";
1001
+ break;
925
1002
  }
1003
+ return { code, reason };
926
1004
  }
927
- function reasonCodeToSeverity(reason) {
928
- if (reason === "ok") return "info";
929
- return "error";
930
- }
931
- function reasonCodeToErrorCode(reason) {
932
- const mapping = {
933
- ok: "",
934
- receipt_too_large: "E_VERIFY_RECEIPT_TOO_LARGE",
935
- malformed_receipt: "E_VERIFY_MALFORMED_RECEIPT",
936
- signature_invalid: "E_VERIFY_SIGNATURE_INVALID",
937
- issuer_not_allowed: "E_VERIFY_ISSUER_NOT_ALLOWED",
938
- key_not_found: "E_VERIFY_KEY_NOT_FOUND",
939
- key_fetch_blocked: "E_VERIFY_KEY_FETCH_BLOCKED",
940
- key_fetch_failed: "E_VERIFY_KEY_FETCH_FAILED",
941
- key_fetch_timeout: "E_VERIFY_KEY_FETCH_TIMEOUT",
942
- pointer_fetch_blocked: "E_VERIFY_POINTER_FETCH_BLOCKED",
943
- pointer_fetch_failed: "E_VERIFY_POINTER_FETCH_FAILED",
944
- pointer_fetch_timeout: "E_VERIFY_POINTER_FETCH_TIMEOUT",
945
- pointer_fetch_too_large: "E_VERIFY_POINTER_FETCH_TOO_LARGE",
946
- pointer_digest_mismatch: "E_VERIFY_POINTER_DIGEST_MISMATCH",
947
- jwks_too_large: "E_VERIFY_JWKS_TOO_LARGE",
948
- jwks_too_many_keys: "E_VERIFY_JWKS_TOO_MANY_KEYS",
949
- expired: "E_VERIFY_EXPIRED",
950
- not_yet_valid: "E_VERIFY_NOT_YET_VALID",
951
- audience_mismatch: "E_VERIFY_AUDIENCE_MISMATCH",
952
- schema_invalid: "E_VERIFY_SCHEMA_INVALID",
953
- policy_violation: "E_VERIFY_POLICY_VIOLATION",
954
- extension_too_large: "E_VERIFY_EXTENSION_TOO_LARGE",
955
- invalid_transport: "E_VERIFY_INVALID_TRANSPORT"
956
- };
957
- return mapping[reason] || "E_VERIFY_POLICY_VIOLATION";
958
- }
959
- var cachedCapabilities = null;
960
- function getSSRFCapabilities() {
961
- if (cachedCapabilities) {
962
- return cachedCapabilities;
1005
+ async function resolveJWKS(issuerUrl, options) {
1006
+ const cacheTtlMs = options?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
1007
+ const maxCacheEntries = options?.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES;
1008
+ const noCache = options?.noCache ?? false;
1009
+ const normalized = canonicalizeIssuerOrigin(issuerUrl);
1010
+ if (!normalized.ok) {
1011
+ return {
1012
+ ok: false,
1013
+ code: "E_VERIFY_ISSUER_CONFIG_INVALID",
1014
+ message: normalized.message,
1015
+ blockedUrl: issuerUrl
1016
+ };
963
1017
  }
964
- cachedCapabilities = detectCapabilities();
965
- return cachedCapabilities;
966
- }
967
- function detectCapabilities() {
968
- if (typeof process !== "undefined" && process.versions?.node) {
1018
+ const normalizedIssuer = normalized.origin;
1019
+ const now = Date.now();
1020
+ if (!noCache) {
1021
+ const cached = cacheGet(normalizedIssuer, now);
1022
+ if (cached) {
1023
+ return { ok: true, jwks: cached.jwks, fromCache: true };
1024
+ }
1025
+ }
1026
+ if (!normalizedIssuer.startsWith("https://")) {
969
1027
  return {
970
- runtime: "node",
971
- dnsPreResolution: true,
972
- ipBlocking: true,
973
- networkIsolation: false,
974
- protectionLevel: "full",
975
- notes: [
976
- "Full SSRF protection available via Node.js dns module",
977
- "DNS resolution checked before HTTP connection",
978
- "All RFC 1918 private ranges blocked"
979
- ]
1028
+ ok: false,
1029
+ code: "E_VERIFY_INSECURE_SCHEME_BLOCKED",
1030
+ message: `Issuer URL must be HTTPS: ${normalizedIssuer}`,
1031
+ blockedUrl: normalizedIssuer
980
1032
  };
981
1033
  }
982
- if (typeof process !== "undefined" && process.versions?.bun) {
1034
+ const configUrl = `${normalizedIssuer}/.well-known/peac-issuer.json`;
1035
+ const configResult = await ssrfSafeFetch(configUrl, {
1036
+ maxBytes: PEAC_ISSUER_CONFIG_MAX_BYTES,
1037
+ headers: { Accept: "application/json" }
1038
+ });
1039
+ if (!configResult.ok) {
1040
+ const mapped = mapSSRFError(configResult.reason, "issuer_config");
983
1041
  return {
984
- runtime: "bun",
985
- dnsPreResolution: true,
986
- ipBlocking: true,
987
- networkIsolation: false,
988
- protectionLevel: "full",
989
- notes: [
990
- "Full SSRF protection available via Bun dns compatibility",
991
- "DNS resolution checked before HTTP connection"
992
- ]
1042
+ ok: false,
1043
+ code: mapped.code,
1044
+ message: `Failed to fetch peac-issuer.json: ${configResult.message}`,
1045
+ reason: mapped.reason,
1046
+ blockedUrl: configResult.blockedUrl
993
1047
  };
994
1048
  }
995
- if (typeof globalThis !== "undefined" && "Deno" in globalThis) {
1049
+ let issuerConfig;
1050
+ try {
1051
+ issuerConfig = parseIssuerConfig(configResult.body);
1052
+ } catch (err) {
996
1053
  return {
997
- runtime: "deno",
998
- dnsPreResolution: false,
999
- ipBlocking: false,
1000
- networkIsolation: false,
1001
- protectionLevel: "partial",
1002
- notes: [
1003
- "DNS pre-resolution not available in Deno by default",
1004
- "SSRF protection limited to URL validation and response limits",
1005
- "Consider using Deno.connect with hostname resolution for enhanced protection"
1006
- ]
1054
+ ok: false,
1055
+ code: "E_VERIFY_ISSUER_CONFIG_INVALID",
1056
+ message: `Invalid peac-issuer.json: ${err instanceof Error ? err.message : String(err)}`
1007
1057
  };
1008
1058
  }
1009
- if (typeof globalThis !== "undefined" && typeof globalThis.caches !== "undefined" && typeof globalThis.HTMLRewriter !== "undefined") {
1059
+ const configIssuerResult = canonicalizeIssuerOrigin(issuerConfig.issuer);
1060
+ const configIssuer = configIssuerResult.ok ? configIssuerResult.origin : issuerConfig.issuer;
1061
+ if (configIssuer !== normalizedIssuer) {
1062
+ return {
1063
+ ok: false,
1064
+ code: "E_VERIFY_ISSUER_MISMATCH",
1065
+ message: `Issuer mismatch: expected ${normalizedIssuer}, got ${configIssuer}`
1066
+ };
1067
+ }
1068
+ if (!issuerConfig.jwks_uri) {
1069
+ return {
1070
+ ok: false,
1071
+ code: "E_VERIFY_JWKS_URI_INVALID",
1072
+ message: "peac-issuer.json missing required jwks_uri field"
1073
+ };
1074
+ }
1075
+ if (!issuerConfig.jwks_uri.startsWith("https://")) {
1076
+ return {
1077
+ ok: false,
1078
+ code: "E_VERIFY_JWKS_URI_INVALID",
1079
+ message: `jwks_uri must be HTTPS: ${issuerConfig.jwks_uri}`,
1080
+ blockedUrl: issuerConfig.jwks_uri
1081
+ };
1082
+ }
1083
+ const jwksResult = await fetchJWKSSafe(issuerConfig.jwks_uri);
1084
+ if (!jwksResult.ok) {
1085
+ const mapped = mapSSRFError(jwksResult.reason, "jwks");
1086
+ return {
1087
+ ok: false,
1088
+ code: mapped.code,
1089
+ message: `Failed to fetch JWKS from ${issuerConfig.jwks_uri}: ${jwksResult.message}`,
1090
+ reason: mapped.reason,
1091
+ blockedUrl: jwksResult.blockedUrl
1092
+ };
1093
+ }
1094
+ let jwks;
1095
+ try {
1096
+ jwks = JSON.parse(jwksResult.body);
1097
+ } catch {
1098
+ return {
1099
+ ok: false,
1100
+ code: "E_VERIFY_JWKS_INVALID",
1101
+ message: "JWKS response is not valid JSON"
1102
+ };
1103
+ }
1104
+ if (!jwks.keys || !Array.isArray(jwks.keys)) {
1105
+ return {
1106
+ ok: false,
1107
+ code: "E_VERIFY_JWKS_INVALID",
1108
+ message: "JWKS missing required keys array"
1109
+ };
1110
+ }
1111
+ if (jwks.keys.length > VERIFIER_LIMITS.maxJwksKeys) {
1112
+ return {
1113
+ ok: false,
1114
+ code: "E_VERIFY_JWKS_TOO_MANY_KEYS",
1115
+ message: `JWKS has too many keys: ${jwks.keys.length} > ${VERIFIER_LIMITS.maxJwksKeys}`
1116
+ };
1117
+ }
1118
+ if (!noCache) {
1119
+ cacheSet(normalizedIssuer, { jwks, expiresAt: now + cacheTtlMs }, maxCacheEntries);
1120
+ }
1121
+ return {
1122
+ ok: true,
1123
+ jwks,
1124
+ fromCache: false,
1125
+ rawBytes: jwksResult.rawBytes
1126
+ };
1127
+ }
1128
+
1129
+ // src/verify.ts
1130
+ function jwkToPublicKey(jwk) {
1131
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
1132
+ throw new Error("Only Ed25519 keys (OKP/Ed25519) are supported");
1133
+ }
1134
+ const xBytes = Buffer.from(jwk.x, "base64url");
1135
+ if (xBytes.length !== 32) {
1136
+ throw new Error("Ed25519 public key must be 32 bytes");
1137
+ }
1138
+ return new Uint8Array(xBytes);
1139
+ }
1140
+ async function verifyReceipt(optionsOrJws) {
1141
+ const receiptJws = typeof optionsOrJws === "string" ? optionsOrJws : optionsOrJws.receiptJws;
1142
+ const inputSnapshot = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.subject_snapshot;
1143
+ const telemetry = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.telemetry;
1144
+ const startTime = performance.now();
1145
+ let jwksFetchTime;
1146
+ try {
1147
+ const { header, payload } = decode(receiptJws);
1148
+ const constraintResult = validateKernelConstraints(payload);
1149
+ if (!constraintResult.valid) {
1150
+ const v = constraintResult.violations[0];
1151
+ return {
1152
+ ok: false,
1153
+ reason: "constraint_violation",
1154
+ details: `Kernel constraint violated: ${v.constraint} (actual: ${v.actual}, limit: ${v.limit})`
1155
+ };
1156
+ }
1157
+ ReceiptClaims.parse(payload);
1158
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) {
1159
+ const durationMs = performance.now() - startTime;
1160
+ fireTelemetryHook(telemetry?.onReceiptVerified, {
1161
+ receiptHash: hashReceipt(receiptJws),
1162
+ valid: false,
1163
+ reasonCode: "expired",
1164
+ issuer: payload.iss,
1165
+ kid: header.kid,
1166
+ durationMs
1167
+ });
1168
+ return {
1169
+ ok: false,
1170
+ reason: "expired",
1171
+ details: `Receipt expired at ${new Date(payload.exp * 1e3).toISOString()}`
1172
+ };
1173
+ }
1174
+ const jwksFetchStart = performance.now();
1175
+ const jwksResult = await resolveJWKS(payload.iss);
1176
+ if (!jwksResult.ok) {
1177
+ return {
1178
+ ok: false,
1179
+ reason: jwksResult.code,
1180
+ details: jwksResult.message
1181
+ };
1182
+ }
1183
+ if (!jwksResult.fromCache) {
1184
+ jwksFetchTime = performance.now() - jwksFetchStart;
1185
+ }
1186
+ const jwk = jwksResult.jwks.keys.find((k) => k.kid === header.kid);
1187
+ if (!jwk) {
1188
+ const durationMs = performance.now() - startTime;
1189
+ fireTelemetryHook(telemetry?.onReceiptVerified, {
1190
+ receiptHash: hashReceipt(receiptJws),
1191
+ valid: false,
1192
+ reasonCode: "unknown_key",
1193
+ issuer: payload.iss,
1194
+ kid: header.kid,
1195
+ durationMs
1196
+ });
1197
+ return {
1198
+ ok: false,
1199
+ reason: "unknown_key",
1200
+ details: `No key found with kid=${header.kid}`
1201
+ };
1202
+ }
1203
+ const publicKey = jwkToPublicKey(jwk);
1204
+ const result = await verify(receiptJws, publicKey);
1205
+ if (!result.valid) {
1206
+ const durationMs = performance.now() - startTime;
1207
+ fireTelemetryHook(telemetry?.onReceiptVerified, {
1208
+ receiptHash: hashReceipt(receiptJws),
1209
+ valid: false,
1210
+ reasonCode: "invalid_signature",
1211
+ issuer: payload.iss,
1212
+ kid: header.kid,
1213
+ durationMs
1214
+ });
1215
+ return {
1216
+ ok: false,
1217
+ reason: "invalid_signature",
1218
+ details: "Ed25519 signature verification failed"
1219
+ };
1220
+ }
1221
+ const validatedSnapshot = validateSubjectSnapshot(inputSnapshot);
1222
+ const verifyTime = performance.now() - startTime;
1223
+ fireTelemetryHook(telemetry?.onReceiptVerified, {
1224
+ receiptHash: hashReceipt(receiptJws),
1225
+ valid: true,
1226
+ issuer: payload.iss,
1227
+ kid: header.kid,
1228
+ durationMs: verifyTime
1229
+ });
1010
1230
  return {
1011
- runtime: "cloudflare-workers",
1012
- dnsPreResolution: false,
1013
- ipBlocking: false,
1014
- networkIsolation: true,
1015
- protectionLevel: "partial",
1016
- notes: [
1017
- "Cloudflare Workers provide network-level isolation",
1018
- "DNS pre-resolution not available in Workers runtime",
1019
- "CF network blocks many SSRF vectors at infrastructure level",
1020
- "SSRF protection supplemented by URL validation and response limits"
1021
- ]
1231
+ ok: true,
1232
+ claims: payload,
1233
+ ...validatedSnapshot && { subject_snapshot: validatedSnapshot },
1234
+ perf: {
1235
+ verify_ms: verifyTime,
1236
+ ...jwksFetchTime && { jwks_fetch_ms: jwksFetchTime }
1237
+ }
1022
1238
  };
1023
- }
1024
- const g = globalThis;
1025
- if (typeof g.window !== "undefined" || typeof g.document !== "undefined") {
1239
+ } catch (err) {
1240
+ const durationMs = performance.now() - startTime;
1241
+ fireTelemetryHook(telemetry?.onReceiptVerified, {
1242
+ receiptHash: hashReceipt(receiptJws),
1243
+ valid: false,
1244
+ reasonCode: "verification_error",
1245
+ durationMs
1246
+ });
1026
1247
  return {
1027
- runtime: "browser",
1028
- dnsPreResolution: false,
1029
- ipBlocking: false,
1030
- networkIsolation: false,
1031
- protectionLevel: "minimal",
1032
- notes: [
1033
- "Browser environment detected; DNS pre-resolution not available",
1034
- "SSRF protection limited to URL scheme validation",
1035
- "Consider validating URLs server-side before browser fetch",
1036
- "Same-origin policy provides some protection against SSRF"
1037
- ]
1248
+ ok: false,
1249
+ reason: "verification_error",
1250
+ details: err instanceof Error ? err.message : String(err)
1038
1251
  };
1039
1252
  }
1040
- return {
1041
- runtime: "edge-generic",
1042
- dnsPreResolution: false,
1043
- ipBlocking: false,
1044
- networkIsolation: false,
1045
- protectionLevel: "partial",
1046
- notes: [
1047
- "Edge runtime detected; DNS pre-resolution may not be available",
1048
- "SSRF protection limited to URL validation and response limits",
1049
- "Verify runtime provides additional network-level protections"
1050
- ]
1051
- };
1052
- }
1053
- function resetSSRFCapabilitiesCache() {
1054
- cachedCapabilities = null;
1055
- }
1056
- function parseIPv4(ip) {
1057
- const parts = ip.split(".");
1058
- if (parts.length !== 4) return null;
1059
- const octets = [];
1060
- for (const part of parts) {
1061
- const num = parseInt(part, 10);
1062
- if (isNaN(num) || num < 0 || num > 255) return null;
1063
- octets.push(num);
1064
- }
1065
- return { octets };
1066
- }
1067
- function isInCIDR(ip, cidr) {
1068
- const [rangeStr, maskStr] = cidr.split("/");
1069
- const range = parseIPv4(rangeStr);
1070
- if (!range) return false;
1071
- const maskBits = parseInt(maskStr, 10);
1072
- if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
1073
- const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
1074
- const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
1075
- const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
1076
- return (ipNum & mask) === (rangeNum & mask);
1077
1253
  }
1078
- function isIPv6Loopback(ip) {
1079
- const normalized = ip.toLowerCase().replace(/^::ffff:/, "");
1080
- return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
1254
+ function isCryptoError(err) {
1255
+ return err !== null && typeof err === "object" && "name" in err && err.name === "CryptoError" && "code" in err && typeof err.code === "string" && err.code.startsWith("CRYPTO_") && "message" in err && typeof err.message === "string";
1081
1256
  }
1082
- function isIPv6LinkLocal(ip) {
1083
- const normalized = ip.toLowerCase();
1084
- return normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
1257
+ var FORMAT_ERROR_CODES = /* @__PURE__ */ new Set([
1258
+ "CRYPTO_INVALID_JWS_FORMAT",
1259
+ "CRYPTO_INVALID_TYP",
1260
+ "CRYPTO_INVALID_ALG",
1261
+ "CRYPTO_INVALID_KEY_LENGTH"
1262
+ ]);
1263
+ var MAX_PARSE_ISSUES = 25;
1264
+ function sanitizeParseIssues(issues) {
1265
+ if (!Array.isArray(issues)) return void 0;
1266
+ return issues.slice(0, MAX_PARSE_ISSUES).map((issue2) => ({
1267
+ path: Array.isArray(issue2?.path) ? issue2.path.join(".") : "",
1268
+ message: typeof issue2?.message === "string" ? issue2.message : String(issue2)
1269
+ }));
1085
1270
  }
1086
- function isBlockedIP(ip) {
1087
- const ipv4Match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
1088
- const effectiveIP = ipv4Match ? ipv4Match[1] : ip;
1089
- const ipv4 = parseIPv4(effectiveIP);
1090
- if (ipv4) {
1091
- if (isInCIDR(ipv4, "10.0.0.0/8") || isInCIDR(ipv4, "172.16.0.0/12") || isInCIDR(ipv4, "192.168.0.0/16")) {
1092
- return { blocked: true, reason: "private_ip" };
1271
+ async function verifyLocal(jws, publicKey, options = {}) {
1272
+ const { issuer, audience, subjectUri, rid, requireExp = false, maxClockSkew = 300 } = options;
1273
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
1274
+ try {
1275
+ const result = await verify(jws, publicKey);
1276
+ if (!result.valid) {
1277
+ return {
1278
+ valid: false,
1279
+ code: "E_INVALID_SIGNATURE",
1280
+ message: "Ed25519 signature verification failed"
1281
+ };
1093
1282
  }
1094
- if (isInCIDR(ipv4, "127.0.0.0/8")) {
1095
- return { blocked: true, reason: "loopback" };
1283
+ const constraintResult = validateKernelConstraints(result.payload);
1284
+ if (!constraintResult.valid) {
1285
+ const v = constraintResult.violations[0];
1286
+ return {
1287
+ valid: false,
1288
+ code: "E_CONSTRAINT_VIOLATION",
1289
+ message: `Kernel constraint violated: ${v.constraint} (actual: ${v.actual}, limit: ${v.limit})`
1290
+ };
1096
1291
  }
1097
- if (isInCIDR(ipv4, "169.254.0.0/16")) {
1098
- return { blocked: true, reason: "link_local" };
1292
+ const pr = parseReceiptClaims(result.payload);
1293
+ if (!pr.ok) {
1294
+ return {
1295
+ valid: false,
1296
+ code: "E_INVALID_FORMAT",
1297
+ message: `Receipt schema validation failed: ${pr.error.message}`,
1298
+ details: { parse_code: pr.error.code, issues: sanitizeParseIssues(pr.error.issues) }
1299
+ };
1099
1300
  }
1100
- return { blocked: false };
1101
- }
1102
- if (isIPv6Loopback(ip)) {
1103
- return { blocked: true, reason: "loopback" };
1104
- }
1105
- if (isIPv6LinkLocal(ip)) {
1106
- return { blocked: true, reason: "link_local" };
1107
- }
1108
- return { blocked: false };
1109
- }
1110
- async function resolveHostname(hostname) {
1111
- if (typeof process !== "undefined" && process.versions?.node) {
1112
- try {
1113
- const dns = await import('dns');
1114
- const { promisify } = await import('util');
1115
- const resolve4 = promisify(dns.resolve4);
1116
- const resolve6 = promisify(dns.resolve6);
1117
- const results = [];
1118
- let ipv4Error = null;
1119
- let ipv6Error = null;
1120
- try {
1121
- const ipv4 = await resolve4(hostname);
1122
- results.push(...ipv4);
1123
- } catch (err) {
1124
- ipv4Error = err;
1125
- }
1126
- try {
1127
- const ipv6 = await resolve6(hostname);
1128
- results.push(...ipv6);
1129
- } catch (err) {
1130
- ipv6Error = err;
1131
- }
1132
- if (results.length > 0) {
1133
- return { ok: true, ips: results, browser: false };
1134
- }
1135
- if (ipv4Error && ipv6Error) {
1136
- return {
1137
- ok: false,
1138
- message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
1139
- };
1140
- }
1141
- return { ok: true, ips: [], browser: false };
1142
- } catch (err) {
1301
+ if (issuer !== void 0 && pr.claims.iss !== issuer) {
1302
+ return {
1303
+ valid: false,
1304
+ code: "E_INVALID_ISSUER",
1305
+ message: `Issuer mismatch: expected "${issuer}", got "${pr.claims.iss}"`
1306
+ };
1307
+ }
1308
+ if (audience !== void 0 && pr.claims.aud !== audience) {
1143
1309
  return {
1144
- ok: false,
1145
- message: `DNS resolution error: ${err instanceof Error ? err.message : String(err)}`
1310
+ valid: false,
1311
+ code: "E_INVALID_AUDIENCE",
1312
+ message: `Audience mismatch: expected "${audience}", got "${pr.claims.aud}"`
1146
1313
  };
1147
1314
  }
1148
- }
1149
- return { ok: true, ips: [], browser: true };
1150
- }
1151
- async function ssrfSafeFetch(url, options = {}) {
1152
- const {
1153
- timeoutMs = VERIFIER_LIMITS.fetchTimeoutMs,
1154
- maxBytes = VERIFIER_LIMITS.maxResponseBytes,
1155
- maxRedirects = 0,
1156
- allowRedirects = VERIFIER_NETWORK.allowRedirects,
1157
- allowCrossOriginRedirects = true,
1158
- // Default: allow for CDN compatibility
1159
- dnsFailureBehavior = "block",
1160
- // Default: fail-closed for security
1161
- headers = {}
1162
- } = options;
1163
- let parsedUrl;
1164
- try {
1165
- parsedUrl = new URL(url);
1166
- } catch {
1167
- return {
1168
- ok: false,
1169
- reason: "invalid_url",
1170
- message: `Invalid URL: ${url}`,
1171
- blockedUrl: url
1172
- };
1173
- }
1174
- if (parsedUrl.protocol !== "https:") {
1175
- return {
1176
- ok: false,
1177
- reason: "not_https",
1178
- message: `URL must use HTTPS: ${url}`,
1179
- blockedUrl: url
1180
- };
1181
- }
1182
- const dnsResult = await resolveHostname(parsedUrl.hostname);
1183
- if (!dnsResult.ok) {
1184
- if (dnsFailureBehavior === "block") {
1315
+ if (rid !== void 0 && pr.claims.rid !== rid) {
1185
1316
  return {
1186
- ok: false,
1187
- reason: "dns_failure",
1188
- message: `DNS resolution blocked: ${dnsResult.message}`,
1189
- blockedUrl: url
1317
+ valid: false,
1318
+ code: "E_INVALID_RECEIPT_ID",
1319
+ message: `Receipt ID mismatch: expected "${rid}", got "${pr.claims.rid}"`
1190
1320
  };
1191
1321
  }
1192
- return {
1193
- ok: false,
1194
- reason: "network_error",
1195
- message: dnsResult.message,
1196
- blockedUrl: url
1197
- };
1198
- }
1199
- if (!dnsResult.browser) {
1200
- for (const ip of dnsResult.ips) {
1201
- const blockResult = isBlockedIP(ip);
1202
- if (blockResult.blocked) {
1203
- return {
1204
- ok: false,
1205
- reason: blockResult.reason,
1206
- message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
1207
- blockedUrl: url
1208
- };
1209
- }
1322
+ if (requireExp && pr.claims.exp === void 0) {
1323
+ return {
1324
+ valid: false,
1325
+ code: "E_MISSING_EXP",
1326
+ message: "Receipt missing required exp claim"
1327
+ };
1210
1328
  }
1211
- }
1212
- let redirectCount = 0;
1213
- let currentUrl = url;
1214
- const originalOrigin = parsedUrl.origin;
1215
- while (true) {
1216
- try {
1217
- const controller = new AbortController();
1218
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1219
- const response = await fetch(currentUrl, {
1220
- headers: {
1221
- Accept: "application/json, text/plain",
1222
- ...headers
1223
- },
1224
- signal: controller.signal,
1225
- redirect: "manual"
1226
- // Handle redirects manually for security
1227
- });
1228
- clearTimeout(timeoutId);
1229
- if (response.status >= 300 && response.status < 400) {
1230
- const location = response.headers.get("location");
1231
- if (!location) {
1232
- return {
1233
- ok: false,
1234
- reason: "network_error",
1235
- message: `Redirect without Location header from ${currentUrl}`
1236
- };
1237
- }
1238
- if (!allowRedirects) {
1239
- return {
1240
- ok: false,
1241
- reason: "too_many_redirects",
1242
- message: `Redirects not allowed: ${currentUrl} -> ${location}`,
1243
- blockedUrl: location
1244
- };
1245
- }
1246
- redirectCount++;
1247
- if (redirectCount > maxRedirects) {
1248
- return {
1249
- ok: false,
1250
- reason: "too_many_redirects",
1251
- message: `Too many redirects (${redirectCount} > ${maxRedirects})`,
1252
- blockedUrl: location
1253
- };
1254
- }
1255
- let redirectUrl;
1256
- try {
1257
- redirectUrl = new URL(location, currentUrl);
1258
- } catch {
1259
- return {
1260
- ok: false,
1261
- reason: "invalid_url",
1262
- message: `Invalid redirect URL: ${location}`,
1263
- blockedUrl: location
1264
- };
1265
- }
1266
- if (redirectUrl.protocol !== "https:") {
1267
- return {
1268
- ok: false,
1269
- reason: "scheme_downgrade",
1270
- message: `HTTPS to HTTP downgrade not allowed: ${currentUrl} -> ${redirectUrl.href}`,
1271
- blockedUrl: redirectUrl.href
1272
- };
1273
- }
1274
- if (redirectUrl.origin !== originalOrigin && !allowCrossOriginRedirects) {
1275
- return {
1276
- ok: false,
1277
- reason: "cross_origin_redirect",
1278
- message: `Cross-origin redirect not allowed: ${originalOrigin} -> ${redirectUrl.origin}`,
1279
- blockedUrl: redirectUrl.href
1280
- };
1281
- }
1282
- const redirectDnsResult = await resolveHostname(redirectUrl.hostname);
1283
- if (!redirectDnsResult.ok) {
1284
- if (dnsFailureBehavior === "block") {
1285
- return {
1286
- ok: false,
1287
- reason: "dns_failure",
1288
- message: `Redirect DNS resolution blocked: ${redirectDnsResult.message}`,
1289
- blockedUrl: redirectUrl.href
1290
- };
1291
- }
1292
- return {
1293
- ok: false,
1294
- reason: "network_error",
1295
- message: redirectDnsResult.message,
1296
- blockedUrl: redirectUrl.href
1297
- };
1298
- }
1299
- if (!redirectDnsResult.browser) {
1300
- for (const ip of redirectDnsResult.ips) {
1301
- const blockResult = isBlockedIP(ip);
1302
- if (blockResult.blocked) {
1303
- return {
1304
- ok: false,
1305
- reason: blockResult.reason,
1306
- message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
1307
- blockedUrl: redirectUrl.href
1308
- };
1309
- }
1310
- }
1311
- }
1312
- currentUrl = redirectUrl.href;
1313
- continue;
1314
- }
1315
- const contentLength = response.headers.get("content-length");
1316
- if (contentLength && parseInt(contentLength, 10) > maxBytes) {
1329
+ if (pr.claims.iat > now + maxClockSkew) {
1330
+ return {
1331
+ valid: false,
1332
+ code: "E_NOT_YET_VALID",
1333
+ message: `Receipt not yet valid: issued at ${new Date(pr.claims.iat * 1e3).toISOString()}, now is ${new Date(now * 1e3).toISOString()}`
1334
+ };
1335
+ }
1336
+ if (pr.claims.exp !== void 0 && pr.claims.exp < now - maxClockSkew) {
1337
+ return {
1338
+ valid: false,
1339
+ code: "E_EXPIRED",
1340
+ message: `Receipt expired at ${new Date(pr.claims.exp * 1e3).toISOString()}`
1341
+ };
1342
+ }
1343
+ if (pr.variant === "commerce") {
1344
+ const claims = pr.claims;
1345
+ if (subjectUri !== void 0 && claims.subject?.uri !== subjectUri) {
1317
1346
  return {
1318
- ok: false,
1319
- reason: "response_too_large",
1320
- message: `Response too large: ${contentLength} bytes > ${maxBytes} max`
1347
+ valid: false,
1348
+ code: "E_INVALID_SUBJECT",
1349
+ message: `Subject mismatch: expected "${subjectUri}", got "${claims.subject?.uri ?? "undefined"}"`
1321
1350
  };
1322
1351
  }
1323
- const reader = response.body?.getReader();
1324
- if (!reader) {
1325
- const body2 = await response.text();
1326
- if (body2.length > maxBytes) {
1327
- return {
1328
- ok: false,
1329
- reason: "response_too_large",
1330
- message: `Response too large: ${body2.length} bytes > ${maxBytes} max`
1331
- };
1332
- }
1333
- const rawBytes2 = new TextEncoder().encode(body2);
1352
+ return {
1353
+ valid: true,
1354
+ variant: "commerce",
1355
+ claims,
1356
+ kid: result.header.kid,
1357
+ policy_binding: "unavailable"
1358
+ };
1359
+ } else {
1360
+ const claims = pr.claims;
1361
+ if (subjectUri !== void 0 && claims.sub !== subjectUri) {
1334
1362
  return {
1335
- ok: true,
1336
- status: response.status,
1337
- body: body2,
1338
- rawBytes: rawBytes2,
1339
- contentType: response.headers.get("content-type") ?? void 0
1340
- };
1341
- }
1342
- const chunks = [];
1343
- let totalSize = 0;
1344
- while (true) {
1345
- const { done, value } = await reader.read();
1346
- if (done) break;
1347
- totalSize += value.length;
1348
- if (totalSize > maxBytes) {
1349
- reader.cancel();
1350
- return {
1351
- ok: false,
1352
- reason: "response_too_large",
1353
- message: `Response too large: ${totalSize} bytes > ${maxBytes} max`
1354
- };
1355
- }
1356
- chunks.push(value);
1357
- }
1358
- const rawBytes = chunks.reduce((acc, chunk) => {
1359
- const result = new Uint8Array(acc.length + chunk.length);
1360
- result.set(acc);
1361
- result.set(chunk, acc.length);
1362
- return result;
1363
- }, new Uint8Array());
1364
- const body = new TextDecoder().decode(rawBytes);
1363
+ valid: false,
1364
+ code: "E_INVALID_SUBJECT",
1365
+ message: `Subject mismatch: expected "${subjectUri}", got "${claims.sub ?? "undefined"}"`
1366
+ };
1367
+ }
1365
1368
  return {
1366
- ok: true,
1367
- status: response.status,
1368
- body,
1369
- rawBytes,
1370
- contentType: response.headers.get("content-type") ?? void 0
1369
+ valid: true,
1370
+ variant: "attestation",
1371
+ claims,
1372
+ kid: result.header.kid,
1373
+ policy_binding: "unavailable"
1371
1374
  };
1372
- } catch (err) {
1373
- if (err instanceof Error) {
1374
- if (err.name === "AbortError" || err.message.includes("timeout")) {
1375
- return {
1376
- ok: false,
1377
- reason: "timeout",
1378
- message: `Fetch timeout after ${timeoutMs}ms: ${currentUrl}`
1379
- };
1380
- }
1375
+ }
1376
+ } catch (err) {
1377
+ if (isCryptoError(err)) {
1378
+ if (FORMAT_ERROR_CODES.has(err.code)) {
1379
+ return {
1380
+ valid: false,
1381
+ code: "E_INVALID_FORMAT",
1382
+ message: err.message
1383
+ };
1384
+ }
1385
+ if (err.code === "CRYPTO_INVALID_SIGNATURE") {
1386
+ return {
1387
+ valid: false,
1388
+ code: "E_INVALID_SIGNATURE",
1389
+ message: err.message
1390
+ };
1381
1391
  }
1392
+ }
1393
+ if (err !== null && typeof err === "object" && "name" in err && err.name === "SyntaxError") {
1394
+ const syntaxMessage = "message" in err && typeof err.message === "string" ? err.message : "Invalid JSON";
1382
1395
  return {
1383
- ok: false,
1384
- reason: "network_error",
1385
- message: `Network error: ${err instanceof Error ? err.message : String(err)}`
1396
+ valid: false,
1397
+ code: "E_INVALID_FORMAT",
1398
+ message: `Invalid receipt payload: ${syntaxMessage}`
1386
1399
  };
1387
1400
  }
1401
+ const message = err instanceof Error ? err.message : String(err);
1402
+ return {
1403
+ valid: false,
1404
+ code: "E_INTERNAL",
1405
+ message: `Unexpected verification error: ${message}`
1406
+ };
1388
1407
  }
1389
1408
  }
1390
- async function fetchJWKSSafe(jwksUrl, options) {
1391
- return ssrfSafeFetch(jwksUrl, {
1392
- ...options,
1393
- maxBytes: VERIFIER_LIMITS.maxJwksBytes,
1394
- headers: {
1395
- Accept: "application/json",
1396
- ...options?.headers
1409
+ function isCommerceResult(r) {
1410
+ return r.valid === true && r.variant === "commerce";
1411
+ }
1412
+ function isAttestationResult(r) {
1413
+ return r.valid === true && r.variant === "attestation";
1414
+ }
1415
+ function setReceiptHeader(headers, receiptJws) {
1416
+ headers.set(PEAC_RECEIPT_HEADER, receiptJws);
1417
+ }
1418
+ function getReceiptHeader(headers) {
1419
+ return headers.get(PEAC_RECEIPT_HEADER);
1420
+ }
1421
+ function setVaryHeader(headers) {
1422
+ const existing = headers.get("Vary");
1423
+ if (existing) {
1424
+ const varies = existing.split(",").map((v) => v.trim());
1425
+ if (!varies.includes(PEAC_RECEIPT_HEADER)) {
1426
+ headers.set("Vary", `${existing}, ${PEAC_RECEIPT_HEADER}`);
1397
1427
  }
1398
- });
1428
+ } else {
1429
+ headers.set("Vary", PEAC_RECEIPT_HEADER);
1430
+ }
1399
1431
  }
1400
- async function fetchPointerSafe(pointerUrl, options) {
1401
- return ssrfSafeFetch(pointerUrl, {
1402
- ...options,
1403
- maxBytes: VERIFIER_LIMITS.maxReceiptBytes,
1404
- headers: {
1405
- Accept: "application/jose, application/json",
1406
- ...options?.headers
1432
+ function getPurposeHeader(headers) {
1433
+ const value = headers.get(PEAC_PURPOSE_HEADER);
1434
+ if (!value) {
1435
+ return [];
1436
+ }
1437
+ return parsePurposeHeader(value);
1438
+ }
1439
+ function setPurposeAppliedHeader(headers, purpose) {
1440
+ headers.set(PEAC_PURPOSE_APPLIED_HEADER, purpose);
1441
+ }
1442
+ function setPurposeReasonHeader(headers, reason) {
1443
+ headers.set(PEAC_PURPOSE_REASON_HEADER, reason);
1444
+ }
1445
+ function setVaryPurposeHeader(headers) {
1446
+ const existing = headers.get("Vary");
1447
+ if (existing) {
1448
+ const varies = existing.split(",").map((v) => v.trim());
1449
+ if (!varies.includes(PEAC_PURPOSE_HEADER)) {
1450
+ headers.set("Vary", `${existing}, ${PEAC_PURPOSE_HEADER}`);
1407
1451
  }
1408
- });
1452
+ } else {
1453
+ headers.set("Vary", PEAC_PURPOSE_HEADER);
1454
+ }
1455
+ }
1456
+ var DEFAULT_VERIFIER_LIMITS = {
1457
+ max_receipt_bytes: VERIFIER_LIMITS.maxReceiptBytes,
1458
+ max_jwks_bytes: VERIFIER_LIMITS.maxJwksBytes,
1459
+ max_jwks_keys: VERIFIER_LIMITS.maxJwksKeys,
1460
+ max_redirects: VERIFIER_LIMITS.maxRedirects,
1461
+ fetch_timeout_ms: VERIFIER_LIMITS.fetchTimeoutMs,
1462
+ max_extension_bytes: VERIFIER_LIMITS.maxExtensionBytes
1463
+ };
1464
+ var DEFAULT_NETWORK_SECURITY = {
1465
+ https_only: VERIFIER_NETWORK.httpsOnly,
1466
+ block_private_ips: VERIFIER_NETWORK.blockPrivateIps,
1467
+ allow_redirects: VERIFIER_NETWORK.allowRedirects,
1468
+ allow_cross_origin_redirects: true,
1469
+ // Allow for CDN compatibility
1470
+ dns_failure_behavior: "block"
1471
+ // Fail-closed by default
1472
+ };
1473
+ function createDefaultPolicy(mode) {
1474
+ return {
1475
+ policy_version: VERIFIER_POLICY_VERSION,
1476
+ mode,
1477
+ limits: { ...DEFAULT_VERIFIER_LIMITS },
1478
+ network: { ...DEFAULT_NETWORK_SECURITY }
1479
+ };
1480
+ }
1481
+ var CHECK_IDS = [
1482
+ "jws.parse",
1483
+ "limits.receipt_bytes",
1484
+ "jws.protected_header",
1485
+ "claims.schema_unverified",
1486
+ "issuer.trust_policy",
1487
+ "issuer.discovery",
1488
+ "key.resolve",
1489
+ "jws.signature",
1490
+ "claims.time_window",
1491
+ "extensions.limits",
1492
+ "transport.profile_binding",
1493
+ "policy.binding"
1494
+ ];
1495
+ var NON_DETERMINISTIC_ARTIFACT_KEYS = [
1496
+ "issuer_jwks_digest"
1497
+ ];
1498
+ function createDigest(hexValue) {
1499
+ return {
1500
+ alg: "sha-256",
1501
+ value: hexValue.toLowerCase()
1502
+ };
1503
+ }
1504
+ function createEmptyReport(policy) {
1505
+ return {
1506
+ report_version: VERIFICATION_REPORT_VERSION,
1507
+ policy
1508
+ };
1509
+ }
1510
+ function ssrfErrorToReasonCode(ssrfReason, fetchType) {
1511
+ const prefix = fetchType === "key" ? "key_fetch" : "pointer_fetch";
1512
+ switch (ssrfReason) {
1513
+ case "not_https":
1514
+ case "private_ip":
1515
+ case "loopback":
1516
+ case "link_local":
1517
+ case "cross_origin_redirect":
1518
+ case "dns_failure":
1519
+ return `${prefix}_blocked`;
1520
+ case "timeout":
1521
+ return `${prefix}_timeout`;
1522
+ case "response_too_large":
1523
+ return fetchType === "pointer" ? "pointer_fetch_too_large" : "jwks_too_large";
1524
+ case "jwks_too_many_keys":
1525
+ return "jwks_too_many_keys";
1526
+ case "too_many_redirects":
1527
+ case "scheme_downgrade":
1528
+ case "network_error":
1529
+ case "invalid_url":
1530
+ default:
1531
+ return `${prefix}_failed`;
1532
+ }
1533
+ }
1534
+ function reasonCodeToSeverity(reason) {
1535
+ if (reason === "ok") return "info";
1536
+ return "error";
1537
+ }
1538
+ function reasonCodeToErrorCode(reason) {
1539
+ const mapping = {
1540
+ ok: "",
1541
+ receipt_too_large: "E_VERIFY_RECEIPT_TOO_LARGE",
1542
+ malformed_receipt: "E_VERIFY_MALFORMED_RECEIPT",
1543
+ signature_invalid: "E_VERIFY_SIGNATURE_INVALID",
1544
+ issuer_not_allowed: "E_VERIFY_ISSUER_NOT_ALLOWED",
1545
+ key_not_found: "E_VERIFY_KEY_NOT_FOUND",
1546
+ key_fetch_blocked: "E_VERIFY_KEY_FETCH_BLOCKED",
1547
+ key_fetch_failed: "E_VERIFY_KEY_FETCH_FAILED",
1548
+ key_fetch_timeout: "E_VERIFY_KEY_FETCH_TIMEOUT",
1549
+ pointer_fetch_blocked: "E_VERIFY_POINTER_FETCH_BLOCKED",
1550
+ pointer_fetch_failed: "E_VERIFY_POINTER_FETCH_FAILED",
1551
+ pointer_fetch_timeout: "E_VERIFY_POINTER_FETCH_TIMEOUT",
1552
+ pointer_fetch_too_large: "E_VERIFY_POINTER_FETCH_TOO_LARGE",
1553
+ pointer_digest_mismatch: "E_VERIFY_POINTER_DIGEST_MISMATCH",
1554
+ jwks_too_large: "E_VERIFY_JWKS_TOO_LARGE",
1555
+ jwks_too_many_keys: "E_VERIFY_JWKS_TOO_MANY_KEYS",
1556
+ expired: "E_VERIFY_EXPIRED",
1557
+ not_yet_valid: "E_VERIFY_NOT_YET_VALID",
1558
+ audience_mismatch: "E_VERIFY_AUDIENCE_MISMATCH",
1559
+ schema_invalid: "E_VERIFY_SCHEMA_INVALID",
1560
+ policy_violation: "E_VERIFY_POLICY_VIOLATION",
1561
+ extension_too_large: "E_VERIFY_EXTENSION_TOO_LARGE",
1562
+ invalid_transport: "E_VERIFY_INVALID_TRANSPORT"
1563
+ };
1564
+ return mapping[reason] || "E_VERIFY_POLICY_VIOLATION";
1409
1565
  }
1410
1566
  var VerificationReportBuilder = class {
1411
1567
  state;
@@ -1674,8 +1830,6 @@ async function buildSuccessReport(policy, receiptBytes, issuer, kid, checkDetail
1674
1830
  }
1675
1831
 
1676
1832
  // src/verifier-core.ts
1677
- var jwksCache2 = /* @__PURE__ */ new Map();
1678
- var CACHE_TTL_MS2 = 5 * 60 * 1e3;
1679
1833
  function normalizeIssuer(issuer) {
1680
1834
  try {
1681
1835
  const url = new URL(issuer);
@@ -1701,75 +1855,23 @@ function findPinnedKey(issuer, kid, pinnedKeys) {
1701
1855
  const normalizedIssuer = normalizeIssuer(issuer);
1702
1856
  return pinnedKeys.find((pk) => normalizeIssuer(pk.issuer) === normalizedIssuer && pk.kid === kid);
1703
1857
  }
1704
- async function fetchIssuerConfig2(issuerOrigin) {
1705
- const configUrl = `${issuerOrigin}/.well-known/peac-issuer.json`;
1706
- const result = await ssrfSafeFetch(configUrl, {
1707
- maxBytes: 65536,
1708
- // 64 KB
1709
- headers: { Accept: "application/json" }
1710
- });
1711
- if (!result.ok) {
1712
- return null;
1713
- }
1714
- try {
1715
- return JSON.parse(result.body);
1716
- } catch {
1717
- return null;
1718
- }
1719
- }
1720
1858
  async function fetchIssuerJWKS(issuerOrigin) {
1721
- const now = Date.now();
1722
- const cached = jwksCache2.get(issuerOrigin);
1723
- if (cached && cached.expiresAt > now) {
1724
- return { jwks: cached.jwks, fromCache: true };
1725
- }
1726
- const config = await fetchIssuerConfig2(issuerOrigin);
1727
- if (!config?.jwks_uri) {
1728
- const fallbackUrl = `${issuerOrigin}/.well-known/jwks.json`;
1729
- const result2 = await fetchJWKSSafe(fallbackUrl);
1730
- if (!result2.ok) {
1731
- return { error: result2 };
1732
- }
1733
- try {
1734
- const jwks = JSON.parse(result2.body);
1735
- jwksCache2.set(issuerOrigin, { jwks, expiresAt: now + CACHE_TTL_MS2 });
1736
- return { jwks, fromCache: false, rawBytes: result2.rawBytes };
1737
- } catch {
1738
- return {
1739
- error: {
1740
- ok: false,
1741
- reason: "network_error",
1742
- message: "Invalid JWKS JSON"
1743
- }
1744
- };
1745
- }
1746
- }
1747
- const result = await fetchJWKSSafe(config.jwks_uri);
1859
+ const result = await resolveJWKS(issuerOrigin);
1748
1860
  if (!result.ok) {
1749
- return { error: result };
1750
- }
1751
- try {
1752
- const jwks = JSON.parse(result.body);
1753
- if (jwks.keys.length > VERIFIER_LIMITS.maxJwksKeys) {
1754
- return {
1755
- error: {
1756
- ok: false,
1757
- reason: "jwks_too_many_keys",
1758
- message: `JWKS has too many keys: ${jwks.keys.length} > ${VERIFIER_LIMITS.maxJwksKeys}`
1759
- }
1760
- };
1761
- }
1762
- jwksCache2.set(issuerOrigin, { jwks, expiresAt: now + CACHE_TTL_MS2 });
1763
- return { jwks, fromCache: false, rawBytes: result.rawBytes };
1764
- } catch {
1765
1861
  return {
1766
1862
  error: {
1767
1863
  ok: false,
1768
- reason: "network_error",
1769
- message: "Invalid JWKS JSON"
1864
+ reason: result.reason ?? "network_error",
1865
+ message: result.message,
1866
+ blockedUrl: result.blockedUrl
1770
1867
  }
1771
1868
  };
1772
1869
  }
1870
+ return {
1871
+ jwks: result.jwks,
1872
+ fromCache: result.fromCache,
1873
+ rawBytes: result.rawBytes
1874
+ };
1773
1875
  }
1774
1876
  async function verifyReceiptCore(options) {
1775
1877
  const {
@@ -2106,12 +2208,6 @@ async function verifyReceiptCore(options) {
2106
2208
  claims: parsedClaims
2107
2209
  };
2108
2210
  }
2109
- function clearJWKSCache() {
2110
- jwksCache2.clear();
2111
- }
2112
- function getJWKSCacheSize() {
2113
- return jwksCache2.size;
2114
- }
2115
2211
 
2116
2212
  // src/transport-profiles.ts
2117
2213
  function parseHeaderProfile(headerValue) {
@@ -2632,6 +2728,6 @@ async function verifyAndFetchPointer(pointerHeader, fetchOptions) {
2632
2728
  });
2633
2729
  }
2634
2730
 
2635
- export { CHECK_IDS, DEFAULT_NETWORK_SECURITY, DEFAULT_VERIFIER_LIMITS, IssueError, NON_DETERMINISTIC_ARTIFACT_KEYS, VerificationReportBuilder, buildFailureReport, buildSuccessReport, clearJWKSCache, computeReceiptDigest, createDefaultPolicy, createDigest, createEmptyReport, createReportBuilder, fetchDiscovery, fetchIssuerConfig, fetchJWKSSafe, fetchPointerSafe, fetchPointerWithDigest, fetchPolicyManifest, getJWKSCacheSize, getPurposeHeader, getReceiptHeader, getSSRFCapabilities, isAttestationResult, isBlockedIP, isCommerceResult, issue, issueJws, parseBodyProfile, parseDiscovery, parseHeaderProfile, parseIssuerConfig, parsePointerProfile, parsePolicyManifest, parseTransportProfile, reasonCodeToErrorCode, reasonCodeToSeverity, resetSSRFCapabilitiesCache, setPurposeAppliedHeader, setPurposeReasonHeader, setReceiptHeader, setVaryHeader, setVaryPurposeHeader, ssrfErrorToReasonCode, ssrfSafeFetch, verifyAndFetchPointer, verifyLocal, verifyReceipt, verifyReceiptCore };
2731
+ export { CHECK_IDS, DEFAULT_NETWORK_SECURITY, DEFAULT_VERIFIER_LIMITS, IssueError, NON_DETERMINISTIC_ARTIFACT_KEYS, VerificationReportBuilder, buildFailureReport, buildSuccessReport, clearJWKSCache, computeReceiptDigest, createDefaultPolicy, createDigest, createEmptyReport, createReportBuilder, fetchDiscovery, fetchIssuerConfig, fetchJWKSSafe, fetchPointerSafe, fetchPointerWithDigest, fetchPolicyManifest, getJWKSCacheSize, getPurposeHeader, getReceiptHeader, getSSRFCapabilities, isAttestationResult, isBlockedIP, isCommerceResult, issue, issueJws, parseBodyProfile, parseDiscovery, parseHeaderProfile, parseIssuerConfig, parsePointerProfile, parsePolicyManifest, parseTransportProfile, reasonCodeToErrorCode, reasonCodeToSeverity, resetSSRFCapabilitiesCache, resolveJWKS, setPurposeAppliedHeader, setPurposeReasonHeader, setReceiptHeader, setVaryHeader, setVaryPurposeHeader, ssrfErrorToReasonCode, ssrfSafeFetch, verifyAndFetchPointer, verifyLocal, verifyReceipt, verifyReceiptCore };
2636
2732
  //# sourceMappingURL=index.mjs.map
2637
2733
  //# sourceMappingURL=index.mjs.map