@kya-os/verifier 1.5.9 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/core.d.ts +145 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +868 -0
- package/dist/core.js.map +1 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +204 -0
- package/dist/express.js.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +103 -0
- package/dist/index.js.map +1 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +302 -0
- package/dist/worker.js.map +1 -0
- package/package.json +13 -13
package/dist/core.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { importJWK, jwtVerify } from "jose";
|
|
2
|
+
import { canonicalize } from "json-canonicalize";
|
|
3
|
+
import { AGENT_HEADERS, ERROR_HTTP_STATUS, VERIFIER_ERROR_CODES } from "@kya-os/contracts/verifier";
|
|
4
|
+
/**
|
|
5
|
+
* Isomorphic verifier core for XMCP-I proof validation
|
|
6
|
+
*
|
|
7
|
+
* This is the heart of the trust system - it verifies that AI agents
|
|
8
|
+
* are who they claim to be and have the authority to perform actions.
|
|
9
|
+
*/
|
|
10
|
+
export class VerifierCore {
|
|
11
|
+
constructor(config = {}) {
|
|
12
|
+
this.didCache = new Map();
|
|
13
|
+
this.delegationCache = new Map();
|
|
14
|
+
this.config = {
|
|
15
|
+
ktaBaseUrl: config.ktaBaseUrl || "https://knowthat.ai",
|
|
16
|
+
enableDelegationCheck: config.enableDelegationCheck ?? true,
|
|
17
|
+
clockSkewTolerance: config.clockSkewTolerance ?? 300, // 5 minutes (generous for clock drift)
|
|
18
|
+
sessionTimeout: config.sessionTimeout ?? 1800, // 30 minutes
|
|
19
|
+
proofMaxAge: config.proofMaxAge ?? 600, // 10 minutes (must be > clockSkewTolerance)
|
|
20
|
+
allowMockData: config.allowMockData ?? false,
|
|
21
|
+
didCacheTtl: config.didCacheTtl ?? 300, // 5 minutes
|
|
22
|
+
delegationCacheTtl: config.delegationCacheTtl ?? 60, // 1 minute
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Verify a detached proof and return verification result
|
|
27
|
+
*
|
|
28
|
+
* This is the main entry point for proof verification. It performs
|
|
29
|
+
* a comprehensive validation of the agent's identity and authorization.
|
|
30
|
+
*/
|
|
31
|
+
async verify(context) {
|
|
32
|
+
const startTime = Date.now();
|
|
33
|
+
try {
|
|
34
|
+
// 1. Validate proof structure
|
|
35
|
+
const structureError = this.validateProofStructure(context.proof);
|
|
36
|
+
if (structureError) {
|
|
37
|
+
this.logVerificationAttempt(context, false, structureError.code);
|
|
38
|
+
return this.createErrorResult(structureError);
|
|
39
|
+
}
|
|
40
|
+
// 2. Verify timestamp and session validity
|
|
41
|
+
const timestampError = this.validateTimestamp(context.proof.meta, context.timestamp);
|
|
42
|
+
if (timestampError) {
|
|
43
|
+
this.logVerificationAttempt(context, false, timestampError.code);
|
|
44
|
+
return this.createErrorResult(timestampError);
|
|
45
|
+
}
|
|
46
|
+
// 3. Verify audience matches
|
|
47
|
+
const audienceError = this.validateAudience(context.proof.meta, context.audience);
|
|
48
|
+
if (audienceError) {
|
|
49
|
+
this.logVerificationAttempt(context, false, audienceError.code);
|
|
50
|
+
return this.createErrorResult(audienceError);
|
|
51
|
+
}
|
|
52
|
+
// 4. Verify Ed25519 signature (for full JWS, also validates meta matches signed payload)
|
|
53
|
+
const signatureError = await this.verifySignature(context.proof, context.audience);
|
|
54
|
+
if (signatureError) {
|
|
55
|
+
this.logVerificationAttempt(context, false, signatureError.code);
|
|
56
|
+
return this.createErrorResult(signatureError);
|
|
57
|
+
}
|
|
58
|
+
// 5. Check delegation if enabled and present
|
|
59
|
+
if (this.config.enableDelegationCheck &&
|
|
60
|
+
context.proof.meta.delegationRef) {
|
|
61
|
+
const delegationError = await this.verifyDelegation(context.proof.meta.delegationRef, context.proof.meta.did, context.proof.meta.kid);
|
|
62
|
+
if (delegationError) {
|
|
63
|
+
this.logVerificationAttempt(context, false, delegationError.code);
|
|
64
|
+
return this.createErrorResult(delegationError);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// 6. Generate trusted headers and context
|
|
68
|
+
const headers = this.generateHeaders(context.proof.meta);
|
|
69
|
+
const agentContext = this.generateAgentContext(context.proof.meta);
|
|
70
|
+
const duration = Date.now() - startTime;
|
|
71
|
+
this.logVerificationAttempt(context, true, "SUCCESS", duration);
|
|
72
|
+
return {
|
|
73
|
+
success: true,
|
|
74
|
+
headers,
|
|
75
|
+
agentContext,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const duration = Date.now() - startTime;
|
|
80
|
+
this.logVerificationAttempt(context, false, "UNEXPECTED_ERROR", duration);
|
|
81
|
+
return this.createErrorResult({
|
|
82
|
+
code: "XMCP_I_EVERIFY",
|
|
83
|
+
message: error instanceof Error ? error.message : "Verification failed",
|
|
84
|
+
httpStatus: 500,
|
|
85
|
+
details: {
|
|
86
|
+
reason: "Unexpected error during verification",
|
|
87
|
+
remediation: "Check proof format and try again",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Validate proof structure with comprehensive checks
|
|
94
|
+
*/
|
|
95
|
+
validateProofStructure(proof) {
|
|
96
|
+
if (!proof.jws || typeof proof.jws !== "string") {
|
|
97
|
+
return {
|
|
98
|
+
code: "XMCP_I_EBADPROOF",
|
|
99
|
+
message: "Invalid proof: missing or invalid JWS",
|
|
100
|
+
httpStatus: 400,
|
|
101
|
+
details: {
|
|
102
|
+
reason: "JWS field is required and must be a string",
|
|
103
|
+
expected: "string",
|
|
104
|
+
received: typeof proof.jws,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (!proof.meta || typeof proof.meta !== "object") {
|
|
109
|
+
return {
|
|
110
|
+
code: "XMCP_I_EBADPROOF",
|
|
111
|
+
message: "Invalid proof: missing or invalid meta",
|
|
112
|
+
httpStatus: 400,
|
|
113
|
+
details: {
|
|
114
|
+
reason: "Meta field is required and must be an object",
|
|
115
|
+
expected: "object",
|
|
116
|
+
received: typeof proof.meta,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const requiredFields = [
|
|
121
|
+
"did",
|
|
122
|
+
"kid",
|
|
123
|
+
"ts",
|
|
124
|
+
"nonce",
|
|
125
|
+
"audience",
|
|
126
|
+
"sessionId",
|
|
127
|
+
"requestHash",
|
|
128
|
+
"responseHash",
|
|
129
|
+
];
|
|
130
|
+
for (const field of requiredFields) {
|
|
131
|
+
if (!proof.meta[field]) {
|
|
132
|
+
return {
|
|
133
|
+
code: "XMCP_I_EBADPROOF",
|
|
134
|
+
message: `Invalid proof: missing required field '${field}'`,
|
|
135
|
+
httpStatus: 400,
|
|
136
|
+
details: {
|
|
137
|
+
reason: `Field '${field}' is required in proof meta`,
|
|
138
|
+
expected: "non-empty value",
|
|
139
|
+
received: proof.meta[field],
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Validate hash format
|
|
145
|
+
const hashRegex = /^sha256:[a-f0-9]{64}$/;
|
|
146
|
+
if (!hashRegex.test(proof.meta.requestHash)) {
|
|
147
|
+
return {
|
|
148
|
+
code: "XMCP_I_EBADPROOF",
|
|
149
|
+
message: "Invalid proof: malformed requestHash",
|
|
150
|
+
httpStatus: 400,
|
|
151
|
+
details: {
|
|
152
|
+
reason: "requestHash must be in format 'sha256:<64-char-hex>'",
|
|
153
|
+
expected: "sha256:[a-f0-9]{64}",
|
|
154
|
+
received: proof.meta.requestHash,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (!hashRegex.test(proof.meta.responseHash)) {
|
|
159
|
+
return {
|
|
160
|
+
code: "XMCP_I_EBADPROOF",
|
|
161
|
+
message: "Invalid proof: malformed responseHash",
|
|
162
|
+
httpStatus: 400,
|
|
163
|
+
details: {
|
|
164
|
+
reason: "responseHash must be in format 'sha256:<64-char-hex>'",
|
|
165
|
+
expected: "sha256:[a-f0-9]{64}",
|
|
166
|
+
received: proof.meta.responseHash,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// Validate DID format
|
|
171
|
+
if (!proof.meta.did.startsWith("did:")) {
|
|
172
|
+
return {
|
|
173
|
+
code: "XMCP_I_EBADPROOF",
|
|
174
|
+
message: "Invalid proof: malformed DID",
|
|
175
|
+
httpStatus: 400,
|
|
176
|
+
details: {
|
|
177
|
+
reason: "DID must start with 'did:'",
|
|
178
|
+
expected: "did:*",
|
|
179
|
+
received: proof.meta.did,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Validate timestamp with configurable clock skew tolerance
|
|
187
|
+
*/
|
|
188
|
+
validateTimestamp(meta, currentTimestamp) {
|
|
189
|
+
// Validate meta.ts is not NaN (prevents client-controlled NaN attacks)
|
|
190
|
+
if (!Number.isFinite(meta.ts)) {
|
|
191
|
+
return {
|
|
192
|
+
code: VERIFIER_ERROR_CODES.PROOF_INVALID_TS,
|
|
193
|
+
message: "Invalid proof: timestamp is not a valid number",
|
|
194
|
+
httpStatus: ERROR_HTTP_STATUS[VERIFIER_ERROR_CODES.PROOF_INVALID_TS],
|
|
195
|
+
details: {
|
|
196
|
+
reason: "Proof timestamp must be a finite number",
|
|
197
|
+
received: meta.ts,
|
|
198
|
+
remediation: "Ensure proof timestamp is properly formatted",
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const now = currentTimestamp || Math.floor(Date.now() / 1000);
|
|
203
|
+
// Validate server timestamp is valid (defense in depth)
|
|
204
|
+
if (!Number.isFinite(now)) {
|
|
205
|
+
return {
|
|
206
|
+
code: VERIFIER_ERROR_CODES.SERVER_TIME_INVALID,
|
|
207
|
+
message: "Server timestamp validation failed",
|
|
208
|
+
httpStatus: ERROR_HTTP_STATUS[VERIFIER_ERROR_CODES.SERVER_TIME_INVALID],
|
|
209
|
+
details: {
|
|
210
|
+
reason: "Server time is not valid",
|
|
211
|
+
remediation: "Contact system administrator",
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const skew = this.config.clockSkewTolerance;
|
|
216
|
+
// Check for future timestamps
|
|
217
|
+
if (meta.ts > now + skew) {
|
|
218
|
+
return {
|
|
219
|
+
code: VERIFIER_ERROR_CODES.PROOF_FUTURE_TS,
|
|
220
|
+
message: "Invalid proof: timestamp is in the future",
|
|
221
|
+
httpStatus: ERROR_HTTP_STATUS[VERIFIER_ERROR_CODES.PROOF_FUTURE_TS],
|
|
222
|
+
details: {
|
|
223
|
+
reason: `Timestamp ${meta.ts} is ${meta.ts - now}s in the future (skew: ${skew}s)`,
|
|
224
|
+
expected: `≤ ${now + skew}`,
|
|
225
|
+
received: meta.ts,
|
|
226
|
+
remediation: "Check client clock sync",
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// Check for past timestamps (clock skew exceeded)
|
|
231
|
+
if (meta.ts < now - skew) {
|
|
232
|
+
return {
|
|
233
|
+
code: VERIFIER_ERROR_CODES.PROOF_SKEW_EXCEEDED,
|
|
234
|
+
message: "Invalid proof: timestamp outside acceptable range",
|
|
235
|
+
httpStatus: ERROR_HTTP_STATUS[VERIFIER_ERROR_CODES.PROOF_SKEW_EXCEEDED],
|
|
236
|
+
details: {
|
|
237
|
+
reason: `Timestamp ${meta.ts} is ${now - meta.ts}s in the past (skew: ${skew}s)`,
|
|
238
|
+
expected: `≥ ${now - skew}`,
|
|
239
|
+
received: meta.ts,
|
|
240
|
+
remediation: "Check NTP sync; adjust XMCP_I_TS_SKEW_SEC if needed",
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Check proof age if configured
|
|
245
|
+
if (this.config.proofMaxAge) {
|
|
246
|
+
const age = now - meta.ts;
|
|
247
|
+
if (age > this.config.proofMaxAge) {
|
|
248
|
+
return {
|
|
249
|
+
code: VERIFIER_ERROR_CODES.PROOF_TOO_OLD,
|
|
250
|
+
message: "Invalid proof: proof is too old",
|
|
251
|
+
httpStatus: ERROR_HTTP_STATUS[VERIFIER_ERROR_CODES.PROOF_TOO_OLD],
|
|
252
|
+
details: {
|
|
253
|
+
reason: `Proof age ${age}s exceeds maximum ${this.config.proofMaxAge}s`,
|
|
254
|
+
expected: `Less than ${this.config.proofMaxAge}s old`,
|
|
255
|
+
received: `${age}s old`,
|
|
256
|
+
remediation: "Generate a fresh proof",
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Validate audience matches expected value
|
|
265
|
+
*/
|
|
266
|
+
validateAudience(meta, expectedAudience) {
|
|
267
|
+
if (meta.audience !== expectedAudience) {
|
|
268
|
+
return {
|
|
269
|
+
code: "XMCP_I_EHANDSHAKE",
|
|
270
|
+
message: "Invalid proof: audience mismatch",
|
|
271
|
+
httpStatus: 401,
|
|
272
|
+
details: {
|
|
273
|
+
reason: "Proof audience does not match request audience",
|
|
274
|
+
expected: expectedAudience,
|
|
275
|
+
received: meta.audience,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Verify Ed25519 signature using JOSE with proper detached JWS handling
|
|
283
|
+
*
|
|
284
|
+
* This is the cryptographic heart of the verification process.
|
|
285
|
+
* It ensures the proof was signed by the claimed identity.
|
|
286
|
+
*
|
|
287
|
+
* CRITICAL for full JWS: proof.meta must match the signed JWT payload.
|
|
288
|
+
* Otherwise an attacker could tamper with meta (audience, timestamp, nonce)
|
|
289
|
+
* while keeping a valid signature. We validate meta against the decoded
|
|
290
|
+
* payload before trusting meta for any security decisions.
|
|
291
|
+
*/
|
|
292
|
+
async verifySignature(proof, expectedAudience) {
|
|
293
|
+
try {
|
|
294
|
+
// For testing with mock data, skip actual signature verification
|
|
295
|
+
if (this.config.allowMockData && proof.meta.did.startsWith("did:test:")) {
|
|
296
|
+
// Parse JWS components for basic validation (support both full and detached formats)
|
|
297
|
+
const jwsParts = proof.jws.split(".");
|
|
298
|
+
if (jwsParts.length !== 3) {
|
|
299
|
+
return {
|
|
300
|
+
code: "XMCP_I_EBADPROOF",
|
|
301
|
+
message: "Invalid JWS format",
|
|
302
|
+
httpStatus: 403,
|
|
303
|
+
details: {
|
|
304
|
+
reason: "JWS must have 3 parts (header.payload.signature)",
|
|
305
|
+
expected: "3 parts",
|
|
306
|
+
received: `${jwsParts.length} parts`,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const [headerB64] = jwsParts;
|
|
311
|
+
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
|
|
312
|
+
if (header.alg !== "EdDSA") {
|
|
313
|
+
return {
|
|
314
|
+
code: "XMCP_I_EBADPROOF",
|
|
315
|
+
message: "Invalid JWS: unsupported algorithm",
|
|
316
|
+
httpStatus: 403,
|
|
317
|
+
details: {
|
|
318
|
+
reason: "Only EdDSA algorithm is supported",
|
|
319
|
+
expected: "EdDSA",
|
|
320
|
+
received: header.alg,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (header.kid !== proof.meta.kid) {
|
|
325
|
+
return {
|
|
326
|
+
code: "XMCP_I_EBADPROOF",
|
|
327
|
+
message: "Invalid JWS: key ID mismatch",
|
|
328
|
+
httpStatus: 403,
|
|
329
|
+
details: {
|
|
330
|
+
reason: "JWS header kid must match proof meta kid",
|
|
331
|
+
expected: proof.meta.kid,
|
|
332
|
+
received: header.kid,
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// Mock signature verification passes for test DIDs
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
// Parse JWS components - support both full JWS and detached format
|
|
340
|
+
const jwsParts = proof.jws.split(".");
|
|
341
|
+
if (jwsParts.length !== 3) {
|
|
342
|
+
return {
|
|
343
|
+
code: "XMCP_I_EBADPROOF",
|
|
344
|
+
message: "Invalid JWS format",
|
|
345
|
+
httpStatus: 403,
|
|
346
|
+
details: {
|
|
347
|
+
reason: "JWS must have 3 parts (header.payload.signature)",
|
|
348
|
+
expected: "3 parts",
|
|
349
|
+
received: `${jwsParts.length} parts`,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const [headerB64, payloadB64Part, signatureB64] = jwsParts;
|
|
354
|
+
const isDetached = payloadB64Part === "";
|
|
355
|
+
// Parse and validate header
|
|
356
|
+
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
|
|
357
|
+
if (header.alg !== "EdDSA") {
|
|
358
|
+
return {
|
|
359
|
+
code: "XMCP_I_EBADPROOF",
|
|
360
|
+
message: "Invalid JWS: unsupported algorithm",
|
|
361
|
+
httpStatus: 403,
|
|
362
|
+
details: {
|
|
363
|
+
reason: "Only EdDSA algorithm is supported",
|
|
364
|
+
expected: "EdDSA",
|
|
365
|
+
received: header.alg,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (header.kid !== proof.meta.kid) {
|
|
370
|
+
return {
|
|
371
|
+
code: "XMCP_I_EBADPROOF",
|
|
372
|
+
message: "Invalid JWS: key ID mismatch",
|
|
373
|
+
httpStatus: 403,
|
|
374
|
+
details: {
|
|
375
|
+
reason: "JWS header kid must match proof meta kid",
|
|
376
|
+
expected: proof.meta.kid,
|
|
377
|
+
received: header.kid,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// Fetch public key from DID document with caching
|
|
382
|
+
const publicKey = await this.fetchPublicKeyWithCache(proof.meta.did, proof.meta.kid);
|
|
383
|
+
if (!publicKey) {
|
|
384
|
+
return {
|
|
385
|
+
code: "XMCP_I_EBADPROOF",
|
|
386
|
+
message: "Unable to resolve public key",
|
|
387
|
+
httpStatus: 403,
|
|
388
|
+
details: {
|
|
389
|
+
reason: "Could not fetch public key from DID document",
|
|
390
|
+
remediation: "Ensure DID document is accessible and contains the key",
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Handle both full JWS (from generators) and detached JWS formats
|
|
395
|
+
let completeJWS;
|
|
396
|
+
if (isDetached) {
|
|
397
|
+
// Detached format: reconstruct payload from proof meta (meta is cryptographically bound)
|
|
398
|
+
const canonicalPayload = this.createCanonicalPayload(proof.meta);
|
|
399
|
+
const payloadB64 = Buffer.from(canonicalPayload, "utf8").toString("base64url");
|
|
400
|
+
completeJWS = `${headerB64}.${payloadB64}.${signatureB64}`;
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
// Full JWS format: validate proof.meta matches the signed payload.
|
|
404
|
+
// Without this, an attacker could tamper with meta (audience, ts, nonce)
|
|
405
|
+
// while keeping a valid JWS, bypassing security checks.
|
|
406
|
+
const payload = JSON.parse(Buffer.from(payloadB64Part, "base64url").toString("utf8"));
|
|
407
|
+
const metaMismatch = this.validateMetaMatchesPayload(proof.meta, payload);
|
|
408
|
+
if (metaMismatch) {
|
|
409
|
+
return metaMismatch;
|
|
410
|
+
}
|
|
411
|
+
completeJWS = proof.jws;
|
|
412
|
+
}
|
|
413
|
+
// Verify signature using JOSE
|
|
414
|
+
const jwk = await importJWK(publicKey);
|
|
415
|
+
await jwtVerify(completeJWS, jwk, {
|
|
416
|
+
algorithms: ["EdDSA"],
|
|
417
|
+
...(expectedAudience !== undefined &&
|
|
418
|
+
!isDetached && { audience: expectedAudience }),
|
|
419
|
+
});
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
return {
|
|
424
|
+
code: "XMCP_I_EBADPROOF",
|
|
425
|
+
message: "Signature verification failed",
|
|
426
|
+
httpStatus: 403,
|
|
427
|
+
details: {
|
|
428
|
+
reason: error instanceof Error ? error.message : "Unknown error",
|
|
429
|
+
remediation: "Check proof signature and public key",
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Validate that proof.meta matches the decoded JWT payload.
|
|
436
|
+
* Required for full JWS to prevent meta tampering (audience, timestamp, nonce)
|
|
437
|
+
* while keeping a valid signature. Returns StructuredError on mismatch.
|
|
438
|
+
*/
|
|
439
|
+
validateMetaMatchesPayload(meta, payload) {
|
|
440
|
+
// JWT aud can be string or string[] (RFC 7519)
|
|
441
|
+
const payloadAud = payload.aud;
|
|
442
|
+
const audienceMatches = typeof payloadAud === "string"
|
|
443
|
+
? meta.audience === payloadAud
|
|
444
|
+
: Array.isArray(payloadAud) && payloadAud.includes(meta.audience);
|
|
445
|
+
if (!audienceMatches) {
|
|
446
|
+
return {
|
|
447
|
+
code: "XMCP_I_EBADPROOF",
|
|
448
|
+
message: "Invalid proof: meta does not match signed payload",
|
|
449
|
+
httpStatus: 403,
|
|
450
|
+
details: {
|
|
451
|
+
reason: "proof.meta.audience does not match JWT payload aud",
|
|
452
|
+
expected: payloadAud,
|
|
453
|
+
received: meta.audience,
|
|
454
|
+
remediation: "Ensure proof.meta matches the signed JWS payload; tampering detected",
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const checks = [
|
|
459
|
+
{ metaVal: meta.did, payloadKey: "sub", payloadVal: payload.sub },
|
|
460
|
+
{ metaVal: meta.did, payloadKey: "iss", payloadVal: payload.iss },
|
|
461
|
+
{
|
|
462
|
+
metaVal: meta.requestHash,
|
|
463
|
+
payloadKey: "requestHash",
|
|
464
|
+
payloadVal: payload.requestHash,
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
metaVal: meta.responseHash,
|
|
468
|
+
payloadKey: "responseHash",
|
|
469
|
+
payloadVal: payload.responseHash,
|
|
470
|
+
},
|
|
471
|
+
{ metaVal: meta.ts, payloadKey: "ts", payloadVal: payload.ts },
|
|
472
|
+
{ metaVal: meta.nonce, payloadKey: "nonce", payloadVal: payload.nonce },
|
|
473
|
+
{
|
|
474
|
+
metaVal: meta.sessionId,
|
|
475
|
+
payloadKey: "sessionId",
|
|
476
|
+
payloadVal: payload.sessionId,
|
|
477
|
+
},
|
|
478
|
+
];
|
|
479
|
+
for (const { metaVal, payloadKey, payloadVal } of checks) {
|
|
480
|
+
if (metaVal !== payloadVal) {
|
|
481
|
+
return {
|
|
482
|
+
code: "XMCP_I_EBADPROOF",
|
|
483
|
+
message: "Invalid proof: meta does not match signed payload",
|
|
484
|
+
httpStatus: 403,
|
|
485
|
+
details: {
|
|
486
|
+
reason: `proof.meta does not match JWT payload (${payloadKey})`,
|
|
487
|
+
expected: payloadVal,
|
|
488
|
+
received: metaVal,
|
|
489
|
+
remediation: "Ensure proof.meta matches the signed JWS payload; tampering detected",
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// Optional fields - use truthiness semantics to match generators.
|
|
495
|
+
// Generators use `...(meta.scopeId && { scopeId })` so falsy values ("" or null)
|
|
496
|
+
// are excluded from the signed payload. We must not report mismatch when
|
|
497
|
+
// meta has falsy value but payload omits the field.
|
|
498
|
+
const checkOptional = (metaVal, payloadVal, field) => {
|
|
499
|
+
const metaHas = !!metaVal;
|
|
500
|
+
const payloadHas = payloadVal !== undefined;
|
|
501
|
+
if (metaHas !== payloadHas) {
|
|
502
|
+
return {
|
|
503
|
+
code: "XMCP_I_EBADPROOF",
|
|
504
|
+
message: "Invalid proof: meta does not match signed payload",
|
|
505
|
+
httpStatus: 403,
|
|
506
|
+
details: {
|
|
507
|
+
reason: `proof.meta.${field} does not match JWT payload ${field}`,
|
|
508
|
+
expected: payloadVal,
|
|
509
|
+
received: metaVal,
|
|
510
|
+
remediation: "Ensure proof.meta matches the signed JWS payload",
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (metaHas && metaVal !== payloadVal) {
|
|
515
|
+
return {
|
|
516
|
+
code: "XMCP_I_EBADPROOF",
|
|
517
|
+
message: "Invalid proof: meta does not match signed payload",
|
|
518
|
+
httpStatus: 403,
|
|
519
|
+
details: {
|
|
520
|
+
reason: `proof.meta.${field} does not match JWT payload ${field}`,
|
|
521
|
+
expected: payloadVal,
|
|
522
|
+
received: metaVal,
|
|
523
|
+
remediation: "Ensure proof.meta matches the signed JWS payload",
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
};
|
|
529
|
+
const scopeErr = checkOptional(meta.scopeId, payload.scopeId, "scopeId");
|
|
530
|
+
if (scopeErr)
|
|
531
|
+
return scopeErr;
|
|
532
|
+
const delegErr = checkOptional(meta.delegationRef, payload.delegationRef, "delegationRef");
|
|
533
|
+
if (delegErr)
|
|
534
|
+
return delegErr;
|
|
535
|
+
const clientErr = checkOptional(meta.clientDid, payload.clientDid, "clientDid");
|
|
536
|
+
if (clientErr)
|
|
537
|
+
return clientErr;
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Create canonical payload that matches runtime implementation
|
|
542
|
+
* Uses JSON Canonicalization Scheme (JCS) RFC 8785 for deterministic ordering
|
|
543
|
+
*
|
|
544
|
+
* CRITICAL: Must match the JWT payload structure used by proof generators.
|
|
545
|
+
* Generators use standard JWT claims (aud, sub, iss) plus custom claims.
|
|
546
|
+
*/
|
|
547
|
+
createCanonicalPayload(meta) {
|
|
548
|
+
// Reconstruct the exact JWT payload structure used by generators
|
|
549
|
+
// (see packages/mcp-i/src/runtime/proof.ts and packages/mcp-i-cloudflare/src/proof-generator.ts)
|
|
550
|
+
const payload = {
|
|
551
|
+
// Standard JWT claims (RFC 7519)
|
|
552
|
+
aud: meta.audience, // Audience (who the token is for)
|
|
553
|
+
sub: meta.did, // Subject (agent DID)
|
|
554
|
+
iss: meta.did, // Issuer (agent DID - self-issued)
|
|
555
|
+
// Custom MCP-I proof claims
|
|
556
|
+
requestHash: meta.requestHash,
|
|
557
|
+
responseHash: meta.responseHash,
|
|
558
|
+
ts: meta.ts,
|
|
559
|
+
nonce: meta.nonce,
|
|
560
|
+
sessionId: meta.sessionId,
|
|
561
|
+
// Optional claims (only include if present)
|
|
562
|
+
...(meta.scopeId && { scopeId: meta.scopeId }),
|
|
563
|
+
...(meta.delegationRef && { delegationRef: meta.delegationRef }),
|
|
564
|
+
...(meta.clientDid && { clientDid: meta.clientDid }),
|
|
565
|
+
};
|
|
566
|
+
// Use RFC 8785 compliant JCS canonicalization
|
|
567
|
+
return canonicalize(payload);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Verify delegation status via KTA with caching
|
|
571
|
+
*/
|
|
572
|
+
async verifyDelegation(delegationRef, did, kid) {
|
|
573
|
+
try {
|
|
574
|
+
// Check cache first
|
|
575
|
+
const cached = this.delegationCache.get(delegationRef);
|
|
576
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
577
|
+
return this.validateDelegationResponse(cached.response, did, kid);
|
|
578
|
+
}
|
|
579
|
+
if (this.config.allowMockData) {
|
|
580
|
+
// Mock delegation for testing
|
|
581
|
+
if (delegationRef.startsWith("mock:")) {
|
|
582
|
+
const mockResponse = {
|
|
583
|
+
active: delegationRef !== "mock:revoked",
|
|
584
|
+
did,
|
|
585
|
+
kid,
|
|
586
|
+
scopes: ["*"],
|
|
587
|
+
};
|
|
588
|
+
return this.validateDelegationResponse(mockResponse, did, kid);
|
|
589
|
+
}
|
|
590
|
+
// For other delegation refs in test mode, assume they're valid
|
|
591
|
+
const mockResponse = {
|
|
592
|
+
active: true,
|
|
593
|
+
did,
|
|
594
|
+
kid,
|
|
595
|
+
scopes: ["*"],
|
|
596
|
+
};
|
|
597
|
+
return this.validateDelegationResponse(mockResponse, did, kid);
|
|
598
|
+
}
|
|
599
|
+
const response = await fetch(`${this.config.ktaBaseUrl}/api/v1/delegations/${encodeURIComponent(delegationRef)}`, {
|
|
600
|
+
method: "GET",
|
|
601
|
+
headers: {
|
|
602
|
+
"Content-Type": "application/json",
|
|
603
|
+
"User-Agent": "XMCP-I-Verifier/1.0",
|
|
604
|
+
},
|
|
605
|
+
// Add timeout for production reliability
|
|
606
|
+
signal: AbortSignal.timeout(5000),
|
|
607
|
+
});
|
|
608
|
+
if (!response || !response.ok) {
|
|
609
|
+
if (response && response.status === 404) {
|
|
610
|
+
return {
|
|
611
|
+
code: "XMCP_I_EBADPROOF",
|
|
612
|
+
message: "Delegation not found",
|
|
613
|
+
httpStatus: 403,
|
|
614
|
+
details: {
|
|
615
|
+
reason: "Delegation reference not found in KTA",
|
|
616
|
+
remediation: "Check delegation reference and ensure it exists",
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
throw new Error(`KTA API error: ${response.status}`);
|
|
621
|
+
}
|
|
622
|
+
const delegation = (await response.json());
|
|
623
|
+
// Cache the response
|
|
624
|
+
this.delegationCache.set(delegationRef, {
|
|
625
|
+
response: delegation,
|
|
626
|
+
expiresAt: Date.now() + this.config.delegationCacheTtl * 1000,
|
|
627
|
+
});
|
|
628
|
+
return this.validateDelegationResponse(delegation, did, kid);
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
// Treat delegation check failures as verification failures
|
|
632
|
+
console.warn("Delegation verification failed:", error);
|
|
633
|
+
return {
|
|
634
|
+
code: "XMCP_I_EBADPROOF",
|
|
635
|
+
message: "Delegation verification failed",
|
|
636
|
+
httpStatus: 403,
|
|
637
|
+
details: {
|
|
638
|
+
reason: error instanceof Error ? error.message : "Unknown error",
|
|
639
|
+
remediation: "Check KTA connectivity and delegation status",
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Validate delegation response
|
|
646
|
+
*/
|
|
647
|
+
validateDelegationResponse(delegation, expectedDid, expectedKeyId) {
|
|
648
|
+
if (!delegation.active) {
|
|
649
|
+
return {
|
|
650
|
+
code: "XMCP_I_EBADPROOF",
|
|
651
|
+
message: "Delegation revoked or inactive",
|
|
652
|
+
httpStatus: 403,
|
|
653
|
+
details: {
|
|
654
|
+
reason: "Delegation is not active",
|
|
655
|
+
remediation: "Renew delegation or use direct identity",
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
if (delegation.did !== expectedDid || delegation.kid !== expectedKeyId) {
|
|
660
|
+
return {
|
|
661
|
+
code: "XMCP_I_EBADPROOF",
|
|
662
|
+
message: "Delegation identity mismatch",
|
|
663
|
+
httpStatus: 403,
|
|
664
|
+
details: {
|
|
665
|
+
reason: "Delegation does not match proof identity",
|
|
666
|
+
expected: `${expectedDid}#${expectedKeyId}`,
|
|
667
|
+
received: `${delegation.did}#${delegation.kid}`,
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
if (delegation.expiresAt && delegation.expiresAt < Date.now() / 1000) {
|
|
672
|
+
return {
|
|
673
|
+
code: "XMCP_I_EBADPROOF",
|
|
674
|
+
message: "Delegation expired",
|
|
675
|
+
httpStatus: 403,
|
|
676
|
+
details: {
|
|
677
|
+
reason: "Delegation has expired",
|
|
678
|
+
remediation: "Renew delegation",
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Fetch public key from DID document with caching
|
|
686
|
+
*/
|
|
687
|
+
async fetchPublicKeyWithCache(did, kid) {
|
|
688
|
+
const cacheKey = `${did}#${kid}`;
|
|
689
|
+
const cached = this.didCache.get(cacheKey);
|
|
690
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
691
|
+
return this.extractPublicKey(cached.document, kid);
|
|
692
|
+
}
|
|
693
|
+
const didDoc = await this.fetchDIDDocument(did);
|
|
694
|
+
if (!didDoc) {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
// Cache the DID document
|
|
698
|
+
this.didCache.set(cacheKey, {
|
|
699
|
+
document: didDoc,
|
|
700
|
+
expiresAt: Date.now() + this.config.didCacheTtl * 1000,
|
|
701
|
+
});
|
|
702
|
+
return this.extractPublicKey(didDoc, kid);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Fetch DID document from well-known endpoint
|
|
706
|
+
*/
|
|
707
|
+
async fetchDIDDocument(did) {
|
|
708
|
+
try {
|
|
709
|
+
if (this.config.allowMockData && did.startsWith("did:test:")) {
|
|
710
|
+
// Mock DID document for testing
|
|
711
|
+
return {
|
|
712
|
+
id: did,
|
|
713
|
+
verificationMethod: [
|
|
714
|
+
{
|
|
715
|
+
id: `#key-test-1`,
|
|
716
|
+
type: "Ed25519VerificationKey2020",
|
|
717
|
+
controller: did,
|
|
718
|
+
publicKeyJwk: {
|
|
719
|
+
kty: "OKP",
|
|
720
|
+
crv: "Ed25519",
|
|
721
|
+
x: "mock-public-key-data",
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
// Convert DID to well-known URL for did:web
|
|
728
|
+
if (!did.startsWith("did:web:")) {
|
|
729
|
+
throw new Error("Only did:web is supported");
|
|
730
|
+
}
|
|
731
|
+
const domain = did.replace("did:web:", "").replace(/:/g, "/");
|
|
732
|
+
const didDocUrl = `https://${domain}/.well-known/did.json`;
|
|
733
|
+
const response = await fetch(didDocUrl, {
|
|
734
|
+
headers: {
|
|
735
|
+
Accept: "application/did+json, application/json",
|
|
736
|
+
"User-Agent": "XMCP-I-Verifier/1.0",
|
|
737
|
+
},
|
|
738
|
+
// Add timeout for production reliability
|
|
739
|
+
signal: AbortSignal.timeout(5000),
|
|
740
|
+
});
|
|
741
|
+
if (!response.ok) {
|
|
742
|
+
throw new Error(`Failed to fetch DID document: ${response.status}`);
|
|
743
|
+
}
|
|
744
|
+
return await response.json();
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
console.warn("Failed to fetch DID document:", error);
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Extract public key from DID document
|
|
753
|
+
*/
|
|
754
|
+
extractPublicKey(didDoc, kid) {
|
|
755
|
+
try {
|
|
756
|
+
// Find verification method
|
|
757
|
+
const verificationMethod = didDoc.verificationMethod?.find((vm) => vm.id === `#${kid}` || vm.id === `${didDoc.id}#${kid}`);
|
|
758
|
+
if (!verificationMethod) {
|
|
759
|
+
throw new Error(`Key ${kid} not found in DID document`);
|
|
760
|
+
}
|
|
761
|
+
// Convert to JWK format if needed
|
|
762
|
+
if (verificationMethod.publicKeyJwk) {
|
|
763
|
+
return verificationMethod.publicKeyJwk;
|
|
764
|
+
}
|
|
765
|
+
if (verificationMethod.publicKeyMultibase) {
|
|
766
|
+
// Convert multibase to JWK (simplified for Ed25519)
|
|
767
|
+
// This is a placeholder - real implementation would use proper multibase decoding
|
|
768
|
+
return {
|
|
769
|
+
kty: "OKP",
|
|
770
|
+
crv: "Ed25519",
|
|
771
|
+
x: verificationMethod.publicKeyMultibase.slice(1), // Remove multibase prefix
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
throw new Error("Unsupported public key format");
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
console.warn("Failed to extract public key:", error);
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Generate trusted headers for successful verification
|
|
783
|
+
*/
|
|
784
|
+
generateHeaders(meta) {
|
|
785
|
+
const headers = {
|
|
786
|
+
[AGENT_HEADERS.DID]: meta.did,
|
|
787
|
+
[AGENT_HEADERS.KEY_ID]: meta.kid,
|
|
788
|
+
[AGENT_HEADERS.SESSION]: meta.sessionId,
|
|
789
|
+
[AGENT_HEADERS.CONFIDENCE]: "verified",
|
|
790
|
+
[AGENT_HEADERS.VERIFIED_AT]: Math.floor(Date.now() / 1000).toString(),
|
|
791
|
+
};
|
|
792
|
+
if (meta.scopeId) {
|
|
793
|
+
headers[AGENT_HEADERS.SCOPES] = meta.scopeId;
|
|
794
|
+
}
|
|
795
|
+
if (meta.delegationRef) {
|
|
796
|
+
headers[AGENT_HEADERS.DELEGATION_REF] = meta.delegationRef;
|
|
797
|
+
}
|
|
798
|
+
// Add registry URL for traceability
|
|
799
|
+
headers[AGENT_HEADERS.REGISTRY] = `${this.config.ktaBaseUrl}/agents/${encodeURIComponent(meta.did)}`;
|
|
800
|
+
return headers;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Generate agent context for MCP recipients
|
|
804
|
+
*/
|
|
805
|
+
generateAgentContext(meta) {
|
|
806
|
+
return {
|
|
807
|
+
did: meta.did,
|
|
808
|
+
kid: meta.kid,
|
|
809
|
+
subject: meta.delegationRef ? meta.did : undefined,
|
|
810
|
+
scopes: meta.scopeId ? meta.scopeId.split(",") : [],
|
|
811
|
+
session: meta.sessionId,
|
|
812
|
+
confidence: "verified",
|
|
813
|
+
delegationRef: meta.delegationRef,
|
|
814
|
+
registry: `${this.config.ktaBaseUrl}/agents/${encodeURIComponent(meta.did)}`,
|
|
815
|
+
verifiedAt: Math.floor(Date.now() / 1000),
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Create error result from structured error
|
|
820
|
+
*/
|
|
821
|
+
createErrorResult(error) {
|
|
822
|
+
return {
|
|
823
|
+
success: false,
|
|
824
|
+
error: {
|
|
825
|
+
code: error.code,
|
|
826
|
+
message: error.message,
|
|
827
|
+
details: error.details,
|
|
828
|
+
httpStatus: error.httpStatus ||
|
|
829
|
+
ERROR_HTTP_STATUS[error.code] ||
|
|
830
|
+
500,
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Log verification attempt for security monitoring
|
|
836
|
+
*/
|
|
837
|
+
logVerificationAttempt(context, success, reason, duration) {
|
|
838
|
+
// In production, this would integrate with your logging system
|
|
839
|
+
const logEntry = {
|
|
840
|
+
timestamp: new Date().toISOString(),
|
|
841
|
+
did: context.proof?.meta?.did || "unknown",
|
|
842
|
+
audience: context.audience,
|
|
843
|
+
success,
|
|
844
|
+
reason,
|
|
845
|
+
duration,
|
|
846
|
+
sessionId: context.proof?.meta?.sessionId,
|
|
847
|
+
};
|
|
848
|
+
// For now, just console.log - in production, use structured logging
|
|
849
|
+
console.log("XMCP-I Verification:", JSON.stringify(logEntry));
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Clean up expired cache entries
|
|
853
|
+
*/
|
|
854
|
+
cleanupCache() {
|
|
855
|
+
const now = Date.now();
|
|
856
|
+
for (const [key, value] of this.didCache.entries()) {
|
|
857
|
+
if (value.expiresAt <= now) {
|
|
858
|
+
this.didCache.delete(key);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
for (const [key, value] of this.delegationCache.entries()) {
|
|
862
|
+
if (value.expiresAt <= now) {
|
|
863
|
+
this.delegationCache.delete(key);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
//# sourceMappingURL=core.js.map
|