@clawbureau/clawverify-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/crypto.d.ts +27 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +124 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/jcs.d.ts +13 -0
- package/dist/jcs.d.ts.map +1 -0
- package/dist/jcs.js +43 -0
- package/dist/jcs.js.map +1 -0
- package/dist/model-identity.d.ts +46 -0
- package/dist/model-identity.d.ts.map +1 -0
- package/dist/model-identity.js +233 -0
- package/dist/model-identity.js.map +1 -0
- package/dist/schema-registry.d.ts +99 -0
- package/dist/schema-registry.d.ts.map +1 -0
- package/dist/schema-registry.js +259 -0
- package/dist/schema-registry.js.map +1 -0
- package/dist/schema-validation.d.ts +35 -0
- package/dist/schema-validation.d.ts.map +1 -0
- package/dist/schema-validation.js +156 -0
- package/dist/schema-validation.js.map +1 -0
- package/dist/schema-validators.generated.d.ts +158 -0
- package/dist/schema-validators.generated.d.ts.map +1 -0
- package/dist/schema-validators.generated.js +19186 -0
- package/dist/schema-validators.generated.js.map +1 -0
- package/dist/types.d.ts +910 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +33 -0
- package/dist/types.js.map +1 -0
- package/dist/verify-audit-result-attestation.d.ts +32 -0
- package/dist/verify-audit-result-attestation.d.ts.map +1 -0
- package/dist/verify-audit-result-attestation.js +396 -0
- package/dist/verify-audit-result-attestation.js.map +1 -0
- package/dist/verify-derivation-attestation.d.ts +30 -0
- package/dist/verify-derivation-attestation.d.ts.map +1 -0
- package/dist/verify-derivation-attestation.js +371 -0
- package/dist/verify-derivation-attestation.js.map +1 -0
- package/dist/verify-execution-attestation.d.ts +32 -0
- package/dist/verify-execution-attestation.d.ts.map +1 -0
- package/dist/verify-execution-attestation.js +578 -0
- package/dist/verify-execution-attestation.js.map +1 -0
- package/dist/verify-export-bundle.d.ts +14 -0
- package/dist/verify-export-bundle.d.ts.map +1 -0
- package/dist/verify-export-bundle.js +307 -0
- package/dist/verify-export-bundle.js.map +1 -0
- package/dist/verify-log-inclusion-proof.d.ts +16 -0
- package/dist/verify-log-inclusion-proof.d.ts.map +1 -0
- package/dist/verify-log-inclusion-proof.js +216 -0
- package/dist/verify-log-inclusion-proof.js.map +1 -0
- package/dist/verify-proof-bundle.d.ts +48 -0
- package/dist/verify-proof-bundle.d.ts.map +1 -0
- package/dist/verify-proof-bundle.js +1708 -0
- package/dist/verify-proof-bundle.js.map +1 -0
- package/dist/verify-receipt.d.ts +30 -0
- package/dist/verify-receipt.d.ts.map +1 -0
- package/dist/verify-receipt.js +408 -0
- package/dist/verify-receipt.js.map +1 -0
- package/dist/verify-web-receipt.d.ts +21 -0
- package/dist/verify-web-receipt.d.ts.map +1 -0
- package/dist/verify-web-receipt.js +341 -0
- package/dist/verify-web-receipt.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof Bundle Verification
|
|
3
|
+
* CVF-US-007: Verify proof bundles for trust tier computation
|
|
4
|
+
* POH-US-003: Validate proof bundles against PoH schema, verify receipts
|
|
5
|
+
* with clawproxy DID, verify event-chain hash linkage, and
|
|
6
|
+
* return trust tier based on validated components.
|
|
7
|
+
*
|
|
8
|
+
* Validates:
|
|
9
|
+
* - Proof bundle payload against PoH schema (proof_bundle.v1)
|
|
10
|
+
* - URM (Universal Resource Manifest) structure
|
|
11
|
+
* - Event chain hash linkage and run_id consistency
|
|
12
|
+
* - Gateway receipt envelopes (cryptographic verification)
|
|
13
|
+
* - Attestations
|
|
14
|
+
*
|
|
15
|
+
* Computes trust tier based on which components are present and valid.
|
|
16
|
+
* Fail-closed: unknown or malformed payloads always result in 'unknown' tier.
|
|
17
|
+
*/
|
|
18
|
+
import { isAllowedVersion, isAllowedType, isAllowedAlgorithm, isAllowedHashAlgorithm, isValidDidFormat, isValidBase64Url, isValidIsoDate, } from './schema-registry.js';
|
|
19
|
+
import { computeHash, base64UrlDecode, extractPublicKeyFromDidKey, verifySignature, } from './crypto.js';
|
|
20
|
+
import { verifyReceipt } from './verify-receipt.js';
|
|
21
|
+
import { computeModelIdentityTierFromReceipts } from './model-identity.js';
|
|
22
|
+
import { jcsCanonicalize } from './jcs.js';
|
|
23
|
+
import { validateProofBundleEnvelopeV1, validateUrmV1, validatePromptPackV1, validateSystemPromptReportV1, } from './schema-validation.js';
|
|
24
|
+
// CVF-US-025: size/count hardening
|
|
25
|
+
const MAX_EVENT_CHAIN_ENTRIES = 1000;
|
|
26
|
+
const MAX_RECEIPTS = 1000;
|
|
27
|
+
const MAX_ATTESTATIONS = 100;
|
|
28
|
+
const MAX_METADATA_BYTES = 16 * 1024;
|
|
29
|
+
function jsonByteSize(value) {
|
|
30
|
+
try {
|
|
31
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
32
|
+
return bytes.byteLength;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return Number.POSITIVE_INFINITY;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validate envelope structure for proof bundle
|
|
40
|
+
*/
|
|
41
|
+
function validateEnvelopeStructure(envelope) {
|
|
42
|
+
if (typeof envelope !== 'object' || envelope === null) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const e = envelope;
|
|
46
|
+
return ('envelope_version' in e &&
|
|
47
|
+
'envelope_type' in e &&
|
|
48
|
+
'payload' in e &&
|
|
49
|
+
'payload_hash_b64u' in e &&
|
|
50
|
+
'hash_algorithm' in e &&
|
|
51
|
+
'signature_b64u' in e &&
|
|
52
|
+
'algorithm' in e &&
|
|
53
|
+
'signer_did' in e &&
|
|
54
|
+
'issued_at' in e);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Validate proof bundle payload structure against PoH schema (proof_bundle.v1).
|
|
58
|
+
*
|
|
59
|
+
* Schema constraints enforced:
|
|
60
|
+
* - bundle_version: const "1"
|
|
61
|
+
* - bundle_id: string, minLength 1
|
|
62
|
+
* - agent_did: string, pattern ^did:
|
|
63
|
+
* - At least one of: urm, event_chain, receipts, attestations
|
|
64
|
+
*/
|
|
65
|
+
function validateBundlePayload(payload) {
|
|
66
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
67
|
+
return { valid: false, error: 'Payload must be an object' };
|
|
68
|
+
}
|
|
69
|
+
const p = payload;
|
|
70
|
+
// Required fields per schema
|
|
71
|
+
if (p.bundle_version !== '1') {
|
|
72
|
+
return { valid: false, error: 'bundle_version must be "1"' };
|
|
73
|
+
}
|
|
74
|
+
if (typeof p.bundle_id !== 'string' || p.bundle_id.length === 0) {
|
|
75
|
+
return { valid: false, error: 'bundle_id is required and must be non-empty' };
|
|
76
|
+
}
|
|
77
|
+
if (typeof p.agent_did !== 'string' || !/^did:/.test(p.agent_did)) {
|
|
78
|
+
return { valid: false, error: 'agent_did must be a string starting with "did:"' };
|
|
79
|
+
}
|
|
80
|
+
// At least one component must be present (schema anyOf)
|
|
81
|
+
const hasUrm = p.urm !== undefined;
|
|
82
|
+
const hasEventChain = Array.isArray(p.event_chain) && p.event_chain.length > 0;
|
|
83
|
+
const hasReceipts = Array.isArray(p.receipts) && p.receipts.length > 0;
|
|
84
|
+
const hasAttestations = Array.isArray(p.attestations) && p.attestations.length > 0;
|
|
85
|
+
if (!hasUrm && !hasEventChain && !hasReceipts && !hasAttestations) {
|
|
86
|
+
return { valid: false, error: 'At least one of urm, event_chain, receipts, or attestations is required' };
|
|
87
|
+
}
|
|
88
|
+
return { valid: true };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Type guard helper (thin wrapper for backward compatibility)
|
|
92
|
+
*/
|
|
93
|
+
function isBundlePayload(payload) {
|
|
94
|
+
return validateBundlePayload(payload).valid;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Validate URM reference structure per PoH schema (proof_bundle.v1 → urm).
|
|
98
|
+
*
|
|
99
|
+
* Schema constraints:
|
|
100
|
+
* - urm_version: const "1"
|
|
101
|
+
* - urm_id: string, minLength 1
|
|
102
|
+
* - resource_type: string, minLength 1
|
|
103
|
+
* - resource_hash_b64u: base64url string, minLength 8
|
|
104
|
+
*/
|
|
105
|
+
function validateURM(urm) {
|
|
106
|
+
if (typeof urm !== 'object' || urm === null)
|
|
107
|
+
return false;
|
|
108
|
+
const u = urm;
|
|
109
|
+
return (u.urm_version === '1' &&
|
|
110
|
+
typeof u.urm_id === 'string' &&
|
|
111
|
+
u.urm_id.length >= 1 &&
|
|
112
|
+
typeof u.resource_type === 'string' &&
|
|
113
|
+
u.resource_type.length >= 1 &&
|
|
114
|
+
typeof u.resource_hash_b64u === 'string' &&
|
|
115
|
+
u.resource_hash_b64u.length >= 8 &&
|
|
116
|
+
isValidBase64Url(u.resource_hash_b64u));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Validate event chain entries and hash chain integrity per PoH schema.
|
|
120
|
+
*
|
|
121
|
+
* Schema constraints per event_chain.v1 / proof_bundle.v1:
|
|
122
|
+
* - event_id, run_id, event_type: string, minLength 1
|
|
123
|
+
* - timestamp: ISO 8601 date-time
|
|
124
|
+
* - payload_hash_b64u, event_hash_b64u: base64url, minLength 8
|
|
125
|
+
* - prev_hash_b64u: base64url (minLength 8) or null for the first event
|
|
126
|
+
* - Hash chain: first event has null prev_hash, subsequent events link
|
|
127
|
+
* - run_id consistency across all events
|
|
128
|
+
*/
|
|
129
|
+
function validateEventChain(events) {
|
|
130
|
+
if (events.length === 0) {
|
|
131
|
+
return { valid: false, error: 'Empty event chain' };
|
|
132
|
+
}
|
|
133
|
+
let prevHash = null;
|
|
134
|
+
let expectedRunId = null;
|
|
135
|
+
let chainRootHash = null;
|
|
136
|
+
for (let i = 0; i < events.length; i++) {
|
|
137
|
+
const event = events[i];
|
|
138
|
+
// Validate required fields with minLength constraints
|
|
139
|
+
if (typeof event.event_id !== 'string' || event.event_id.length < 1) {
|
|
140
|
+
return { valid: false, error: `Event ${i}: missing or empty event_id` };
|
|
141
|
+
}
|
|
142
|
+
if (typeof event.run_id !== 'string' || event.run_id.length < 1) {
|
|
143
|
+
return { valid: false, error: `Event ${i}: missing or empty run_id` };
|
|
144
|
+
}
|
|
145
|
+
if (typeof event.event_type !== 'string' || event.event_type.length < 1) {
|
|
146
|
+
return { valid: false, error: `Event ${i}: missing or empty event_type` };
|
|
147
|
+
}
|
|
148
|
+
if (!isValidIsoDate(event.timestamp)) {
|
|
149
|
+
return { valid: false, error: `Event ${i}: invalid timestamp` };
|
|
150
|
+
}
|
|
151
|
+
if (!isValidBase64Url(event.payload_hash_b64u) ||
|
|
152
|
+
event.payload_hash_b64u.length < 8) {
|
|
153
|
+
return { valid: false, error: `Event ${i}: invalid payload_hash_b64u (must be base64url, minLength 8)` };
|
|
154
|
+
}
|
|
155
|
+
if (!isValidBase64Url(event.event_hash_b64u) ||
|
|
156
|
+
event.event_hash_b64u.length < 8) {
|
|
157
|
+
return { valid: false, error: `Event ${i}: invalid event_hash_b64u (must be base64url, minLength 8)` };
|
|
158
|
+
}
|
|
159
|
+
// Enforce run_id consistency
|
|
160
|
+
if (expectedRunId === null) {
|
|
161
|
+
expectedRunId = event.run_id;
|
|
162
|
+
}
|
|
163
|
+
else if (event.run_id !== expectedRunId) {
|
|
164
|
+
return {
|
|
165
|
+
valid: false,
|
|
166
|
+
error: `Event ${i}: inconsistent run_id (expected ${expectedRunId})`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Validate hash chain linkage
|
|
170
|
+
const eventPrevHash = event.prev_hash_b64u;
|
|
171
|
+
if (i === 0) {
|
|
172
|
+
// First event should have null prev_hash
|
|
173
|
+
if (eventPrevHash !== null && eventPrevHash !== '') {
|
|
174
|
+
return {
|
|
175
|
+
valid: false,
|
|
176
|
+
error: 'First event should have null prev_hash_b64u',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
chainRootHash = event.event_hash_b64u;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Non-first events: prev_hash must be base64url, minLength 8
|
|
183
|
+
if (typeof eventPrevHash !== 'string' ||
|
|
184
|
+
!isValidBase64Url(eventPrevHash) ||
|
|
185
|
+
eventPrevHash.length < 8) {
|
|
186
|
+
return {
|
|
187
|
+
valid: false,
|
|
188
|
+
error: `Event ${i}: invalid prev_hash_b64u (must be base64url, minLength 8)`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Must link to previous event's hash
|
|
192
|
+
if (eventPrevHash !== prevHash) {
|
|
193
|
+
return {
|
|
194
|
+
valid: false,
|
|
195
|
+
error: `Event ${i}: hash chain break detected`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
prevHash = event.event_hash_b64u;
|
|
200
|
+
}
|
|
201
|
+
return { valid: true, chain_root_hash: chainRootHash ?? undefined };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Validate attestation references per PoH schema (proof_bundle.v1 → attestations).
|
|
205
|
+
*
|
|
206
|
+
* Schema constraints:
|
|
207
|
+
* - attestation_id: string, minLength 1
|
|
208
|
+
* - attestation_type: enum ["owner", "third_party"]
|
|
209
|
+
* - attester_did: string, pattern ^did:
|
|
210
|
+
* - subject_did: string, pattern ^did:
|
|
211
|
+
* - signature_b64u: base64url, minLength 8
|
|
212
|
+
* - expires_at: optional ISO 8601 date-time
|
|
213
|
+
*/
|
|
214
|
+
function validateAttestation(attestation) {
|
|
215
|
+
if (typeof attestation !== 'object' || attestation === null)
|
|
216
|
+
return false;
|
|
217
|
+
const a = attestation;
|
|
218
|
+
// Fail-closed: reject unknown fields (schemas use additionalProperties:false)
|
|
219
|
+
const allowedKeys = new Set([
|
|
220
|
+
'attestation_id',
|
|
221
|
+
'attestation_type',
|
|
222
|
+
'attester_did',
|
|
223
|
+
'subject_did',
|
|
224
|
+
'expires_at',
|
|
225
|
+
'signature_b64u',
|
|
226
|
+
]);
|
|
227
|
+
for (const k of Object.keys(a)) {
|
|
228
|
+
if (!allowedKeys.has(k))
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
// Check required fields with schema constraints
|
|
232
|
+
if (typeof a.attestation_id !== 'string' || a.attestation_id.length < 1)
|
|
233
|
+
return false;
|
|
234
|
+
if (a.attestation_type !== 'owner' && a.attestation_type !== 'third_party')
|
|
235
|
+
return false;
|
|
236
|
+
if (!isValidDidFormat(a.attester_did))
|
|
237
|
+
return false;
|
|
238
|
+
if (!isValidDidFormat(a.subject_did))
|
|
239
|
+
return false;
|
|
240
|
+
if (!isValidBase64Url(a.signature_b64u) ||
|
|
241
|
+
a.signature_b64u.length < 8)
|
|
242
|
+
return false;
|
|
243
|
+
// Check expiry if present
|
|
244
|
+
if (a.expires_at !== undefined) {
|
|
245
|
+
if (!isValidIsoDate(a.expires_at))
|
|
246
|
+
return false;
|
|
247
|
+
const expiryDate = new Date(a.expires_at);
|
|
248
|
+
if (expiryDate < new Date()) {
|
|
249
|
+
return false; // Expired — fail closed
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
async function verifyAttestationReference(attestation, expectedSubjectDid, allowlistedAttesterDids) {
|
|
255
|
+
const allowlisted = Array.isArray(allowlistedAttesterDids) &&
|
|
256
|
+
allowlistedAttesterDids.includes(attestation.attester_did);
|
|
257
|
+
const subjectValid = attestation.subject_did === expectedSubjectDid;
|
|
258
|
+
const pub = extractPublicKeyFromDidKey(attestation.attester_did);
|
|
259
|
+
if (!pub) {
|
|
260
|
+
return {
|
|
261
|
+
valid: false,
|
|
262
|
+
signature_valid: false,
|
|
263
|
+
allowlisted,
|
|
264
|
+
subject_valid: subjectValid,
|
|
265
|
+
attester_did: attestation.attester_did,
|
|
266
|
+
error: 'Unable to extract Ed25519 public key from attester_did (expected did:key with 0xed01 multicodec prefix)',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
let canonical;
|
|
270
|
+
try {
|
|
271
|
+
const canonicalObject = {
|
|
272
|
+
...attestation,
|
|
273
|
+
signature_b64u: '',
|
|
274
|
+
};
|
|
275
|
+
canonical = jcsCanonicalize(canonicalObject);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
return {
|
|
279
|
+
valid: false,
|
|
280
|
+
signature_valid: false,
|
|
281
|
+
allowlisted,
|
|
282
|
+
subject_valid: subjectValid,
|
|
283
|
+
attester_did: attestation.attester_did,
|
|
284
|
+
error: `Attestation canonicalization failed: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
let signatureValid = false;
|
|
288
|
+
try {
|
|
289
|
+
const sigBytes = base64UrlDecode(attestation.signature_b64u);
|
|
290
|
+
if (sigBytes.length !== 64) {
|
|
291
|
+
return {
|
|
292
|
+
valid: false,
|
|
293
|
+
signature_valid: false,
|
|
294
|
+
allowlisted,
|
|
295
|
+
subject_valid: subjectValid,
|
|
296
|
+
attester_did: attestation.attester_did,
|
|
297
|
+
error: 'Invalid attestation signature length (expected 64 bytes for Ed25519)',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const msgBytes = new TextEncoder().encode(canonical);
|
|
301
|
+
signatureValid = await verifySignature('Ed25519', pub, sigBytes, msgBytes);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
return {
|
|
305
|
+
valid: false,
|
|
306
|
+
signature_valid: false,
|
|
307
|
+
allowlisted,
|
|
308
|
+
subject_valid: subjectValid,
|
|
309
|
+
attester_did: attestation.attester_did,
|
|
310
|
+
error: `Attestation signature verification error: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const valid = signatureValid && allowlisted && subjectValid;
|
|
314
|
+
if (!signatureValid) {
|
|
315
|
+
return {
|
|
316
|
+
valid: false,
|
|
317
|
+
signature_valid: false,
|
|
318
|
+
allowlisted,
|
|
319
|
+
subject_valid: subjectValid,
|
|
320
|
+
attester_did: attestation.attester_did,
|
|
321
|
+
error: 'Attestation signature verification failed',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (!allowlisted) {
|
|
325
|
+
return {
|
|
326
|
+
valid: false,
|
|
327
|
+
signature_valid: true,
|
|
328
|
+
allowlisted,
|
|
329
|
+
subject_valid: subjectValid,
|
|
330
|
+
attester_did: attestation.attester_did,
|
|
331
|
+
error: 'Attester DID is not allowlisted',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (!subjectValid) {
|
|
335
|
+
return {
|
|
336
|
+
valid: false,
|
|
337
|
+
signature_valid: true,
|
|
338
|
+
allowlisted,
|
|
339
|
+
subject_valid: subjectValid,
|
|
340
|
+
attester_did: attestation.attester_did,
|
|
341
|
+
error: 'Attestation subject_did does not match proof bundle agent_did',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
valid,
|
|
346
|
+
signature_valid: signatureValid,
|
|
347
|
+
allowlisted,
|
|
348
|
+
subject_valid: subjectValid,
|
|
349
|
+
attester_did: attestation.attester_did,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Verify a gateway receipt envelope cryptographically *and* ensure it is bound
|
|
354
|
+
* to the proof bundle's event chain.
|
|
355
|
+
*
|
|
356
|
+
* Security note (POH-US-010):
|
|
357
|
+
* - A receipt that is signature-valid but not bound to this bundle's run/event
|
|
358
|
+
* chain MUST NOT count toward gateway-tier trust. Otherwise, receipts can be
|
|
359
|
+
* replayed across bundles.
|
|
360
|
+
*
|
|
361
|
+
* Binding rules (fail-closed for counting):
|
|
362
|
+
* - Proof bundle must include a valid event_chain
|
|
363
|
+
* - receipt.payload.binding.run_id must equal the bundle run_id
|
|
364
|
+
* - receipt.payload.binding.event_hash_b64u must reference an event_hash_b64u
|
|
365
|
+
* present in the bundle event_chain
|
|
366
|
+
*/
|
|
367
|
+
async function verifyReceiptEnvelope(receipt, allowlistedSignerDids, bindingContext) {
|
|
368
|
+
const verification = await verifyReceipt(receipt, { allowlistedSignerDids });
|
|
369
|
+
if (verification.result.status !== 'VALID') {
|
|
370
|
+
return {
|
|
371
|
+
valid: false,
|
|
372
|
+
signature_valid: false,
|
|
373
|
+
binding_valid: false,
|
|
374
|
+
error: verification.error?.message ?? verification.result.reason,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (!bindingContext) {
|
|
378
|
+
return {
|
|
379
|
+
valid: false,
|
|
380
|
+
signature_valid: true,
|
|
381
|
+
binding_valid: false,
|
|
382
|
+
provider: verification.provider,
|
|
383
|
+
model: verification.model,
|
|
384
|
+
gateway_id: verification.gateway_id,
|
|
385
|
+
signer_did: verification.result.signer_did,
|
|
386
|
+
error: 'Receipt binding cannot be verified: proof bundle event_chain is missing or invalid',
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
const env = receipt;
|
|
390
|
+
const binding = env.payload.binding;
|
|
391
|
+
if (!binding || typeof binding !== 'object') {
|
|
392
|
+
return {
|
|
393
|
+
valid: false,
|
|
394
|
+
signature_valid: true,
|
|
395
|
+
binding_valid: false,
|
|
396
|
+
provider: verification.provider,
|
|
397
|
+
model: verification.model,
|
|
398
|
+
gateway_id: verification.gateway_id,
|
|
399
|
+
signer_did: verification.result.signer_did,
|
|
400
|
+
error: 'Receipt is missing binding (expected run_id + event_hash_b64u)',
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const runId = binding.run_id;
|
|
404
|
+
const eventHash = binding.event_hash_b64u;
|
|
405
|
+
if (typeof runId !== 'string' || runId.trim().length === 0) {
|
|
406
|
+
return {
|
|
407
|
+
valid: false,
|
|
408
|
+
signature_valid: true,
|
|
409
|
+
binding_valid: false,
|
|
410
|
+
provider: verification.provider,
|
|
411
|
+
model: verification.model,
|
|
412
|
+
gateway_id: verification.gateway_id,
|
|
413
|
+
signer_did: verification.result.signer_did,
|
|
414
|
+
error: 'Receipt binding.run_id is missing or invalid',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (runId !== bindingContext.expectedRunId) {
|
|
418
|
+
return {
|
|
419
|
+
valid: false,
|
|
420
|
+
signature_valid: true,
|
|
421
|
+
binding_valid: false,
|
|
422
|
+
provider: verification.provider,
|
|
423
|
+
model: verification.model,
|
|
424
|
+
gateway_id: verification.gateway_id,
|
|
425
|
+
signer_did: verification.result.signer_did,
|
|
426
|
+
error: 'Receipt binding.run_id does not match proof bundle run_id',
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (typeof eventHash !== 'string' ||
|
|
430
|
+
eventHash.length < 8 ||
|
|
431
|
+
!isValidBase64Url(eventHash)) {
|
|
432
|
+
return {
|
|
433
|
+
valid: false,
|
|
434
|
+
signature_valid: true,
|
|
435
|
+
binding_valid: false,
|
|
436
|
+
provider: verification.provider,
|
|
437
|
+
model: verification.model,
|
|
438
|
+
gateway_id: verification.gateway_id,
|
|
439
|
+
signer_did: verification.result.signer_did,
|
|
440
|
+
error: 'Receipt binding.event_hash_b64u is missing or invalid',
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (!bindingContext.allowedEventHashes.has(eventHash)) {
|
|
444
|
+
return {
|
|
445
|
+
valid: false,
|
|
446
|
+
signature_valid: true,
|
|
447
|
+
binding_valid: false,
|
|
448
|
+
provider: verification.provider,
|
|
449
|
+
model: verification.model,
|
|
450
|
+
gateway_id: verification.gateway_id,
|
|
451
|
+
signer_did: verification.result.signer_did,
|
|
452
|
+
error: 'Receipt binding.event_hash_b64u does not reference an event in the proof bundle event chain',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
valid: true,
|
|
457
|
+
signature_valid: true,
|
|
458
|
+
binding_valid: true,
|
|
459
|
+
provider: verification.provider,
|
|
460
|
+
model: verification.model,
|
|
461
|
+
gateway_id: verification.gateway_id,
|
|
462
|
+
signer_did: verification.result.signer_did,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Compute trust tier based on validated components
|
|
467
|
+
*
|
|
468
|
+
* Trust Tier Levels:
|
|
469
|
+
* - unknown: No valid components
|
|
470
|
+
* - basic: Valid envelope signature only
|
|
471
|
+
* - verified: Valid event chain or receipts
|
|
472
|
+
* - attested: Valid allowlisted signature-verified attestations
|
|
473
|
+
* - full: All components valid (URM + events + receipts + attestations)
|
|
474
|
+
*/
|
|
475
|
+
function computeTrustTier(components) {
|
|
476
|
+
if (!components.envelope_valid) {
|
|
477
|
+
return 'unknown';
|
|
478
|
+
}
|
|
479
|
+
// Full trust: all components present and valid
|
|
480
|
+
if (components.urm_valid &&
|
|
481
|
+
components.event_chain_valid &&
|
|
482
|
+
components.receipts_valid &&
|
|
483
|
+
components.attestations_valid) {
|
|
484
|
+
return 'full';
|
|
485
|
+
}
|
|
486
|
+
// Attested: has valid attestations
|
|
487
|
+
if (components.attestations_valid) {
|
|
488
|
+
return 'attested';
|
|
489
|
+
}
|
|
490
|
+
// Verified: has valid event chain or receipts
|
|
491
|
+
if (components.event_chain_valid || components.receipts_valid) {
|
|
492
|
+
return 'verified';
|
|
493
|
+
}
|
|
494
|
+
// Basic: envelope is valid but no strong proofs
|
|
495
|
+
return 'basic';
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Compute canonical proof tier (marketplace-facing) based on verified components.
|
|
499
|
+
*
|
|
500
|
+
* NOTE: This is intentionally *not* the same as trust_tier. For example, an
|
|
501
|
+
* event_chain-only bundle may be trust_tier=verified but proof_tier=self.
|
|
502
|
+
*/
|
|
503
|
+
function computeProofTier(components) {
|
|
504
|
+
if (!components.envelope_valid)
|
|
505
|
+
return 'unknown';
|
|
506
|
+
// Higher tiers win. Proof tiers are based on *at least one* verified component,
|
|
507
|
+
// not on the all-or-nothing `*_valid` booleans.
|
|
508
|
+
if ((components.attestations_verified_count ?? 0) > 0)
|
|
509
|
+
return 'sandbox';
|
|
510
|
+
if ((components.receipts_verified_count ?? 0) > 0)
|
|
511
|
+
return 'gateway';
|
|
512
|
+
return 'self';
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Verify a proof bundle envelope
|
|
516
|
+
*
|
|
517
|
+
* Acceptance Criteria:
|
|
518
|
+
* - Validate URM + event chain + receipts + attestations
|
|
519
|
+
* - Fail closed on unknown schema/version
|
|
520
|
+
* - Return computed trust tier
|
|
521
|
+
*/
|
|
522
|
+
export async function verifyProofBundle(envelope, options = {}) {
|
|
523
|
+
const now = new Date().toISOString();
|
|
524
|
+
// 1. Validate envelope structure
|
|
525
|
+
if (!validateEnvelopeStructure(envelope)) {
|
|
526
|
+
return {
|
|
527
|
+
result: {
|
|
528
|
+
status: 'INVALID',
|
|
529
|
+
reason: 'Malformed envelope: missing required fields',
|
|
530
|
+
verified_at: now,
|
|
531
|
+
},
|
|
532
|
+
error: {
|
|
533
|
+
code: 'MALFORMED_ENVELOPE',
|
|
534
|
+
message: 'Envelope is missing required fields or has invalid structure',
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
// 2. Fail-closed: reject unknown envelope version
|
|
539
|
+
if (!isAllowedVersion(envelope.envelope_version)) {
|
|
540
|
+
return {
|
|
541
|
+
result: {
|
|
542
|
+
status: 'INVALID',
|
|
543
|
+
reason: `Unknown envelope version: ${envelope.envelope_version}`,
|
|
544
|
+
verified_at: now,
|
|
545
|
+
},
|
|
546
|
+
error: {
|
|
547
|
+
code: 'UNKNOWN_ENVELOPE_VERSION',
|
|
548
|
+
message: `Envelope version "${envelope.envelope_version}" is not in the allowlist`,
|
|
549
|
+
field: 'envelope_version',
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
// 3. Fail-closed: reject unknown envelope type
|
|
554
|
+
if (!isAllowedType(envelope.envelope_type)) {
|
|
555
|
+
return {
|
|
556
|
+
result: {
|
|
557
|
+
status: 'INVALID',
|
|
558
|
+
reason: `Unknown envelope type: ${envelope.envelope_type}`,
|
|
559
|
+
verified_at: now,
|
|
560
|
+
},
|
|
561
|
+
error: {
|
|
562
|
+
code: 'UNKNOWN_ENVELOPE_TYPE',
|
|
563
|
+
message: `Envelope type "${envelope.envelope_type}" is not in the allowlist`,
|
|
564
|
+
field: 'envelope_type',
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
// 4. Verify this is a proof_bundle envelope
|
|
569
|
+
if (envelope.envelope_type !== 'proof_bundle') {
|
|
570
|
+
return {
|
|
571
|
+
result: {
|
|
572
|
+
status: 'INVALID',
|
|
573
|
+
reason: `Expected proof_bundle envelope, got: ${envelope.envelope_type}`,
|
|
574
|
+
verified_at: now,
|
|
575
|
+
},
|
|
576
|
+
error: {
|
|
577
|
+
code: 'UNKNOWN_ENVELOPE_TYPE',
|
|
578
|
+
message: 'This endpoint only accepts proof_bundle envelopes',
|
|
579
|
+
field: 'envelope_type',
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
// 5. Fail-closed: reject unknown signature algorithm
|
|
584
|
+
if (!isAllowedAlgorithm(envelope.algorithm)) {
|
|
585
|
+
return {
|
|
586
|
+
result: {
|
|
587
|
+
status: 'INVALID',
|
|
588
|
+
reason: `Unknown signature algorithm: ${envelope.algorithm}`,
|
|
589
|
+
verified_at: now,
|
|
590
|
+
},
|
|
591
|
+
error: {
|
|
592
|
+
code: 'UNKNOWN_ALGORITHM',
|
|
593
|
+
message: `Signature algorithm "${envelope.algorithm}" is not in the allowlist`,
|
|
594
|
+
field: 'algorithm',
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
// 6. Fail-closed: reject unknown hash algorithm
|
|
599
|
+
if (!isAllowedHashAlgorithm(envelope.hash_algorithm)) {
|
|
600
|
+
return {
|
|
601
|
+
result: {
|
|
602
|
+
status: 'INVALID',
|
|
603
|
+
reason: `Unknown hash algorithm: ${envelope.hash_algorithm}`,
|
|
604
|
+
verified_at: now,
|
|
605
|
+
},
|
|
606
|
+
error: {
|
|
607
|
+
code: 'UNKNOWN_HASH_ALGORITHM',
|
|
608
|
+
message: `Hash algorithm "${envelope.hash_algorithm}" is not in the allowlist`,
|
|
609
|
+
field: 'hash_algorithm',
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
// 7. Validate DID format
|
|
614
|
+
if (!isValidDidFormat(envelope.signer_did)) {
|
|
615
|
+
return {
|
|
616
|
+
result: {
|
|
617
|
+
status: 'INVALID',
|
|
618
|
+
reason: `Invalid DID format: ${envelope.signer_did}`,
|
|
619
|
+
verified_at: now,
|
|
620
|
+
},
|
|
621
|
+
error: {
|
|
622
|
+
code: 'INVALID_DID_FORMAT',
|
|
623
|
+
message: 'Signer DID does not match expected format (did:key:... or did:web:...)',
|
|
624
|
+
field: 'signer_did',
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
// 8. Validate issued_at format
|
|
629
|
+
if (!isValidIsoDate(envelope.issued_at)) {
|
|
630
|
+
return {
|
|
631
|
+
result: {
|
|
632
|
+
status: 'INVALID',
|
|
633
|
+
reason: 'Invalid issued_at date format',
|
|
634
|
+
verified_at: now,
|
|
635
|
+
},
|
|
636
|
+
error: {
|
|
637
|
+
code: 'MALFORMED_ENVELOPE',
|
|
638
|
+
message: 'issued_at must be a valid ISO 8601 date string',
|
|
639
|
+
field: 'issued_at',
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
// 9. Validate base64url fields
|
|
644
|
+
if (!isValidBase64Url(envelope.payload_hash_b64u)) {
|
|
645
|
+
return {
|
|
646
|
+
result: {
|
|
647
|
+
status: 'INVALID',
|
|
648
|
+
reason: 'Invalid payload_hash_b64u format',
|
|
649
|
+
verified_at: now,
|
|
650
|
+
},
|
|
651
|
+
error: {
|
|
652
|
+
code: 'MALFORMED_ENVELOPE',
|
|
653
|
+
message: 'payload_hash_b64u must be a valid base64url string',
|
|
654
|
+
field: 'payload_hash_b64u',
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (!isValidBase64Url(envelope.signature_b64u)) {
|
|
659
|
+
return {
|
|
660
|
+
result: {
|
|
661
|
+
status: 'INVALID',
|
|
662
|
+
reason: 'Invalid signature_b64u format',
|
|
663
|
+
verified_at: now,
|
|
664
|
+
},
|
|
665
|
+
error: {
|
|
666
|
+
code: 'MALFORMED_ENVELOPE',
|
|
667
|
+
message: 'signature_b64u must be a valid base64url string',
|
|
668
|
+
field: 'signature_b64u',
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
// 9.75 Strict JSON schema validation (Ajv) for envelope + payload
|
|
673
|
+
// CVF-US-024: Fail closed on schema violations (additionalProperties:false, missing fields, etc.)
|
|
674
|
+
const schemaResult = validateProofBundleEnvelopeV1(envelope);
|
|
675
|
+
if (!schemaResult.valid) {
|
|
676
|
+
return {
|
|
677
|
+
result: {
|
|
678
|
+
status: 'INVALID',
|
|
679
|
+
reason: schemaResult.message,
|
|
680
|
+
verified_at: now,
|
|
681
|
+
},
|
|
682
|
+
error: {
|
|
683
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
684
|
+
message: schemaResult.message,
|
|
685
|
+
field: schemaResult.field,
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
// 10. Validate proof bundle payload structure against PoH schema
|
|
690
|
+
const payloadValidation = validateBundlePayload(envelope.payload);
|
|
691
|
+
if (!payloadValidation.valid) {
|
|
692
|
+
return {
|
|
693
|
+
result: {
|
|
694
|
+
status: 'INVALID',
|
|
695
|
+
reason: `Invalid proof bundle payload: ${payloadValidation.error}`,
|
|
696
|
+
verified_at: now,
|
|
697
|
+
},
|
|
698
|
+
error: {
|
|
699
|
+
code: 'MALFORMED_ENVELOPE',
|
|
700
|
+
message: payloadValidation.error ?? 'Proof bundle payload is missing required fields or has no components',
|
|
701
|
+
field: 'payload',
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
// Type assertion after schema validation
|
|
706
|
+
if (!isBundlePayload(envelope.payload)) {
|
|
707
|
+
return {
|
|
708
|
+
result: {
|
|
709
|
+
status: 'INVALID',
|
|
710
|
+
reason: 'Invalid proof bundle payload structure',
|
|
711
|
+
verified_at: now,
|
|
712
|
+
},
|
|
713
|
+
error: {
|
|
714
|
+
code: 'MALFORMED_ENVELOPE',
|
|
715
|
+
message: 'Proof bundle payload failed type guard after schema validation',
|
|
716
|
+
field: 'payload',
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
// CVF-US-025: enforce count/size limits and uniqueness constraints (fail-closed)
|
|
721
|
+
const p = envelope.payload;
|
|
722
|
+
if (p.event_chain && p.event_chain.length > MAX_EVENT_CHAIN_ENTRIES) {
|
|
723
|
+
return {
|
|
724
|
+
result: {
|
|
725
|
+
status: 'INVALID',
|
|
726
|
+
reason: `event_chain exceeds max length (${MAX_EVENT_CHAIN_ENTRIES})`,
|
|
727
|
+
verified_at: now,
|
|
728
|
+
},
|
|
729
|
+
error: {
|
|
730
|
+
code: 'MALFORMED_ENVELOPE',
|
|
731
|
+
message: `payload.event_chain length exceeds limit (${MAX_EVENT_CHAIN_ENTRIES})`,
|
|
732
|
+
field: 'payload.event_chain',
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (p.receipts && p.receipts.length > MAX_RECEIPTS) {
|
|
737
|
+
return {
|
|
738
|
+
result: {
|
|
739
|
+
status: 'INVALID',
|
|
740
|
+
reason: `receipts exceeds max length (${MAX_RECEIPTS})`,
|
|
741
|
+
verified_at: now,
|
|
742
|
+
},
|
|
743
|
+
error: {
|
|
744
|
+
code: 'MALFORMED_ENVELOPE',
|
|
745
|
+
message: `payload.receipts length exceeds limit (${MAX_RECEIPTS})`,
|
|
746
|
+
field: 'payload.receipts',
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
if (p.attestations && p.attestations.length > MAX_ATTESTATIONS) {
|
|
751
|
+
return {
|
|
752
|
+
result: {
|
|
753
|
+
status: 'INVALID',
|
|
754
|
+
reason: `attestations exceeds max length (${MAX_ATTESTATIONS})`,
|
|
755
|
+
verified_at: now,
|
|
756
|
+
},
|
|
757
|
+
error: {
|
|
758
|
+
code: 'MALFORMED_ENVELOPE',
|
|
759
|
+
message: `payload.attestations length exceeds limit (${MAX_ATTESTATIONS})`,
|
|
760
|
+
field: 'payload.attestations',
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
// Metadata byte-size limits (metadata objects are intentionally flexible; bound size to prevent DoS)
|
|
765
|
+
if (p.metadata && jsonByteSize(p.metadata) > MAX_METADATA_BYTES) {
|
|
766
|
+
return {
|
|
767
|
+
result: {
|
|
768
|
+
status: 'INVALID',
|
|
769
|
+
reason: `payload.metadata exceeds max size (${MAX_METADATA_BYTES} bytes)`,
|
|
770
|
+
verified_at: now,
|
|
771
|
+
},
|
|
772
|
+
error: {
|
|
773
|
+
code: 'MALFORMED_ENVELOPE',
|
|
774
|
+
message: `payload.metadata exceeds max size (${MAX_METADATA_BYTES} bytes)`,
|
|
775
|
+
field: 'payload.metadata',
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (p.urm?.metadata && jsonByteSize(p.urm.metadata) > MAX_METADATA_BYTES) {
|
|
780
|
+
return {
|
|
781
|
+
result: {
|
|
782
|
+
status: 'INVALID',
|
|
783
|
+
reason: `payload.urm.metadata exceeds max size (${MAX_METADATA_BYTES} bytes)`,
|
|
784
|
+
verified_at: now,
|
|
785
|
+
},
|
|
786
|
+
error: {
|
|
787
|
+
code: 'MALFORMED_ENVELOPE',
|
|
788
|
+
message: `payload.urm.metadata exceeds max size (${MAX_METADATA_BYTES} bytes)`,
|
|
789
|
+
field: 'payload.urm.metadata',
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
if (p.receipts) {
|
|
794
|
+
for (let i = 0; i < p.receipts.length; i++) {
|
|
795
|
+
const md = p.receipts[i].payload.metadata;
|
|
796
|
+
if (md !== undefined && jsonByteSize(md) > MAX_METADATA_BYTES) {
|
|
797
|
+
return {
|
|
798
|
+
result: {
|
|
799
|
+
status: 'INVALID',
|
|
800
|
+
reason: `payload.receipts[${i}].payload.metadata exceeds max size (${MAX_METADATA_BYTES} bytes)`,
|
|
801
|
+
verified_at: now,
|
|
802
|
+
},
|
|
803
|
+
error: {
|
|
804
|
+
code: 'MALFORMED_ENVELOPE',
|
|
805
|
+
message: `receipt metadata exceeds max size (${MAX_METADATA_BYTES} bytes)`,
|
|
806
|
+
field: `payload.receipts[${i}].payload.metadata`,
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// Uniqueness constraints within a bundle
|
|
813
|
+
if (p.event_chain) {
|
|
814
|
+
const seenEventIds = new Set();
|
|
815
|
+
for (let i = 0; i < p.event_chain.length; i++) {
|
|
816
|
+
const id = p.event_chain[i].event_id;
|
|
817
|
+
if (seenEventIds.has(id)) {
|
|
818
|
+
return {
|
|
819
|
+
result: {
|
|
820
|
+
status: 'INVALID',
|
|
821
|
+
reason: 'Duplicate event_id in payload.event_chain',
|
|
822
|
+
verified_at: now,
|
|
823
|
+
},
|
|
824
|
+
error: {
|
|
825
|
+
code: 'MALFORMED_ENVELOPE',
|
|
826
|
+
message: 'event_id must be unique within payload.event_chain',
|
|
827
|
+
field: `payload.event_chain[${i}].event_id`,
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
seenEventIds.add(id);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (p.receipts) {
|
|
835
|
+
const seenReceiptIds = new Set();
|
|
836
|
+
for (let i = 0; i < p.receipts.length; i++) {
|
|
837
|
+
const rid = p.receipts[i].payload.receipt_id;
|
|
838
|
+
if (seenReceiptIds.has(rid)) {
|
|
839
|
+
return {
|
|
840
|
+
result: {
|
|
841
|
+
status: 'INVALID',
|
|
842
|
+
reason: 'Duplicate receipt_id in payload.receipts',
|
|
843
|
+
verified_at: now,
|
|
844
|
+
},
|
|
845
|
+
error: {
|
|
846
|
+
code: 'MALFORMED_ENVELOPE',
|
|
847
|
+
message: 'receipt_id must be unique within payload.receipts',
|
|
848
|
+
field: `payload.receipts[${i}].payload.receipt_id`,
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
seenReceiptIds.add(rid);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// 11. Validate agent_did in payload matches expected format
|
|
856
|
+
if (!isValidDidFormat(envelope.payload.agent_did)) {
|
|
857
|
+
return {
|
|
858
|
+
result: {
|
|
859
|
+
status: 'INVALID',
|
|
860
|
+
reason: `Invalid agent_did format: ${envelope.payload.agent_did}`,
|
|
861
|
+
verified_at: now,
|
|
862
|
+
},
|
|
863
|
+
error: {
|
|
864
|
+
code: 'INVALID_DID_FORMAT',
|
|
865
|
+
message: 'agent_did does not match expected DID format',
|
|
866
|
+
field: 'payload.agent_did',
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
// CVF-US-022: Enforce envelope signer DID equals payload agent DID
|
|
871
|
+
if (envelope.signer_did !== envelope.payload.agent_did) {
|
|
872
|
+
return {
|
|
873
|
+
result: {
|
|
874
|
+
status: 'INVALID',
|
|
875
|
+
reason: 'Proof bundle signer_did must match payload.agent_did',
|
|
876
|
+
verified_at: now,
|
|
877
|
+
},
|
|
878
|
+
error: {
|
|
879
|
+
code: 'INVALID_DID_FORMAT',
|
|
880
|
+
message: 'envelope.signer_did must equal payload.agent_did',
|
|
881
|
+
field: 'signer_did',
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
// 12. Recompute hash and verify it matches
|
|
886
|
+
try {
|
|
887
|
+
const computedHash = await computeHash(envelope.payload, envelope.hash_algorithm);
|
|
888
|
+
if (computedHash !== envelope.payload_hash_b64u) {
|
|
889
|
+
return {
|
|
890
|
+
result: {
|
|
891
|
+
status: 'INVALID',
|
|
892
|
+
reason: 'Payload hash mismatch: envelope may have been tampered with',
|
|
893
|
+
verified_at: now,
|
|
894
|
+
},
|
|
895
|
+
error: {
|
|
896
|
+
code: 'HASH_MISMATCH',
|
|
897
|
+
message: 'Computed payload hash does not match envelope hash',
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
return {
|
|
904
|
+
result: {
|
|
905
|
+
status: 'INVALID',
|
|
906
|
+
reason: `Hash computation failed: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
907
|
+
verified_at: now,
|
|
908
|
+
},
|
|
909
|
+
error: {
|
|
910
|
+
code: 'HASH_MISMATCH',
|
|
911
|
+
message: 'Failed to compute payload hash',
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
// 13. Extract public key from DID
|
|
916
|
+
const publicKeyBytes = extractPublicKeyFromDidKey(envelope.signer_did);
|
|
917
|
+
if (!publicKeyBytes) {
|
|
918
|
+
return {
|
|
919
|
+
result: {
|
|
920
|
+
status: 'INVALID',
|
|
921
|
+
reason: 'Could not extract public key from signer DID',
|
|
922
|
+
verified_at: now,
|
|
923
|
+
},
|
|
924
|
+
error: {
|
|
925
|
+
code: 'INVALID_DID_FORMAT',
|
|
926
|
+
message: 'Unable to extract Ed25519 public key from did:key. Ensure the DID uses the Ed25519 multicodec prefix.',
|
|
927
|
+
field: 'signer_did',
|
|
928
|
+
},
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
// 14. Verify envelope signature
|
|
932
|
+
try {
|
|
933
|
+
const signatureBytes = base64UrlDecode(envelope.signature_b64u);
|
|
934
|
+
const messageBytes = new TextEncoder().encode(envelope.payload_hash_b64u);
|
|
935
|
+
const isValid = await verifySignature(envelope.algorithm, publicKeyBytes, signatureBytes, messageBytes);
|
|
936
|
+
if (!isValid) {
|
|
937
|
+
return {
|
|
938
|
+
result: {
|
|
939
|
+
status: 'INVALID',
|
|
940
|
+
reason: 'Signature verification failed',
|
|
941
|
+
verified_at: now,
|
|
942
|
+
},
|
|
943
|
+
error: {
|
|
944
|
+
code: 'SIGNATURE_INVALID',
|
|
945
|
+
message: 'The Ed25519 signature does not match the payload hash',
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
catch (err) {
|
|
951
|
+
return {
|
|
952
|
+
result: {
|
|
953
|
+
status: 'INVALID',
|
|
954
|
+
reason: `Signature verification error: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
955
|
+
verified_at: now,
|
|
956
|
+
},
|
|
957
|
+
error: {
|
|
958
|
+
code: 'SIGNATURE_INVALID',
|
|
959
|
+
message: 'Failed to verify signature',
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
// 15. Validate individual components
|
|
964
|
+
const payload = envelope.payload;
|
|
965
|
+
const componentResults = {
|
|
966
|
+
envelope_valid: true,
|
|
967
|
+
};
|
|
968
|
+
// CVF-US-016: model identity is an orthogonal axis to PoH tiers.
|
|
969
|
+
let modelIdentityTier = 'unknown';
|
|
970
|
+
const modelIdentityRiskFlags = new Set();
|
|
971
|
+
// Validate event chain if present (verify hash linkage per POH-US-003)
|
|
972
|
+
if (payload.event_chain !== undefined && payload.event_chain.length > 0) {
|
|
973
|
+
const chainResult = validateEventChain(payload.event_chain);
|
|
974
|
+
if (!chainResult.valid) {
|
|
975
|
+
return {
|
|
976
|
+
result: {
|
|
977
|
+
status: 'INVALID',
|
|
978
|
+
reason: chainResult.error ?? 'Event chain validation failed',
|
|
979
|
+
verified_at: now,
|
|
980
|
+
},
|
|
981
|
+
error: {
|
|
982
|
+
code: 'MALFORMED_ENVELOPE',
|
|
983
|
+
message: chainResult.error ?? 'Invalid event_chain',
|
|
984
|
+
field: 'payload.event_chain',
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
// CVF-US-021: Recompute event_hash_b64u from canonical event headers (fail-closed)
|
|
989
|
+
// Canonical header key order per ADAPTER_SPEC_v1 §4.2.
|
|
990
|
+
for (let i = 0; i < payload.event_chain.length; i++) {
|
|
991
|
+
const e = payload.event_chain[i];
|
|
992
|
+
const canonical = {
|
|
993
|
+
event_id: e.event_id,
|
|
994
|
+
run_id: e.run_id,
|
|
995
|
+
event_type: e.event_type,
|
|
996
|
+
timestamp: e.timestamp,
|
|
997
|
+
payload_hash_b64u: e.payload_hash_b64u,
|
|
998
|
+
prev_hash_b64u: e.prev_hash_b64u ?? null,
|
|
999
|
+
};
|
|
1000
|
+
let expectedHash;
|
|
1001
|
+
try {
|
|
1002
|
+
expectedHash = await computeHash(canonical, 'SHA-256');
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
return {
|
|
1006
|
+
result: {
|
|
1007
|
+
status: 'INVALID',
|
|
1008
|
+
reason: `Event ${i}: event hash recomputation failed`,
|
|
1009
|
+
verified_at: now,
|
|
1010
|
+
},
|
|
1011
|
+
error: {
|
|
1012
|
+
code: 'HASH_MISMATCH',
|
|
1013
|
+
message: `Failed to recompute event hash: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
1014
|
+
field: `payload.event_chain[${i}]`,
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (expectedHash !== e.event_hash_b64u) {
|
|
1019
|
+
return {
|
|
1020
|
+
result: {
|
|
1021
|
+
status: 'INVALID',
|
|
1022
|
+
reason: `Event ${i}: event_hash_b64u mismatch`,
|
|
1023
|
+
verified_at: now,
|
|
1024
|
+
},
|
|
1025
|
+
error: {
|
|
1026
|
+
code: 'HASH_MISMATCH',
|
|
1027
|
+
message: 'event_hash_b64u does not match SHA-256 hash of the canonical event header',
|
|
1028
|
+
field: `payload.event_chain[${i}].event_hash_b64u`,
|
|
1029
|
+
},
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
componentResults.event_chain_valid = true;
|
|
1034
|
+
if (chainResult.chain_root_hash) {
|
|
1035
|
+
componentResults.chain_root_hash = chainResult.chain_root_hash;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// POH-US-016/017: Prompt commitments (optional; fail-closed when present)
|
|
1039
|
+
//
|
|
1040
|
+
// These are *hash-only* objects carried in payload.metadata that commit to:
|
|
1041
|
+
// - prompt pack inputs (prompt_pack.prompt_root_hash_b64u)
|
|
1042
|
+
// - per-llm_call rendered system prompt hashes (system_prompt_report)
|
|
1043
|
+
//
|
|
1044
|
+
// They do not uplift proof tier; they are evidence for replay/audit safety.
|
|
1045
|
+
let promptPackRootHashB64u = null;
|
|
1046
|
+
const md = payload.metadata;
|
|
1047
|
+
const mdRecord = md && typeof md === 'object' && md !== null && !Array.isArray(md)
|
|
1048
|
+
? md
|
|
1049
|
+
: null;
|
|
1050
|
+
const promptPackRaw = mdRecord ? mdRecord.prompt_pack : undefined;
|
|
1051
|
+
if (promptPackRaw !== undefined) {
|
|
1052
|
+
const schemaResult = validatePromptPackV1(promptPackRaw);
|
|
1053
|
+
if (!schemaResult.valid) {
|
|
1054
|
+
return {
|
|
1055
|
+
result: {
|
|
1056
|
+
status: 'INVALID',
|
|
1057
|
+
reason: schemaResult.message,
|
|
1058
|
+
verified_at: now,
|
|
1059
|
+
},
|
|
1060
|
+
error: {
|
|
1061
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1062
|
+
message: schemaResult.message,
|
|
1063
|
+
field: schemaResult.field
|
|
1064
|
+
? `payload.metadata.prompt_pack.${schemaResult.field}`
|
|
1065
|
+
: 'payload.metadata.prompt_pack',
|
|
1066
|
+
},
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
const pp = promptPackRaw;
|
|
1070
|
+
const claimed = typeof pp.prompt_root_hash_b64u === 'string' ? pp.prompt_root_hash_b64u.trim() : null;
|
|
1071
|
+
const entries = Array.isArray(pp.entries) ? pp.entries : [];
|
|
1072
|
+
const canonicalEntries = entries
|
|
1073
|
+
.filter((e) => typeof e === 'object' && e !== null && !Array.isArray(e))
|
|
1074
|
+
.map((e) => {
|
|
1075
|
+
const er = e;
|
|
1076
|
+
return {
|
|
1077
|
+
entry_id: typeof er.entry_id === 'string' ? er.entry_id.trim() : '',
|
|
1078
|
+
content_hash_b64u: typeof er.content_hash_b64u === 'string' ? er.content_hash_b64u.trim() : '',
|
|
1079
|
+
};
|
|
1080
|
+
})
|
|
1081
|
+
.filter((e) => e.entry_id.length > 0 && e.content_hash_b64u.length > 0)
|
|
1082
|
+
.sort((a, b) => a.entry_id.localeCompare(b.entry_id));
|
|
1083
|
+
const canonical = {
|
|
1084
|
+
prompt_pack_version: '1',
|
|
1085
|
+
entries: canonicalEntries,
|
|
1086
|
+
};
|
|
1087
|
+
let computed;
|
|
1088
|
+
try {
|
|
1089
|
+
computed = await computeHash(canonical, 'SHA-256');
|
|
1090
|
+
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
return {
|
|
1093
|
+
result: {
|
|
1094
|
+
status: 'INVALID',
|
|
1095
|
+
reason: 'Failed to compute prompt_pack root hash',
|
|
1096
|
+
verified_at: now,
|
|
1097
|
+
},
|
|
1098
|
+
error: {
|
|
1099
|
+
code: 'HASH_MISMATCH',
|
|
1100
|
+
message: `Failed to compute prompt_pack prompt_root_hash_b64u: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
1101
|
+
field: 'payload.metadata.prompt_pack',
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
if (!claimed || claimed !== computed) {
|
|
1106
|
+
return {
|
|
1107
|
+
result: {
|
|
1108
|
+
status: 'INVALID',
|
|
1109
|
+
reason: 'prompt_pack.prompt_root_hash_b64u mismatch',
|
|
1110
|
+
verified_at: now,
|
|
1111
|
+
},
|
|
1112
|
+
error: {
|
|
1113
|
+
code: 'HASH_MISMATCH',
|
|
1114
|
+
message: 'prompt_root_hash_b64u does not match canonical entry list hash',
|
|
1115
|
+
field: 'payload.metadata.prompt_pack.prompt_root_hash_b64u',
|
|
1116
|
+
},
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
promptPackRootHashB64u = claimed;
|
|
1120
|
+
componentResults.prompt_pack_valid = true;
|
|
1121
|
+
}
|
|
1122
|
+
const systemPromptReportRaw = mdRecord ? mdRecord.system_prompt_report : undefined;
|
|
1123
|
+
if (systemPromptReportRaw !== undefined) {
|
|
1124
|
+
const schemaResult = validateSystemPromptReportV1(systemPromptReportRaw);
|
|
1125
|
+
if (!schemaResult.valid) {
|
|
1126
|
+
return {
|
|
1127
|
+
result: {
|
|
1128
|
+
status: 'INVALID',
|
|
1129
|
+
reason: schemaResult.message,
|
|
1130
|
+
verified_at: now,
|
|
1131
|
+
},
|
|
1132
|
+
error: {
|
|
1133
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1134
|
+
message: schemaResult.message,
|
|
1135
|
+
field: schemaResult.field
|
|
1136
|
+
? `payload.metadata.system_prompt_report.${schemaResult.field}`
|
|
1137
|
+
: 'payload.metadata.system_prompt_report',
|
|
1138
|
+
},
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
if (!payload.event_chain || payload.event_chain.length === 0) {
|
|
1142
|
+
return {
|
|
1143
|
+
result: {
|
|
1144
|
+
status: 'INVALID',
|
|
1145
|
+
reason: 'system_prompt_report requires payload.event_chain',
|
|
1146
|
+
verified_at: now,
|
|
1147
|
+
},
|
|
1148
|
+
error: {
|
|
1149
|
+
code: 'MALFORMED_ENVELOPE',
|
|
1150
|
+
message: 'payload.event_chain is required when payload.metadata.system_prompt_report is present',
|
|
1151
|
+
field: 'payload.event_chain',
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
const spr = systemPromptReportRaw;
|
|
1156
|
+
const sprRunId = typeof spr.run_id === 'string' ? spr.run_id.trim() : null;
|
|
1157
|
+
const sprAgentDid = typeof spr.agent_did === 'string' ? spr.agent_did.trim() : null;
|
|
1158
|
+
const expectedRunId = payload.event_chain[0].run_id;
|
|
1159
|
+
if (!sprRunId || sprRunId !== expectedRunId) {
|
|
1160
|
+
return {
|
|
1161
|
+
result: {
|
|
1162
|
+
status: 'INVALID',
|
|
1163
|
+
reason: 'system_prompt_report.run_id mismatch',
|
|
1164
|
+
verified_at: now,
|
|
1165
|
+
},
|
|
1166
|
+
error: {
|
|
1167
|
+
code: 'PROMPT_COMMITMENT_MISMATCH',
|
|
1168
|
+
message: 'system_prompt_report.run_id must equal payload.event_chain[0].run_id',
|
|
1169
|
+
field: 'payload.metadata.system_prompt_report.run_id',
|
|
1170
|
+
},
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
if (!sprAgentDid || sprAgentDid !== payload.agent_did) {
|
|
1174
|
+
return {
|
|
1175
|
+
result: {
|
|
1176
|
+
status: 'INVALID',
|
|
1177
|
+
reason: 'system_prompt_report.agent_did mismatch',
|
|
1178
|
+
verified_at: now,
|
|
1179
|
+
},
|
|
1180
|
+
error: {
|
|
1181
|
+
code: 'PROMPT_COMMITMENT_MISMATCH',
|
|
1182
|
+
message: 'system_prompt_report.agent_did must equal payload.agent_did',
|
|
1183
|
+
field: 'payload.metadata.system_prompt_report.agent_did',
|
|
1184
|
+
},
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
const sprPromptRoot = typeof spr.prompt_root_hash_b64u === 'string' ? spr.prompt_root_hash_b64u.trim() : null;
|
|
1188
|
+
if (promptPackRootHashB64u && sprPromptRoot && sprPromptRoot !== promptPackRootHashB64u) {
|
|
1189
|
+
return {
|
|
1190
|
+
result: {
|
|
1191
|
+
status: 'INVALID',
|
|
1192
|
+
reason: 'system_prompt_report.prompt_root_hash_b64u mismatch',
|
|
1193
|
+
verified_at: now,
|
|
1194
|
+
},
|
|
1195
|
+
error: {
|
|
1196
|
+
code: 'PROMPT_COMMITMENT_MISMATCH',
|
|
1197
|
+
message: 'system_prompt_report.prompt_root_hash_b64u must match prompt_pack.prompt_root_hash_b64u (when both present)',
|
|
1198
|
+
field: 'payload.metadata.system_prompt_report.prompt_root_hash_b64u',
|
|
1199
|
+
},
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
const eventsById = new Map(payload.event_chain.map((e) => [e.event_id, e]));
|
|
1203
|
+
const calls = Array.isArray(spr.calls) ? spr.calls : [];
|
|
1204
|
+
for (let i = 0; i < calls.length; i++) {
|
|
1205
|
+
const c = calls[i];
|
|
1206
|
+
if (typeof c !== 'object' || c === null || Array.isArray(c))
|
|
1207
|
+
continue;
|
|
1208
|
+
const cr = c;
|
|
1209
|
+
const eventId = typeof cr.event_id === 'string' ? cr.event_id.trim() : null;
|
|
1210
|
+
if (!eventId)
|
|
1211
|
+
continue;
|
|
1212
|
+
const evt = eventsById.get(eventId);
|
|
1213
|
+
if (!evt) {
|
|
1214
|
+
return {
|
|
1215
|
+
result: {
|
|
1216
|
+
status: 'INVALID',
|
|
1217
|
+
reason: 'system_prompt_report references unknown event_id',
|
|
1218
|
+
verified_at: now,
|
|
1219
|
+
},
|
|
1220
|
+
error: {
|
|
1221
|
+
code: 'PROMPT_COMMITMENT_MISMATCH',
|
|
1222
|
+
message: 'system_prompt_report.calls[*].event_id must refer to an event in payload.event_chain',
|
|
1223
|
+
field: `payload.metadata.system_prompt_report.calls[${i}].event_id`,
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
if (evt.event_type !== 'llm_call') {
|
|
1228
|
+
return {
|
|
1229
|
+
result: {
|
|
1230
|
+
status: 'INVALID',
|
|
1231
|
+
reason: 'system_prompt_report references a non-llm_call event',
|
|
1232
|
+
verified_at: now,
|
|
1233
|
+
},
|
|
1234
|
+
error: {
|
|
1235
|
+
code: 'PROMPT_COMMITMENT_MISMATCH',
|
|
1236
|
+
message: 'system_prompt_report.calls[*] must reference llm_call events',
|
|
1237
|
+
field: `payload.metadata.system_prompt_report.calls[${i}].event_id`,
|
|
1238
|
+
},
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
const claimedEventHash = typeof cr.event_hash_b64u === 'string' ? cr.event_hash_b64u.trim() : null;
|
|
1242
|
+
if (claimedEventHash && claimedEventHash !== evt.event_hash_b64u) {
|
|
1243
|
+
return {
|
|
1244
|
+
result: {
|
|
1245
|
+
status: 'INVALID',
|
|
1246
|
+
reason: 'system_prompt_report event_hash_b64u mismatch',
|
|
1247
|
+
verified_at: now,
|
|
1248
|
+
},
|
|
1249
|
+
error: {
|
|
1250
|
+
code: 'PROMPT_COMMITMENT_MISMATCH',
|
|
1251
|
+
message: 'system_prompt_report.calls[*].event_hash_b64u must match payload.event_chain[event_id].event_hash_b64u',
|
|
1252
|
+
field: `payload.metadata.system_prompt_report.calls[${i}].event_hash_b64u`,
|
|
1253
|
+
},
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
componentResults.system_prompt_report_valid = true;
|
|
1258
|
+
}
|
|
1259
|
+
// POH-US-015: URM materialization + hash verification.
|
|
1260
|
+
// Proof bundles carry only a URM *reference* (hash). To make that meaningful,
|
|
1261
|
+
// callers may provide the materialized URM document bytes (as a JSON object).
|
|
1262
|
+
//
|
|
1263
|
+
// Fail-closed semantics:
|
|
1264
|
+
// - If URM reference is present but URM bytes are not provided, the bundle is INVALID.
|
|
1265
|
+
// - If URM bytes are provided but fail schema validation, binding checks, or hash verification,
|
|
1266
|
+
// the bundle is INVALID.
|
|
1267
|
+
if (payload.urm !== undefined) {
|
|
1268
|
+
if (!validateURM(payload.urm)) {
|
|
1269
|
+
componentResults.urm_valid = false;
|
|
1270
|
+
}
|
|
1271
|
+
else if (options.urm === undefined) {
|
|
1272
|
+
return {
|
|
1273
|
+
result: {
|
|
1274
|
+
status: 'INVALID',
|
|
1275
|
+
reason: 'URM document is required when payload.urm is present',
|
|
1276
|
+
verified_at: now,
|
|
1277
|
+
},
|
|
1278
|
+
error: {
|
|
1279
|
+
code: 'URM_MISSING',
|
|
1280
|
+
message: 'Missing URM document (provide request field: urm)',
|
|
1281
|
+
field: 'urm',
|
|
1282
|
+
},
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
const ref = payload.urm;
|
|
1287
|
+
const schemaResult = validateUrmV1(options.urm);
|
|
1288
|
+
if (!schemaResult.valid) {
|
|
1289
|
+
return {
|
|
1290
|
+
result: {
|
|
1291
|
+
status: 'INVALID',
|
|
1292
|
+
reason: schemaResult.message,
|
|
1293
|
+
verified_at: now,
|
|
1294
|
+
},
|
|
1295
|
+
error: {
|
|
1296
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1297
|
+
message: schemaResult.message,
|
|
1298
|
+
field: schemaResult.field ? `urm.${schemaResult.field}` : 'urm',
|
|
1299
|
+
},
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
const u = options.urm;
|
|
1303
|
+
// Binding checks (fail-closed): URM must describe the same run/agent.
|
|
1304
|
+
const urmId = typeof u.urm_id === 'string' ? u.urm_id.trim() : null;
|
|
1305
|
+
const agentDid = typeof u.agent_did === 'string' ? u.agent_did.trim() : null;
|
|
1306
|
+
const runId = typeof u.run_id === 'string' ? u.run_id.trim() : null;
|
|
1307
|
+
if (!urmId || urmId !== ref.urm_id) {
|
|
1308
|
+
return {
|
|
1309
|
+
result: {
|
|
1310
|
+
status: 'INVALID',
|
|
1311
|
+
reason: 'URM urm_id does not match proof bundle URM reference',
|
|
1312
|
+
verified_at: now,
|
|
1313
|
+
},
|
|
1314
|
+
error: {
|
|
1315
|
+
code: 'URM_MISMATCH',
|
|
1316
|
+
message: 'urm.urm_id must equal payload.urm.urm_id',
|
|
1317
|
+
field: 'urm.urm_id',
|
|
1318
|
+
},
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
if (!agentDid || agentDid !== payload.agent_did) {
|
|
1322
|
+
return {
|
|
1323
|
+
result: {
|
|
1324
|
+
status: 'INVALID',
|
|
1325
|
+
reason: 'URM agent_did does not match proof bundle agent_did',
|
|
1326
|
+
verified_at: now,
|
|
1327
|
+
},
|
|
1328
|
+
error: {
|
|
1329
|
+
code: 'URM_MISMATCH',
|
|
1330
|
+
message: 'urm.agent_did must equal payload.agent_did',
|
|
1331
|
+
field: 'urm.agent_did',
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
if (componentResults.event_chain_valid && payload.event_chain && payload.event_chain.length > 0) {
|
|
1336
|
+
const expectedRunId = payload.event_chain[0].run_id;
|
|
1337
|
+
if (!runId || runId !== expectedRunId) {
|
|
1338
|
+
return {
|
|
1339
|
+
result: {
|
|
1340
|
+
status: 'INVALID',
|
|
1341
|
+
reason: 'URM run_id does not match proof bundle run_id',
|
|
1342
|
+
verified_at: now,
|
|
1343
|
+
},
|
|
1344
|
+
error: {
|
|
1345
|
+
code: 'URM_MISMATCH',
|
|
1346
|
+
message: 'urm.run_id must equal payload.event_chain[0].run_id',
|
|
1347
|
+
field: 'urm.run_id',
|
|
1348
|
+
},
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
const chainRoot = componentResults.chain_root_hash;
|
|
1352
|
+
const claimedRoot = typeof u.event_chain_root_hash_b64u === 'string' ? u.event_chain_root_hash_b64u.trim() : null;
|
|
1353
|
+
if (chainRoot && claimedRoot && claimedRoot !== chainRoot) {
|
|
1354
|
+
return {
|
|
1355
|
+
result: {
|
|
1356
|
+
status: 'INVALID',
|
|
1357
|
+
reason: 'URM event_chain_root_hash_b64u does not match proof bundle event chain root',
|
|
1358
|
+
verified_at: now,
|
|
1359
|
+
},
|
|
1360
|
+
error: {
|
|
1361
|
+
code: 'URM_MISMATCH',
|
|
1362
|
+
message: 'urm.event_chain_root_hash_b64u must match the proof bundle event chain root hash',
|
|
1363
|
+
field: 'urm.event_chain_root_hash_b64u',
|
|
1364
|
+
},
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
let computedUrmHash;
|
|
1369
|
+
try {
|
|
1370
|
+
computedUrmHash = await computeHash(options.urm, 'SHA-256');
|
|
1371
|
+
}
|
|
1372
|
+
catch (err) {
|
|
1373
|
+
return {
|
|
1374
|
+
result: {
|
|
1375
|
+
status: 'INVALID',
|
|
1376
|
+
reason: 'Failed to hash URM document',
|
|
1377
|
+
verified_at: now,
|
|
1378
|
+
},
|
|
1379
|
+
error: {
|
|
1380
|
+
code: 'HASH_MISMATCH',
|
|
1381
|
+
message: `Failed to compute URM hash: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
1382
|
+
field: 'urm',
|
|
1383
|
+
},
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
if (computedUrmHash !== ref.resource_hash_b64u) {
|
|
1387
|
+
return {
|
|
1388
|
+
result: {
|
|
1389
|
+
status: 'INVALID',
|
|
1390
|
+
reason: 'URM hash mismatch',
|
|
1391
|
+
verified_at: now,
|
|
1392
|
+
},
|
|
1393
|
+
error: {
|
|
1394
|
+
code: 'HASH_MISMATCH',
|
|
1395
|
+
message: 'Computed URM hash does not match payload.urm.resource_hash_b64u',
|
|
1396
|
+
field: 'payload.urm.resource_hash_b64u',
|
|
1397
|
+
},
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
componentResults.urm_valid = true;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// Verify receipt envelopes cryptographically (POH-US-003)
|
|
1404
|
+
// Each receipt is verified with its signer DID (clawproxy DID) using full
|
|
1405
|
+
// signature verification — not just structural validation.
|
|
1406
|
+
if (payload.receipts !== undefined && payload.receipts.length > 0) {
|
|
1407
|
+
// POH-US-010: Require receipts to be bound to this bundle's event chain.
|
|
1408
|
+
// Without binding, a signature-valid receipt could be replayed across bundles.
|
|
1409
|
+
const bindingContext = componentResults.event_chain_valid &&
|
|
1410
|
+
payload.event_chain !== undefined &&
|
|
1411
|
+
payload.event_chain.length > 0
|
|
1412
|
+
? {
|
|
1413
|
+
expectedRunId: payload.event_chain[0].run_id,
|
|
1414
|
+
allowedEventHashes: new Set(payload.event_chain.map((e) => e.event_hash_b64u)),
|
|
1415
|
+
}
|
|
1416
|
+
: null;
|
|
1417
|
+
const receiptResults = await Promise.all(payload.receipts.map((r) => verifyReceiptEnvelope(r, options.allowlistedReceiptSignerDids, bindingContext)));
|
|
1418
|
+
const signatureValidCount = receiptResults.filter((r) => r.signature_valid).length;
|
|
1419
|
+
const boundValidCount = receiptResults.filter((r) => r.valid).length;
|
|
1420
|
+
componentResults.receipts_valid = boundValidCount === payload.receipts.length;
|
|
1421
|
+
componentResults.receipts_count = payload.receipts.length;
|
|
1422
|
+
componentResults.receipts_signature_verified_count = signatureValidCount;
|
|
1423
|
+
componentResults.receipts_verified_count = boundValidCount;
|
|
1424
|
+
// CVF-US-016: Extract + verify model identity and compute an overall tier.
|
|
1425
|
+
try {
|
|
1426
|
+
const modelIdentity = await computeModelIdentityTierFromReceipts({
|
|
1427
|
+
receipts: payload.receipts,
|
|
1428
|
+
receiptResults,
|
|
1429
|
+
});
|
|
1430
|
+
modelIdentityTier = modelIdentity.model_identity_tier;
|
|
1431
|
+
for (const f of modelIdentity.risk_flags)
|
|
1432
|
+
modelIdentityRiskFlags.add(f);
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
modelIdentityTier = 'unknown';
|
|
1436
|
+
modelIdentityRiskFlags.add('MODEL_IDENTITY_VERIFY_FAILED');
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
// Validate tool receipts when present (CPL-US-006: fail-closed on unknown schema)
|
|
1440
|
+
if (payload.tool_receipts !== undefined) {
|
|
1441
|
+
const toolReceipts = payload.tool_receipts;
|
|
1442
|
+
if (!Array.isArray(toolReceipts)) {
|
|
1443
|
+
return {
|
|
1444
|
+
result: {
|
|
1445
|
+
status: 'INVALID',
|
|
1446
|
+
reason: 'tool_receipts must be an array',
|
|
1447
|
+
verified_at: now,
|
|
1448
|
+
},
|
|
1449
|
+
error: {
|
|
1450
|
+
code: 'SCHEMA_VALIDATION_FAILED',
|
|
1451
|
+
message: 'payload.tool_receipts must be an array',
|
|
1452
|
+
field: 'payload.tool_receipts',
|
|
1453
|
+
},
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
const REQUIRED_TOOL_RECEIPT_FIELDS = [
|
|
1457
|
+
'receipt_version', 'receipt_id', 'tool_name', 'args_hash_b64u',
|
|
1458
|
+
'result_hash_b64u', 'hash_algorithm', 'agent_did', 'timestamp', 'latency_ms',
|
|
1459
|
+
];
|
|
1460
|
+
for (let i = 0; i < toolReceipts.length; i++) {
|
|
1461
|
+
const tr = toolReceipts[i];
|
|
1462
|
+
if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) {
|
|
1463
|
+
return {
|
|
1464
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}] must be an object`, verified_at: now },
|
|
1465
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `tool_receipts[${i}] must be an object`, field: `payload.tool_receipts[${i}]` },
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
const rec = tr;
|
|
1469
|
+
// Fail-closed: unknown receipt_version
|
|
1470
|
+
if (rec.receipt_version !== '1') {
|
|
1471
|
+
return {
|
|
1472
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}]: unknown receipt_version`, verified_at: now },
|
|
1473
|
+
error: { code: 'UNKNOWN_VERSION', message: `tool_receipts[${i}].receipt_version must be "1"`, field: `payload.tool_receipts[${i}].receipt_version` },
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
// Fail-closed: unknown hash algorithm
|
|
1477
|
+
if (rec.hash_algorithm !== 'SHA-256') {
|
|
1478
|
+
return {
|
|
1479
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}]: unknown hash_algorithm`, verified_at: now },
|
|
1480
|
+
error: { code: 'UNKNOWN_HASH_ALGORITHM', message: `tool_receipts[${i}].hash_algorithm must be "SHA-256"`, field: `payload.tool_receipts[${i}].hash_algorithm` },
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
for (const field of REQUIRED_TOOL_RECEIPT_FIELDS) {
|
|
1484
|
+
if (rec[field] === undefined || rec[field] === null) {
|
|
1485
|
+
return {
|
|
1486
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}]: missing ${field}`, verified_at: now },
|
|
1487
|
+
error: { code: 'MISSING_REQUIRED_FIELD', message: `tool_receipts[${i}].${field} is required`, field: `payload.tool_receipts[${i}].${field}` },
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
// Validate string fields
|
|
1492
|
+
for (const f of ['receipt_id', 'tool_name', 'args_hash_b64u', 'result_hash_b64u', 'agent_did', 'timestamp']) {
|
|
1493
|
+
if (typeof rec[f] !== 'string' || rec[f].length === 0) {
|
|
1494
|
+
return {
|
|
1495
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}]: invalid ${f}`, verified_at: now },
|
|
1496
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `tool_receipts[${i}].${f} must be a non-empty string`, field: `payload.tool_receipts[${i}].${f}` },
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
if (typeof rec.latency_ms !== 'number' || rec.latency_ms < 0) {
|
|
1501
|
+
return {
|
|
1502
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}]: invalid latency_ms`, verified_at: now },
|
|
1503
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `tool_receipts[${i}].latency_ms must be a non-negative number`, field: `payload.tool_receipts[${i}].latency_ms` },
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
// Validate base64url hashes
|
|
1507
|
+
for (const f of ['args_hash_b64u', 'result_hash_b64u']) {
|
|
1508
|
+
if (!isValidBase64Url(rec[f])) {
|
|
1509
|
+
return {
|
|
1510
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}]: invalid ${f} format`, verified_at: now },
|
|
1511
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `tool_receipts[${i}].${f} must be valid base64url`, field: `payload.tool_receipts[${i}].${f}` },
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
// Agent DID must match bundle agent
|
|
1516
|
+
if (rec.agent_did !== payload.agent_did) {
|
|
1517
|
+
return {
|
|
1518
|
+
result: { status: 'INVALID', reason: `tool_receipts[${i}]: agent_did mismatch`, verified_at: now },
|
|
1519
|
+
error: { code: 'PROOF_BUNDLE_AGENT_MISMATCH', message: `tool_receipts[${i}].agent_did must equal payload.agent_did`, field: `payload.tool_receipts[${i}].agent_did` },
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
componentResults.tool_receipts_count = toolReceipts.length;
|
|
1524
|
+
componentResults.tool_receipts_valid = true;
|
|
1525
|
+
}
|
|
1526
|
+
// Validate side-effect receipts when present (CPL-US-007: fail-closed on unknown schema)
|
|
1527
|
+
if (payload.side_effect_receipts !== undefined) {
|
|
1528
|
+
const sideEffectReceipts = payload.side_effect_receipts;
|
|
1529
|
+
if (!Array.isArray(sideEffectReceipts)) {
|
|
1530
|
+
return {
|
|
1531
|
+
result: { status: 'INVALID', reason: 'side_effect_receipts must be an array', verified_at: now },
|
|
1532
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: 'payload.side_effect_receipts must be an array', field: 'payload.side_effect_receipts' },
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
const REQUIRED_SE_FIELDS = [
|
|
1536
|
+
'receipt_version', 'receipt_id', 'effect_class', 'target_hash_b64u',
|
|
1537
|
+
'request_hash_b64u', 'response_hash_b64u', 'hash_algorithm', 'agent_did', 'timestamp', 'latency_ms',
|
|
1538
|
+
];
|
|
1539
|
+
const VALID_EFFECT_CLASSES = ['network_egress', 'filesystem_write', 'external_api_write'];
|
|
1540
|
+
for (let i = 0; i < sideEffectReceipts.length; i++) {
|
|
1541
|
+
const se = sideEffectReceipts[i];
|
|
1542
|
+
if (typeof se !== 'object' || se === null || Array.isArray(se)) {
|
|
1543
|
+
return {
|
|
1544
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}] must be an object`, verified_at: now },
|
|
1545
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `side_effect_receipts[${i}] must be an object`, field: `payload.side_effect_receipts[${i}]` },
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
const rec = se;
|
|
1549
|
+
if (rec.receipt_version !== '1') {
|
|
1550
|
+
return {
|
|
1551
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}]: unknown receipt_version`, verified_at: now },
|
|
1552
|
+
error: { code: 'UNKNOWN_VERSION', message: `side_effect_receipts[${i}].receipt_version must be "1"`, field: `payload.side_effect_receipts[${i}].receipt_version` },
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
if (rec.hash_algorithm !== 'SHA-256') {
|
|
1556
|
+
return {
|
|
1557
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}]: unknown hash_algorithm`, verified_at: now },
|
|
1558
|
+
error: { code: 'UNKNOWN_HASH_ALGORITHM', message: `side_effect_receipts[${i}].hash_algorithm must be "SHA-256"`, field: `payload.side_effect_receipts[${i}].hash_algorithm` },
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
if (!VALID_EFFECT_CLASSES.includes(rec.effect_class)) {
|
|
1562
|
+
return {
|
|
1563
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}]: unknown effect_class`, verified_at: now },
|
|
1564
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `side_effect_receipts[${i}].effect_class must be one of: ${VALID_EFFECT_CLASSES.join(', ')}`, field: `payload.side_effect_receipts[${i}].effect_class` },
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
for (const field of REQUIRED_SE_FIELDS) {
|
|
1568
|
+
if (rec[field] === undefined || rec[field] === null) {
|
|
1569
|
+
return {
|
|
1570
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}]: missing ${field}`, verified_at: now },
|
|
1571
|
+
error: { code: 'MISSING_REQUIRED_FIELD', message: `side_effect_receipts[${i}].${field} is required`, field: `payload.side_effect_receipts[${i}].${field}` },
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
for (const f of ['receipt_id', 'target_hash_b64u', 'request_hash_b64u', 'response_hash_b64u', 'agent_did', 'timestamp']) {
|
|
1576
|
+
if (typeof rec[f] !== 'string' || rec[f].length === 0) {
|
|
1577
|
+
return {
|
|
1578
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}]: invalid ${f}`, verified_at: now },
|
|
1579
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `side_effect_receipts[${i}].${f} must be a non-empty string`, field: `payload.side_effect_receipts[${i}].${f}` },
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (typeof rec.latency_ms !== 'number' || rec.latency_ms < 0) {
|
|
1584
|
+
return {
|
|
1585
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}]: invalid latency_ms`, verified_at: now },
|
|
1586
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `side_effect_receipts[${i}].latency_ms must be >= 0`, field: `payload.side_effect_receipts[${i}].latency_ms` },
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
if (rec.agent_did !== payload.agent_did) {
|
|
1590
|
+
return {
|
|
1591
|
+
result: { status: 'INVALID', reason: `side_effect_receipts[${i}]: agent_did mismatch`, verified_at: now },
|
|
1592
|
+
error: { code: 'PROOF_BUNDLE_AGENT_MISMATCH', message: `side_effect_receipts[${i}].agent_did must equal payload.agent_did`, field: `payload.side_effect_receipts[${i}].agent_did` },
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
componentResults.side_effect_receipts_count = sideEffectReceipts.length;
|
|
1597
|
+
componentResults.side_effect_receipts_valid = true;
|
|
1598
|
+
}
|
|
1599
|
+
// Validate human approval receipts when present (CPL-US-008: fail-closed on unknown schema)
|
|
1600
|
+
if (payload.human_approval_receipts !== undefined) {
|
|
1601
|
+
const approvalReceipts = payload.human_approval_receipts;
|
|
1602
|
+
if (!Array.isArray(approvalReceipts)) {
|
|
1603
|
+
return {
|
|
1604
|
+
result: { status: 'INVALID', reason: 'human_approval_receipts must be an array', verified_at: now },
|
|
1605
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: 'payload.human_approval_receipts must be an array', field: 'payload.human_approval_receipts' },
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
const REQUIRED_HA_FIELDS = [
|
|
1609
|
+
'receipt_version', 'receipt_id', 'approval_type', 'approver_subject',
|
|
1610
|
+
'agent_did', 'scope_hash_b64u', 'hash_algorithm', 'timestamp',
|
|
1611
|
+
];
|
|
1612
|
+
const VALID_APPROVAL_TYPES = ['explicit_approve', 'explicit_deny', 'auto_approve', 'timeout_deny'];
|
|
1613
|
+
for (let i = 0; i < approvalReceipts.length; i++) {
|
|
1614
|
+
const ha = approvalReceipts[i];
|
|
1615
|
+
if (typeof ha !== 'object' || ha === null || Array.isArray(ha)) {
|
|
1616
|
+
return {
|
|
1617
|
+
result: { status: 'INVALID', reason: `human_approval_receipts[${i}] must be an object`, verified_at: now },
|
|
1618
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `human_approval_receipts[${i}] must be an object`, field: `payload.human_approval_receipts[${i}]` },
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
const rec = ha;
|
|
1622
|
+
if (rec.receipt_version !== '1') {
|
|
1623
|
+
return {
|
|
1624
|
+
result: { status: 'INVALID', reason: `human_approval_receipts[${i}]: unknown receipt_version`, verified_at: now },
|
|
1625
|
+
error: { code: 'UNKNOWN_VERSION', message: `human_approval_receipts[${i}].receipt_version must be "1"`, field: `payload.human_approval_receipts[${i}].receipt_version` },
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
if (rec.hash_algorithm !== 'SHA-256') {
|
|
1629
|
+
return {
|
|
1630
|
+
result: { status: 'INVALID', reason: `human_approval_receipts[${i}]: unknown hash_algorithm`, verified_at: now },
|
|
1631
|
+
error: { code: 'UNKNOWN_HASH_ALGORITHM', message: `human_approval_receipts[${i}].hash_algorithm must be "SHA-256"`, field: `payload.human_approval_receipts[${i}].hash_algorithm` },
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
if (!VALID_APPROVAL_TYPES.includes(rec.approval_type)) {
|
|
1635
|
+
return {
|
|
1636
|
+
result: { status: 'INVALID', reason: `human_approval_receipts[${i}]: unknown approval_type`, verified_at: now },
|
|
1637
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `human_approval_receipts[${i}].approval_type must be one of: ${VALID_APPROVAL_TYPES.join(', ')}`, field: `payload.human_approval_receipts[${i}].approval_type` },
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
for (const field of REQUIRED_HA_FIELDS) {
|
|
1641
|
+
if (rec[field] === undefined || rec[field] === null) {
|
|
1642
|
+
return {
|
|
1643
|
+
result: { status: 'INVALID', reason: `human_approval_receipts[${i}]: missing ${field}`, verified_at: now },
|
|
1644
|
+
error: { code: 'MISSING_REQUIRED_FIELD', message: `human_approval_receipts[${i}].${field} is required`, field: `payload.human_approval_receipts[${i}].${field}` },
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
for (const f of ['receipt_id', 'approver_subject', 'agent_did', 'scope_hash_b64u', 'timestamp']) {
|
|
1649
|
+
if (typeof rec[f] !== 'string' || rec[f].length === 0) {
|
|
1650
|
+
return {
|
|
1651
|
+
result: { status: 'INVALID', reason: `human_approval_receipts[${i}]: invalid ${f}`, verified_at: now },
|
|
1652
|
+
error: { code: 'SCHEMA_VALIDATION_FAILED', message: `human_approval_receipts[${i}].${f} must be a non-empty string`, field: `payload.human_approval_receipts[${i}].${f}` },
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
if (rec.agent_did !== payload.agent_did) {
|
|
1657
|
+
return {
|
|
1658
|
+
result: { status: 'INVALID', reason: `human_approval_receipts[${i}]: agent_did mismatch`, verified_at: now },
|
|
1659
|
+
error: { code: 'PROOF_BUNDLE_AGENT_MISMATCH', message: `human_approval_receipts[${i}].agent_did must equal payload.agent_did`, field: `payload.human_approval_receipts[${i}].agent_did` },
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
componentResults.human_approval_receipts_count = approvalReceipts.length;
|
|
1664
|
+
componentResults.human_approval_receipts_valid = true;
|
|
1665
|
+
}
|
|
1666
|
+
// Validate + verify attestations if present
|
|
1667
|
+
// CVF-US-023: Attestations MUST be signature-verified AND attester_did allowlisted
|
|
1668
|
+
// before they can uplift trust tier.
|
|
1669
|
+
if (payload.attestations !== undefined && payload.attestations.length > 0) {
|
|
1670
|
+
const attestationResults = await Promise.all(payload.attestations.map(async (a) => {
|
|
1671
|
+
if (!validateAttestation(a)) {
|
|
1672
|
+
return {
|
|
1673
|
+
valid: false,
|
|
1674
|
+
signature_valid: false,
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
return verifyAttestationReference(a, payload.agent_did, options.allowlistedAttesterDids);
|
|
1678
|
+
}));
|
|
1679
|
+
const signatureVerifiedCount = attestationResults.filter((r) => r.signature_valid).length;
|
|
1680
|
+
const verifiedCount = attestationResults.filter((r) => r.valid).length;
|
|
1681
|
+
componentResults.attestations_count = payload.attestations.length;
|
|
1682
|
+
componentResults.attestations_signature_verified_count = signatureVerifiedCount;
|
|
1683
|
+
componentResults.attestations_verified_count = verifiedCount;
|
|
1684
|
+
// Strict: all attestations must verify to count this component as valid.
|
|
1685
|
+
componentResults.attestations_valid = verifiedCount === payload.attestations.length;
|
|
1686
|
+
}
|
|
1687
|
+
// 16. Compute tiers based on validated components
|
|
1688
|
+
const trustTier = computeTrustTier(componentResults);
|
|
1689
|
+
const proofTier = computeProofTier(componentResults);
|
|
1690
|
+
// 17. Return success with computed tiers
|
|
1691
|
+
return {
|
|
1692
|
+
result: {
|
|
1693
|
+
status: 'VALID',
|
|
1694
|
+
reason: 'Proof bundle verified successfully',
|
|
1695
|
+
verified_at: now,
|
|
1696
|
+
bundle_id: payload.bundle_id,
|
|
1697
|
+
agent_did: payload.agent_did,
|
|
1698
|
+
trust_tier: trustTier,
|
|
1699
|
+
proof_tier: proofTier,
|
|
1700
|
+
model_identity_tier: modelIdentityTier,
|
|
1701
|
+
risk_flags: modelIdentityRiskFlags.size > 0
|
|
1702
|
+
? [...modelIdentityRiskFlags].sort()
|
|
1703
|
+
: undefined,
|
|
1704
|
+
component_results: componentResults,
|
|
1705
|
+
},
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
//# sourceMappingURL=verify-proof-bundle.js.map
|