@peac/protocol 0.11.0 → 0.11.1
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/discovery.d.ts.map +1 -1
- package/dist/index.cjs +1276 -1179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +1277 -1181
- package/dist/index.mjs.map +1 -1
- package/dist/jwks-resolver.d.ts +88 -0
- package/dist/jwks-resolver.d.ts.map +1 -0
- package/dist/verifier-core.d.ts +0 -8
- package/dist/verifier-core.d.ts.map +1 -1
- package/dist/verify.d.ts +8 -1
- package/dist/verify.d.ts.map +1 -1
- package/package.json +4 -4
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,
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
throw new Error(
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
if (!
|
|
200
|
-
throw new Error("
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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(
|
|
260
|
+
signal: AbortSignal.timeout(1e4)
|
|
209
261
|
});
|
|
210
|
-
if (!
|
|
211
|
-
throw new Error(`
|
|
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
|
-
|
|
214
|
-
return jwks;
|
|
272
|
+
return config;
|
|
215
273
|
} catch (err) {
|
|
216
|
-
throw new Error(
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
if (
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
}
|
|
337
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
352
|
-
|
|
573
|
+
function resetSSRFCapabilitiesCache() {
|
|
574
|
+
cachedCapabilities = null;
|
|
353
575
|
}
|
|
354
|
-
|
|
355
|
-
"
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
return
|
|
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
|
-
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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 (
|
|
655
|
+
if (ipv4Error && ipv6Error) {
|
|
483
656
|
return {
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
message: `
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
message:
|
|
713
|
+
ok: false,
|
|
714
|
+
reason: "network_error",
|
|
715
|
+
message: dnsResult.message,
|
|
716
|
+
blockedUrl: url
|
|
503
717
|
};
|
|
504
718
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
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
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
const
|
|
772
|
-
|
|
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
|
-
|
|
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
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
850
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
|
904
|
-
|
|
905
|
-
switch (
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
return `${prefix}_blocked`;
|
|
980
|
+
code = "E_VERIFY_KEY_FETCH_BLOCKED";
|
|
981
|
+
break;
|
|
913
982
|
case "timeout":
|
|
914
|
-
|
|
983
|
+
code = "E_VERIFY_KEY_FETCH_TIMEOUT";
|
|
984
|
+
break;
|
|
915
985
|
case "response_too_large":
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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 "
|
|
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
|
-
|
|
1000
|
+
code = "E_VERIFY_KEY_FETCH_FAILED";
|
|
1001
|
+
break;
|
|
925
1002
|
}
|
|
1003
|
+
return { code, reason };
|
|
926
1004
|
}
|
|
927
|
-
function
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
1049
|
+
let issuerConfig;
|
|
1050
|
+
try {
|
|
1051
|
+
issuerConfig = parseIssuerConfig(configResult.body);
|
|
1052
|
+
} catch (err) {
|
|
996
1053
|
return {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
|
1079
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
|
1087
|
-
const
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
if (
|
|
1092
|
-
return {
|
|
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
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
message: `
|
|
1347
|
+
valid: false,
|
|
1348
|
+
code: "E_INVALID_SUBJECT",
|
|
1349
|
+
message: `Subject mismatch: expected "${subjectUri}", got "${claims.subject?.uri ?? "undefined"}"`
|
|
1321
1350
|
};
|
|
1322
1351
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1369
|
+
valid: true,
|
|
1370
|
+
variant: "attestation",
|
|
1371
|
+
claims,
|
|
1372
|
+
kid: result.header.kid,
|
|
1373
|
+
policy_binding: "unavailable"
|
|
1371
1374
|
};
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
message: `
|
|
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
|
-
|
|
1391
|
-
return
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
|
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:
|
|
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
|