@peac/protocol 0.10.9 → 0.10.10
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/LICENSE +1 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/index.cjs +2694 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +2612 -0
- package/dist/index.mjs.map +1 -0
- package/dist/verification-report.d.ts.map +1 -1
- package/dist/verifier-types.d.ts +42 -2
- package/dist/verifier-types.d.ts.map +1 -1
- package/dist/verify-local.cjs +164 -0
- package/dist/verify-local.cjs.map +1 -0
- package/dist/verify-local.d.ts +15 -0
- package/dist/verify-local.d.ts.map +1 -1
- package/dist/verify-local.mjs +160 -0
- package/dist/verify-local.mjs.map +1 -0
- package/dist/verify.d.ts.map +1 -1
- package/package.json +20 -13
- package/dist/crypto-utils.js +0 -21
- package/dist/crypto-utils.js.map +0 -1
- package/dist/discovery.js +0 -405
- package/dist/discovery.js.map +0 -1
- package/dist/headers.js +0 -110
- package/dist/headers.js.map +0 -1
- package/dist/index.js +0 -44
- package/dist/index.js.map +0 -1
- package/dist/issue.js +0 -198
- package/dist/issue.js.map +0 -1
- package/dist/pointer-fetch.js +0 -305
- package/dist/pointer-fetch.js.map +0 -1
- package/dist/ssrf-safe-fetch.js +0 -671
- package/dist/ssrf-safe-fetch.js.map +0 -1
- package/dist/telemetry.js +0 -43
- package/dist/telemetry.js.map +0 -1
- package/dist/transport-profiles.js +0 -424
- package/dist/transport-profiles.js.map +0 -1
- package/dist/verification-report.js +0 -322
- package/dist/verification-report.js.map +0 -1
- package/dist/verifier-core.js +0 -578
- package/dist/verifier-core.js.map +0 -1
- package/dist/verifier-types.js +0 -161
- package/dist/verifier-types.js.map +0 -1
- package/dist/verify-local.js +0 -230
- package/dist/verify-local.js.map +0 -1
- package/dist/verify.js +0 -213
- package/dist/verify.js.map +0 -1
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2694 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var uuidv7 = require('uuidv7');
|
|
4
|
+
var crypto = require('@peac/crypto');
|
|
5
|
+
var zod = require('zod');
|
|
6
|
+
var schema = require('@peac/schema');
|
|
7
|
+
var crypto$1 = require('crypto');
|
|
8
|
+
var kernel = require('@peac/kernel');
|
|
9
|
+
|
|
10
|
+
// src/issue.ts
|
|
11
|
+
function fireTelemetryHook(fn, input) {
|
|
12
|
+
if (!fn) return;
|
|
13
|
+
try {
|
|
14
|
+
const result = fn(input);
|
|
15
|
+
if (result && typeof result.catch === "function") {
|
|
16
|
+
result.catch(() => {
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function hashReceipt(jws) {
|
|
23
|
+
const hash = crypto$1.createHash("sha256").update(jws).digest("hex");
|
|
24
|
+
return `sha256:${hash.slice(0, 16)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/issue.ts
|
|
28
|
+
var IssueError = class extends Error {
|
|
29
|
+
/** Structured error details */
|
|
30
|
+
peacError;
|
|
31
|
+
constructor(peacError) {
|
|
32
|
+
const details = peacError.details;
|
|
33
|
+
super(details?.message ?? peacError.code);
|
|
34
|
+
this.name = "IssueError";
|
|
35
|
+
this.peacError = peacError;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
async function issue(options) {
|
|
39
|
+
if (!options.iss.startsWith("https://")) {
|
|
40
|
+
throw new Error("Issuer URL must start with https://");
|
|
41
|
+
}
|
|
42
|
+
if (!options.aud.startsWith("https://")) {
|
|
43
|
+
throw new Error("Audience URL must start with https://");
|
|
44
|
+
}
|
|
45
|
+
if (options.subject && !options.subject.startsWith("https://")) {
|
|
46
|
+
throw new Error("Subject URI must start with https://");
|
|
47
|
+
}
|
|
48
|
+
if (!/^[A-Z]{3}$/.test(options.cur)) {
|
|
49
|
+
throw new Error("Currency must be ISO 4217 uppercase (e.g., USD)");
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isInteger(options.amt) || options.amt < 0) {
|
|
52
|
+
throw new Error("Amount must be a non-negative integer");
|
|
53
|
+
}
|
|
54
|
+
if (options.exp !== void 0) {
|
|
55
|
+
if (!Number.isInteger(options.exp) || options.exp < 0) {
|
|
56
|
+
throw new Error("Expiry must be a non-negative integer");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let purposeDeclared;
|
|
60
|
+
if (options.purpose !== void 0) {
|
|
61
|
+
const rawPurposes = Array.isArray(options.purpose) ? options.purpose : [options.purpose];
|
|
62
|
+
const invalidTokens = [];
|
|
63
|
+
for (const token of rawPurposes) {
|
|
64
|
+
if (!schema.isValidPurposeToken(token)) {
|
|
65
|
+
invalidTokens.push(token);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (invalidTokens.length > 0) {
|
|
69
|
+
throw new Error(`Invalid purpose tokens: ${invalidTokens.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
if (rawPurposes.includes("undeclared")) {
|
|
72
|
+
throw new Error("Explicit 'undeclared' is not a valid purpose token (internal-only)");
|
|
73
|
+
}
|
|
74
|
+
purposeDeclared = rawPurposes;
|
|
75
|
+
}
|
|
76
|
+
if (options.purpose_enforced !== void 0) {
|
|
77
|
+
if (!schema.isCanonicalPurpose(options.purpose_enforced)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`purpose_enforced must be a canonical purpose, got: ${options.purpose_enforced}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (options.purpose_reason !== void 0) {
|
|
84
|
+
if (!schema.isValidPurposeReason(options.purpose_reason)) {
|
|
85
|
+
throw new Error(`Invalid purpose_reason: ${options.purpose_reason}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (options.workflow_context !== void 0) {
|
|
89
|
+
if (!schema.isValidWorkflowContext(options.workflow_context)) {
|
|
90
|
+
throw new IssueError(
|
|
91
|
+
schema.createWorkflowContextInvalidError("Does not conform to WorkflowContextSchema")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (!schema.hasValidDagSemantics(options.workflow_context)) {
|
|
95
|
+
const ctx = options.workflow_context;
|
|
96
|
+
const isSelfParent = ctx.parent_step_ids.includes(ctx.step_id);
|
|
97
|
+
const hasDuplicates = new Set(ctx.parent_step_ids).size !== ctx.parent_step_ids.length;
|
|
98
|
+
const reason = isSelfParent ? "self_parent" : hasDuplicates ? "duplicate_parent" : "cycle";
|
|
99
|
+
throw new IssueError(schema.createWorkflowDagInvalidError(reason));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const rid = uuidv7.uuidv7();
|
|
103
|
+
const iat = Math.floor(Date.now() / 1e3);
|
|
104
|
+
const claims = {
|
|
105
|
+
iss: options.iss,
|
|
106
|
+
aud: options.aud,
|
|
107
|
+
iat,
|
|
108
|
+
rid,
|
|
109
|
+
amt: options.amt,
|
|
110
|
+
cur: options.cur,
|
|
111
|
+
payment: {
|
|
112
|
+
rail: options.rail,
|
|
113
|
+
reference: options.reference,
|
|
114
|
+
amount: options.amt,
|
|
115
|
+
currency: options.cur,
|
|
116
|
+
asset: options.asset ?? options.cur,
|
|
117
|
+
// Default asset to currency for backward compatibility
|
|
118
|
+
env: options.env ?? "test",
|
|
119
|
+
// Default to test environment for backward compatibility
|
|
120
|
+
evidence: options.evidence ?? {},
|
|
121
|
+
// Default to empty object for backward compatibility
|
|
122
|
+
...options.network && { network: options.network },
|
|
123
|
+
...options.facilitator_ref && { facilitator_ref: options.facilitator_ref },
|
|
124
|
+
...options.idempotency_key && { idempotency_key: options.idempotency_key },
|
|
125
|
+
...options.metadata && { metadata: options.metadata }
|
|
126
|
+
},
|
|
127
|
+
...options.exp && { exp: options.exp },
|
|
128
|
+
...options.subject && { subject: { uri: options.subject } },
|
|
129
|
+
// Build extensions (merge user-provided ext with workflow_context)
|
|
130
|
+
...(options.ext || options.workflow_context) && {
|
|
131
|
+
ext: {
|
|
132
|
+
...options.ext,
|
|
133
|
+
...options.workflow_context && {
|
|
134
|
+
[schema.WORKFLOW_EXTENSION_KEY]: options.workflow_context
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
// Purpose claims (v0.9.24+)
|
|
139
|
+
...purposeDeclared && { purpose_declared: purposeDeclared },
|
|
140
|
+
...options.purpose_enforced && { purpose_enforced: options.purpose_enforced },
|
|
141
|
+
...options.purpose_reason && { purpose_reason: options.purpose_reason }
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
schema.ReceiptClaims.parse(claims);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err instanceof zod.ZodError) {
|
|
147
|
+
const evidenceIssue = err.issues.find(
|
|
148
|
+
(issue2) => issue2.path.some((p) => p === "evidence" || p === "payment")
|
|
149
|
+
);
|
|
150
|
+
if (evidenceIssue && evidenceIssue.path.includes("evidence")) {
|
|
151
|
+
const peacError = schema.createEvidenceNotJsonError(evidenceIssue.message, evidenceIssue.path);
|
|
152
|
+
throw new IssueError(peacError);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
const validatedSnapshot = schema.validateSubjectSnapshot(options.subject_snapshot);
|
|
158
|
+
const startTime = performance.now();
|
|
159
|
+
const jws = await crypto.sign(claims, options.privateKey, options.kid);
|
|
160
|
+
fireTelemetryHook(options.telemetry?.onReceiptIssued, {
|
|
161
|
+
receiptHash: hashReceipt(jws),
|
|
162
|
+
issuer: options.iss,
|
|
163
|
+
kid: options.kid,
|
|
164
|
+
durationMs: performance.now() - startTime
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
jws,
|
|
168
|
+
...validatedSnapshot && { subject_snapshot: validatedSnapshot }
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async function issueJws(options) {
|
|
172
|
+
const result = await issue(options);
|
|
173
|
+
return result.jws;
|
|
174
|
+
}
|
|
175
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
176
|
+
var CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
177
|
+
async function fetchJWKS(issuerUrl) {
|
|
178
|
+
if (!issuerUrl.startsWith("https://")) {
|
|
179
|
+
throw new Error("Issuer URL must be https://");
|
|
180
|
+
}
|
|
181
|
+
const discoveryUrl = `${issuerUrl}/.well-known/peac.txt`;
|
|
182
|
+
try {
|
|
183
|
+
const discoveryResp = await fetch(discoveryUrl, {
|
|
184
|
+
headers: { Accept: "text/plain" },
|
|
185
|
+
// Timeout after 5 seconds
|
|
186
|
+
signal: AbortSignal.timeout(5e3)
|
|
187
|
+
});
|
|
188
|
+
if (!discoveryResp.ok) {
|
|
189
|
+
throw new Error(`Discovery fetch failed: ${discoveryResp.status}`);
|
|
190
|
+
}
|
|
191
|
+
const discoveryText = await discoveryResp.text();
|
|
192
|
+
const jwksLine = discoveryText.split("\n").find((line) => line.startsWith("jwks:"));
|
|
193
|
+
if (!jwksLine) {
|
|
194
|
+
throw new Error("No jwks field in discovery");
|
|
195
|
+
}
|
|
196
|
+
const jwksUrl = jwksLine.replace("jwks:", "").trim();
|
|
197
|
+
if (!jwksUrl.startsWith("https://")) {
|
|
198
|
+
throw new Error("JWKS URL must be https://");
|
|
199
|
+
}
|
|
200
|
+
const jwksResp = await fetch(jwksUrl, {
|
|
201
|
+
headers: { Accept: "application/json" },
|
|
202
|
+
signal: AbortSignal.timeout(5e3)
|
|
203
|
+
});
|
|
204
|
+
if (!jwksResp.ok) {
|
|
205
|
+
throw new Error(`JWKS fetch failed: ${jwksResp.status}`);
|
|
206
|
+
}
|
|
207
|
+
const jwks = await jwksResp.json();
|
|
208
|
+
return jwks;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw new Error(`JWKS fetch failed: ${err instanceof Error ? err.message : String(err)}`, {
|
|
211
|
+
cause: err
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function getJWKS(issuerUrl) {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const cached = jwksCache.get(issuerUrl);
|
|
218
|
+
if (cached && cached.expiresAt > now) {
|
|
219
|
+
return { jwks: cached.keys, fromCache: true };
|
|
220
|
+
}
|
|
221
|
+
const jwks = await fetchJWKS(issuerUrl);
|
|
222
|
+
jwksCache.set(issuerUrl, {
|
|
223
|
+
keys: jwks,
|
|
224
|
+
expiresAt: now + CACHE_TTL_MS
|
|
225
|
+
});
|
|
226
|
+
return { jwks, fromCache: false };
|
|
227
|
+
}
|
|
228
|
+
function jwkToPublicKey(jwk) {
|
|
229
|
+
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
|
|
230
|
+
throw new Error("Only Ed25519 keys (OKP/Ed25519) are supported");
|
|
231
|
+
}
|
|
232
|
+
const xBytes = Buffer.from(jwk.x, "base64url");
|
|
233
|
+
if (xBytes.length !== 32) {
|
|
234
|
+
throw new Error("Ed25519 public key must be 32 bytes");
|
|
235
|
+
}
|
|
236
|
+
return new Uint8Array(xBytes);
|
|
237
|
+
}
|
|
238
|
+
async function verifyReceipt(optionsOrJws) {
|
|
239
|
+
const receiptJws = typeof optionsOrJws === "string" ? optionsOrJws : optionsOrJws.receiptJws;
|
|
240
|
+
const inputSnapshot = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.subject_snapshot;
|
|
241
|
+
const telemetry = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.telemetry;
|
|
242
|
+
const startTime = performance.now();
|
|
243
|
+
let jwksFetchTime;
|
|
244
|
+
try {
|
|
245
|
+
const { header, payload } = crypto.decode(receiptJws);
|
|
246
|
+
schema.ReceiptClaims.parse(payload);
|
|
247
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) {
|
|
248
|
+
const durationMs = performance.now() - startTime;
|
|
249
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
250
|
+
receiptHash: hashReceipt(receiptJws),
|
|
251
|
+
valid: false,
|
|
252
|
+
reasonCode: "expired",
|
|
253
|
+
issuer: payload.iss,
|
|
254
|
+
kid: header.kid,
|
|
255
|
+
durationMs
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
reason: "expired",
|
|
260
|
+
details: `Receipt expired at ${new Date(payload.exp * 1e3).toISOString()}`
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const jwksFetchStart = performance.now();
|
|
264
|
+
const { jwks, fromCache } = await getJWKS(payload.iss);
|
|
265
|
+
if (!fromCache) {
|
|
266
|
+
jwksFetchTime = performance.now() - jwksFetchStart;
|
|
267
|
+
}
|
|
268
|
+
const jwk = jwks.keys.find((k) => k.kid === header.kid);
|
|
269
|
+
if (!jwk) {
|
|
270
|
+
const durationMs = performance.now() - startTime;
|
|
271
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
272
|
+
receiptHash: hashReceipt(receiptJws),
|
|
273
|
+
valid: false,
|
|
274
|
+
reasonCode: "unknown_key",
|
|
275
|
+
issuer: payload.iss,
|
|
276
|
+
kid: header.kid,
|
|
277
|
+
durationMs
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: "unknown_key",
|
|
282
|
+
details: `No key found with kid=${header.kid}`
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const publicKey = jwkToPublicKey(jwk);
|
|
286
|
+
const result = await crypto.verify(receiptJws, publicKey);
|
|
287
|
+
if (!result.valid) {
|
|
288
|
+
const durationMs = performance.now() - startTime;
|
|
289
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
290
|
+
receiptHash: hashReceipt(receiptJws),
|
|
291
|
+
valid: false,
|
|
292
|
+
reasonCode: "invalid_signature",
|
|
293
|
+
issuer: payload.iss,
|
|
294
|
+
kid: header.kid,
|
|
295
|
+
durationMs
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
reason: "invalid_signature",
|
|
300
|
+
details: "Ed25519 signature verification failed"
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const validatedSnapshot = schema.validateSubjectSnapshot(inputSnapshot);
|
|
304
|
+
const verifyTime = performance.now() - startTime;
|
|
305
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
306
|
+
receiptHash: hashReceipt(receiptJws),
|
|
307
|
+
valid: true,
|
|
308
|
+
issuer: payload.iss,
|
|
309
|
+
kid: header.kid,
|
|
310
|
+
durationMs: verifyTime
|
|
311
|
+
});
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
claims: payload,
|
|
315
|
+
...validatedSnapshot && { subject_snapshot: validatedSnapshot },
|
|
316
|
+
perf: {
|
|
317
|
+
verify_ms: verifyTime,
|
|
318
|
+
...jwksFetchTime && { jwks_fetch_ms: jwksFetchTime }
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
} catch (err) {
|
|
322
|
+
const durationMs = performance.now() - startTime;
|
|
323
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
324
|
+
receiptHash: hashReceipt(receiptJws),
|
|
325
|
+
valid: false,
|
|
326
|
+
reasonCode: "verification_error",
|
|
327
|
+
durationMs
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
reason: "verification_error",
|
|
332
|
+
details: err instanceof Error ? err.message : String(err)
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function isCryptoError(err) {
|
|
337
|
+
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";
|
|
338
|
+
}
|
|
339
|
+
var FORMAT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
340
|
+
"CRYPTO_INVALID_JWS_FORMAT",
|
|
341
|
+
"CRYPTO_INVALID_TYP",
|
|
342
|
+
"CRYPTO_INVALID_ALG",
|
|
343
|
+
"CRYPTO_INVALID_KEY_LENGTH"
|
|
344
|
+
]);
|
|
345
|
+
var MAX_PARSE_ISSUES = 25;
|
|
346
|
+
function sanitizeParseIssues(issues) {
|
|
347
|
+
if (!Array.isArray(issues)) return void 0;
|
|
348
|
+
return issues.slice(0, MAX_PARSE_ISSUES).map((issue2) => ({
|
|
349
|
+
path: Array.isArray(issue2?.path) ? issue2.path.join(".") : "",
|
|
350
|
+
message: typeof issue2?.message === "string" ? issue2.message : String(issue2)
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
async function verifyLocal(jws, publicKey, options = {}) {
|
|
354
|
+
const { issuer, audience, subjectUri, rid, requireExp = false, maxClockSkew = 300 } = options;
|
|
355
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
356
|
+
try {
|
|
357
|
+
const result = await crypto.verify(jws, publicKey);
|
|
358
|
+
if (!result.valid) {
|
|
359
|
+
return {
|
|
360
|
+
valid: false,
|
|
361
|
+
code: "E_INVALID_SIGNATURE",
|
|
362
|
+
message: "Ed25519 signature verification failed"
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const pr = schema.parseReceiptClaims(result.payload);
|
|
366
|
+
if (!pr.ok) {
|
|
367
|
+
return {
|
|
368
|
+
valid: false,
|
|
369
|
+
code: "E_INVALID_FORMAT",
|
|
370
|
+
message: `Receipt schema validation failed: ${pr.error.message}`,
|
|
371
|
+
details: { parse_code: pr.error.code, issues: sanitizeParseIssues(pr.error.issues) }
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (issuer !== void 0 && pr.claims.iss !== issuer) {
|
|
375
|
+
return {
|
|
376
|
+
valid: false,
|
|
377
|
+
code: "E_INVALID_ISSUER",
|
|
378
|
+
message: `Issuer mismatch: expected "${issuer}", got "${pr.claims.iss}"`
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (audience !== void 0 && pr.claims.aud !== audience) {
|
|
382
|
+
return {
|
|
383
|
+
valid: false,
|
|
384
|
+
code: "E_INVALID_AUDIENCE",
|
|
385
|
+
message: `Audience mismatch: expected "${audience}", got "${pr.claims.aud}"`
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (rid !== void 0 && pr.claims.rid !== rid) {
|
|
389
|
+
return {
|
|
390
|
+
valid: false,
|
|
391
|
+
code: "E_INVALID_RECEIPT_ID",
|
|
392
|
+
message: `Receipt ID mismatch: expected "${rid}", got "${pr.claims.rid}"`
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (requireExp && pr.claims.exp === void 0) {
|
|
396
|
+
return {
|
|
397
|
+
valid: false,
|
|
398
|
+
code: "E_MISSING_EXP",
|
|
399
|
+
message: "Receipt missing required exp claim"
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (pr.claims.iat > now + maxClockSkew) {
|
|
403
|
+
return {
|
|
404
|
+
valid: false,
|
|
405
|
+
code: "E_NOT_YET_VALID",
|
|
406
|
+
message: `Receipt not yet valid: issued at ${new Date(pr.claims.iat * 1e3).toISOString()}, now is ${new Date(now * 1e3).toISOString()}`
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (pr.claims.exp !== void 0 && pr.claims.exp < now - maxClockSkew) {
|
|
410
|
+
return {
|
|
411
|
+
valid: false,
|
|
412
|
+
code: "E_EXPIRED",
|
|
413
|
+
message: `Receipt expired at ${new Date(pr.claims.exp * 1e3).toISOString()}`
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (pr.variant === "commerce") {
|
|
417
|
+
const claims = pr.claims;
|
|
418
|
+
if (subjectUri !== void 0 && claims.subject?.uri !== subjectUri) {
|
|
419
|
+
return {
|
|
420
|
+
valid: false,
|
|
421
|
+
code: "E_INVALID_SUBJECT",
|
|
422
|
+
message: `Subject mismatch: expected "${subjectUri}", got "${claims.subject?.uri ?? "undefined"}"`
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
valid: true,
|
|
427
|
+
variant: "commerce",
|
|
428
|
+
claims,
|
|
429
|
+
kid: result.header.kid,
|
|
430
|
+
policy_binding: "unavailable"
|
|
431
|
+
};
|
|
432
|
+
} else {
|
|
433
|
+
const claims = pr.claims;
|
|
434
|
+
if (subjectUri !== void 0 && claims.sub !== subjectUri) {
|
|
435
|
+
return {
|
|
436
|
+
valid: false,
|
|
437
|
+
code: "E_INVALID_SUBJECT",
|
|
438
|
+
message: `Subject mismatch: expected "${subjectUri}", got "${claims.sub ?? "undefined"}"`
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
valid: true,
|
|
443
|
+
variant: "attestation",
|
|
444
|
+
claims,
|
|
445
|
+
kid: result.header.kid,
|
|
446
|
+
policy_binding: "unavailable"
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (isCryptoError(err)) {
|
|
451
|
+
if (FORMAT_ERROR_CODES.has(err.code)) {
|
|
452
|
+
return {
|
|
453
|
+
valid: false,
|
|
454
|
+
code: "E_INVALID_FORMAT",
|
|
455
|
+
message: err.message
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
if (err.code === "CRYPTO_INVALID_SIGNATURE") {
|
|
459
|
+
return {
|
|
460
|
+
valid: false,
|
|
461
|
+
code: "E_INVALID_SIGNATURE",
|
|
462
|
+
message: err.message
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (err !== null && typeof err === "object" && "name" in err && err.name === "SyntaxError") {
|
|
467
|
+
const syntaxMessage = "message" in err && typeof err.message === "string" ? err.message : "Invalid JSON";
|
|
468
|
+
return {
|
|
469
|
+
valid: false,
|
|
470
|
+
code: "E_INVALID_FORMAT",
|
|
471
|
+
message: `Invalid receipt payload: ${syntaxMessage}`
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
475
|
+
return {
|
|
476
|
+
valid: false,
|
|
477
|
+
code: "E_INTERNAL",
|
|
478
|
+
message: `Unexpected verification error: ${message}`
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function isCommerceResult(r) {
|
|
483
|
+
return r.valid === true && r.variant === "commerce";
|
|
484
|
+
}
|
|
485
|
+
function isAttestationResult(r) {
|
|
486
|
+
return r.valid === true && r.variant === "attestation";
|
|
487
|
+
}
|
|
488
|
+
function setReceiptHeader(headers, receiptJws) {
|
|
489
|
+
headers.set(schema.PEAC_RECEIPT_HEADER, receiptJws);
|
|
490
|
+
}
|
|
491
|
+
function getReceiptHeader(headers) {
|
|
492
|
+
return headers.get(schema.PEAC_RECEIPT_HEADER);
|
|
493
|
+
}
|
|
494
|
+
function setVaryHeader(headers) {
|
|
495
|
+
const existing = headers.get("Vary");
|
|
496
|
+
if (existing) {
|
|
497
|
+
const varies = existing.split(",").map((v) => v.trim());
|
|
498
|
+
if (!varies.includes(schema.PEAC_RECEIPT_HEADER)) {
|
|
499
|
+
headers.set("Vary", `${existing}, ${schema.PEAC_RECEIPT_HEADER}`);
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
headers.set("Vary", schema.PEAC_RECEIPT_HEADER);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function getPurposeHeader(headers) {
|
|
506
|
+
const value = headers.get(schema.PEAC_PURPOSE_HEADER);
|
|
507
|
+
if (!value) {
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
return schema.parsePurposeHeader(value);
|
|
511
|
+
}
|
|
512
|
+
function setPurposeAppliedHeader(headers, purpose) {
|
|
513
|
+
headers.set(schema.PEAC_PURPOSE_APPLIED_HEADER, purpose);
|
|
514
|
+
}
|
|
515
|
+
function setPurposeReasonHeader(headers, reason) {
|
|
516
|
+
headers.set(schema.PEAC_PURPOSE_REASON_HEADER, reason);
|
|
517
|
+
}
|
|
518
|
+
function setVaryPurposeHeader(headers) {
|
|
519
|
+
const existing = headers.get("Vary");
|
|
520
|
+
if (existing) {
|
|
521
|
+
const varies = existing.split(",").map((v) => v.trim());
|
|
522
|
+
if (!varies.includes(schema.PEAC_PURPOSE_HEADER)) {
|
|
523
|
+
headers.set("Vary", `${existing}, ${schema.PEAC_PURPOSE_HEADER}`);
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
headers.set("Vary", schema.PEAC_PURPOSE_HEADER);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function parseIssuerConfig(json) {
|
|
530
|
+
let config;
|
|
531
|
+
if (typeof json === "string") {
|
|
532
|
+
const bytes = new TextEncoder().encode(json).length;
|
|
533
|
+
if (bytes > schema.PEAC_ISSUER_CONFIG_MAX_BYTES) {
|
|
534
|
+
throw new Error(`Issuer config exceeds ${schema.PEAC_ISSUER_CONFIG_MAX_BYTES} bytes (got ${bytes})`);
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
config = JSON.parse(json);
|
|
538
|
+
} catch {
|
|
539
|
+
throw new Error("Issuer config is not valid JSON");
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
config = json;
|
|
543
|
+
}
|
|
544
|
+
if (typeof config !== "object" || config === null) {
|
|
545
|
+
throw new Error("Issuer config must be an object");
|
|
546
|
+
}
|
|
547
|
+
const obj = config;
|
|
548
|
+
if (typeof obj.version !== "string" || !obj.version) {
|
|
549
|
+
throw new Error("Missing required field: version");
|
|
550
|
+
}
|
|
551
|
+
if (typeof obj.issuer !== "string" || !obj.issuer) {
|
|
552
|
+
throw new Error("Missing required field: issuer");
|
|
553
|
+
}
|
|
554
|
+
if (typeof obj.jwks_uri !== "string" || !obj.jwks_uri) {
|
|
555
|
+
throw new Error("Missing required field: jwks_uri");
|
|
556
|
+
}
|
|
557
|
+
if (!obj.issuer.startsWith("https://")) {
|
|
558
|
+
throw new Error("issuer must be an HTTPS URL");
|
|
559
|
+
}
|
|
560
|
+
if (!obj.jwks_uri.startsWith("https://")) {
|
|
561
|
+
throw new Error("jwks_uri must be an HTTPS URL");
|
|
562
|
+
}
|
|
563
|
+
if (obj.verify_endpoint !== void 0) {
|
|
564
|
+
if (typeof obj.verify_endpoint !== "string") {
|
|
565
|
+
throw new Error("verify_endpoint must be a string");
|
|
566
|
+
}
|
|
567
|
+
if (!obj.verify_endpoint.startsWith("https://")) {
|
|
568
|
+
throw new Error("verify_endpoint must be an HTTPS URL");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (obj.receipt_versions !== void 0) {
|
|
572
|
+
if (!Array.isArray(obj.receipt_versions)) {
|
|
573
|
+
throw new Error("receipt_versions must be an array");
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (obj.algorithms !== void 0) {
|
|
577
|
+
if (!Array.isArray(obj.algorithms)) {
|
|
578
|
+
throw new Error("algorithms must be an array");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (obj.payment_rails !== void 0) {
|
|
582
|
+
if (!Array.isArray(obj.payment_rails)) {
|
|
583
|
+
throw new Error("payment_rails must be an array");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
version: obj.version,
|
|
588
|
+
issuer: obj.issuer,
|
|
589
|
+
jwks_uri: obj.jwks_uri,
|
|
590
|
+
verify_endpoint: obj.verify_endpoint,
|
|
591
|
+
receipt_versions: obj.receipt_versions,
|
|
592
|
+
algorithms: obj.algorithms,
|
|
593
|
+
payment_rails: obj.payment_rails,
|
|
594
|
+
security_contact: obj.security_contact
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async function fetchIssuerConfig(issuerUrl) {
|
|
598
|
+
if (!issuerUrl.startsWith("https://")) {
|
|
599
|
+
throw new Error("Issuer URL must be https://");
|
|
600
|
+
}
|
|
601
|
+
const baseUrl = issuerUrl.replace(/\/$/, "");
|
|
602
|
+
const configUrl = `${baseUrl}${schema.PEAC_ISSUER_CONFIG_PATH}`;
|
|
603
|
+
try {
|
|
604
|
+
const resp = await fetch(configUrl, {
|
|
605
|
+
headers: { Accept: "application/json" },
|
|
606
|
+
signal: AbortSignal.timeout(1e4)
|
|
607
|
+
});
|
|
608
|
+
if (!resp.ok) {
|
|
609
|
+
throw new Error(`Issuer config fetch failed: ${resp.status}`);
|
|
610
|
+
}
|
|
611
|
+
const text = await resp.text();
|
|
612
|
+
const config = parseIssuerConfig(text);
|
|
613
|
+
const normalizedExpected = baseUrl.replace(/\/$/, "");
|
|
614
|
+
const normalizedActual = config.issuer.replace(/\/$/, "");
|
|
615
|
+
if (normalizedActual !== normalizedExpected) {
|
|
616
|
+
throw new Error(`Issuer mismatch: expected ${normalizedExpected}, got ${normalizedActual}`);
|
|
617
|
+
}
|
|
618
|
+
return config;
|
|
619
|
+
} catch (err) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
`Failed to fetch issuer config from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
622
|
+
{ cause: err }
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function isJsonContent(text, contentType) {
|
|
627
|
+
if (contentType?.includes("application/json")) {
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
const firstChar = text.trimStart()[0];
|
|
631
|
+
return firstChar === "{";
|
|
632
|
+
}
|
|
633
|
+
function parsePolicyManifest(text, contentType) {
|
|
634
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
635
|
+
if (bytes > schema.PEAC_POLICY_MAX_BYTES) {
|
|
636
|
+
throw new Error(`Policy manifest exceeds ${schema.PEAC_POLICY_MAX_BYTES} bytes (got ${bytes})`);
|
|
637
|
+
}
|
|
638
|
+
let manifest;
|
|
639
|
+
if (isJsonContent(text, contentType)) {
|
|
640
|
+
try {
|
|
641
|
+
manifest = JSON.parse(text);
|
|
642
|
+
} catch {
|
|
643
|
+
throw new Error("Policy manifest is not valid JSON");
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
manifest = parseSimpleYaml(text);
|
|
647
|
+
}
|
|
648
|
+
if (typeof manifest.version !== "string" || !manifest.version) {
|
|
649
|
+
throw new Error("Missing required field: version");
|
|
650
|
+
}
|
|
651
|
+
if (!manifest.version.startsWith("peac-policy/")) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`Invalid version format: "${manifest.version}". Must start with "peac-policy/" (e.g., "peac-policy/0.1")`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (manifest.usage !== "open" && manifest.usage !== "conditional") {
|
|
657
|
+
throw new Error('Missing or invalid field: usage (must be "open" or "conditional")');
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
version: manifest.version,
|
|
661
|
+
usage: manifest.usage,
|
|
662
|
+
purposes: manifest.purposes,
|
|
663
|
+
receipts: manifest.receipts,
|
|
664
|
+
attribution: manifest.attribution,
|
|
665
|
+
rate_limit: manifest.rate_limit,
|
|
666
|
+
daily_limit: manifest.daily_limit,
|
|
667
|
+
negotiate: manifest.negotiate,
|
|
668
|
+
contact: manifest.contact,
|
|
669
|
+
license: manifest.license,
|
|
670
|
+
price: manifest.price,
|
|
671
|
+
currency: manifest.currency,
|
|
672
|
+
payment_methods: manifest.payment_methods,
|
|
673
|
+
payment_endpoint: manifest.payment_endpoint
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
function parseSimpleYaml(text) {
|
|
677
|
+
const lines = text.split("\n");
|
|
678
|
+
const result = {};
|
|
679
|
+
if (text.includes("<<:")) {
|
|
680
|
+
throw new Error("YAML merge keys are not allowed");
|
|
681
|
+
}
|
|
682
|
+
if (text.includes("&") || text.includes("*")) {
|
|
683
|
+
throw new Error("YAML anchors and aliases are not allowed");
|
|
684
|
+
}
|
|
685
|
+
if (/!\w+/.test(text)) {
|
|
686
|
+
throw new Error("YAML custom tags are not allowed");
|
|
687
|
+
}
|
|
688
|
+
const docSeparators = text.match(/^---$/gm);
|
|
689
|
+
if (docSeparators && docSeparators.length > 1) {
|
|
690
|
+
throw new Error("Multi-document YAML is not allowed");
|
|
691
|
+
}
|
|
692
|
+
for (const line of lines) {
|
|
693
|
+
const trimmed = line.trim();
|
|
694
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed === "---") {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const colonIndex = trimmed.indexOf(":");
|
|
698
|
+
if (colonIndex === -1) continue;
|
|
699
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
700
|
+
let value = trimmed.slice(colonIndex + 1).trim();
|
|
701
|
+
if (value === "") {
|
|
702
|
+
value = void 0;
|
|
703
|
+
} else if (typeof value === "string") {
|
|
704
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
705
|
+
value = value.slice(1, -1);
|
|
706
|
+
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
707
|
+
const inner = value.slice(1, -1);
|
|
708
|
+
value = inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
709
|
+
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
710
|
+
value = parseFloat(value);
|
|
711
|
+
} else if (value === "true") {
|
|
712
|
+
value = true;
|
|
713
|
+
} else if (value === "false") {
|
|
714
|
+
value = false;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (value !== void 0) {
|
|
718
|
+
result[key] = value;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
async function fetchPolicyManifest(baseUrl) {
|
|
724
|
+
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
|
|
725
|
+
throw new Error("Base URL must be https://");
|
|
726
|
+
}
|
|
727
|
+
const normalizedBase = baseUrl.replace(/\/$/, "");
|
|
728
|
+
const primaryUrl = `${normalizedBase}${schema.PEAC_POLICY_PATH}`;
|
|
729
|
+
const fallbackUrl = `${normalizedBase}${schema.PEAC_POLICY_FALLBACK_PATH}`;
|
|
730
|
+
try {
|
|
731
|
+
const resp = await fetch(primaryUrl, {
|
|
732
|
+
headers: { Accept: "text/plain, application/json" },
|
|
733
|
+
signal: AbortSignal.timeout(5e3)
|
|
734
|
+
});
|
|
735
|
+
if (resp.ok) {
|
|
736
|
+
const text = await resp.text();
|
|
737
|
+
const contentType = resp.headers.get("content-type") || void 0;
|
|
738
|
+
return parsePolicyManifest(text, contentType);
|
|
739
|
+
}
|
|
740
|
+
if (resp.status === 404) {
|
|
741
|
+
const fallbackResp = await fetch(fallbackUrl, {
|
|
742
|
+
headers: { Accept: "text/plain, application/json" },
|
|
743
|
+
signal: AbortSignal.timeout(5e3)
|
|
744
|
+
});
|
|
745
|
+
if (fallbackResp.ok) {
|
|
746
|
+
const text = await fallbackResp.text();
|
|
747
|
+
const contentType = fallbackResp.headers.get("content-type") || void 0;
|
|
748
|
+
return parsePolicyManifest(text, contentType);
|
|
749
|
+
}
|
|
750
|
+
throw new Error("Policy manifest not found at primary or fallback location");
|
|
751
|
+
}
|
|
752
|
+
throw new Error(`Policy manifest fetch failed: ${resp.status}`);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
throw new Error(
|
|
755
|
+
`Failed to fetch policy manifest from ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
756
|
+
{ cause: err }
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function parseDiscovery(text) {
|
|
761
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
762
|
+
if (bytes > 2e3) {
|
|
763
|
+
throw new Error(`Discovery manifest exceeds 2000 bytes (got ${bytes})`);
|
|
764
|
+
}
|
|
765
|
+
const lines = text.trim().split("\n");
|
|
766
|
+
if (lines.length > 20) {
|
|
767
|
+
throw new Error(`Discovery manifest exceeds 20 lines (got ${lines.length})`);
|
|
768
|
+
}
|
|
769
|
+
const discovery = {};
|
|
770
|
+
for (const line of lines) {
|
|
771
|
+
const trimmed = line.trim();
|
|
772
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (trimmed.includes(":")) {
|
|
776
|
+
const [key, ...valueParts] = trimmed.split(":");
|
|
777
|
+
const value = valueParts.join(":").trim();
|
|
778
|
+
switch (key.trim()) {
|
|
779
|
+
case "version":
|
|
780
|
+
discovery.version = value;
|
|
781
|
+
break;
|
|
782
|
+
case "issuer":
|
|
783
|
+
discovery.issuer = value;
|
|
784
|
+
break;
|
|
785
|
+
case "verify":
|
|
786
|
+
discovery.verify_endpoint = value;
|
|
787
|
+
break;
|
|
788
|
+
case "jwks":
|
|
789
|
+
discovery.jwks_uri = value;
|
|
790
|
+
break;
|
|
791
|
+
case "security":
|
|
792
|
+
discovery.security_contact = value;
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (!discovery.version) throw new Error("Missing required field: version");
|
|
798
|
+
if (!discovery.issuer) throw new Error("Missing required field: issuer");
|
|
799
|
+
if (!discovery.verify_endpoint) throw new Error("Missing required field: verify");
|
|
800
|
+
if (!discovery.jwks_uri) throw new Error("Missing required field: jwks");
|
|
801
|
+
return discovery;
|
|
802
|
+
}
|
|
803
|
+
async function fetchDiscovery(issuerUrl) {
|
|
804
|
+
if (!issuerUrl.startsWith("https://")) {
|
|
805
|
+
throw new Error("Issuer URL must be https://");
|
|
806
|
+
}
|
|
807
|
+
const discoveryUrl = `${issuerUrl}/.well-known/peac.txt`;
|
|
808
|
+
try {
|
|
809
|
+
const resp = await fetch(discoveryUrl, {
|
|
810
|
+
headers: { Accept: "text/plain" },
|
|
811
|
+
signal: AbortSignal.timeout(5e3)
|
|
812
|
+
});
|
|
813
|
+
if (!resp.ok) {
|
|
814
|
+
throw new Error(`Discovery fetch failed: ${resp.status}`);
|
|
815
|
+
}
|
|
816
|
+
const text = await resp.text();
|
|
817
|
+
return parseDiscovery(text);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
`Failed to fetch discovery from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
821
|
+
{ cause: err }
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
var DEFAULT_VERIFIER_LIMITS = {
|
|
826
|
+
max_receipt_bytes: kernel.VERIFIER_LIMITS.maxReceiptBytes,
|
|
827
|
+
max_jwks_bytes: kernel.VERIFIER_LIMITS.maxJwksBytes,
|
|
828
|
+
max_jwks_keys: kernel.VERIFIER_LIMITS.maxJwksKeys,
|
|
829
|
+
max_redirects: kernel.VERIFIER_LIMITS.maxRedirects,
|
|
830
|
+
fetch_timeout_ms: kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
831
|
+
max_extension_bytes: kernel.VERIFIER_LIMITS.maxExtensionBytes
|
|
832
|
+
};
|
|
833
|
+
var DEFAULT_NETWORK_SECURITY = {
|
|
834
|
+
https_only: kernel.VERIFIER_NETWORK.httpsOnly,
|
|
835
|
+
block_private_ips: kernel.VERIFIER_NETWORK.blockPrivateIps,
|
|
836
|
+
allow_redirects: kernel.VERIFIER_NETWORK.allowRedirects,
|
|
837
|
+
allow_cross_origin_redirects: true,
|
|
838
|
+
// Allow for CDN compatibility
|
|
839
|
+
dns_failure_behavior: "block"
|
|
840
|
+
// Fail-closed by default
|
|
841
|
+
};
|
|
842
|
+
function createDefaultPolicy(mode) {
|
|
843
|
+
return {
|
|
844
|
+
policy_version: kernel.VERIFIER_POLICY_VERSION,
|
|
845
|
+
mode,
|
|
846
|
+
limits: { ...DEFAULT_VERIFIER_LIMITS },
|
|
847
|
+
network: { ...DEFAULT_NETWORK_SECURITY }
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
var CHECK_IDS = [
|
|
851
|
+
"jws.parse",
|
|
852
|
+
"limits.receipt_bytes",
|
|
853
|
+
"jws.protected_header",
|
|
854
|
+
"claims.schema_unverified",
|
|
855
|
+
"issuer.trust_policy",
|
|
856
|
+
"issuer.discovery",
|
|
857
|
+
"key.resolve",
|
|
858
|
+
"jws.signature",
|
|
859
|
+
"claims.time_window",
|
|
860
|
+
"extensions.limits",
|
|
861
|
+
"transport.profile_binding",
|
|
862
|
+
"policy.binding"
|
|
863
|
+
];
|
|
864
|
+
var NON_DETERMINISTIC_ARTIFACT_KEYS = [
|
|
865
|
+
"issuer_jwks_digest"
|
|
866
|
+
];
|
|
867
|
+
function createDigest(hexValue) {
|
|
868
|
+
return {
|
|
869
|
+
alg: "sha-256",
|
|
870
|
+
value: hexValue.toLowerCase()
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function createEmptyReport(policy) {
|
|
874
|
+
return {
|
|
875
|
+
report_version: kernel.VERIFICATION_REPORT_VERSION,
|
|
876
|
+
policy
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
function ssrfErrorToReasonCode(ssrfReason, fetchType) {
|
|
880
|
+
const prefix = fetchType === "key" ? "key_fetch" : "pointer_fetch";
|
|
881
|
+
switch (ssrfReason) {
|
|
882
|
+
case "not_https":
|
|
883
|
+
case "private_ip":
|
|
884
|
+
case "loopback":
|
|
885
|
+
case "link_local":
|
|
886
|
+
case "cross_origin_redirect":
|
|
887
|
+
case "dns_failure":
|
|
888
|
+
return `${prefix}_blocked`;
|
|
889
|
+
case "timeout":
|
|
890
|
+
return `${prefix}_timeout`;
|
|
891
|
+
case "response_too_large":
|
|
892
|
+
return fetchType === "pointer" ? "pointer_fetch_too_large" : "jwks_too_large";
|
|
893
|
+
case "jwks_too_many_keys":
|
|
894
|
+
return "jwks_too_many_keys";
|
|
895
|
+
case "too_many_redirects":
|
|
896
|
+
case "scheme_downgrade":
|
|
897
|
+
case "network_error":
|
|
898
|
+
case "invalid_url":
|
|
899
|
+
default:
|
|
900
|
+
return `${prefix}_failed`;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
function reasonCodeToSeverity(reason) {
|
|
904
|
+
if (reason === "ok") return "info";
|
|
905
|
+
return "error";
|
|
906
|
+
}
|
|
907
|
+
function reasonCodeToErrorCode(reason) {
|
|
908
|
+
const mapping = {
|
|
909
|
+
ok: "",
|
|
910
|
+
receipt_too_large: "E_VERIFY_RECEIPT_TOO_LARGE",
|
|
911
|
+
malformed_receipt: "E_VERIFY_MALFORMED_RECEIPT",
|
|
912
|
+
signature_invalid: "E_VERIFY_SIGNATURE_INVALID",
|
|
913
|
+
issuer_not_allowed: "E_VERIFY_ISSUER_NOT_ALLOWED",
|
|
914
|
+
key_not_found: "E_VERIFY_KEY_NOT_FOUND",
|
|
915
|
+
key_fetch_blocked: "E_VERIFY_KEY_FETCH_BLOCKED",
|
|
916
|
+
key_fetch_failed: "E_VERIFY_KEY_FETCH_FAILED",
|
|
917
|
+
key_fetch_timeout: "E_VERIFY_KEY_FETCH_TIMEOUT",
|
|
918
|
+
pointer_fetch_blocked: "E_VERIFY_POINTER_FETCH_BLOCKED",
|
|
919
|
+
pointer_fetch_failed: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
920
|
+
pointer_fetch_timeout: "E_VERIFY_POINTER_FETCH_TIMEOUT",
|
|
921
|
+
pointer_fetch_too_large: "E_VERIFY_POINTER_FETCH_TOO_LARGE",
|
|
922
|
+
pointer_digest_mismatch: "E_VERIFY_POINTER_DIGEST_MISMATCH",
|
|
923
|
+
jwks_too_large: "E_VERIFY_JWKS_TOO_LARGE",
|
|
924
|
+
jwks_too_many_keys: "E_VERIFY_JWKS_TOO_MANY_KEYS",
|
|
925
|
+
expired: "E_VERIFY_EXPIRED",
|
|
926
|
+
not_yet_valid: "E_VERIFY_NOT_YET_VALID",
|
|
927
|
+
audience_mismatch: "E_VERIFY_AUDIENCE_MISMATCH",
|
|
928
|
+
schema_invalid: "E_VERIFY_SCHEMA_INVALID",
|
|
929
|
+
policy_violation: "E_VERIFY_POLICY_VIOLATION",
|
|
930
|
+
extension_too_large: "E_VERIFY_EXTENSION_TOO_LARGE",
|
|
931
|
+
invalid_transport: "E_VERIFY_INVALID_TRANSPORT"
|
|
932
|
+
};
|
|
933
|
+
return mapping[reason] || "E_VERIFY_POLICY_VIOLATION";
|
|
934
|
+
}
|
|
935
|
+
var cachedCapabilities = null;
|
|
936
|
+
function getSSRFCapabilities() {
|
|
937
|
+
if (cachedCapabilities) {
|
|
938
|
+
return cachedCapabilities;
|
|
939
|
+
}
|
|
940
|
+
cachedCapabilities = detectCapabilities();
|
|
941
|
+
return cachedCapabilities;
|
|
942
|
+
}
|
|
943
|
+
function detectCapabilities() {
|
|
944
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
945
|
+
return {
|
|
946
|
+
runtime: "node",
|
|
947
|
+
dnsPreResolution: true,
|
|
948
|
+
ipBlocking: true,
|
|
949
|
+
networkIsolation: false,
|
|
950
|
+
protectionLevel: "full",
|
|
951
|
+
notes: [
|
|
952
|
+
"Full SSRF protection available via Node.js dns module",
|
|
953
|
+
"DNS resolution checked before HTTP connection",
|
|
954
|
+
"All RFC 1918 private ranges blocked"
|
|
955
|
+
]
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
if (typeof process !== "undefined" && process.versions?.bun) {
|
|
959
|
+
return {
|
|
960
|
+
runtime: "bun",
|
|
961
|
+
dnsPreResolution: true,
|
|
962
|
+
ipBlocking: true,
|
|
963
|
+
networkIsolation: false,
|
|
964
|
+
protectionLevel: "full",
|
|
965
|
+
notes: [
|
|
966
|
+
"Full SSRF protection available via Bun dns compatibility",
|
|
967
|
+
"DNS resolution checked before HTTP connection"
|
|
968
|
+
]
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
if (typeof globalThis !== "undefined" && "Deno" in globalThis) {
|
|
972
|
+
return {
|
|
973
|
+
runtime: "deno",
|
|
974
|
+
dnsPreResolution: false,
|
|
975
|
+
ipBlocking: false,
|
|
976
|
+
networkIsolation: false,
|
|
977
|
+
protectionLevel: "partial",
|
|
978
|
+
notes: [
|
|
979
|
+
"DNS pre-resolution not available in Deno by default",
|
|
980
|
+
"SSRF protection limited to URL validation and response limits",
|
|
981
|
+
"Consider using Deno.connect with hostname resolution for enhanced protection"
|
|
982
|
+
]
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
if (typeof globalThis !== "undefined" && typeof globalThis.caches !== "undefined" && typeof globalThis.HTMLRewriter !== "undefined") {
|
|
986
|
+
return {
|
|
987
|
+
runtime: "cloudflare-workers",
|
|
988
|
+
dnsPreResolution: false,
|
|
989
|
+
ipBlocking: false,
|
|
990
|
+
networkIsolation: true,
|
|
991
|
+
protectionLevel: "partial",
|
|
992
|
+
notes: [
|
|
993
|
+
"Cloudflare Workers provide network-level isolation",
|
|
994
|
+
"DNS pre-resolution not available in Workers runtime",
|
|
995
|
+
"CF network blocks many SSRF vectors at infrastructure level",
|
|
996
|
+
"SSRF protection supplemented by URL validation and response limits"
|
|
997
|
+
]
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const g = globalThis;
|
|
1001
|
+
if (typeof g.window !== "undefined" || typeof g.document !== "undefined") {
|
|
1002
|
+
return {
|
|
1003
|
+
runtime: "browser",
|
|
1004
|
+
dnsPreResolution: false,
|
|
1005
|
+
ipBlocking: false,
|
|
1006
|
+
networkIsolation: false,
|
|
1007
|
+
protectionLevel: "minimal",
|
|
1008
|
+
notes: [
|
|
1009
|
+
"Browser environment detected; DNS pre-resolution not available",
|
|
1010
|
+
"SSRF protection limited to URL scheme validation",
|
|
1011
|
+
"Consider validating URLs server-side before browser fetch",
|
|
1012
|
+
"Same-origin policy provides some protection against SSRF"
|
|
1013
|
+
]
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
runtime: "edge-generic",
|
|
1018
|
+
dnsPreResolution: false,
|
|
1019
|
+
ipBlocking: false,
|
|
1020
|
+
networkIsolation: false,
|
|
1021
|
+
protectionLevel: "partial",
|
|
1022
|
+
notes: [
|
|
1023
|
+
"Edge runtime detected; DNS pre-resolution may not be available",
|
|
1024
|
+
"SSRF protection limited to URL validation and response limits",
|
|
1025
|
+
"Verify runtime provides additional network-level protections"
|
|
1026
|
+
]
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function resetSSRFCapabilitiesCache() {
|
|
1030
|
+
cachedCapabilities = null;
|
|
1031
|
+
}
|
|
1032
|
+
function parseIPv4(ip) {
|
|
1033
|
+
const parts = ip.split(".");
|
|
1034
|
+
if (parts.length !== 4) return null;
|
|
1035
|
+
const octets = [];
|
|
1036
|
+
for (const part of parts) {
|
|
1037
|
+
const num = parseInt(part, 10);
|
|
1038
|
+
if (isNaN(num) || num < 0 || num > 255) return null;
|
|
1039
|
+
octets.push(num);
|
|
1040
|
+
}
|
|
1041
|
+
return { octets };
|
|
1042
|
+
}
|
|
1043
|
+
function isInCIDR(ip, cidr) {
|
|
1044
|
+
const [rangeStr, maskStr] = cidr.split("/");
|
|
1045
|
+
const range = parseIPv4(rangeStr);
|
|
1046
|
+
if (!range) return false;
|
|
1047
|
+
const maskBits = parseInt(maskStr, 10);
|
|
1048
|
+
if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
|
|
1049
|
+
const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
|
|
1050
|
+
const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
|
|
1051
|
+
const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
|
|
1052
|
+
return (ipNum & mask) === (rangeNum & mask);
|
|
1053
|
+
}
|
|
1054
|
+
function isIPv6Loopback(ip) {
|
|
1055
|
+
const normalized = ip.toLowerCase().replace(/^::ffff:/, "");
|
|
1056
|
+
return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
|
|
1057
|
+
}
|
|
1058
|
+
function isIPv6LinkLocal(ip) {
|
|
1059
|
+
const normalized = ip.toLowerCase();
|
|
1060
|
+
return normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
|
1061
|
+
}
|
|
1062
|
+
function isBlockedIP(ip) {
|
|
1063
|
+
const ipv4Match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
1064
|
+
const effectiveIP = ipv4Match ? ipv4Match[1] : ip;
|
|
1065
|
+
const ipv4 = parseIPv4(effectiveIP);
|
|
1066
|
+
if (ipv4) {
|
|
1067
|
+
if (isInCIDR(ipv4, "10.0.0.0/8") || isInCIDR(ipv4, "172.16.0.0/12") || isInCIDR(ipv4, "192.168.0.0/16")) {
|
|
1068
|
+
return { blocked: true, reason: "private_ip" };
|
|
1069
|
+
}
|
|
1070
|
+
if (isInCIDR(ipv4, "127.0.0.0/8")) {
|
|
1071
|
+
return { blocked: true, reason: "loopback" };
|
|
1072
|
+
}
|
|
1073
|
+
if (isInCIDR(ipv4, "169.254.0.0/16")) {
|
|
1074
|
+
return { blocked: true, reason: "link_local" };
|
|
1075
|
+
}
|
|
1076
|
+
return { blocked: false };
|
|
1077
|
+
}
|
|
1078
|
+
if (isIPv6Loopback(ip)) {
|
|
1079
|
+
return { blocked: true, reason: "loopback" };
|
|
1080
|
+
}
|
|
1081
|
+
if (isIPv6LinkLocal(ip)) {
|
|
1082
|
+
return { blocked: true, reason: "link_local" };
|
|
1083
|
+
}
|
|
1084
|
+
return { blocked: false };
|
|
1085
|
+
}
|
|
1086
|
+
async function resolveHostname(hostname) {
|
|
1087
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
1088
|
+
try {
|
|
1089
|
+
const dns = await import('dns');
|
|
1090
|
+
const { promisify } = await import('util');
|
|
1091
|
+
const resolve4 = promisify(dns.resolve4);
|
|
1092
|
+
const resolve6 = promisify(dns.resolve6);
|
|
1093
|
+
const results = [];
|
|
1094
|
+
let ipv4Error = null;
|
|
1095
|
+
let ipv6Error = null;
|
|
1096
|
+
try {
|
|
1097
|
+
const ipv4 = await resolve4(hostname);
|
|
1098
|
+
results.push(...ipv4);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
ipv4Error = err;
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
const ipv6 = await resolve6(hostname);
|
|
1104
|
+
results.push(...ipv6);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
ipv6Error = err;
|
|
1107
|
+
}
|
|
1108
|
+
if (results.length > 0) {
|
|
1109
|
+
return { ok: true, ips: results, browser: false };
|
|
1110
|
+
}
|
|
1111
|
+
if (ipv4Error && ipv6Error) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
return { ok: true, ips: [], browser: false };
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
return {
|
|
1120
|
+
ok: false,
|
|
1121
|
+
message: `DNS resolution error: ${err instanceof Error ? err.message : String(err)}`
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return { ok: true, ips: [], browser: true };
|
|
1126
|
+
}
|
|
1127
|
+
async function ssrfSafeFetch(url, options = {}) {
|
|
1128
|
+
const {
|
|
1129
|
+
timeoutMs = kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
1130
|
+
maxBytes = kernel.VERIFIER_LIMITS.maxResponseBytes,
|
|
1131
|
+
maxRedirects = 0,
|
|
1132
|
+
allowRedirects = kernel.VERIFIER_NETWORK.allowRedirects,
|
|
1133
|
+
allowCrossOriginRedirects = true,
|
|
1134
|
+
// Default: allow for CDN compatibility
|
|
1135
|
+
dnsFailureBehavior = "block",
|
|
1136
|
+
// Default: fail-closed for security
|
|
1137
|
+
headers = {}
|
|
1138
|
+
} = options;
|
|
1139
|
+
let parsedUrl;
|
|
1140
|
+
try {
|
|
1141
|
+
parsedUrl = new URL(url);
|
|
1142
|
+
} catch {
|
|
1143
|
+
return {
|
|
1144
|
+
ok: false,
|
|
1145
|
+
reason: "invalid_url",
|
|
1146
|
+
message: `Invalid URL: ${url}`,
|
|
1147
|
+
blockedUrl: url
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
if (parsedUrl.protocol !== "https:") {
|
|
1151
|
+
return {
|
|
1152
|
+
ok: false,
|
|
1153
|
+
reason: "not_https",
|
|
1154
|
+
message: `URL must use HTTPS: ${url}`,
|
|
1155
|
+
blockedUrl: url
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
const dnsResult = await resolveHostname(parsedUrl.hostname);
|
|
1159
|
+
if (!dnsResult.ok) {
|
|
1160
|
+
if (dnsFailureBehavior === "block") {
|
|
1161
|
+
return {
|
|
1162
|
+
ok: false,
|
|
1163
|
+
reason: "dns_failure",
|
|
1164
|
+
message: `DNS resolution blocked: ${dnsResult.message}`,
|
|
1165
|
+
blockedUrl: url
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
return {
|
|
1169
|
+
ok: false,
|
|
1170
|
+
reason: "network_error",
|
|
1171
|
+
message: dnsResult.message,
|
|
1172
|
+
blockedUrl: url
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
if (!dnsResult.browser) {
|
|
1176
|
+
for (const ip of dnsResult.ips) {
|
|
1177
|
+
const blockResult = isBlockedIP(ip);
|
|
1178
|
+
if (blockResult.blocked) {
|
|
1179
|
+
return {
|
|
1180
|
+
ok: false,
|
|
1181
|
+
reason: blockResult.reason,
|
|
1182
|
+
message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
|
|
1183
|
+
blockedUrl: url
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
let redirectCount = 0;
|
|
1189
|
+
let currentUrl = url;
|
|
1190
|
+
const originalOrigin = parsedUrl.origin;
|
|
1191
|
+
while (true) {
|
|
1192
|
+
try {
|
|
1193
|
+
const controller = new AbortController();
|
|
1194
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1195
|
+
const response = await fetch(currentUrl, {
|
|
1196
|
+
headers: {
|
|
1197
|
+
Accept: "application/json, text/plain",
|
|
1198
|
+
...headers
|
|
1199
|
+
},
|
|
1200
|
+
signal: controller.signal,
|
|
1201
|
+
redirect: "manual"
|
|
1202
|
+
// Handle redirects manually for security
|
|
1203
|
+
});
|
|
1204
|
+
clearTimeout(timeoutId);
|
|
1205
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1206
|
+
const location = response.headers.get("location");
|
|
1207
|
+
if (!location) {
|
|
1208
|
+
return {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
reason: "network_error",
|
|
1211
|
+
message: `Redirect without Location header from ${currentUrl}`
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
if (!allowRedirects) {
|
|
1215
|
+
return {
|
|
1216
|
+
ok: false,
|
|
1217
|
+
reason: "too_many_redirects",
|
|
1218
|
+
message: `Redirects not allowed: ${currentUrl} -> ${location}`,
|
|
1219
|
+
blockedUrl: location
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
redirectCount++;
|
|
1223
|
+
if (redirectCount > maxRedirects) {
|
|
1224
|
+
return {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
reason: "too_many_redirects",
|
|
1227
|
+
message: `Too many redirects (${redirectCount} > ${maxRedirects})`,
|
|
1228
|
+
blockedUrl: location
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
let redirectUrl;
|
|
1232
|
+
try {
|
|
1233
|
+
redirectUrl = new URL(location, currentUrl);
|
|
1234
|
+
} catch {
|
|
1235
|
+
return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
reason: "invalid_url",
|
|
1238
|
+
message: `Invalid redirect URL: ${location}`,
|
|
1239
|
+
blockedUrl: location
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
if (redirectUrl.protocol !== "https:") {
|
|
1243
|
+
return {
|
|
1244
|
+
ok: false,
|
|
1245
|
+
reason: "scheme_downgrade",
|
|
1246
|
+
message: `HTTPS to HTTP downgrade not allowed: ${currentUrl} -> ${redirectUrl.href}`,
|
|
1247
|
+
blockedUrl: redirectUrl.href
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
if (redirectUrl.origin !== originalOrigin && !allowCrossOriginRedirects) {
|
|
1251
|
+
return {
|
|
1252
|
+
ok: false,
|
|
1253
|
+
reason: "cross_origin_redirect",
|
|
1254
|
+
message: `Cross-origin redirect not allowed: ${originalOrigin} -> ${redirectUrl.origin}`,
|
|
1255
|
+
blockedUrl: redirectUrl.href
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
const redirectDnsResult = await resolveHostname(redirectUrl.hostname);
|
|
1259
|
+
if (!redirectDnsResult.ok) {
|
|
1260
|
+
if (dnsFailureBehavior === "block") {
|
|
1261
|
+
return {
|
|
1262
|
+
ok: false,
|
|
1263
|
+
reason: "dns_failure",
|
|
1264
|
+
message: `Redirect DNS resolution blocked: ${redirectDnsResult.message}`,
|
|
1265
|
+
blockedUrl: redirectUrl.href
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
return {
|
|
1269
|
+
ok: false,
|
|
1270
|
+
reason: "network_error",
|
|
1271
|
+
message: redirectDnsResult.message,
|
|
1272
|
+
blockedUrl: redirectUrl.href
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
if (!redirectDnsResult.browser) {
|
|
1276
|
+
for (const ip of redirectDnsResult.ips) {
|
|
1277
|
+
const blockResult = isBlockedIP(ip);
|
|
1278
|
+
if (blockResult.blocked) {
|
|
1279
|
+
return {
|
|
1280
|
+
ok: false,
|
|
1281
|
+
reason: blockResult.reason,
|
|
1282
|
+
message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
|
|
1283
|
+
blockedUrl: redirectUrl.href
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
currentUrl = redirectUrl.href;
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
const contentLength = response.headers.get("content-length");
|
|
1292
|
+
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
|
1293
|
+
return {
|
|
1294
|
+
ok: false,
|
|
1295
|
+
reason: "response_too_large",
|
|
1296
|
+
message: `Response too large: ${contentLength} bytes > ${maxBytes} max`
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
const reader = response.body?.getReader();
|
|
1300
|
+
if (!reader) {
|
|
1301
|
+
const body2 = await response.text();
|
|
1302
|
+
if (body2.length > maxBytes) {
|
|
1303
|
+
return {
|
|
1304
|
+
ok: false,
|
|
1305
|
+
reason: "response_too_large",
|
|
1306
|
+
message: `Response too large: ${body2.length} bytes > ${maxBytes} max`
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
const rawBytes2 = new TextEncoder().encode(body2);
|
|
1310
|
+
return {
|
|
1311
|
+
ok: true,
|
|
1312
|
+
status: response.status,
|
|
1313
|
+
body: body2,
|
|
1314
|
+
rawBytes: rawBytes2,
|
|
1315
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
const chunks = [];
|
|
1319
|
+
let totalSize = 0;
|
|
1320
|
+
while (true) {
|
|
1321
|
+
const { done, value } = await reader.read();
|
|
1322
|
+
if (done) break;
|
|
1323
|
+
totalSize += value.length;
|
|
1324
|
+
if (totalSize > maxBytes) {
|
|
1325
|
+
reader.cancel();
|
|
1326
|
+
return {
|
|
1327
|
+
ok: false,
|
|
1328
|
+
reason: "response_too_large",
|
|
1329
|
+
message: `Response too large: ${totalSize} bytes > ${maxBytes} max`
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
chunks.push(value);
|
|
1333
|
+
}
|
|
1334
|
+
const rawBytes = chunks.reduce((acc, chunk) => {
|
|
1335
|
+
const result = new Uint8Array(acc.length + chunk.length);
|
|
1336
|
+
result.set(acc);
|
|
1337
|
+
result.set(chunk, acc.length);
|
|
1338
|
+
return result;
|
|
1339
|
+
}, new Uint8Array());
|
|
1340
|
+
const body = new TextDecoder().decode(rawBytes);
|
|
1341
|
+
return {
|
|
1342
|
+
ok: true,
|
|
1343
|
+
status: response.status,
|
|
1344
|
+
body,
|
|
1345
|
+
rawBytes,
|
|
1346
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
1347
|
+
};
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
if (err instanceof Error) {
|
|
1350
|
+
if (err.name === "AbortError" || err.message.includes("timeout")) {
|
|
1351
|
+
return {
|
|
1352
|
+
ok: false,
|
|
1353
|
+
reason: "timeout",
|
|
1354
|
+
message: `Fetch timeout after ${timeoutMs}ms: ${currentUrl}`
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return {
|
|
1359
|
+
ok: false,
|
|
1360
|
+
reason: "network_error",
|
|
1361
|
+
message: `Network error: ${err instanceof Error ? err.message : String(err)}`
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
async function fetchJWKSSafe(jwksUrl, options) {
|
|
1367
|
+
return ssrfSafeFetch(jwksUrl, {
|
|
1368
|
+
...options,
|
|
1369
|
+
maxBytes: kernel.VERIFIER_LIMITS.maxJwksBytes,
|
|
1370
|
+
headers: {
|
|
1371
|
+
Accept: "application/json",
|
|
1372
|
+
...options?.headers
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
async function fetchPointerSafe(pointerUrl, options) {
|
|
1377
|
+
return ssrfSafeFetch(pointerUrl, {
|
|
1378
|
+
...options,
|
|
1379
|
+
maxBytes: kernel.VERIFIER_LIMITS.maxReceiptBytes,
|
|
1380
|
+
headers: {
|
|
1381
|
+
Accept: "application/jose, application/json",
|
|
1382
|
+
...options?.headers
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
var VerificationReportBuilder = class {
|
|
1387
|
+
state;
|
|
1388
|
+
constructor(policy) {
|
|
1389
|
+
this.state = {
|
|
1390
|
+
policy,
|
|
1391
|
+
checks: /* @__PURE__ */ new Map(),
|
|
1392
|
+
shortCircuited: false
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Set the input descriptor with pre-computed digest
|
|
1397
|
+
*
|
|
1398
|
+
* Use this when you've already computed the SHA-256 hash.
|
|
1399
|
+
*
|
|
1400
|
+
* @param digestHex - SHA-256 digest as lowercase hex (64 chars)
|
|
1401
|
+
* @param type - Input type
|
|
1402
|
+
*/
|
|
1403
|
+
setInputWithDigest(digestHex, type = "receipt_jws") {
|
|
1404
|
+
this.state.receiptDigestHex = digestHex;
|
|
1405
|
+
this.state.input = {
|
|
1406
|
+
type,
|
|
1407
|
+
receipt_digest: createDigest(digestHex)
|
|
1408
|
+
};
|
|
1409
|
+
return this;
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Set the input descriptor (async - computes SHA-256)
|
|
1413
|
+
*
|
|
1414
|
+
* @param receiptBytes - Raw receipt bytes
|
|
1415
|
+
* @param type - Input type
|
|
1416
|
+
*/
|
|
1417
|
+
async setInputAsync(receiptBytes, type = "receipt_jws") {
|
|
1418
|
+
const digestHex = await crypto.sha256Hex(receiptBytes);
|
|
1419
|
+
return this.setInputWithDigest(digestHex, type);
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Add a check result
|
|
1423
|
+
*
|
|
1424
|
+
* Checks can be added in any order; they will be sorted in build().
|
|
1425
|
+
* If a previous check failed, subsequent checks should be marked as skip.
|
|
1426
|
+
*/
|
|
1427
|
+
addCheck(id, status, detail, errorCode) {
|
|
1428
|
+
const check = { id, status };
|
|
1429
|
+
if (detail && Object.keys(detail).length > 0) {
|
|
1430
|
+
check.detail = detail;
|
|
1431
|
+
}
|
|
1432
|
+
if (errorCode) {
|
|
1433
|
+
check.error_code = errorCode;
|
|
1434
|
+
}
|
|
1435
|
+
this.state.checks.set(id, check);
|
|
1436
|
+
if (status === "fail" && !this.state.shortCircuited) {
|
|
1437
|
+
this.state.shortCircuited = true;
|
|
1438
|
+
this.state.failedAtCheck = id;
|
|
1439
|
+
}
|
|
1440
|
+
return this;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Add a passing check
|
|
1444
|
+
*/
|
|
1445
|
+
pass(id, detail) {
|
|
1446
|
+
return this.addCheck(id, "pass", detail);
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Add a failing check
|
|
1450
|
+
*/
|
|
1451
|
+
fail(id, errorCode, detail) {
|
|
1452
|
+
return this.addCheck(id, "fail", detail, errorCode);
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Add a skipped check
|
|
1456
|
+
*/
|
|
1457
|
+
skip(id, detail) {
|
|
1458
|
+
return this.addCheck(id, "skip", detail);
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Set the final result
|
|
1462
|
+
*/
|
|
1463
|
+
setResult(valid, reason, options) {
|
|
1464
|
+
this.state.result = {
|
|
1465
|
+
valid,
|
|
1466
|
+
reason,
|
|
1467
|
+
severity: reasonCodeToSeverity(reason),
|
|
1468
|
+
receipt_type: options?.receiptType ?? kernel.WIRE_TYPE,
|
|
1469
|
+
// Wire 0.1: always 'unavailable' (DD-49). Wire 0.2 will set this via options.
|
|
1470
|
+
policy_binding: "unavailable",
|
|
1471
|
+
...options?.issuer && { issuer: options.issuer },
|
|
1472
|
+
...options?.kid && { kid: options.kid }
|
|
1473
|
+
};
|
|
1474
|
+
return this;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Set success result
|
|
1478
|
+
*/
|
|
1479
|
+
success(issuer, kid) {
|
|
1480
|
+
return this.setResult(true, "ok", { issuer, kid });
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Set failure result
|
|
1484
|
+
*/
|
|
1485
|
+
failure(reason, issuer, kid) {
|
|
1486
|
+
return this.setResult(false, reason, { issuer, kid });
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Add artifacts
|
|
1490
|
+
*/
|
|
1491
|
+
addArtifact(key, value) {
|
|
1492
|
+
if (!this.state.artifacts) {
|
|
1493
|
+
this.state.artifacts = {};
|
|
1494
|
+
}
|
|
1495
|
+
this.state.artifacts[key] = value;
|
|
1496
|
+
return this;
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Set metadata (non-deterministic fields)
|
|
1500
|
+
*/
|
|
1501
|
+
setMeta(meta) {
|
|
1502
|
+
this.state.meta = meta;
|
|
1503
|
+
return this;
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Add current timestamp to meta
|
|
1507
|
+
*/
|
|
1508
|
+
addTimestamp() {
|
|
1509
|
+
if (!this.state.meta) {
|
|
1510
|
+
this.state.meta = {};
|
|
1511
|
+
}
|
|
1512
|
+
this.state.meta.generated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1513
|
+
return this;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Build the final report
|
|
1517
|
+
*
|
|
1518
|
+
* Ensures all checks are present (shape-stable).
|
|
1519
|
+
* Missing checks after a failure are marked as 'skip'.
|
|
1520
|
+
* Missing checks before a failure (or in success) are marked as 'pass'.
|
|
1521
|
+
*/
|
|
1522
|
+
build() {
|
|
1523
|
+
if (!this.state.input) {
|
|
1524
|
+
throw new Error("Input is required. Call setInputWithDigest() or setInputAsync() first.");
|
|
1525
|
+
}
|
|
1526
|
+
if (!this.state.result) {
|
|
1527
|
+
throw new Error("Result is required. Call setResult() or success()/failure() first.");
|
|
1528
|
+
}
|
|
1529
|
+
const checks = [];
|
|
1530
|
+
const failedIndex = this.state.failedAtCheck ? CHECK_IDS.indexOf(this.state.failedAtCheck) : -1;
|
|
1531
|
+
for (let i = 0; i < CHECK_IDS.length; i++) {
|
|
1532
|
+
const checkId = CHECK_IDS[i];
|
|
1533
|
+
const existing = this.state.checks.get(checkId);
|
|
1534
|
+
if (existing) {
|
|
1535
|
+
checks.push(existing);
|
|
1536
|
+
} else if (this.state.shortCircuited && i > failedIndex) {
|
|
1537
|
+
checks.push({ id: checkId, status: "skip", detail: { reason: "short_circuit" } });
|
|
1538
|
+
} else {
|
|
1539
|
+
if (checkId === "transport.profile_binding") {
|
|
1540
|
+
checks.push({ id: checkId, status: "skip", detail: { reason: "not_applicable" } });
|
|
1541
|
+
} else if (checkId === "policy.binding") {
|
|
1542
|
+
checks.push({
|
|
1543
|
+
id: checkId,
|
|
1544
|
+
status: "skip",
|
|
1545
|
+
detail: { reason: "wire_01_no_policy_digest" }
|
|
1546
|
+
});
|
|
1547
|
+
} else {
|
|
1548
|
+
checks.push({ id: checkId, status: "skip", detail: { reason: "not_executed" } });
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
const report = {
|
|
1553
|
+
report_version: kernel.VERIFICATION_REPORT_VERSION,
|
|
1554
|
+
input: this.state.input,
|
|
1555
|
+
policy: this.state.policy,
|
|
1556
|
+
result: this.state.result,
|
|
1557
|
+
checks
|
|
1558
|
+
};
|
|
1559
|
+
if (this.state.artifacts && Object.keys(this.state.artifacts).length > 0) {
|
|
1560
|
+
report.artifacts = this.state.artifacts;
|
|
1561
|
+
}
|
|
1562
|
+
if (this.state.meta) {
|
|
1563
|
+
report.meta = this.state.meta;
|
|
1564
|
+
}
|
|
1565
|
+
return report;
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Build in deterministic mode (excludes meta and non-deterministic artifacts)
|
|
1569
|
+
*
|
|
1570
|
+
* Deterministic mode ensures that the same inputs and policy always produce
|
|
1571
|
+
* the same report output, regardless of cache state or timing.
|
|
1572
|
+
*
|
|
1573
|
+
* Excludes:
|
|
1574
|
+
* - `meta`: Contains timestamps and verifier info
|
|
1575
|
+
* - Non-deterministic artifacts: `issuer_jwks_digest` (depends on cache state)
|
|
1576
|
+
*
|
|
1577
|
+
* @returns Report without meta and with only deterministic artifacts
|
|
1578
|
+
*/
|
|
1579
|
+
buildDeterministic() {
|
|
1580
|
+
const report = this.build();
|
|
1581
|
+
const { meta: _meta, ...deterministic } = report;
|
|
1582
|
+
if (deterministic.artifacts) {
|
|
1583
|
+
const filteredArtifacts = { ...deterministic.artifacts };
|
|
1584
|
+
for (const key of NON_DETERMINISTIC_ARTIFACT_KEYS) {
|
|
1585
|
+
delete filteredArtifacts[key];
|
|
1586
|
+
}
|
|
1587
|
+
if (Object.keys(filteredArtifacts).length === 0) {
|
|
1588
|
+
delete deterministic.artifacts;
|
|
1589
|
+
} else {
|
|
1590
|
+
deterministic.artifacts = filteredArtifacts;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return deterministic;
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
function createReportBuilder(policy) {
|
|
1597
|
+
return new VerificationReportBuilder(policy);
|
|
1598
|
+
}
|
|
1599
|
+
async function computeReceiptDigest(receiptBytes) {
|
|
1600
|
+
const bytes = typeof receiptBytes === "string" ? new TextEncoder().encode(receiptBytes) : receiptBytes;
|
|
1601
|
+
return crypto.sha256Hex(bytes);
|
|
1602
|
+
}
|
|
1603
|
+
async function buildFailureReport(policy, receiptBytes, reason, failedCheckId, errorCode, detail, options) {
|
|
1604
|
+
const bytes = typeof receiptBytes === "string" ? new TextEncoder().encode(receiptBytes) : receiptBytes;
|
|
1605
|
+
const digestHex = await crypto.sha256Hex(bytes);
|
|
1606
|
+
const builder = createReportBuilder(policy).setInputWithDigest(digestHex).failure(reason, options?.issuer, options?.kid);
|
|
1607
|
+
const failedIndex = CHECK_IDS.indexOf(failedCheckId);
|
|
1608
|
+
for (let i = 0; i < CHECK_IDS.length; i++) {
|
|
1609
|
+
const checkId = CHECK_IDS[i];
|
|
1610
|
+
if (i < failedIndex) {
|
|
1611
|
+
builder.pass(checkId);
|
|
1612
|
+
} else if (i === failedIndex) {
|
|
1613
|
+
builder.fail(checkId, errorCode ?? reasonCodeToErrorCode(reason), detail);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (options?.meta) {
|
|
1617
|
+
builder.setMeta(options.meta);
|
|
1618
|
+
}
|
|
1619
|
+
return builder.build();
|
|
1620
|
+
}
|
|
1621
|
+
async function buildSuccessReport(policy, receiptBytes, issuer, kid, checkDetails, options) {
|
|
1622
|
+
const bytes = typeof receiptBytes === "string" ? new TextEncoder().encode(receiptBytes) : receiptBytes;
|
|
1623
|
+
const digestHex = await crypto.sha256Hex(bytes);
|
|
1624
|
+
const builder = createReportBuilder(policy).setInputWithDigest(digestHex).success(issuer, kid);
|
|
1625
|
+
for (const checkId of CHECK_IDS) {
|
|
1626
|
+
if (checkId === "issuer.discovery" && policy.mode === "offline_only") {
|
|
1627
|
+
builder.skip(checkId, { reason: "offline_mode" });
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
if (checkId === "transport.profile_binding") {
|
|
1631
|
+
if (checkDetails?.[checkId]) {
|
|
1632
|
+
builder.pass(checkId, checkDetails[checkId]);
|
|
1633
|
+
}
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
if (checkId === "policy.binding") {
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
builder.pass(checkId, checkDetails?.[checkId]);
|
|
1640
|
+
}
|
|
1641
|
+
if (options?.artifacts) {
|
|
1642
|
+
for (const [key, value] of Object.entries(options.artifacts)) {
|
|
1643
|
+
builder.addArtifact(key, value);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (options?.meta) {
|
|
1647
|
+
builder.setMeta(options.meta);
|
|
1648
|
+
}
|
|
1649
|
+
return builder.build();
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// src/verifier-core.ts
|
|
1653
|
+
var jwksCache2 = /* @__PURE__ */ new Map();
|
|
1654
|
+
var CACHE_TTL_MS2 = 5 * 60 * 1e3;
|
|
1655
|
+
function normalizeIssuer(issuer) {
|
|
1656
|
+
try {
|
|
1657
|
+
const url = new URL(issuer);
|
|
1658
|
+
if (url.port && url.port !== "443") {
|
|
1659
|
+
return `${url.protocol}//${url.hostname}:${url.port}`;
|
|
1660
|
+
}
|
|
1661
|
+
return `${url.protocol}//${url.hostname}`;
|
|
1662
|
+
} catch {
|
|
1663
|
+
return issuer;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
function isIssuerAllowed(issuer, allowlist) {
|
|
1667
|
+
if (!allowlist || allowlist.length === 0) {
|
|
1668
|
+
return true;
|
|
1669
|
+
}
|
|
1670
|
+
const normalized = normalizeIssuer(issuer);
|
|
1671
|
+
return allowlist.some((allowed) => normalizeIssuer(allowed) === normalized);
|
|
1672
|
+
}
|
|
1673
|
+
function findPinnedKey(issuer, kid, pinnedKeys) {
|
|
1674
|
+
if (!pinnedKeys || pinnedKeys.length === 0) {
|
|
1675
|
+
return void 0;
|
|
1676
|
+
}
|
|
1677
|
+
const normalizedIssuer = normalizeIssuer(issuer);
|
|
1678
|
+
return pinnedKeys.find((pk) => normalizeIssuer(pk.issuer) === normalizedIssuer && pk.kid === kid);
|
|
1679
|
+
}
|
|
1680
|
+
async function fetchIssuerConfig2(issuerOrigin) {
|
|
1681
|
+
const configUrl = `${issuerOrigin}/.well-known/peac-issuer.json`;
|
|
1682
|
+
const result = await ssrfSafeFetch(configUrl, {
|
|
1683
|
+
maxBytes: 65536,
|
|
1684
|
+
// 64 KB
|
|
1685
|
+
headers: { Accept: "application/json" }
|
|
1686
|
+
});
|
|
1687
|
+
if (!result.ok) {
|
|
1688
|
+
return null;
|
|
1689
|
+
}
|
|
1690
|
+
try {
|
|
1691
|
+
return JSON.parse(result.body);
|
|
1692
|
+
} catch {
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
async function fetchIssuerJWKS(issuerOrigin) {
|
|
1697
|
+
const now = Date.now();
|
|
1698
|
+
const cached = jwksCache2.get(issuerOrigin);
|
|
1699
|
+
if (cached && cached.expiresAt > now) {
|
|
1700
|
+
return { jwks: cached.jwks, fromCache: true };
|
|
1701
|
+
}
|
|
1702
|
+
const config = await fetchIssuerConfig2(issuerOrigin);
|
|
1703
|
+
if (!config?.jwks_uri) {
|
|
1704
|
+
const fallbackUrl = `${issuerOrigin}/.well-known/jwks.json`;
|
|
1705
|
+
const result2 = await fetchJWKSSafe(fallbackUrl);
|
|
1706
|
+
if (!result2.ok) {
|
|
1707
|
+
return { error: result2 };
|
|
1708
|
+
}
|
|
1709
|
+
try {
|
|
1710
|
+
const jwks = JSON.parse(result2.body);
|
|
1711
|
+
jwksCache2.set(issuerOrigin, { jwks, expiresAt: now + CACHE_TTL_MS2 });
|
|
1712
|
+
return { jwks, fromCache: false, rawBytes: result2.rawBytes };
|
|
1713
|
+
} catch {
|
|
1714
|
+
return {
|
|
1715
|
+
error: {
|
|
1716
|
+
ok: false,
|
|
1717
|
+
reason: "network_error",
|
|
1718
|
+
message: "Invalid JWKS JSON"
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
const result = await fetchJWKSSafe(config.jwks_uri);
|
|
1724
|
+
if (!result.ok) {
|
|
1725
|
+
return { error: result };
|
|
1726
|
+
}
|
|
1727
|
+
try {
|
|
1728
|
+
const jwks = JSON.parse(result.body);
|
|
1729
|
+
if (jwks.keys.length > kernel.VERIFIER_LIMITS.maxJwksKeys) {
|
|
1730
|
+
return {
|
|
1731
|
+
error: {
|
|
1732
|
+
ok: false,
|
|
1733
|
+
reason: "jwks_too_many_keys",
|
|
1734
|
+
message: `JWKS has too many keys: ${jwks.keys.length} > ${kernel.VERIFIER_LIMITS.maxJwksKeys}`
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
jwksCache2.set(issuerOrigin, { jwks, expiresAt: now + CACHE_TTL_MS2 });
|
|
1739
|
+
return { jwks, fromCache: false, rawBytes: result.rawBytes };
|
|
1740
|
+
} catch {
|
|
1741
|
+
return {
|
|
1742
|
+
error: {
|
|
1743
|
+
ok: false,
|
|
1744
|
+
reason: "network_error",
|
|
1745
|
+
message: "Invalid JWKS JSON"
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
async function verifyReceiptCore(options) {
|
|
1751
|
+
const {
|
|
1752
|
+
receipt,
|
|
1753
|
+
policy = createDefaultPolicy("offline_preferred"),
|
|
1754
|
+
referenceTime,
|
|
1755
|
+
includeMeta = false
|
|
1756
|
+
} = options;
|
|
1757
|
+
const receiptJws = typeof receipt === "string" ? receipt : new TextDecoder().decode(receipt);
|
|
1758
|
+
const receiptBytes = typeof receipt === "string" ? new TextEncoder().encode(receipt) : receipt;
|
|
1759
|
+
const receiptDigestHex = await crypto.sha256Hex(receiptBytes);
|
|
1760
|
+
const builder = createReportBuilder(policy);
|
|
1761
|
+
builder.setInputWithDigest(receiptDigestHex);
|
|
1762
|
+
const nowSeconds = referenceTime ?? Math.floor(Date.now() / 1e3);
|
|
1763
|
+
let issuer;
|
|
1764
|
+
let kid;
|
|
1765
|
+
let parsedClaims;
|
|
1766
|
+
let header;
|
|
1767
|
+
let payload;
|
|
1768
|
+
try {
|
|
1769
|
+
const decoded = crypto.decode(receiptJws);
|
|
1770
|
+
header = decoded.header;
|
|
1771
|
+
payload = decoded.payload;
|
|
1772
|
+
builder.pass("jws.parse");
|
|
1773
|
+
} catch (err) {
|
|
1774
|
+
builder.fail("jws.parse", "E_VERIFY_MALFORMED_RECEIPT", {
|
|
1775
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1776
|
+
});
|
|
1777
|
+
builder.failure("malformed_receipt");
|
|
1778
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
1779
|
+
}
|
|
1780
|
+
if (receiptBytes.length > policy.limits.max_receipt_bytes) {
|
|
1781
|
+
builder.fail("limits.receipt_bytes", "E_VERIFY_RECEIPT_TOO_LARGE", {
|
|
1782
|
+
size: receiptBytes.length,
|
|
1783
|
+
limit: policy.limits.max_receipt_bytes
|
|
1784
|
+
});
|
|
1785
|
+
builder.failure("receipt_too_large");
|
|
1786
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
1787
|
+
}
|
|
1788
|
+
builder.pass("limits.receipt_bytes", { size: receiptBytes.length });
|
|
1789
|
+
if (header.alg !== "EdDSA") {
|
|
1790
|
+
builder.fail("jws.protected_header", "E_VERIFY_MALFORMED_RECEIPT", {
|
|
1791
|
+
expected_alg: "EdDSA",
|
|
1792
|
+
actual_alg: header.alg
|
|
1793
|
+
});
|
|
1794
|
+
builder.failure("malformed_receipt");
|
|
1795
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
1796
|
+
}
|
|
1797
|
+
if (header.typ !== kernel.WIRE_TYPE) {
|
|
1798
|
+
builder.fail("jws.protected_header", "E_VERIFY_MALFORMED_RECEIPT", {
|
|
1799
|
+
expected_typ: kernel.WIRE_TYPE,
|
|
1800
|
+
actual_typ: header.typ
|
|
1801
|
+
});
|
|
1802
|
+
builder.failure("malformed_receipt");
|
|
1803
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
1804
|
+
}
|
|
1805
|
+
if (!header.kid) {
|
|
1806
|
+
builder.fail("jws.protected_header", "E_VERIFY_MALFORMED_RECEIPT", {
|
|
1807
|
+
error: "Missing kid in protected header"
|
|
1808
|
+
});
|
|
1809
|
+
builder.failure("malformed_receipt");
|
|
1810
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
1811
|
+
}
|
|
1812
|
+
kid = header.kid;
|
|
1813
|
+
builder.pass("jws.protected_header", { alg: header.alg, typ: header.typ, kid: header.kid });
|
|
1814
|
+
try {
|
|
1815
|
+
schema.ReceiptClaims.parse(payload);
|
|
1816
|
+
issuer = payload.iss;
|
|
1817
|
+
builder.pass("claims.schema_unverified");
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
builder.fail("claims.schema_unverified", "E_VERIFY_SCHEMA_INVALID", {
|
|
1820
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1821
|
+
});
|
|
1822
|
+
builder.failure("schema_invalid");
|
|
1823
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
1824
|
+
}
|
|
1825
|
+
const normalizedIssuer = normalizeIssuer(issuer);
|
|
1826
|
+
if (!isIssuerAllowed(issuer, policy.issuer_allowlist)) {
|
|
1827
|
+
builder.fail("issuer.trust_policy", "E_VERIFY_ISSUER_NOT_ALLOWED", {
|
|
1828
|
+
issuer: normalizedIssuer,
|
|
1829
|
+
allowlist: policy.issuer_allowlist
|
|
1830
|
+
});
|
|
1831
|
+
builder.failure("issuer_not_allowed", normalizedIssuer, kid);
|
|
1832
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
1833
|
+
}
|
|
1834
|
+
builder.pass("issuer.trust_policy", { issuer: normalizedIssuer });
|
|
1835
|
+
let publicKey;
|
|
1836
|
+
let keySource;
|
|
1837
|
+
let keyThumbprint;
|
|
1838
|
+
let jwksRawBytes;
|
|
1839
|
+
const pinnedKey = findPinnedKey(issuer, kid, policy.pinned_keys);
|
|
1840
|
+
if (pinnedKey) {
|
|
1841
|
+
builder.skip("issuer.discovery", { reason: "pinned_key_available" });
|
|
1842
|
+
if (pinnedKey.jwk) {
|
|
1843
|
+
const actualThumbprint = await crypto.computeJwkThumbprint(pinnedKey.jwk);
|
|
1844
|
+
if (actualThumbprint !== pinnedKey.jwk_thumbprint_sha256) {
|
|
1845
|
+
builder.fail("key.resolve", "E_VERIFY_POLICY_VIOLATION", {
|
|
1846
|
+
error: "Pinned JWK thumbprint does not match declared thumbprint",
|
|
1847
|
+
expected: pinnedKey.jwk_thumbprint_sha256,
|
|
1848
|
+
actual: actualThumbprint
|
|
1849
|
+
});
|
|
1850
|
+
builder.failure("policy_violation", normalizedIssuer, kid);
|
|
1851
|
+
return {
|
|
1852
|
+
valid: false,
|
|
1853
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
publicKey = crypto.jwkToPublicKeyBytes(pinnedKey.jwk);
|
|
1857
|
+
keySource = "pinned_keys";
|
|
1858
|
+
keyThumbprint = actualThumbprint;
|
|
1859
|
+
builder.pass("key.resolve", {
|
|
1860
|
+
source: keySource,
|
|
1861
|
+
kid,
|
|
1862
|
+
thumbprint_verified: true,
|
|
1863
|
+
offline: true
|
|
1864
|
+
});
|
|
1865
|
+
} else if (pinnedKey.public_key) {
|
|
1866
|
+
try {
|
|
1867
|
+
publicKey = crypto.base64urlDecode(pinnedKey.public_key);
|
|
1868
|
+
if (publicKey.length !== 32) {
|
|
1869
|
+
throw new Error(`Expected 32 bytes, got ${publicKey.length}`);
|
|
1870
|
+
}
|
|
1871
|
+
keySource = "pinned_keys";
|
|
1872
|
+
keyThumbprint = pinnedKey.jwk_thumbprint_sha256;
|
|
1873
|
+
builder.pass("key.resolve", {
|
|
1874
|
+
source: keySource,
|
|
1875
|
+
kid,
|
|
1876
|
+
offline: true,
|
|
1877
|
+
thumbprint_verified: false
|
|
1878
|
+
});
|
|
1879
|
+
} catch (err) {
|
|
1880
|
+
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|
|
1881
|
+
error: `Invalid pinned public_key: ${err instanceof Error ? err.message : String(err)}`
|
|
1882
|
+
});
|
|
1883
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
1884
|
+
return {
|
|
1885
|
+
valid: false,
|
|
1886
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
} else if (policy.mode === "offline_only") {
|
|
1890
|
+
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|
|
1891
|
+
error: "Offline mode requires key material (jwk or public_key) in pinned_keys"
|
|
1892
|
+
});
|
|
1893
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
1894
|
+
return {
|
|
1895
|
+
valid: false,
|
|
1896
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1897
|
+
};
|
|
1898
|
+
} else {
|
|
1899
|
+
const jwksResult = await fetchIssuerJWKS(normalizedIssuer);
|
|
1900
|
+
if ("error" in jwksResult) {
|
|
1901
|
+
const reason = ssrfErrorToReasonCode(jwksResult.error.reason, "key");
|
|
1902
|
+
builder.fail("issuer.discovery", reasonCodeToErrorCode(reason), {
|
|
1903
|
+
error: jwksResult.error.message,
|
|
1904
|
+
url: jwksResult.error.blockedUrl
|
|
1905
|
+
});
|
|
1906
|
+
builder.failure(reason, normalizedIssuer, kid);
|
|
1907
|
+
return {
|
|
1908
|
+
valid: false,
|
|
1909
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
if (jwksResult.rawBytes) {
|
|
1913
|
+
jwksRawBytes = jwksResult.rawBytes;
|
|
1914
|
+
}
|
|
1915
|
+
builder.pass("issuer.discovery", {
|
|
1916
|
+
from_cache: jwksResult.fromCache,
|
|
1917
|
+
keys_count: jwksResult.jwks.keys.length
|
|
1918
|
+
});
|
|
1919
|
+
const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
|
|
1920
|
+
if (!jwk) {
|
|
1921
|
+
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|
|
1922
|
+
kid,
|
|
1923
|
+
available_kids: jwksResult.jwks.keys.map((k) => k.kid)
|
|
1924
|
+
});
|
|
1925
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
1926
|
+
return {
|
|
1927
|
+
valid: false,
|
|
1928
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
const actualThumbprint = await crypto.computeJwkThumbprint(jwk);
|
|
1932
|
+
if (actualThumbprint !== pinnedKey.jwk_thumbprint_sha256) {
|
|
1933
|
+
builder.fail("key.resolve", "E_VERIFY_POLICY_VIOLATION", {
|
|
1934
|
+
error: "JWK thumbprint does not match pinned key",
|
|
1935
|
+
expected: pinnedKey.jwk_thumbprint_sha256,
|
|
1936
|
+
actual: actualThumbprint
|
|
1937
|
+
});
|
|
1938
|
+
builder.failure("policy_violation", normalizedIssuer, kid);
|
|
1939
|
+
return {
|
|
1940
|
+
valid: false,
|
|
1941
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
publicKey = crypto.jwkToPublicKeyBytes(jwk);
|
|
1945
|
+
keySource = "pinned_keys";
|
|
1946
|
+
keyThumbprint = actualThumbprint;
|
|
1947
|
+
builder.pass("key.resolve", { source: keySource, kid, thumbprint_verified: true });
|
|
1948
|
+
}
|
|
1949
|
+
} else {
|
|
1950
|
+
if (policy.mode === "offline_only") {
|
|
1951
|
+
builder.fail("issuer.discovery", "E_VERIFY_KEY_NOT_FOUND", {
|
|
1952
|
+
error: "Offline mode requires pinned keys"
|
|
1953
|
+
});
|
|
1954
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
1955
|
+
return {
|
|
1956
|
+
valid: false,
|
|
1957
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
const jwksResult = await fetchIssuerJWKS(normalizedIssuer);
|
|
1961
|
+
if ("error" in jwksResult) {
|
|
1962
|
+
const reason = ssrfErrorToReasonCode(jwksResult.error.reason, "key");
|
|
1963
|
+
builder.fail("issuer.discovery", reasonCodeToErrorCode(reason), {
|
|
1964
|
+
error: jwksResult.error.message,
|
|
1965
|
+
url: jwksResult.error.blockedUrl
|
|
1966
|
+
});
|
|
1967
|
+
builder.failure(reason, normalizedIssuer, kid);
|
|
1968
|
+
return {
|
|
1969
|
+
valid: false,
|
|
1970
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
if (jwksResult.rawBytes) {
|
|
1974
|
+
jwksRawBytes = jwksResult.rawBytes;
|
|
1975
|
+
}
|
|
1976
|
+
builder.pass("issuer.discovery", {
|
|
1977
|
+
from_cache: jwksResult.fromCache,
|
|
1978
|
+
keys_count: jwksResult.jwks.keys.length
|
|
1979
|
+
});
|
|
1980
|
+
const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
|
|
1981
|
+
if (!jwk) {
|
|
1982
|
+
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|
|
1983
|
+
kid,
|
|
1984
|
+
available_kids: jwksResult.jwks.keys.map((k) => k.kid)
|
|
1985
|
+
});
|
|
1986
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
1987
|
+
return {
|
|
1988
|
+
valid: false,
|
|
1989
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
publicKey = crypto.jwkToPublicKeyBytes(jwk);
|
|
1993
|
+
keySource = "jwks_discovery";
|
|
1994
|
+
keyThumbprint = await crypto.computeJwkThumbprint(jwk);
|
|
1995
|
+
builder.pass("key.resolve", { source: keySource, kid, thumbprint: keyThumbprint });
|
|
1996
|
+
}
|
|
1997
|
+
try {
|
|
1998
|
+
const result = await crypto.verify(receiptJws, publicKey);
|
|
1999
|
+
if (!result.valid) {
|
|
2000
|
+
builder.fail("jws.signature", "E_VERIFY_SIGNATURE_INVALID", {
|
|
2001
|
+
error: "Ed25519 signature verification failed"
|
|
2002
|
+
});
|
|
2003
|
+
builder.failure("signature_invalid", normalizedIssuer, kid);
|
|
2004
|
+
return {
|
|
2005
|
+
valid: false,
|
|
2006
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
parsedClaims = result.payload;
|
|
2010
|
+
builder.pass("jws.signature");
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
builder.fail("jws.signature", "E_VERIFY_SIGNATURE_INVALID", {
|
|
2013
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2014
|
+
});
|
|
2015
|
+
builder.failure("signature_invalid", normalizedIssuer, kid);
|
|
2016
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
2017
|
+
}
|
|
2018
|
+
const iatTolerance = 60;
|
|
2019
|
+
if (parsedClaims.iat > nowSeconds + iatTolerance) {
|
|
2020
|
+
builder.fail("claims.time_window", "E_VERIFY_NOT_YET_VALID", {
|
|
2021
|
+
error: "Receipt issued in the future",
|
|
2022
|
+
iat: parsedClaims.iat,
|
|
2023
|
+
now: nowSeconds,
|
|
2024
|
+
tolerance: iatTolerance
|
|
2025
|
+
});
|
|
2026
|
+
builder.failure("not_yet_valid", normalizedIssuer, kid);
|
|
2027
|
+
return { valid: false, report: includeMeta ? builder.addTimestamp().build() : builder.build() };
|
|
2028
|
+
}
|
|
2029
|
+
if (parsedClaims.exp) {
|
|
2030
|
+
if (parsedClaims.exp < nowSeconds) {
|
|
2031
|
+
builder.fail("claims.time_window", "E_VERIFY_EXPIRED", {
|
|
2032
|
+
error: "Receipt expired",
|
|
2033
|
+
exp: parsedClaims.exp,
|
|
2034
|
+
now: nowSeconds
|
|
2035
|
+
});
|
|
2036
|
+
builder.failure("expired", normalizedIssuer, kid);
|
|
2037
|
+
return {
|
|
2038
|
+
valid: false,
|
|
2039
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
builder.pass("claims.time_window", {
|
|
2044
|
+
iat: parsedClaims.iat,
|
|
2045
|
+
exp: parsedClaims.exp,
|
|
2046
|
+
now: nowSeconds
|
|
2047
|
+
});
|
|
2048
|
+
if (parsedClaims.ext) {
|
|
2049
|
+
for (const [extKey, extValue] of Object.entries(parsedClaims.ext)) {
|
|
2050
|
+
if (extValue !== void 0) {
|
|
2051
|
+
const extJson = JSON.stringify(extValue);
|
|
2052
|
+
if (extJson.length > policy.limits.max_extension_bytes) {
|
|
2053
|
+
builder.fail("extensions.limits", "E_VERIFY_EXTENSION_TOO_LARGE", {
|
|
2054
|
+
extension: extKey,
|
|
2055
|
+
size: extJson.length,
|
|
2056
|
+
limit: policy.limits.max_extension_bytes
|
|
2057
|
+
});
|
|
2058
|
+
builder.failure("extension_too_large", normalizedIssuer, kid);
|
|
2059
|
+
return {
|
|
2060
|
+
valid: false,
|
|
2061
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
builder.pass("extensions.limits");
|
|
2068
|
+
builder.success(normalizedIssuer, kid);
|
|
2069
|
+
const artifactKeySource = keySource === "pinned_keys" ? "pinned" : "jwks_fetch";
|
|
2070
|
+
builder.addArtifact("issuer_key_source", artifactKeySource);
|
|
2071
|
+
if (keyThumbprint) {
|
|
2072
|
+
builder.addArtifact("issuer_key_thumbprint", keyThumbprint);
|
|
2073
|
+
}
|
|
2074
|
+
if (jwksRawBytes) {
|
|
2075
|
+
const jwksDigestHex = await crypto.sha256Hex(jwksRawBytes);
|
|
2076
|
+
builder.addArtifact("issuer_jwks_digest", createDigest(jwksDigestHex));
|
|
2077
|
+
}
|
|
2078
|
+
const report = includeMeta ? builder.addTimestamp().build() : builder.build();
|
|
2079
|
+
return {
|
|
2080
|
+
valid: true,
|
|
2081
|
+
report,
|
|
2082
|
+
claims: parsedClaims
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
function clearJWKSCache() {
|
|
2086
|
+
jwksCache2.clear();
|
|
2087
|
+
}
|
|
2088
|
+
function getJWKSCacheSize() {
|
|
2089
|
+
return jwksCache2.size;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// src/transport-profiles.ts
|
|
2093
|
+
function parseHeaderProfile(headerValue) {
|
|
2094
|
+
if (headerValue === void 0 || headerValue === "") {
|
|
2095
|
+
return {
|
|
2096
|
+
ok: false,
|
|
2097
|
+
reason: "invalid_transport",
|
|
2098
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2099
|
+
message: "PEAC-Receipt header is missing"
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
if (Array.isArray(headerValue)) {
|
|
2103
|
+
return {
|
|
2104
|
+
ok: false,
|
|
2105
|
+
reason: "invalid_transport",
|
|
2106
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2107
|
+
message: "Multiple PEAC-Receipt headers are not allowed"
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
if (headerValue.includes(",")) {
|
|
2111
|
+
const parts = headerValue.split(".");
|
|
2112
|
+
if (parts.length !== 3 || parts.some((p) => p.includes(","))) {
|
|
2113
|
+
return {
|
|
2114
|
+
ok: false,
|
|
2115
|
+
reason: "invalid_transport",
|
|
2116
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2117
|
+
message: "Comma-separated PEAC-Receipt values are not allowed"
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
const segments = headerValue.split(".");
|
|
2122
|
+
if (segments.length !== 3) {
|
|
2123
|
+
return {
|
|
2124
|
+
ok: false,
|
|
2125
|
+
reason: "malformed_receipt",
|
|
2126
|
+
errorCode: "E_VERIFY_MALFORMED_RECEIPT",
|
|
2127
|
+
message: `Invalid JWS compact serialization: expected 3 segments, got ${segments.length}`
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
const base64urlRegex = /^[A-Za-z0-9_-]*$/;
|
|
2131
|
+
for (let i = 0; i < segments.length; i++) {
|
|
2132
|
+
const segment = segments[i];
|
|
2133
|
+
if (segment.length === 0) {
|
|
2134
|
+
return {
|
|
2135
|
+
ok: false,
|
|
2136
|
+
reason: "malformed_receipt",
|
|
2137
|
+
errorCode: "E_VERIFY_MALFORMED_RECEIPT",
|
|
2138
|
+
message: `Invalid JWS compact serialization: segment ${i + 1} is empty`
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
if (!base64urlRegex.test(segment)) {
|
|
2142
|
+
return {
|
|
2143
|
+
ok: false,
|
|
2144
|
+
reason: "malformed_receipt",
|
|
2145
|
+
errorCode: "E_VERIFY_MALFORMED_RECEIPT",
|
|
2146
|
+
message: `Invalid JWS compact serialization: segment ${i + 1} contains invalid characters`
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
return {
|
|
2151
|
+
ok: true,
|
|
2152
|
+
result: {
|
|
2153
|
+
profile: "header",
|
|
2154
|
+
receipt: headerValue
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
function parsePointerProfile(headerValue) {
|
|
2159
|
+
if (headerValue === void 0 || headerValue === "") {
|
|
2160
|
+
return {
|
|
2161
|
+
ok: false,
|
|
2162
|
+
reason: "invalid_transport",
|
|
2163
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2164
|
+
message: "PEAC-Receipt-Pointer header is missing"
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
if (Array.isArray(headerValue)) {
|
|
2168
|
+
return {
|
|
2169
|
+
ok: false,
|
|
2170
|
+
reason: "invalid_transport",
|
|
2171
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2172
|
+
message: "Multiple PEAC-Receipt-Pointer headers are not allowed"
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
const parseResult = parseSimpleDictionary(headerValue);
|
|
2176
|
+
if (parseResult.duplicates.length > 0) {
|
|
2177
|
+
return {
|
|
2178
|
+
ok: false,
|
|
2179
|
+
reason: "invalid_transport",
|
|
2180
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2181
|
+
message: `PEAC-Receipt-Pointer has duplicate parameter: ${parseResult.duplicates[0]}`
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
const ALLOWED_KEYS = /* @__PURE__ */ new Set(["sha256", "url"]);
|
|
2185
|
+
const unknownKeys = parseResult.keys.filter((k) => !ALLOWED_KEYS.has(k) && !k.startsWith("ext_"));
|
|
2186
|
+
if (unknownKeys.length > 0) {
|
|
2187
|
+
return {
|
|
2188
|
+
ok: false,
|
|
2189
|
+
reason: "invalid_transport",
|
|
2190
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2191
|
+
message: `PEAC-Receipt-Pointer has unknown parameter: ${unknownKeys[0]}`
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
const params = parseResult.params;
|
|
2195
|
+
if (!params.sha256) {
|
|
2196
|
+
return {
|
|
2197
|
+
ok: false,
|
|
2198
|
+
reason: "invalid_transport",
|
|
2199
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2200
|
+
message: "PEAC-Receipt-Pointer missing sha256 parameter"
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
if (!params.url) {
|
|
2204
|
+
return {
|
|
2205
|
+
ok: false,
|
|
2206
|
+
reason: "invalid_transport",
|
|
2207
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2208
|
+
message: "PEAC-Receipt-Pointer missing url parameter"
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
const hexRegex = /^[0-9a-f]{64}$/;
|
|
2212
|
+
if (!hexRegex.test(params.sha256)) {
|
|
2213
|
+
return {
|
|
2214
|
+
ok: false,
|
|
2215
|
+
reason: "invalid_transport",
|
|
2216
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2217
|
+
message: "PEAC-Receipt-Pointer sha256 must be 64 lowercase hex characters"
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
try {
|
|
2221
|
+
const url = new URL(params.url);
|
|
2222
|
+
if (url.protocol !== "https:") {
|
|
2223
|
+
return {
|
|
2224
|
+
ok: false,
|
|
2225
|
+
reason: "pointer_fetch_blocked",
|
|
2226
|
+
errorCode: "E_VERIFY_POINTER_FETCH_BLOCKED",
|
|
2227
|
+
message: "Pointer URL must use HTTPS"
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
} catch {
|
|
2231
|
+
return {
|
|
2232
|
+
ok: false,
|
|
2233
|
+
reason: "invalid_transport",
|
|
2234
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2235
|
+
message: "PEAC-Receipt-Pointer url is not a valid URL"
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
const extensions = {};
|
|
2239
|
+
for (const key of parseResult.keys) {
|
|
2240
|
+
if (key.startsWith("ext_")) {
|
|
2241
|
+
extensions[key] = params[key];
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
return {
|
|
2245
|
+
ok: true,
|
|
2246
|
+
result: {
|
|
2247
|
+
profile: "pointer",
|
|
2248
|
+
digestAlg: "sha256",
|
|
2249
|
+
digestValue: params.sha256,
|
|
2250
|
+
url: params.url,
|
|
2251
|
+
...Object.keys(extensions).length > 0 && { extensions }
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
function parseSimpleDictionary(input) {
|
|
2256
|
+
const params = {};
|
|
2257
|
+
const duplicates = [];
|
|
2258
|
+
const keys = [];
|
|
2259
|
+
let i = 0;
|
|
2260
|
+
const len = input.length;
|
|
2261
|
+
while (i < len) {
|
|
2262
|
+
while (i < len && (input[i] === " " || input[i] === "," || input[i] === " ")) {
|
|
2263
|
+
i++;
|
|
2264
|
+
}
|
|
2265
|
+
if (i >= len) break;
|
|
2266
|
+
const keyStart = i;
|
|
2267
|
+
while (i < len && /\w/.test(input[i])) {
|
|
2268
|
+
i++;
|
|
2269
|
+
}
|
|
2270
|
+
const key = input.slice(keyStart, i);
|
|
2271
|
+
if (!key) break;
|
|
2272
|
+
while (i < len && input[i] === " ") i++;
|
|
2273
|
+
if (i >= len || input[i] !== "=") break;
|
|
2274
|
+
i++;
|
|
2275
|
+
while (i < len && input[i] === " ") i++;
|
|
2276
|
+
let value;
|
|
2277
|
+
if (input[i] === '"') {
|
|
2278
|
+
i++;
|
|
2279
|
+
const valueStart = i;
|
|
2280
|
+
while (i < len && input[i] !== '"') {
|
|
2281
|
+
i++;
|
|
2282
|
+
}
|
|
2283
|
+
value = input.slice(valueStart, i);
|
|
2284
|
+
if (i < len) i++;
|
|
2285
|
+
} else {
|
|
2286
|
+
const valueStart = i;
|
|
2287
|
+
while (i < len && input[i] !== "," && input[i] !== " " && input[i] !== " ") {
|
|
2288
|
+
i++;
|
|
2289
|
+
}
|
|
2290
|
+
value = input.slice(valueStart, i);
|
|
2291
|
+
}
|
|
2292
|
+
keys.push(key);
|
|
2293
|
+
if (key in params) {
|
|
2294
|
+
duplicates.push(key);
|
|
2295
|
+
}
|
|
2296
|
+
params[key] = value;
|
|
2297
|
+
}
|
|
2298
|
+
return { params, duplicates, keys };
|
|
2299
|
+
}
|
|
2300
|
+
function parseBodyProfile(body) {
|
|
2301
|
+
if (body === null || typeof body !== "object") {
|
|
2302
|
+
return {
|
|
2303
|
+
ok: false,
|
|
2304
|
+
reason: "invalid_transport",
|
|
2305
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2306
|
+
message: "Body must be a JSON object"
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
const obj = body;
|
|
2310
|
+
if ("peac_receipts" in obj) {
|
|
2311
|
+
if (!Array.isArray(obj.peac_receipts)) {
|
|
2312
|
+
return {
|
|
2313
|
+
ok: false,
|
|
2314
|
+
reason: "invalid_transport",
|
|
2315
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2316
|
+
message: "peac_receipts must be an array"
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
const receipts = [];
|
|
2320
|
+
for (let i = 0; i < obj.peac_receipts.length; i++) {
|
|
2321
|
+
const receipt = obj.peac_receipts[i];
|
|
2322
|
+
if (typeof receipt !== "string") {
|
|
2323
|
+
return {
|
|
2324
|
+
ok: false,
|
|
2325
|
+
reason: "invalid_transport",
|
|
2326
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2327
|
+
message: `peac_receipts[${i}] must be a string`
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
receipts.push(receipt);
|
|
2331
|
+
}
|
|
2332
|
+
if (receipts.length === 0) {
|
|
2333
|
+
return {
|
|
2334
|
+
ok: false,
|
|
2335
|
+
reason: "invalid_transport",
|
|
2336
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2337
|
+
message: "peac_receipts array is empty"
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
return {
|
|
2341
|
+
ok: true,
|
|
2342
|
+
result: {
|
|
2343
|
+
profile: "body",
|
|
2344
|
+
receipts
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
if ("peac_receipt" in obj) {
|
|
2349
|
+
if (typeof obj.peac_receipt !== "string") {
|
|
2350
|
+
return {
|
|
2351
|
+
ok: false,
|
|
2352
|
+
reason: "invalid_transport",
|
|
2353
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2354
|
+
message: "peac_receipt must be a string"
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
if (obj.peac_receipt.length === 0) {
|
|
2358
|
+
return {
|
|
2359
|
+
ok: false,
|
|
2360
|
+
reason: "invalid_transport",
|
|
2361
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2362
|
+
message: "peac_receipt is empty"
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
return {
|
|
2366
|
+
ok: true,
|
|
2367
|
+
result: {
|
|
2368
|
+
profile: "body",
|
|
2369
|
+
receipts: [obj.peac_receipt]
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
return {
|
|
2374
|
+
ok: false,
|
|
2375
|
+
reason: "invalid_transport",
|
|
2376
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2377
|
+
message: "Body must contain peac_receipt or peac_receipts"
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
function parseTransportProfile(context) {
|
|
2381
|
+
const peacReceipt = context.headers["peac-receipt"] ?? context.headers["PEAC-Receipt"];
|
|
2382
|
+
const peacPointer = context.headers["peac-receipt-pointer"] ?? context.headers["PEAC-Receipt-Pointer"];
|
|
2383
|
+
if (peacReceipt !== void 0) {
|
|
2384
|
+
return parseHeaderProfile(peacReceipt);
|
|
2385
|
+
}
|
|
2386
|
+
if (peacPointer !== void 0) {
|
|
2387
|
+
return parsePointerProfile(peacPointer);
|
|
2388
|
+
}
|
|
2389
|
+
if (context.body !== void 0) {
|
|
2390
|
+
return parseBodyProfile(context.body);
|
|
2391
|
+
}
|
|
2392
|
+
return {
|
|
2393
|
+
ok: false,
|
|
2394
|
+
reason: "invalid_transport",
|
|
2395
|
+
errorCode: "E_VERIFY_INVALID_TRANSPORT",
|
|
2396
|
+
message: "No transport profile detected (missing PEAC-Receipt, PEAC-Receipt-Pointer, or body receipt)"
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
function mapSsrfError(ssrfError) {
|
|
2400
|
+
const reason = ssrfError.reason;
|
|
2401
|
+
switch (reason) {
|
|
2402
|
+
case "not_https":
|
|
2403
|
+
case "private_ip":
|
|
2404
|
+
case "loopback":
|
|
2405
|
+
case "link_local":
|
|
2406
|
+
case "dns_failure":
|
|
2407
|
+
case "cross_origin_redirect":
|
|
2408
|
+
return {
|
|
2409
|
+
ok: false,
|
|
2410
|
+
reason: "pointer_fetch_blocked",
|
|
2411
|
+
errorCode: "E_VERIFY_POINTER_FETCH_BLOCKED",
|
|
2412
|
+
message: ssrfError.message
|
|
2413
|
+
};
|
|
2414
|
+
case "timeout":
|
|
2415
|
+
return {
|
|
2416
|
+
ok: false,
|
|
2417
|
+
reason: "pointer_fetch_timeout",
|
|
2418
|
+
errorCode: "E_VERIFY_POINTER_FETCH_TIMEOUT",
|
|
2419
|
+
message: ssrfError.message
|
|
2420
|
+
};
|
|
2421
|
+
case "response_too_large":
|
|
2422
|
+
return {
|
|
2423
|
+
ok: false,
|
|
2424
|
+
reason: "pointer_fetch_too_large",
|
|
2425
|
+
errorCode: "E_VERIFY_POINTER_FETCH_TOO_LARGE",
|
|
2426
|
+
message: ssrfError.message
|
|
2427
|
+
};
|
|
2428
|
+
default:
|
|
2429
|
+
return {
|
|
2430
|
+
ok: false,
|
|
2431
|
+
reason: "pointer_fetch_failed",
|
|
2432
|
+
errorCode: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
2433
|
+
message: ssrfError.message
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
async function fetchPointerWithDigest(options) {
|
|
2438
|
+
const { url, expectedDigest, fetchOptions = {} } = options;
|
|
2439
|
+
const hexRegex = /^[0-9a-f]{64}$/;
|
|
2440
|
+
if (!hexRegex.test(expectedDigest)) {
|
|
2441
|
+
return {
|
|
2442
|
+
ok: false,
|
|
2443
|
+
reason: "pointer_fetch_failed",
|
|
2444
|
+
errorCode: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
2445
|
+
message: "Invalid expected digest: must be 64 lowercase hex characters"
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
try {
|
|
2449
|
+
const parsedUrl = new URL(url);
|
|
2450
|
+
if (parsedUrl.protocol !== "https:") {
|
|
2451
|
+
return {
|
|
2452
|
+
ok: false,
|
|
2453
|
+
reason: "pointer_fetch_blocked",
|
|
2454
|
+
errorCode: "E_VERIFY_POINTER_FETCH_BLOCKED",
|
|
2455
|
+
message: "Pointer URL must use HTTPS"
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
} catch {
|
|
2459
|
+
return {
|
|
2460
|
+
ok: false,
|
|
2461
|
+
reason: "pointer_fetch_failed",
|
|
2462
|
+
errorCode: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
2463
|
+
message: "Invalid pointer URL"
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
const fetchResult = await ssrfSafeFetch(url, {
|
|
2467
|
+
...fetchOptions,
|
|
2468
|
+
maxBytes: kernel.VERIFIER_LIMITS.maxReceiptBytes,
|
|
2469
|
+
allowRedirects: false,
|
|
2470
|
+
// Pointer URL must be direct - no redirects
|
|
2471
|
+
timeoutMs: fetchOptions?.timeoutMs ?? kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
2472
|
+
headers: {
|
|
2473
|
+
Accept: "application/jose, application/json, text/plain",
|
|
2474
|
+
...fetchOptions.headers
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
if (!fetchResult.ok) {
|
|
2478
|
+
return mapSsrfError(fetchResult);
|
|
2479
|
+
}
|
|
2480
|
+
const receipt = fetchResult.body;
|
|
2481
|
+
const contentType = fetchResult.contentType;
|
|
2482
|
+
const expectedContentTypes = ["application/jose", "application/json", "text/plain"];
|
|
2483
|
+
const contentTypeWarning = contentType && !expectedContentTypes.some((expected) => contentType.startsWith(expected)) ? `Unexpected Content-Type: ${contentType}; expected application/jose, application/json, or text/plain` : void 0;
|
|
2484
|
+
if (!receipt || receipt.trim().length === 0) {
|
|
2485
|
+
return {
|
|
2486
|
+
ok: false,
|
|
2487
|
+
reason: "malformed_receipt",
|
|
2488
|
+
errorCode: "E_VERIFY_MALFORMED_RECEIPT",
|
|
2489
|
+
message: "Pointer target returned empty content"
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
const jwsValidation = validateJwsCompactStructure(receipt);
|
|
2493
|
+
if (!jwsValidation.valid) {
|
|
2494
|
+
return {
|
|
2495
|
+
ok: false,
|
|
2496
|
+
reason: "malformed_receipt",
|
|
2497
|
+
errorCode: "E_VERIFY_MALFORMED_RECEIPT",
|
|
2498
|
+
message: jwsValidation.message
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
const actualDigest = await crypto.sha256Hex(receipt);
|
|
2502
|
+
if (actualDigest !== expectedDigest) {
|
|
2503
|
+
return {
|
|
2504
|
+
ok: false,
|
|
2505
|
+
reason: "pointer_digest_mismatch",
|
|
2506
|
+
errorCode: "E_VERIFY_POINTER_DIGEST_MISMATCH",
|
|
2507
|
+
message: "Fetched receipt digest does not match expected digest",
|
|
2508
|
+
actualDigest,
|
|
2509
|
+
expectedDigest
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
return {
|
|
2513
|
+
ok: true,
|
|
2514
|
+
receipt,
|
|
2515
|
+
actualDigest,
|
|
2516
|
+
digestMatched: true,
|
|
2517
|
+
contentType: fetchResult.contentType,
|
|
2518
|
+
...contentTypeWarning && { contentTypeWarning }
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
function validateJwsCompactStructure(value) {
|
|
2522
|
+
const segments = value.split(".");
|
|
2523
|
+
if (segments.length !== 3) {
|
|
2524
|
+
return {
|
|
2525
|
+
valid: false,
|
|
2526
|
+
message: `Invalid JWS compact serialization: expected 3 segments, got ${segments.length}`
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
const base64urlRegex = /^[A-Za-z0-9_-]+$/;
|
|
2530
|
+
for (let i = 0; i < segments.length; i++) {
|
|
2531
|
+
const segment = segments[i];
|
|
2532
|
+
if (segment.length === 0) {
|
|
2533
|
+
return {
|
|
2534
|
+
valid: false,
|
|
2535
|
+
message: `Invalid JWS compact serialization: segment ${i + 1} is empty`
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
if (!base64urlRegex.test(segment)) {
|
|
2539
|
+
return {
|
|
2540
|
+
valid: false,
|
|
2541
|
+
message: `Invalid JWS compact serialization: segment ${i + 1} contains invalid characters`
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
return { valid: true };
|
|
2546
|
+
}
|
|
2547
|
+
function parsePointerHeader(input) {
|
|
2548
|
+
const params = {};
|
|
2549
|
+
let i = 0;
|
|
2550
|
+
const len = input.length;
|
|
2551
|
+
while (i < len) {
|
|
2552
|
+
while (i < len && (input[i] === " " || input[i] === "," || input[i] === " ")) {
|
|
2553
|
+
i++;
|
|
2554
|
+
}
|
|
2555
|
+
if (i >= len) break;
|
|
2556
|
+
const keyStart = i;
|
|
2557
|
+
while (i < len && /\w/.test(input[i])) {
|
|
2558
|
+
i++;
|
|
2559
|
+
}
|
|
2560
|
+
const key = input.slice(keyStart, i);
|
|
2561
|
+
if (!key) break;
|
|
2562
|
+
while (i < len && input[i] === " ") i++;
|
|
2563
|
+
if (i >= len || input[i] !== "=") break;
|
|
2564
|
+
i++;
|
|
2565
|
+
while (i < len && input[i] === " ") i++;
|
|
2566
|
+
let value;
|
|
2567
|
+
if (input[i] === '"') {
|
|
2568
|
+
i++;
|
|
2569
|
+
const valueStart = i;
|
|
2570
|
+
while (i < len && input[i] !== '"') {
|
|
2571
|
+
i++;
|
|
2572
|
+
}
|
|
2573
|
+
value = input.slice(valueStart, i);
|
|
2574
|
+
if (i < len) i++;
|
|
2575
|
+
} else {
|
|
2576
|
+
const valueStart = i;
|
|
2577
|
+
while (i < len && input[i] !== "," && input[i] !== " " && input[i] !== " ") {
|
|
2578
|
+
i++;
|
|
2579
|
+
}
|
|
2580
|
+
value = input.slice(valueStart, i);
|
|
2581
|
+
}
|
|
2582
|
+
params[key] = value;
|
|
2583
|
+
}
|
|
2584
|
+
return params;
|
|
2585
|
+
}
|
|
2586
|
+
async function verifyAndFetchPointer(pointerHeader, fetchOptions) {
|
|
2587
|
+
const params = parsePointerHeader(pointerHeader);
|
|
2588
|
+
if (!params.sha256) {
|
|
2589
|
+
return {
|
|
2590
|
+
ok: false,
|
|
2591
|
+
reason: "pointer_fetch_failed",
|
|
2592
|
+
errorCode: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
2593
|
+
message: "PEAC-Receipt-Pointer missing sha256 parameter"
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
if (!params.url) {
|
|
2597
|
+
return {
|
|
2598
|
+
ok: false,
|
|
2599
|
+
reason: "pointer_fetch_failed",
|
|
2600
|
+
errorCode: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
2601
|
+
message: "PEAC-Receipt-Pointer missing url parameter"
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
return fetchPointerWithDigest({
|
|
2605
|
+
url: params.url,
|
|
2606
|
+
expectedDigest: params.sha256,
|
|
2607
|
+
fetchOptions
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
Object.defineProperty(exports, "base64urlDecode", {
|
|
2612
|
+
enumerable: true,
|
|
2613
|
+
get: function () { return crypto.base64urlDecode; }
|
|
2614
|
+
});
|
|
2615
|
+
Object.defineProperty(exports, "base64urlEncode", {
|
|
2616
|
+
enumerable: true,
|
|
2617
|
+
get: function () { return crypto.base64urlEncode; }
|
|
2618
|
+
});
|
|
2619
|
+
Object.defineProperty(exports, "computeJwkThumbprint", {
|
|
2620
|
+
enumerable: true,
|
|
2621
|
+
get: function () { return crypto.computeJwkThumbprint; }
|
|
2622
|
+
});
|
|
2623
|
+
Object.defineProperty(exports, "generateKeypair", {
|
|
2624
|
+
enumerable: true,
|
|
2625
|
+
get: function () { return crypto.generateKeypair; }
|
|
2626
|
+
});
|
|
2627
|
+
Object.defineProperty(exports, "jwkToPublicKeyBytes", {
|
|
2628
|
+
enumerable: true,
|
|
2629
|
+
get: function () { return crypto.jwkToPublicKeyBytes; }
|
|
2630
|
+
});
|
|
2631
|
+
Object.defineProperty(exports, "sha256Bytes", {
|
|
2632
|
+
enumerable: true,
|
|
2633
|
+
get: function () { return crypto.sha256Bytes; }
|
|
2634
|
+
});
|
|
2635
|
+
Object.defineProperty(exports, "sha256Hex", {
|
|
2636
|
+
enumerable: true,
|
|
2637
|
+
get: function () { return crypto.sha256Hex; }
|
|
2638
|
+
});
|
|
2639
|
+
Object.defineProperty(exports, "verify", {
|
|
2640
|
+
enumerable: true,
|
|
2641
|
+
get: function () { return crypto.verify; }
|
|
2642
|
+
});
|
|
2643
|
+
exports.CHECK_IDS = CHECK_IDS;
|
|
2644
|
+
exports.DEFAULT_NETWORK_SECURITY = DEFAULT_NETWORK_SECURITY;
|
|
2645
|
+
exports.DEFAULT_VERIFIER_LIMITS = DEFAULT_VERIFIER_LIMITS;
|
|
2646
|
+
exports.IssueError = IssueError;
|
|
2647
|
+
exports.NON_DETERMINISTIC_ARTIFACT_KEYS = NON_DETERMINISTIC_ARTIFACT_KEYS;
|
|
2648
|
+
exports.VerificationReportBuilder = VerificationReportBuilder;
|
|
2649
|
+
exports.buildFailureReport = buildFailureReport;
|
|
2650
|
+
exports.buildSuccessReport = buildSuccessReport;
|
|
2651
|
+
exports.clearJWKSCache = clearJWKSCache;
|
|
2652
|
+
exports.computeReceiptDigest = computeReceiptDigest;
|
|
2653
|
+
exports.createDefaultPolicy = createDefaultPolicy;
|
|
2654
|
+
exports.createDigest = createDigest;
|
|
2655
|
+
exports.createEmptyReport = createEmptyReport;
|
|
2656
|
+
exports.createReportBuilder = createReportBuilder;
|
|
2657
|
+
exports.fetchDiscovery = fetchDiscovery;
|
|
2658
|
+
exports.fetchIssuerConfig = fetchIssuerConfig;
|
|
2659
|
+
exports.fetchJWKSSafe = fetchJWKSSafe;
|
|
2660
|
+
exports.fetchPointerSafe = fetchPointerSafe;
|
|
2661
|
+
exports.fetchPointerWithDigest = fetchPointerWithDigest;
|
|
2662
|
+
exports.fetchPolicyManifest = fetchPolicyManifest;
|
|
2663
|
+
exports.getJWKSCacheSize = getJWKSCacheSize;
|
|
2664
|
+
exports.getPurposeHeader = getPurposeHeader;
|
|
2665
|
+
exports.getReceiptHeader = getReceiptHeader;
|
|
2666
|
+
exports.getSSRFCapabilities = getSSRFCapabilities;
|
|
2667
|
+
exports.isAttestationResult = isAttestationResult;
|
|
2668
|
+
exports.isBlockedIP = isBlockedIP;
|
|
2669
|
+
exports.isCommerceResult = isCommerceResult;
|
|
2670
|
+
exports.issue = issue;
|
|
2671
|
+
exports.issueJws = issueJws;
|
|
2672
|
+
exports.parseBodyProfile = parseBodyProfile;
|
|
2673
|
+
exports.parseDiscovery = parseDiscovery;
|
|
2674
|
+
exports.parseHeaderProfile = parseHeaderProfile;
|
|
2675
|
+
exports.parseIssuerConfig = parseIssuerConfig;
|
|
2676
|
+
exports.parsePointerProfile = parsePointerProfile;
|
|
2677
|
+
exports.parsePolicyManifest = parsePolicyManifest;
|
|
2678
|
+
exports.parseTransportProfile = parseTransportProfile;
|
|
2679
|
+
exports.reasonCodeToErrorCode = reasonCodeToErrorCode;
|
|
2680
|
+
exports.reasonCodeToSeverity = reasonCodeToSeverity;
|
|
2681
|
+
exports.resetSSRFCapabilitiesCache = resetSSRFCapabilitiesCache;
|
|
2682
|
+
exports.setPurposeAppliedHeader = setPurposeAppliedHeader;
|
|
2683
|
+
exports.setPurposeReasonHeader = setPurposeReasonHeader;
|
|
2684
|
+
exports.setReceiptHeader = setReceiptHeader;
|
|
2685
|
+
exports.setVaryHeader = setVaryHeader;
|
|
2686
|
+
exports.setVaryPurposeHeader = setVaryPurposeHeader;
|
|
2687
|
+
exports.ssrfErrorToReasonCode = ssrfErrorToReasonCode;
|
|
2688
|
+
exports.ssrfSafeFetch = ssrfSafeFetch;
|
|
2689
|
+
exports.verifyAndFetchPointer = verifyAndFetchPointer;
|
|
2690
|
+
exports.verifyLocal = verifyLocal;
|
|
2691
|
+
exports.verifyReceipt = verifyReceipt;
|
|
2692
|
+
exports.verifyReceiptCore = verifyReceiptCore;
|
|
2693
|
+
//# sourceMappingURL=index.cjs.map
|
|
2694
|
+
//# sourceMappingURL=index.cjs.map
|