proofbundle 3.2.0__tar.gz → 3.2.1__tar.gz

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.
Files changed (187) hide show
  1. {proofbundle-3.2.0/src/proofbundle.egg-info → proofbundle-3.2.1}/PKG-INFO +1 -1
  2. {proofbundle-3.2.0 → proofbundle-3.2.1}/pyproject.toml +1 -1
  3. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/__init__.py +1 -1
  4. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/renewal.py +61 -6
  5. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/sdjwt.py +25 -2
  6. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/trust_pack.py +32 -4
  7. {proofbundle-3.2.0 → proofbundle-3.2.1/src/proofbundle.egg-info}/PKG-INFO +1 -1
  8. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/SOURCES.txt +5 -0
  9. proofbundle-3.2.1/tests/test_anchor_hardening_321.py +187 -0
  10. proofbundle-3.2.1/tests/test_checkpoint_external_vectors.py +163 -0
  11. proofbundle-3.2.1/tests/test_mldsa_acvp_vectors.py +150 -0
  12. proofbundle-3.2.1/tests/test_ots_external_vectors.py +183 -0
  13. proofbundle-3.2.1/tests/test_sdjwtvc_external_vectors.py +162 -0
  14. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_trust_pack.py +48 -12
  15. {proofbundle-3.2.0 → proofbundle-3.2.1}/LICENSE +0 -0
  16. {proofbundle-3.2.0 → proofbundle-3.2.1}/README.md +0 -0
  17. {proofbundle-3.2.0 → proofbundle-3.2.1}/setup.cfg +0 -0
  18. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/_inspect_registry.py +0 -0
  19. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/_integration.py +0 -0
  20. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/_strict_json.py +0 -0
  21. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/__init__.py +0 -0
  22. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/_provenance.py +0 -0
  23. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/eee.py +0 -0
  24. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/inspect_ai.py +0 -0
  25. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/lm_eval.py +0 -0
  26. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/promptfoo.py +0 -0
  27. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/samples.py +0 -0
  28. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors.py +0 -0
  29. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_chia.py +0 -0
  30. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_chia_add.py +0 -0
  31. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_markovian.py +0 -0
  32. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_ots.py +0 -0
  33. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_rfc3161.py +0 -0
  34. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/beacon.py +0 -0
  35. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/bundle.py +0 -0
  36. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/canonical.py +0 -0
  37. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/checkpoint.py +0 -0
  38. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/cli.py +0 -0
  39. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/decision.py +0 -0
  40. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/demo.py +0 -0
  41. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/dsse.py +0 -0
  42. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/eee_eval_schema.json +0 -0
  43. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/emit.py +0 -0
  44. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/errors.py +0 -0
  45. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/evalclaim.py +0 -0
  46. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/evidence_pack.py +0 -0
  47. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/experimental/__init__.py +0 -0
  48. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/experimental/enclave.py +0 -0
  49. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/hashalg.py +0 -0
  50. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/hf_evals.py +0 -0
  51. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/inspect_hook.py +0 -0
  52. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/intoto.py +0 -0
  53. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/kbjwt.py +0 -0
  54. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/merkle.py +0 -0
  55. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/outcome.py +0 -0
  56. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/persample.py +0 -0
  57. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/decision-receipt-template-v1.json +0 -0
  58. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/research-preview-v1.json +0 -0
  59. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/strict-eval-authenticated-root-template-v1.json +0 -0
  60. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/strict-eval-template-v1.json +0 -0
  61. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/strict-prereg-template-v1.json +0 -0
  62. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policy.py +0 -0
  63. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policy_profiles.py +0 -0
  64. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/pqsig.py +0 -0
  65. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/prereg.py +0 -0
  66. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/public_transparency.py +0 -0
  67. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/py.typed +0 -0
  68. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/pytest_plugin.py +0 -0
  69. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/run_ledger.py +0 -0
  70. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/sdjwt_issue.py +0 -0
  71. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/sdjwt_vc.py +0 -0
  72. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/signature.py +0 -0
  73. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/statuslist.py +0 -0
  74. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/subject_binding.py +0 -0
  75. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/tlogproof.py +0 -0
  76. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/verification_summary.py +0 -0
  77. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  78. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/entry_points.txt +0 -0
  79. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/requires.txt +0 -0
  80. {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/top_level.txt +0 -0
  81. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_adapters.py +0 -0
  82. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_adversarial.py +0 -0
  83. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchor_longevity_property.py +0 -0
  84. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchor_target_trustedtime.py +0 -0
  85. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_chia.py +0 -0
  86. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_chia_add.py +0 -0
  87. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_chia_claims.py +0 -0
  88. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_generic.py +0 -0
  89. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_markovian.py +0 -0
  90. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_ots.py +0 -0
  91. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_rfc3161.py +0 -0
  92. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_beacon.py +0 -0
  93. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_branch_base_check.py +0 -0
  94. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_bundle.py +0 -0
  95. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_bundle_robustness.py +0 -0
  96. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_canonical.py +0 -0
  97. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_checkpoint.py +0 -0
  98. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_checkpoint_quorum_property.py +0 -0
  99. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_claims_hygiene.py +0 -0
  100. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cli.py +0 -0
  101. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cli_eval.py +0 -0
  102. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cli_require_anchor.py +0 -0
  103. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_conformance.py +0 -0
  104. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_content_root_property.py +0 -0
  105. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cosignature.py +0 -0
  106. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cosignature_mldsa.py +0 -0
  107. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_anchors.py +0 -0
  108. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_cli.py +0 -0
  109. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_evidence.py +0 -0
  110. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_fuzz.py +0 -0
  111. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_hardening.py +0 -0
  112. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_policy.py +0 -0
  113. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_schema.py +0 -0
  114. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_validator_api.py +0 -0
  115. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_verify.py +0 -0
  116. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_demo.py +0 -0
  117. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_docs_truth.py +0 -0
  118. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_dsse_adversarial.py +0 -0
  119. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_dup_key_reject.py +0 -0
  120. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_ed25519_semantics.py +0 -0
  121. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_eee.py +0 -0
  122. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_emit.py +0 -0
  123. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_eval_claim_schema.py +0 -0
  124. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_eval_evidence_class.py +0 -0
  125. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_evalclaim.py +0 -0
  126. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_evidence_pack.py +0 -0
  127. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_examples.py +0 -0
  128. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_experimental_enclave.py +0 -0
  129. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_fork_pr_secret_isolation.py +0 -0
  130. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_fuzz_parsers.py +0 -0
  131. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_hashalg.py +0 -0
  132. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_hf_entry_verify_eee_digest.py +0 -0
  133. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_hf_evals.py +0 -0
  134. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_inspect_hook.py +0 -0
  135. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto.py +0 -0
  136. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_claims_hygiene.py +0 -0
  137. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_content_root_migration.py +0 -0
  138. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_dsse.py +0 -0
  139. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_eval_result.py +0 -0
  140. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_examples.py +0 -0
  141. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_spec_diff.py +0 -0
  142. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_svr.py +0 -0
  143. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_kbjwt.py +0 -0
  144. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_lens_review_fixes_3_1_3.py +0 -0
  145. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_merkle.py +0 -0
  146. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_merkle_consistency_property.py +0 -0
  147. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_merkle_property.py +0 -0
  148. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_official_vectors.py +0 -0
  149. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_ok_semantics.py +0 -0
  150. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_outcome_verify.py +0 -0
  151. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_oversized_int_guard.py +0 -0
  152. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_persample.py +0 -0
  153. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_pinned_key_validation.py +0 -0
  154. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_explain_lint.py +0 -0
  155. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_lifecycle_purpose.py +0 -0
  156. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_profiles.py +0 -0
  157. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_templates.py +0 -0
  158. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_pqsig.py +0 -0
  159. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_predicate_type_enforcement.py +0 -0
  160. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_prereg.py +0 -0
  161. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_promptfoo.py +0 -0
  162. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_provenance.py +0 -0
  163. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_public_transparency.py +0 -0
  164. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_pytest_plugin.py +0 -0
  165. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_rekor_interop.py +0 -0
  166. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_release_integrity_gate.py +0 -0
  167. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_renewal.py +0 -0
  168. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_renewal_policy.py +0 -0
  169. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_renewal_signed.py +0 -0
  170. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_rfc6962_external_vectors.py +0 -0
  171. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_root_authenticity.py +0 -0
  172. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_run_ledger.py +0 -0
  173. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_schema.py +0 -0
  174. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_adversarial.py +0 -0
  175. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_duplicate_cnf.py +0 -0
  176. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_issue.py +0 -0
  177. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_reference.py +0 -0
  178. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_vc.py +0 -0
  179. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_verify_binding.py +0 -0
  180. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_signature.py +0 -0
  181. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_statuslist.py +0 -0
  182. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_subject_binding.py +0 -0
  183. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_tlogproof.py +0 -0
  184. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_tree_context_authenticity.py +0 -0
  185. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_trust_policy.py +0 -0
  186. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_verification_summary.py +0 -0
  187. {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_verify_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
5
5
  Author: Konrad Gruszka
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "3.2.0"
7
+ version = "3.2.1"
8
8
  description = "Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "3.2.0"
16
+ __version__ = "3.2.1"
17
17
 
18
18
  # The `proofbundle/v0.1` normative spec revision this build implements — kept in sync with the
19
19
  # `Revision:` line at the top of SPEC.md by tests/test_docs_truth.py (WP-B1, closes #28). Bump
@@ -30,7 +30,7 @@ from dataclasses import dataclass, field
30
30
  from typing import Optional
31
31
 
32
32
  from .errors import Check, ProofBundleError, VerificationResult
33
- from .hashalg import HashAlgError, compute_digest, resolve_hash_alg
33
+ from .hashalg import HASH_REGISTRY, HashAlgError, compute_digest, resolve_hash_alg
34
34
  from .pqsig import sign_mldsa, verify_hybrid, verify_mldsa
35
35
  from .signature import verify_ed25519
36
36
 
@@ -159,6 +159,13 @@ def _verify_ats_signature(ats: ArchiveTimeStamp, authority_keys: dict) -> bool:
159
159
  return False
160
160
 
161
161
 
162
+ def _is_deprecated_hash(alg: str) -> bool:
163
+ """True iff ``alg`` is a KNOWN but deprecated registry hash (sha1/md5). An unknown/absent alg is not
164
+ 'deprecated' (it fails the resolvable-hash check instead)."""
165
+ spec = HASH_REGISTRY.get(alg)
166
+ return spec is not None and spec.status == "deprecated"
167
+
168
+
162
169
  def _validate_digests(data_digests: Sequence[str]) -> None:
163
170
  """Fail closed unless every data digest is lowercase hex — this excludes the ``_SEP`` separator, so a
164
171
  single crafted string cannot masquerade as ``_SEP.join`` of several real digests (set/cardinality
@@ -276,7 +283,8 @@ def verify_sequence(sequence: list[list[ArchiveTimeStamp]], data_digests: Sequen
276
283
  authority_keys: Optional[dict] = None,
277
284
  anchor_verifier: Optional[Callable[[ArchiveTimeStamp], bool]] = None,
278
285
  allow_unauthenticated_anchor: bool = False,
279
- require_pq: bool = False
286
+ require_pq: bool = False,
287
+ require_current_hash: bool = False
280
288
  ) -> VerificationResult:
281
289
  """Verify an ArchiveTimeStampSequence end-to-end, walking the newest strong anchor back to the origin.
282
290
 
@@ -301,6 +309,17 @@ def verify_sequence(sequence: list[list[ArchiveTimeStamp]], data_digests: Sequen
301
309
  A PASS here means "structurally consistent", never "cryptographically anchored".
302
310
  * NONE of the above: fail closed — the newest-anchor check is FALSE with a clear message. This makes
303
311
  a naive ``verify_sequence(seq, data)`` refuse to certify an unauthenticated anchor (API-safety audit).
312
+
313
+ ``require_pq`` (PQ floor): the newest ATS's PQ signature must be VERIFIED, not merely labeled. A PQ leg
314
+ is only cryptographically checked in ``authority_keys`` mode (``_verify_ats_signature`` verifies the
315
+ hybrid/mldsa65 signature); in ``anchor_verifier`` / unauthenticated / no-anchor modes the ATS signature
316
+ is never checked, so a ``sig_alg`` label alone is not proof — ``require_pq`` fails closed there (No-Fake).
317
+
318
+ ``require_current_hash`` (hash-strength floor): by default a DEPRECATED newest hash is TOLERATED (a
319
+ hash-tree-renewed sequence must stay verifiable while its once-used hash ages; ``evaluate_renewal_policy``
320
+ is what flags a deprecated newest as renewal-overdue). A deprecated newest hash is always SURFACED as a
321
+ ``renewal:current_hash`` check so ``.ok`` alone never hides it; ``require_current_hash=True`` makes it a
322
+ hard fail-closed error for a relying party that wants one call to reject a weak-hash anchor.
304
323
  """
305
324
  result = VerificationResult()
306
325
 
@@ -418,10 +437,40 @@ def verify_sequence(sequence: list[list[ArchiveTimeStamp]], data_digests: Sequen
418
437
  # hybrid), so a relying party that wants PQ protection is not satisfied by a (forgeable-after-quantum)
419
438
  # ed25519-only anchor even while it still holds the ed25519 key for legacy validation.
420
439
  if require_pq:
421
- pq_ok = "mldsa" in (newest.sig_alg or "")
422
- result.checks.append(Check("renewal:pq_floor", pq_ok,
423
- f"newest ATS sig_alg {newest.sig_alg!r} carries a PQ leg" if pq_ok
424
- else f"newest ATS sig_alg {newest.sig_alg!r} has no PQ leg (require_pq)"))
440
+ # No-Fake: require_pq means the PQ leg was actually VERIFIED, not merely labeled. The mldsa leg is
441
+ # only cryptographically checked in authority-signature mode (_verify_ats_signature verifies the
442
+ # hybrid/mldsa65 signature over _ats_content); in anchor_verifier / unauthenticated / no-anchor modes
443
+ # the ATS signature is never checked, so a PQ label on newest.sig_alg proves nothing fail closed.
444
+ pq_verified = anchored and anchor_mode == "authority signature" and "mldsa" in (newest.sig_alg or "")
445
+ if pq_verified:
446
+ pq_detail = (f"newest ATS carries a VERIFIED PQ leg (sig_alg {newest.sig_alg!r}, authority "
447
+ "signature)")
448
+ elif anchor_mode != "authority signature":
449
+ pq_detail = (f"require_pq needs authority_keys to verify a PQ signature; anchor mode is "
450
+ f"{anchor_mode!r} (a PQ label on sig_alg alone is not verification, fail-closed)")
451
+ else:
452
+ pq_detail = f"newest ATS sig_alg {newest.sig_alg!r} has no verified PQ leg (require_pq)"
453
+ result.checks.append(Check("renewal:pq_floor", pq_verified, pq_detail))
454
+
455
+ # hash-strength floor: a DEPRECATED newest hash is tolerated by default (historical-chain survival) but
456
+ # must never be hidden behind .ok — surface it as a check, and fail closed when require_current_hash.
457
+ # require_current_hash demands a KNOWN CURRENT hash: a deprecated OR unknown newest hash fails closed
458
+ # (an unknown hash also fails the resolvable-hash check above; here it is never mislabeled "current").
459
+ newest_dep = _is_deprecated_hash(newest.hash_alg)
460
+ _spec = HASH_REGISTRY.get(newest.hash_alg)
461
+ newest_current = _spec is not None and _spec.status == "current"
462
+ if newest_dep or require_current_hash:
463
+ hash_ok = newest_current if require_current_hash else True
464
+ if newest_dep:
465
+ hash_detail = (f"newest ATS hash {newest.hash_alg!r} is deprecated"
466
+ + (" (require_current_hash, fail-closed)" if require_current_hash
467
+ else " — .ok reflects structure, not hash strength; call evaluate_renewal_policy "
468
+ "or pass require_current_hash=True to reject"))
469
+ elif newest_current:
470
+ hash_detail = f"newest ATS hash {newest.hash_alg!r} is current"
471
+ else:
472
+ hash_detail = f"newest ATS hash {newest.hash_alg!r} is not a known current hash (require_current_hash)"
473
+ result.checks.append(Check("renewal:current_hash", hash_ok, hash_detail))
425
474
  return result
426
475
 
427
476
 
@@ -467,6 +516,12 @@ def evaluate_renewal_policy(sequence: list[list[ArchiveTimeStamp]], *, policy: R
467
516
  reasons = []
468
517
  if newest.hash_alg in policy.deprecated_algs:
469
518
  reasons.append(f"newest ATS uses policy-deprecated hash {newest.hash_alg!r}")
519
+ # Future-dated guard (No-Fake): a newest.time AFTER `now` is anomalous — the freshness/age test below
520
+ # ((now - newest.time) > max_ats_age) goes NEGATIVE for a future time and would report it as perpetually
521
+ # fresh, so a future date could otherwise permanently evade the renewal-due signal. Flag it as overdue.
522
+ _ints = all(isinstance(v, int) and not isinstance(v, bool) for v in (newest.time, now))
523
+ if _ints and newest.time > now:
524
+ reasons.append(f"newest ATS time {newest.time} is in the future (now={now}) — anomalous, not fresh")
470
525
  if policy.max_ats_age is not None and (now - newest.time) > policy.max_ats_age:
471
526
  reasons.append(f"newest ATS age {now - newest.time} exceeds max {policy.max_ats_age}")
472
527
 
@@ -121,6 +121,7 @@ def verify_sd_jwt(compact: str, issuer_pubkey: Optional[bytes] = None) -> dict:
121
121
  _collect_committed_digests(payload, committed)
122
122
 
123
123
  all_committed = True
124
+ parsed_disclosures: list = []
124
125
  for d in disclosures:
125
126
  try:
126
127
  parsed = loads_strict(_b64url_decode(d))
@@ -136,9 +137,31 @@ def verify_sd_jwt(compact: str, issuer_pubkey: Optional[bytes] = None) -> dict:
136
137
  if not (isinstance(parsed, list) and len(parsed) in (2, 3)):
137
138
  all_committed = False
138
139
  break
139
- if _digest(d, sd_alg) not in committed:
140
+ parsed_disclosures.append((d, parsed))
141
+
142
+ if all_committed:
143
+ # Recursive disclosures (RFC 9901): a disclosure's VALUE may itself carry ``_sd``/``...`` digests that
144
+ # commit to FURTHER disclosures. Resolve to a fixpoint — a disclosure is committed iff its digest is
145
+ # already in ``committed``; each newly-committed disclosure then contributes the ``_sd``/``...`` digests
146
+ # inside its own value (the last array element). Disclosure order is not guaranteed parent-first, hence
147
+ # the loop. Security is preserved: the fixpoint only ever GROWS ``committed`` from the issuer-signed
148
+ # payload outward, so a self-referential or otherwise unrooted disclosure never bootstraps itself. At the
149
+ # end EVERY disclosure must be committed (transitively rooted in the signed payload) — fail-closed.
150
+ pending = list(parsed_disclosures)
151
+ progressed = True
152
+ while progressed and pending:
153
+ progressed = False
154
+ still = []
155
+ for d, parsed in pending:
156
+ if _digest(d, sd_alg) in committed:
157
+ _collect_committed_digests(parsed[-1], committed)
158
+ progressed = True
159
+ else:
160
+ still.append((d, parsed))
161
+ pending = still
162
+ if pending: # a disclosure never became committed — uncommitted / not rooted in the signed payload
140
163
  all_committed = False
141
- break
164
+
142
165
  result["structure_ok"] = all_committed and bool(parts[0])
143
166
 
144
167
  if issuer_pubkey is not None:
@@ -55,13 +55,34 @@ def _is_int(v: Any) -> TypeGuard[int]:
55
55
  return isinstance(v, int) and not isinstance(v, bool)
56
56
 
57
57
 
58
+ def _parse_rfc3339_z(s: str) -> datetime:
59
+ """Parse an RFC-3339 UTC 'Z' timestamp, tolerating optional fractional seconds of ANY length.
60
+
61
+ ``_RFC3339_Z`` accepts ``(\\.\\d+)?`` fractional seconds, but ``strptime`` with ``%S`` (no ``%f``) rejects
62
+ them, and ``%f`` itself caps at 6 digits — so an ``expires`` like ``...T00:00:00.5Z`` (regex-valid) would
63
+ raise and be read as EXPIRED (a false-closed availability bug). This parser splits off the fractional part
64
+ and truncates it to microseconds (enough for an expiry comparison). Raises ``ValueError`` on a non-match."""
65
+ m = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$", s)
66
+ if not m:
67
+ raise ValueError(f"not an RFC-3339 UTC 'Z' timestamp: {s!r}")
68
+ dt = datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
69
+ frac = m.group(2)
70
+ if frac:
71
+ dt = dt.replace(microsecond=int((frac + "000000")[:6]))
72
+ return dt
73
+
74
+
58
75
  def validate_trust_pack_predicate(predicate: Any, *, strict: bool = False) -> list[str]:
59
76
  """Return fail-closed errors for a ``trust-pack/v0.1`` predicate (empty = valid).
60
77
 
61
78
  Beyond shape this enforces: threshold in 1..len(keyIds) per role; every keyId referenced by a role is
62
79
  present in ``keys``; ``revoked`` entries are known keyIds; a role's threshold is not already impossible
63
- once revoked keys are removed (a pack whose root can never meet threshold is dead-on-arrival, fail-closed).
64
- """
80
+ once revoked keys are removed (a pack whose root can never meet threshold is dead-on-arrival, fail-closed);
81
+ a ``version`` > 1 pack MUST carry a non-null ``prevVersionDigest`` (only version 1 may be a genesis).
82
+
83
+ ``strict`` currently adds no extra predicate-level required fields (the trust-pack predicate is small and
84
+ fully required by default); it is kept for signature parity with the emit/verify entry points, where it
85
+ additionally makes RFC-8785 canonicality fail-closed in ``verify_trust_pack`` (mirrors ``outcome.py``)."""
65
86
  errors: list[str] = []
66
87
  if not isinstance(predicate, dict):
67
88
  return ["predicate must be a JSON object"]
@@ -88,6 +109,13 @@ def validate_trust_pack_predicate(predicate: Any, *, strict: bool = False) -> li
88
109
  pv = predicate.get("prevVersionDigest")
89
110
  if "prevVersionDigest" in predicate and pv is not None and not _is_digest(pv):
90
111
  errors.append("prevVersionDigest must be a sha256 digest object or null")
112
+ # A version > 1 pack MUST chain to a predecessor (fail-closed, No-Fake): a "version-2 genesis" declaring
113
+ # prevVersionDigest=null evades two-stage rotation authorization entirely — verify_trust_pack only enters
114
+ # the rotation-vouch branch when prevVersionDigest is a digest, so a null predecessor on a v>=2 pack passes
115
+ # on its own self-signature. Only version 1 (the genuine genesis) may have a null prevVersionDigest.
116
+ if _is_int(ver) and ver >= 2 and pv is None:
117
+ errors.append("version > 1 requires a non-null prevVersionDigest (a version-N pack must chain to its "
118
+ "predecessor; only version 1 may have a null prevVersionDigest)")
91
119
 
92
120
  keys = predicate.get("keys")
93
121
  key_ids: set[str] = set()
@@ -349,9 +377,9 @@ def verify_trust_pack(envelope: dict, *, strict: bool = False, now: datetime | N
349
377
  # Expiry.
350
378
  _now = now or datetime.now(timezone.utc)
351
379
  try:
352
- exp = datetime.strptime(predicate["expires"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
380
+ exp = _parse_rfc3339_z(predicate["expires"])
353
381
  r["not_expired"] = exp > _now
354
- except (ValueError, KeyError):
382
+ except (ValueError, KeyError, TypeError):
355
383
  r["not_expired"] = False
356
384
  if r["not_expired"] is False:
357
385
  r["errors"].append("trust pack is expired (expires <= now, fail-closed)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
5
5
  Author: Konrad Gruszka
6
6
  License: MIT
@@ -72,6 +72,7 @@ src/proofbundle/policies/strict-eval-template-v1.json
72
72
  src/proofbundle/policies/strict-prereg-template-v1.json
73
73
  tests/test_adapters.py
74
74
  tests/test_adversarial.py
75
+ tests/test_anchor_hardening_321.py
75
76
  tests/test_anchor_longevity_property.py
76
77
  tests/test_anchor_target_trustedtime.py
77
78
  tests/test_anchors_chia.py
@@ -87,6 +88,7 @@ tests/test_bundle.py
87
88
  tests/test_bundle_robustness.py
88
89
  tests/test_canonical.py
89
90
  tests/test_checkpoint.py
91
+ tests/test_checkpoint_external_vectors.py
90
92
  tests/test_checkpoint_quorum_property.py
91
93
  tests/test_claims_hygiene.py
92
94
  tests/test_cli.py
@@ -137,8 +139,10 @@ tests/test_lens_review_fixes_3_1_3.py
137
139
  tests/test_merkle.py
138
140
  tests/test_merkle_consistency_property.py
139
141
  tests/test_merkle_property.py
142
+ tests/test_mldsa_acvp_vectors.py
140
143
  tests/test_official_vectors.py
141
144
  tests/test_ok_semantics.py
145
+ tests/test_ots_external_vectors.py
142
146
  tests/test_outcome_verify.py
143
147
  tests/test_oversized_int_guard.py
144
148
  tests/test_persample.py
@@ -169,6 +173,7 @@ tests/test_sdjwt_issue.py
169
173
  tests/test_sdjwt_reference.py
170
174
  tests/test_sdjwt_vc.py
171
175
  tests/test_sdjwt_verify_binding.py
176
+ tests/test_sdjwtvc_external_vectors.py
172
177
  tests/test_signature.py
173
178
  tests/test_statuslist.py
174
179
  tests/test_subject_binding.py
@@ -0,0 +1,187 @@
1
+ """3.2.1 anchor-longevity hardening — the final-audit findings, each pinned RED->GREEN.
2
+
3
+ Covers the defense-in-depth gaps a six-lens + red-team audit surfaced in the 3.2.0 anchor modules
4
+ (all were non-default / incomplete-caller / weak-input cases, no forgery against a correct caller):
5
+
6
+ * F1 ``require_pq`` was a LABEL check ("mldsa" in sig_alg) — it now requires the PQ signature to be
7
+ actually VERIFIED (only possible in authority_keys mode); a PQ label with an unverified anchor is not
8
+ proof and fails closed.
9
+ * F2 ``evaluate_renewal_policy`` never flagged a FUTURE-dated newest ATS (age went negative -> perpetually
10
+ "fresh"); a future time is now anomalous and overdue.
11
+ * R1 ``verify_sequence`` tolerated a deprecated newest hash with ``ok=True`` and no signal; it now surfaces
12
+ a ``renewal:current_hash`` check and, with ``require_current_hash=True``, fails closed.
13
+ * F7 ``verify_sd_jwt`` collected committed digests only from the payload, so RFC-9901 RECURSIVE disclosures
14
+ (a digest committed inside a parent disclosure's value) failed valid vectors; a fixpoint now roots them.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import hashlib
20
+ import unittest
21
+
22
+ from proofbundle.renewal import (
23
+ ArchiveTimeStamp,
24
+ RenewalPolicy,
25
+ build_initial_sequence,
26
+ evaluate_renewal_policy,
27
+ verify_sequence,
28
+ )
29
+ from proofbundle.sdjwt import verify_sd_jwt
30
+ from proofbundle.sdjwt_issue import _make_disclosure
31
+
32
+ try:
33
+ from cryptography.hazmat.primitives.asymmetric import mldsa # noqa: F401
34
+ _HAS_MLDSA = True
35
+ except ImportError:
36
+ _HAS_MLDSA = False
37
+
38
+ DATA = [hashlib.sha256(b"a").hexdigest(), hashlib.sha256(b"b").hexdigest()]
39
+
40
+
41
+ def _check(result, name: str):
42
+ for c in result.checks:
43
+ if c.name == name:
44
+ return c
45
+ return None
46
+
47
+
48
+ class TestRequirePqIsVerifiedNotLabeled(unittest.TestCase):
49
+ """F1: require_pq must reflect a VERIFIED PQ signature, not a sig_alg string."""
50
+
51
+ def test_pq_label_in_callback_mode_fails_closed(self):
52
+ # the exact label attack: a newest ATS LABELED mldsa65 but anchored via a caller callback (no ATS
53
+ # signature is ever verified). Old code: pq_ok = "mldsa" in "mldsa65" -> passed. Now: fail-closed.
54
+ seq = build_initial_sequence(DATA, hash_alg="sha256", time=1000)
55
+ faked = dataclasses.replace(seq[0][0], sig_alg="mldsa65") # label only, empty signatures
56
+ r = verify_sequence([[faked]], DATA, anchor_verifier=lambda a: True, require_pq=True)
57
+ pq = _check(r, "renewal:pq_floor")
58
+ self.assertIsNotNone(pq)
59
+ self.assertFalse(pq.ok, pq)
60
+ self.assertFalse(r.ok)
61
+ self.assertIn("not verification", pq.detail)
62
+
63
+ def test_pq_label_in_unauthenticated_mode_fails_closed(self):
64
+ seq = build_initial_sequence(DATA, hash_alg="sha256", time=1000)
65
+ faked = dataclasses.replace(seq[0][0], sig_alg="mldsa65")
66
+ r = verify_sequence([[faked]], DATA, allow_unauthenticated_anchor=True, require_pq=True)
67
+ self.assertFalse(_check(r, "renewal:pq_floor").ok)
68
+
69
+ def test_ed25519_authority_has_no_pq_leg(self):
70
+ # a real ed25519 authority signature verifies the anchor, but carries no PQ leg -> require_pq fails.
71
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
72
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
73
+ ed = Ed25519PrivateKey.generate()
74
+ pub = ed.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
75
+ seq = build_initial_sequence(DATA, hash_alg="sha256", time=1000,
76
+ sig_alg="ed25519", signers={"ed25519": ed})
77
+ r = verify_sequence(seq, DATA, authority_keys={"ed25519": pub}, require_pq=True)
78
+ self.assertTrue(_check(r, "renewal:last_anchor").ok) # anchor itself IS verified
79
+ self.assertFalse(_check(r, "renewal:pq_floor").ok) # but there is no PQ leg
80
+ self.assertFalse(r.ok)
81
+
82
+ @unittest.skipUnless(_HAS_MLDSA, "needs cryptography with FIPS 204 (ML-DSA)")
83
+ def test_verified_pq_authority_passes(self):
84
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
85
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
86
+ from proofbundle.pqsig import generate_mldsa
87
+ ed = Ed25519PrivateKey.generate()
88
+ ed_pub = ed.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
89
+ m = generate_mldsa("mldsa65")
90
+ m_pub = m.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
91
+ seq = build_initial_sequence(DATA, hash_alg="sha256", time=1000, sig_alg="mldsa65",
92
+ signers={"ed25519": ed, "mldsa65": m})
93
+ r = verify_sequence(seq, DATA, authority_keys={"ed25519": ed_pub, "mldsa65": m_pub},
94
+ require_pq=True)
95
+ self.assertTrue(_check(r, "renewal:pq_floor").ok, r.as_dict())
96
+ self.assertTrue(r.ok, r.as_dict())
97
+
98
+
99
+ class TestFutureDatedRenewalPolicy(unittest.TestCase):
100
+ """F2: a future-dated newest ATS is anomalous, not perpetually fresh."""
101
+
102
+ def test_future_time_is_overdue(self):
103
+ seq = build_initial_sequence(DATA, hash_alg="sha256", time=2000)
104
+ policy = RenewalPolicy(max_ats_age=1_000_000, strictness="fail") # age alone would never trigger
105
+ r = evaluate_renewal_policy(seq, policy=policy, now=1000) # newest.time 2000 is in the future
106
+ self.assertFalse(r.ok)
107
+ self.assertIn("future", _check(r, "renewal:policy").detail)
108
+
109
+ def test_present_time_still_passes(self):
110
+ seq = build_initial_sequence(DATA, hash_alg="sha256", time=900)
111
+ policy = RenewalPolicy(max_ats_age=1_000_000, strictness="fail")
112
+ r = evaluate_renewal_policy(seq, policy=policy, now=1000)
113
+ self.assertTrue(r.ok, r.as_dict())
114
+
115
+
116
+ class TestRequireCurrentHash(unittest.TestCase):
117
+ """R1: a deprecated newest hash is surfaced, and fail-closed on demand."""
118
+
119
+ @staticmethod
120
+ def _sha1_seq():
121
+ covered = hashlib.sha1("\n".join(sorted(DATA)).encode()).hexdigest() # noqa: S324 — historical chain
122
+ return [[ArchiveTimeStamp("sha1", covered, 1000)]]
123
+
124
+ def test_deprecated_newest_is_surfaced_but_tolerated_by_default(self):
125
+ r = verify_sequence(self._sha1_seq(), DATA, allow_unauthenticated_anchor=True)
126
+ ch = _check(r, "renewal:current_hash")
127
+ self.assertIsNotNone(ch) # the signal is present (never hidden behind .ok)
128
+ self.assertTrue(ch.ok) # tolerated by default (historical-chain survival)
129
+ self.assertIn("deprecated", ch.detail)
130
+ self.assertTrue(r.ok) # structure verifies
131
+
132
+ def test_require_current_hash_fails_closed(self):
133
+ r = verify_sequence(self._sha1_seq(), DATA, allow_unauthenticated_anchor=True,
134
+ require_current_hash=True)
135
+ self.assertFalse(_check(r, "renewal:current_hash").ok)
136
+ self.assertFalse(r.ok)
137
+
138
+ def test_current_newest_passes_require_current_hash(self):
139
+ seq = build_initial_sequence(DATA, hash_alg="sha256", time=1000)
140
+ r = verify_sequence(seq, DATA, allow_unauthenticated_anchor=True, require_current_hash=True)
141
+ self.assertTrue(_check(r, "renewal:current_hash").ok)
142
+ self.assertTrue(r.ok)
143
+
144
+
145
+ class TestRecursiveDisclosures(unittest.TestCase):
146
+ """F7: RFC-9901 recursive disclosures (a digest committed inside a parent disclosure's value)."""
147
+
148
+ @staticmethod
149
+ def _jwt(payload_sd: list[str]) -> str:
150
+ import base64
151
+ import json
152
+
153
+ def b64(obj) -> str:
154
+ return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode("ascii")
155
+
156
+ header = b64({"alg": "EdDSA", "typ": "example+sd-jwt"})
157
+ payload = b64({"_sd": payload_sd, "_sd_alg": "sha-256"})
158
+ return f"{header}.{payload}.c2ln" # sig irrelevant to structure_ok
159
+
160
+ def test_recursive_disclosure_verifies(self):
161
+ # child is selectively disclosable; the parent's VALUE is an object that commits to the child.
162
+ child_d, child_dig = _make_disclosure("secret", "child-value", "c2FsdC1jaGlsZA")
163
+ parent_d, parent_dig = _make_disclosure("parent", {"_sd": [child_dig]}, "c2FsdC1wYXJlbnQ")
164
+ compact = self._jwt([parent_dig]) + "~" + parent_d + "~" + child_d + "~"
165
+ r = verify_sd_jwt(compact)
166
+ self.assertTrue(r["structure_ok"], r)
167
+
168
+ def test_uncommitted_child_still_fails(self):
169
+ # the child's digest is committed NOWHERE (not in the payload, not in the parent's value) -> fail.
170
+ child_d, _child_dig = _make_disclosure("secret", "child-value", "c2FsdC1jaGlsZA")
171
+ parent_d, parent_dig = _make_disclosure("parent", {"plain": 1}, "c2FsdC1wYXJlbnQ")
172
+ compact = self._jwt([parent_dig]) + "~" + parent_d + "~" + child_d + "~"
173
+ r = verify_sd_jwt(compact)
174
+ self.assertFalse(r["structure_ok"])
175
+
176
+ def test_self_referential_disclosure_does_not_bootstrap(self):
177
+ # a disclosure whose value commits to its OWN digest, uncommitted by the payload, must not self-root.
178
+ # build the disclosure, read its digest, then reference it inside itself is impossible in one shot;
179
+ # instead: a disclosure committed nowhere stays uncommitted even though its value carries an _sd.
180
+ d, _dig = _make_disclosure("x", {"_sd": ["ZmFrZS1kaWdlc3Q"]}, "c2FsdC14")
181
+ compact = self._jwt([]) + "~" + d + "~" # payload commits to nothing
182
+ r = verify_sd_jwt(compact)
183
+ self.assertFalse(r["structure_ok"])
184
+
185
+
186
+ if __name__ == "__main__":
187
+ unittest.main()
@@ -0,0 +1,163 @@
1
+ """proofbundle.checkpoint.verify_checkpoint against REAL C2SP tlog-checkpoint signed notes.
2
+
3
+ These are not proofbundle-generated notes — they are frozen snapshots of the current signed note
4
+ from two independent, currently-operating production transparency logs (see
5
+ tests/fixtures/checkpoint/PROVENANCE.json):
6
+
7
+ * sum.golang.org — the Go module checksum database (`sumdb_latest.txt`).
8
+ * log2025-1.rekor.sigstore.dev — a Sigstore Rekor v2 transparency log (`rekor_v2_checkpoint.txt`).
9
+
10
+ Neither test hand-types a "vkey" magic string. Each vkey is DERIVED here from independently
11
+ sourced key material and its correctness is PROVEN by the fact that it makes
12
+ `checkpoint.verify_checkpoint()` return `ok=True` against the real, live-fetched signed note:
13
+
14
+ * the sum.golang.org vkey is read out of the vendored `gosumdb_known_keys.go` (the Go
15
+ toolchain's OWN hardcoded trust anchor for that log, `knownGOSUMDB["sum.golang.org"]`) — used
16
+ directly, since a C2SP vkey string IS the canonical distribution format for this key.
17
+ * the log2025-1.rekor.sigstore.dev key is DECODED from the vendored `trusted_root.json`
18
+ (Sigstore's public trust root) — its DER SubjectPublicKeyInfo is parsed to raw Ed25519 bytes,
19
+ then run through `checkpoint.key_id()` / `checkpoint.vkey()` exactly as proofbundle's own
20
+ signer would. The resulting computed key ID must equal the checkpoint's own signature-line key
21
+ ID (`cf119915`).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import hashlib
27
+ import json
28
+ import re
29
+ import unittest
30
+ from pathlib import Path
31
+
32
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_der_public_key
33
+
34
+ from proofbundle import checkpoint as cp
35
+
36
+ FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "checkpoint"
37
+ PROVENANCE_PATH = FIXTURE_DIR / "PROVENANCE.json"
38
+
39
+ _GOSUMDB_KEY_RE = re.compile(r'"sum\.golang\.org":\s*"([^"]+)"')
40
+
41
+
42
+ def _load_provenance() -> dict:
43
+ return json.loads(PROVENANCE_PATH.read_text(encoding="utf-8"))
44
+
45
+
46
+ def _sumdb_note() -> str:
47
+ return (FIXTURE_DIR / "sumdb_latest.txt").read_text(encoding="utf-8")
48
+
49
+
50
+ def _sumdb_vkey() -> str:
51
+ src = (FIXTURE_DIR / "gosumdb_known_keys.go").read_text(encoding="utf-8")
52
+ m = _GOSUMDB_KEY_RE.search(src)
53
+ if not m:
54
+ raise AssertionError("could not find the sum.golang.org vkey in gosumdb_known_keys.go")
55
+ return m.group(1)
56
+
57
+
58
+ def _rekor_note() -> str:
59
+ return (FIXTURE_DIR / "rekor_v2_checkpoint.txt").read_text(encoding="utf-8")
60
+
61
+
62
+ def _rekor_vkey() -> tuple[str, bytes]:
63
+ """Derive the log2025-1.rekor.sigstore.dev C2SP vkey from the Sigstore trusted_root.json.
64
+ Returns (vkey, raw_pubkey)."""
65
+ root = json.loads((FIXTURE_DIR / "trusted_root.json").read_text(encoding="utf-8"))
66
+ tlog = next(t for t in root["tlogs"] if t["baseUrl"] == "https://log2025-1.rekor.sigstore.dev")
67
+ der = base64.b64decode(tlog["publicKey"]["rawBytes"])
68
+ pub = load_der_public_key(der)
69
+ raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
70
+ keyname = "log2025-1.rekor.sigstore.dev"
71
+ return cp.vkey(keyname, raw), raw
72
+
73
+
74
+ @unittest.skipUnless(PROVENANCE_PATH.exists(), "checkpoint fixtures not vendored (tests/fixtures/checkpoint/)")
75
+ class TestCheckpointFixtureIntegrity(unittest.TestCase):
76
+ def test_provenance_pins_every_vendored_file(self) -> None:
77
+ prov = _load_provenance()
78
+ entries = {e["filename"] for e in prov["files"]}
79
+ vendored = {p.name for p in FIXTURE_DIR.iterdir()
80
+ if p.is_file() and p.name != "PROVENANCE.json"}
81
+ self.assertTrue(vendored)
82
+ self.assertEqual(vendored, entries,
83
+ "every vendored file must have a PROVENANCE.json entry (and vice versa)")
84
+
85
+ def test_fixture_sha256_matches_provenance(self) -> None:
86
+ prov = _load_provenance()
87
+ for entry in prov["files"]:
88
+ actual = hashlib.sha256((FIXTURE_DIR / entry["filename"]).read_bytes()).hexdigest()
89
+ self.assertEqual(actual, entry["sha256"],
90
+ f"{entry['filename']} does not match its PROVENANCE.json pin (tampered)")
91
+
92
+ def test_tampered_fixture_is_detected(self) -> None:
93
+ real = (FIXTURE_DIR / "sumdb_latest.txt").read_bytes()
94
+ tampered = bytearray(real)
95
+ tampered[0] ^= 0xFF
96
+ prov = _load_provenance()
97
+ pin = next(e["sha256"] for e in prov["files"] if e["filename"] == "sumdb_latest.txt")
98
+ self.assertNotEqual(hashlib.sha256(bytes(tampered)).hexdigest(), pin,
99
+ "a single-byte tamper must change the SHA-256 (pin is not vacuous)")
100
+
101
+
102
+ class TestGoSumdbCheckpointKat(unittest.TestCase):
103
+ def test_real_sumdb_note_verifies(self) -> None:
104
+ note = _sumdb_note()
105
+ res = cp.verify_checkpoint(note, _sumdb_vkey())
106
+ self.assertTrue(res["ok"], "sum.golang.org checkpoint failed to verify against its own "
107
+ "well-known public vkey (from the Go toolchain source)")
108
+ self.assertEqual(res["origin"], "go.sum database tree")
109
+ self.assertEqual(res["tree_size"], 57389448)
110
+
111
+ def test_root_tamper_is_rejected(self) -> None:
112
+ note = _sumdb_note()
113
+ vk = _sumdb_vkey()
114
+ lines = note.split("\n")
115
+ # line index 2 is the base64 root — flip it to an all-zero root of the same length
116
+ tampered_root_b64 = base64.b64encode(bytes(32)).decode("ascii")
117
+ self.assertNotEqual(lines[2], tampered_root_b64)
118
+ lines[2] = tampered_root_b64
119
+ self.assertFalse(cp.verify_checkpoint("\n".join(lines), vk)["ok"])
120
+
121
+ def test_foreign_key_rejected(self) -> None:
122
+ # a real, unrelated Ed25519 checkpoint key (proofbundle's own key generator) must not
123
+ # verify a note it never signed.
124
+ from proofbundle.emit import _raw_pub, generate_signer
125
+ note = _sumdb_note()
126
+ foreign_vkey = cp.vkey("sum.golang.org", _raw_pub(generate_signer()))
127
+ self.assertFalse(cp.verify_checkpoint(note, foreign_vkey)["ok"])
128
+
129
+
130
+ class TestRekorV2CheckpointKat(unittest.TestCase):
131
+ def test_derived_vkey_key_id_matches_signature_line(self) -> None:
132
+ vk, raw_pub = _rekor_vkey()
133
+ kid = cp.key_id("log2025-1.rekor.sigstore.dev", raw_pub)
134
+ self.assertEqual(kid.hex(), "cf119915",
135
+ "the C2SP key ID derived from the Sigstore trusted_root.json Ed25519 key "
136
+ "must equal the well-known log2025-1 checkpoint key ID")
137
+
138
+ def test_real_rekor_v2_checkpoint_verifies(self) -> None:
139
+ vk, _ = _rekor_vkey()
140
+ res = cp.verify_checkpoint(_rekor_note(), vk)
141
+ self.assertTrue(res["ok"], "log2025-1.rekor.sigstore.dev checkpoint failed to verify "
142
+ "against the key derived from Sigstore's trusted_root.json")
143
+ self.assertEqual(res["origin"], "log2025-1.rekor.sigstore.dev")
144
+ self.assertEqual(res["tree_size"], 22578297)
145
+
146
+ def test_size_tamper_is_rejected(self) -> None:
147
+ vk, _ = _rekor_vkey()
148
+ note = _rekor_note()
149
+ lines = note.split("\n")
150
+ lines[1] = str(int(lines[1]) + 1)
151
+ self.assertFalse(cp.verify_checkpoint("\n".join(lines), vk)["ok"])
152
+
153
+ def test_sumdb_key_does_not_verify_rekor_checkpoint(self) -> None:
154
+ # cross-log negative: a real, valid vkey for a DIFFERENT log/keyname must not verify.
155
+ self.assertFalse(cp.verify_checkpoint(_rekor_note(), _sumdb_vkey())["ok"])
156
+
157
+ def test_rekor_key_does_not_verify_sumdb_note(self) -> None:
158
+ vk, _ = _rekor_vkey()
159
+ self.assertFalse(cp.verify_checkpoint(_sumdb_note(), vk)["ok"])
160
+
161
+
162
+ if __name__ == "__main__":
163
+ unittest.main()