|
21 | 21 |
|
22 | 22 |
|
23 | 23 | EVIDENCE_EXPORT_SCHEMA_VERSION = 1 |
| 24 | +_RECEIPT_RECORD_CROSS_CHECK_FIELDS = ( |
| 25 | + ("payload_hash", "payload_hash"), |
| 26 | + ("risk_class", "client_risk_class"), |
| 27 | + ("policy_context_hash", "client_policy_context_hash"), |
| 28 | +) |
24 | 29 |
|
25 | 30 |
|
26 | 31 | class EvidenceProofError(RuntimeError): |
@@ -82,7 +87,8 @@ def build_evidence_bundle( |
82 | 87 | request_ids=request_ids, |
83 | 88 | ) |
84 | 89 | signed_receipts: dict[str, str] = {} |
85 | | - export_records = _bundle_records(records) |
| 90 | + has_filter = since_timestamp is not None or until_timestamp is not None or request_ids is not None |
| 91 | + export_records = _bundle_records(records, require_genesis=not has_filter) |
86 | 92 | unverified_receipt_count = 0 |
87 | 93 | for record in records: |
88 | 94 | if not record.decision_audit_id or not record.decision_receipt_sha256: |
@@ -164,7 +170,12 @@ def verify_evidence_bundle( |
164 | 170 | records = bundle.get("records") |
165 | 171 | if not isinstance(records, list): |
166 | 172 | raise EvidenceVerificationError("records must be a list") |
167 | | - expected_prev = GENESIS_PREV_EVENT_HASH |
| 173 | + has_filter = _bundle_uses_filter(bundle.get("filter")) |
| 174 | + expected_prev = ( |
| 175 | + records[0].get("prev_event_hash") |
| 176 | + if records and has_filter and isinstance(records[0], dict) |
| 177 | + else GENESIS_PREV_EVENT_HASH |
| 178 | + ) |
168 | 179 | last_hash = GENESIS_PREV_EVENT_HASH |
169 | 180 | for index, record in enumerate(records): |
170 | 181 | if not isinstance(record, dict): |
@@ -222,10 +233,15 @@ def verify_evidence_bundle( |
222 | 233 | if receipt_digest not in verified_bodies: |
223 | 234 | continue |
224 | 235 | receipt_body = verified_bodies[receipt_digest] |
225 | | - expected_payload = record.get("payload_hash") |
226 | | - receipt_payload = receipt_body.get("payload_hash") |
227 | | - if receipt_payload is not None and receipt_payload != expected_payload: |
228 | | - raise EvidenceVerificationError("DecisionReceipt payload_hash mismatch") |
| 236 | + for record_field, receipt_field in _RECEIPT_RECORD_CROSS_CHECK_FIELDS: |
| 237 | + record_value = record.get(record_field) |
| 238 | + receipt_value = receipt_body.get(receipt_field) |
| 239 | + if record_value is None or receipt_value is None: |
| 240 | + continue |
| 241 | + if receipt_value != record_value: |
| 242 | + raise EvidenceVerificationError( |
| 243 | + f"DecisionReceipt {receipt_field} mismatch with record {record_field}" |
| 244 | + ) |
229 | 245 |
|
230 | 246 | return EvidenceVerificationResult( |
231 | 247 | valid=True, |
@@ -254,18 +270,36 @@ def verify_evidence_bundle_file( |
254 | 270 | return verify_evidence_bundle(bundle, trusted_signer_dids=trusted_signer_dids) |
255 | 271 |
|
256 | 272 |
|
257 | | -def _bundle_records(records: list[PendingApproval]) -> list[dict[str, Any]]: |
| 273 | +def _bundle_records(records: list[PendingApproval], *, require_genesis: bool = True) -> list[dict[str, Any]]: |
258 | 274 | export_records: list[dict[str, Any]] = [] |
259 | | - prev_hash = GENESIS_PREV_EVENT_HASH |
| 275 | + expected_prev_hash = ( |
| 276 | + GENESIS_PREV_EVENT_HASH |
| 277 | + if require_genesis or not records |
| 278 | + else records[0].prev_event_hash |
| 279 | + ) |
260 | 280 | for record in records: |
| 281 | + if record.prev_event_hash != expected_prev_hash: |
| 282 | + raise EvidenceExportError( |
| 283 | + f"chain integrity broken at request_id {record.request_id}: " |
| 284 | + "stored prev_event_hash diverges from expected chain link; " |
| 285 | + "run doctor or inspect evidence DB integrity" |
| 286 | + ) |
261 | 287 | data = asdict(record) |
262 | | - data["prev_event_hash"] = prev_hash |
263 | 288 | data["record_hash"] = record_hash(data) |
264 | | - prev_hash = data["record_hash"] |
| 289 | + expected_prev_hash = data["record_hash"] |
265 | 290 | export_records.append(data) |
266 | 291 | return export_records |
267 | 292 |
|
268 | 293 |
|
| 294 | +def _bundle_uses_filter(filter_value: Any) -> bool: |
| 295 | + if not isinstance(filter_value, Mapping): |
| 296 | + return False |
| 297 | + return any( |
| 298 | + filter_value.get(key) is not None |
| 299 | + for key in ("since_timestamp", "until_timestamp", "request_ids") |
| 300 | + ) |
| 301 | + |
| 302 | + |
269 | 303 | def _verify_receipt_with_pinned_signers( |
270 | 304 | receipt_jcs: str, |
271 | 305 | trusted_signer_dids: Iterable[str], |
|
0 commit comments