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.
- {proofbundle-3.2.0/src/proofbundle.egg-info → proofbundle-3.2.1}/PKG-INFO +1 -1
- {proofbundle-3.2.0 → proofbundle-3.2.1}/pyproject.toml +1 -1
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/__init__.py +1 -1
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/renewal.py +61 -6
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/sdjwt.py +25 -2
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/trust_pack.py +32 -4
- {proofbundle-3.2.0 → proofbundle-3.2.1/src/proofbundle.egg-info}/PKG-INFO +1 -1
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/SOURCES.txt +5 -0
- proofbundle-3.2.1/tests/test_anchor_hardening_321.py +187 -0
- proofbundle-3.2.1/tests/test_checkpoint_external_vectors.py +163 -0
- proofbundle-3.2.1/tests/test_mldsa_acvp_vectors.py +150 -0
- proofbundle-3.2.1/tests/test_ots_external_vectors.py +183 -0
- proofbundle-3.2.1/tests/test_sdjwtvc_external_vectors.py +162 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_trust_pack.py +48 -12
- {proofbundle-3.2.0 → proofbundle-3.2.1}/LICENSE +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/README.md +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/setup.cfg +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/_integration.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/_strict_json.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/_provenance.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/promptfoo.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/adapters/samples.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_chia.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_chia_add.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_markovian.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_ots.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/anchors_rfc3161.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/beacon.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/bundle.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/canonical.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/checkpoint.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/cli.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/decision.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/demo.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/dsse.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/emit.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/errors.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/evalclaim.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/evidence_pack.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/experimental/__init__.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/experimental/enclave.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/hashalg.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/hf_evals.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/intoto.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/kbjwt.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/merkle.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/outcome.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/persample.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/decision-receipt-template-v1.json +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/research-preview-v1.json +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/strict-eval-authenticated-root-template-v1.json +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/strict-eval-template-v1.json +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policies/strict-prereg-template-v1.json +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policy.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/policy_profiles.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/pqsig.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/prereg.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/public_transparency.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/py.typed +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/run_ledger.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/sdjwt_issue.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/sdjwt_vc.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/signature.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/statuslist.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/subject_binding.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/tlogproof.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle/verification_summary.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/requires.txt +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_adapters.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_adversarial.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchor_longevity_property.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchor_target_trustedtime.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_chia.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_chia_add.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_chia_claims.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_generic.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_markovian.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_ots.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_anchors_rfc3161.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_beacon.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_branch_base_check.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_bundle.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_canonical.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_checkpoint.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_checkpoint_quorum_property.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_claims_hygiene.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cli.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cli_eval.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cli_require_anchor.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_conformance.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_content_root_property.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cosignature.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_cosignature_mldsa.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_anchors.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_cli.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_evidence.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_fuzz.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_hardening.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_policy.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_schema.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_validator_api.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_decision_verify.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_demo.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_docs_truth.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_dsse_adversarial.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_dup_key_reject.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_ed25519_semantics.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_eee.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_emit.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_eval_evidence_class.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_evalclaim.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_evidence_pack.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_examples.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_experimental_enclave.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_fork_pr_secret_isolation.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_fuzz_parsers.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_hashalg.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_hf_entry_verify_eee_digest.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_hf_evals.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_inspect_hook.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_claims_hygiene.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_content_root_migration.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_eval_result.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_examples.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_spec_diff.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_intoto_svr.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_kbjwt.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_lens_review_fixes_3_1_3.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_merkle.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_merkle_consistency_property.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_merkle_property.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_official_vectors.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_ok_semantics.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_outcome_verify.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_oversized_int_guard.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_persample.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_pinned_key_validation.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_explain_lint.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_lifecycle_purpose.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_profiles.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_policy_templates.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_pqsig.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_predicate_type_enforcement.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_prereg.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_promptfoo.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_provenance.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_public_transparency.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_rekor_interop.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_release_integrity_gate.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_renewal.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_renewal_policy.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_renewal_signed.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_root_authenticity.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_run_ledger.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_schema.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_adversarial.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_duplicate_cnf.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_reference.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_vc.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_sdjwt_verify_binding.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_signature.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_statuslist.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_subject_binding.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_tlogproof.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_tree_context_authenticity.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_trust_policy.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_verification_summary.py +0 -0
- {proofbundle-3.2.0 → proofbundle-3.2.1}/tests/test_verify_matrix.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proofbundle"
|
|
7
|
-
version = "3.2.
|
|
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.
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)")
|
|
@@ -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()
|