Skip to content

Commit 6ec6af7

Browse files
[cross-repo from workflow#395] server + workflow + sdk-python: make replay verification a first-class platform contract (#20)
1 parent f33cb94 commit 6ec6af7

2 files changed

Lines changed: 210 additions & 69 deletions

File tree

src/durable_workflow/history_bundle_verify.py

Lines changed: 163 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,15 @@ def verify_bundle(bundle: Any, signing_key: str | None = None) -> dict[str, Any]
100100

101101
workflow = bundle.get("workflow") if isinstance(bundle.get("workflow"), Mapping) else {}
102102
history_events = bundle.get("history_events")
103+
instance_id = _string_or_none(workflow.get("instance_id")) if isinstance(workflow, Mapping) else None
104+
run_id = _string_or_none(workflow.get("run_id")) if isinstance(workflow, Mapping) else None
103105
bundle_summary = {
104106
"schema": _string_or_none(bundle.get("schema")),
105107
"schema_version": _int_or_none(bundle.get("schema_version")),
106-
"instance_id": _string_or_none(workflow.get("instance_id")) if isinstance(workflow, Mapping) else None,
107-
"run_id": _string_or_none(workflow.get("run_id")) if isinstance(workflow, Mapping) else None,
108+
"instance_id": instance_id,
109+
"run_id": run_id,
110+
"workflow_instance_id": instance_id,
111+
"workflow_run_id": run_id,
108112
"history_event_count": len(history_events) if isinstance(history_events, list) else 0,
109113
}
110114

@@ -151,7 +155,24 @@ def _check_envelope(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -
151155
)
152156
)
153157

154-
for section in ("workflow", "history_events", "commands", "payloads"):
158+
required_sections = {
159+
"workflow": dict,
160+
"payloads": dict,
161+
"history_events": list,
162+
"commands": list,
163+
"signals": list,
164+
"updates": list,
165+
"tasks": list,
166+
"activities": list,
167+
"timers": list,
168+
"failures": list,
169+
"links": dict,
170+
"redaction": dict,
171+
"codec_schemas": dict,
172+
"payload_manifest": dict,
173+
}
174+
175+
for section, expected_type in required_sections.items():
155176
if section not in bundle:
156177
findings.append(
157178
_finding(
@@ -163,7 +184,6 @@ def _check_envelope(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -
163184
)
164185
continue
165186
value = bundle[section]
166-
expected_type = list if section in ("history_events", "commands") else dict
167187
if not isinstance(value, expected_type):
168188
findings.append(
169189
_finding(
@@ -363,7 +383,7 @@ def _check_payload_manifest(bundle: Mapping[str, Any], findings: list[dict[str,
363383
findings.append(
364384
_finding(
365385
"payload_manifest.codec_missing",
366-
SEVERITY_WARNING,
386+
SEVERITY_ERROR,
367387
f"payload_manifest entry [{path}] does not declare a codec.",
368388
{"path": path},
369389
)
@@ -373,13 +393,13 @@ def _check_payload_manifest(bundle: Mapping[str, Any], findings: list[dict[str,
373393
redacted = bool(entry.get("redacted"))
374394
diagnostic = _string_or_none(entry.get("diagnostic"))
375395

376-
if not available and not redacted and diagnostic == "payload_missing":
396+
if available and not redacted and diagnostic == "payload_missing":
377397
findings.append(
378398
_finding(
379399
"payload_manifest.payload_missing",
380-
SEVERITY_WARNING,
400+
SEVERITY_ERROR,
381401
(
382-
f"payload_manifest entry [{path}] is marked unavailable but flagged "
402+
f"payload_manifest entry [{path}] is marked available but flagged "
383403
"as payload_missing — bundle decode will be lossy."
384404
),
385405
{"path": path},
@@ -424,7 +444,7 @@ def _check_redaction(bundle: Mapping[str, Any], findings: list[dict[str, Any]])
424444
findings.append(
425445
_finding(
426446
"redaction.empty_paths",
427-
SEVERITY_WARNING,
447+
SEVERITY_INFO,
428448
"redaction.applied=true but redaction.paths is empty — operators cannot tell what was redacted.",
429449
)
430450
)
@@ -449,7 +469,7 @@ def _check_integrity(
449469
signature = _string_or_none(integrity.get("signature"))
450470
key_id = _string_or_none(integrity.get("key_id"))
451471

452-
if canonicalization is not None and canonicalization not in SUPPORTED_CANONICALIZATIONS:
472+
if canonicalization not in SUPPORTED_CANONICALIZATIONS:
453473
findings.append(
454474
_finding(
455475
"integrity.canonicalization_unsupported",
@@ -461,9 +481,18 @@ def _check_integrity(
461481
{"canonicalization": canonicalization},
462482
)
463483
)
464-
return _integrity_report_for(canonicalization, checksum_algorithm, checksum, signature_algorithm, key_id)
484+
return _integrity_report_for(
485+
canonicalization,
486+
checksum_algorithm,
487+
checksum,
488+
signature_algorithm,
489+
key_id,
490+
present=True,
491+
signature_present=signature is not None,
492+
signing_key_provided=bool(signing_key),
493+
)
465494

466-
if checksum_algorithm is not None and checksum_algorithm not in SUPPORTED_CHECKSUM_ALGORITHMS:
495+
if checksum_algorithm not in SUPPORTED_CHECKSUM_ALGORITHMS:
467496
findings.append(
468497
_finding(
469498
"integrity.checksum_algorithm_unsupported",
@@ -475,6 +504,16 @@ def _check_integrity(
475504
{"checksum_algorithm": checksum_algorithm},
476505
)
477506
)
507+
return _integrity_report_for(
508+
canonicalization,
509+
checksum_algorithm,
510+
checksum,
511+
signature_algorithm,
512+
key_id,
513+
present=True,
514+
signature_present=signature is not None,
515+
signing_key_provided=bool(signing_key),
516+
)
478517

479518
if checksum is None:
480519
findings.append(
@@ -491,81 +530,113 @@ def _check_integrity(
491530
f"Bundle could not be canonicalized: {exc}",
492531
)
493532
)
494-
return _integrity_report_for(canonicalization, checksum_algorithm, checksum, signature_algorithm, key_id)
533+
return _integrity_report_for(
534+
canonicalization,
535+
checksum_algorithm,
536+
checksum,
537+
signature_algorithm,
538+
key_id,
539+
present=True,
540+
signature_present=signature is not None,
541+
signing_key_provided=bool(signing_key),
542+
)
495543

496544
checksum_ok: bool | None = None
545+
recomputed_checksum: str | None = None
497546
if checksum_algorithm == "sha256" and checksum is not None:
498-
expected = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
499-
checksum_ok = hmac.compare_digest(expected, checksum)
547+
recomputed_checksum = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
548+
checksum_ok = hmac.compare_digest(recomputed_checksum, checksum)
500549
if not checksum_ok:
501550
findings.append(
502551
_finding(
503552
"integrity.checksum_mismatch",
504553
SEVERITY_ERROR,
505554
(
506-
f"integrity.checksum=[{checksum}] does not match recomputed sha256=[{expected}]. "
555+
f"integrity.checksum=[{checksum}] does not match recomputed sha256=[{recomputed_checksum}]. "
507556
"Bundle has been tampered with or canonicalization disagrees."
508557
),
509-
{"expected": expected, "observed": checksum},
558+
{"expected": recomputed_checksum, "observed": checksum},
510559
)
511560
)
512561

513562
signature_ok: bool | None = None
514-
if signature_algorithm is not None:
515-
if signature_algorithm not in SUPPORTED_SIGNATURE_ALGORITHMS:
563+
if signature_algorithm is None and signature is None:
564+
return _integrity_report_for(
565+
canonicalization,
566+
checksum_algorithm,
567+
checksum,
568+
signature_algorithm,
569+
key_id,
570+
present=True,
571+
recomputed_checksum=recomputed_checksum,
572+
checksum_matches=checksum_ok,
573+
signature_present=False,
574+
signature_verified=None,
575+
signing_key_provided=bool(signing_key),
576+
)
577+
578+
if signature_algorithm not in SUPPORTED_SIGNATURE_ALGORITHMS:
579+
findings.append(
580+
_finding(
581+
"integrity.signature_algorithm_unsupported",
582+
SEVERITY_ERROR,
583+
(
584+
f"integrity.signature_algorithm=[{signature_algorithm}] is not supported "
585+
f"(supported: {sorted(SUPPORTED_SIGNATURE_ALGORITHMS)})."
586+
),
587+
{"signature_algorithm": signature_algorithm},
588+
)
589+
)
590+
elif signature is None:
591+
findings.append(
592+
_finding(
593+
"integrity.signature_missing",
594+
SEVERITY_ERROR,
595+
"Bundle declares a signature_algorithm but does not include a signature.",
596+
)
597+
)
598+
elif not signing_key:
599+
findings.append(
600+
_finding(
601+
"integrity.signature_key_unavailable",
602+
SEVERITY_WARNING,
603+
"Bundle is signed but the verifier was not given a signing key — signature was not validated.",
604+
{"key_id": key_id},
605+
)
606+
)
607+
else:
608+
expected = hmac.new(
609+
signing_key.encode("utf-8"),
610+
canonical_json.encode("utf-8"),
611+
hashlib.sha256,
612+
).hexdigest()
613+
signature_ok = hmac.compare_digest(expected, signature)
614+
if not signature_ok:
516615
findings.append(
517616
_finding(
518-
"integrity.signature_algorithm_unsupported",
617+
"integrity.signature_mismatch",
519618
SEVERITY_ERROR,
520619
(
521-
f"integrity.signature_algorithm=[{signature_algorithm}] is not supported "
522-
f"(supported: {sorted(SUPPORTED_SIGNATURE_ALGORITHMS)})."
620+
"integrity.signature does not match HMAC-SHA256 of the canonical bundle "
621+
f"under the provided key (key_id={key_id or 'unknown'})."
523622
),
524-
{"signature_algorithm": signature_algorithm},
525-
)
526-
)
527-
elif signature is None:
528-
findings.append(
529-
_finding(
530-
"integrity.signature_missing",
531-
SEVERITY_ERROR,
532-
"Bundle declares a signature_algorithm but does not include a signature.",
533-
)
534-
)
535-
elif not signing_key:
536-
findings.append(
537-
_finding(
538-
"integrity.signature_key_unavailable",
539-
SEVERITY_WARNING,
540-
"Bundle is signed but the verifier was not given a signing key — signature was not validated.",
541623
{"key_id": key_id},
542624
)
543625
)
544-
else:
545-
expected = hmac.new(
546-
signing_key.encode("utf-8"),
547-
canonical_json.encode("utf-8"),
548-
hashlib.sha256,
549-
).hexdigest()
550-
signature_ok = hmac.compare_digest(expected, signature)
551-
if not signature_ok:
552-
findings.append(
553-
_finding(
554-
"integrity.signature_mismatch",
555-
SEVERITY_ERROR,
556-
(
557-
"integrity.signature does not match HMAC-SHA256 of the canonical bundle "
558-
f"under the provided key (key_id={key_id or 'unknown'})."
559-
),
560-
{"key_id": key_id},
561-
)
562-
)
563626

564-
report = _integrity_report_for(canonicalization, checksum_algorithm, checksum, signature_algorithm, key_id)
565-
report["checksum_ok"] = checksum_ok
566-
report["signature_ok"] = signature_ok
567-
report["signing_key_provided"] = bool(signing_key)
568-
return report
627+
return _integrity_report_for(
628+
canonicalization,
629+
checksum_algorithm,
630+
checksum,
631+
signature_algorithm,
632+
key_id,
633+
present=True,
634+
recomputed_checksum=recomputed_checksum,
635+
checksum_matches=checksum_ok,
636+
signature_present=signature is not None,
637+
signature_verified=signature_ok,
638+
signing_key_provided=bool(signing_key),
639+
)
569640

570641

571642
def _canonicalize(bundle: Mapping[str, Any]) -> str:
@@ -587,8 +658,17 @@ def _finalize(
587658
bundle_summary: Mapping[str, Any],
588659
integrity_report: Mapping[str, Any],
589660
) -> dict[str, Any]:
590-
summary = {"findings": len(findings), "errors": 0, "warnings": 0, "info": 0}
661+
summary: dict[str, Any] = {
662+
"findings": len(findings),
663+
"errors": 0,
664+
"warnings": 0,
665+
"info": 0,
666+
"findings_by_rule": {},
667+
}
591668
for finding in findings:
669+
rule = str(finding.get("rule", "unknown"))
670+
summary["findings_by_rule"][rule] = summary["findings_by_rule"].get(rule, 0) + 1
671+
592672
severity = finding.get("severity", SEVERITY_INFO)
593673
if severity == SEVERITY_ERROR:
594674
summary["errors"] += 1
@@ -633,17 +713,29 @@ def _integrity_report_for(
633713
checksum: str | None,
634714
signature_algorithm: str | None,
635715
key_id: str | None,
716+
*,
717+
present: bool = False,
718+
recomputed_checksum: str | None = None,
719+
checksum_matches: bool | None = None,
720+
signature_present: bool = False,
721+
signature_verified: bool | None = None,
722+
signing_key_provided: bool = False,
636723
) -> dict[str, Any]:
637724
return {
725+
"present": present,
638726
"canonicalization": canonicalization,
639727
"checksum_algorithm": checksum_algorithm,
640-
"checksum": checksum,
728+
"expected_checksum": checksum,
729+
"recomputed_checksum": recomputed_checksum,
730+
"checksum_matches": checksum_matches,
641731
"signature_algorithm": signature_algorithm,
642-
"signature_present": signature_algorithm is not None,
732+
"signature_present": signature_present,
733+
"signature_verified": signature_verified,
643734
"key_id": key_id,
644-
"checksum_ok": None,
645-
"signature_ok": None,
646-
"signing_key_provided": False,
735+
"checksum": checksum,
736+
"checksum_ok": checksum_matches,
737+
"signature_ok": signature_verified,
738+
"signing_key_provided": signing_key_provided,
647739
}
648740

649741

@@ -653,6 +745,8 @@ def _empty_bundle_summary() -> dict[str, Any]:
653745
"schema_version": None,
654746
"instance_id": None,
655747
"run_id": None,
748+
"workflow_instance_id": None,
749+
"workflow_run_id": None,
656750
"history_event_count": 0,
657751
}
658752

0 commit comments

Comments
 (0)