proofbundle 3.2.1__tar.gz → 3.2.2__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.1/src/proofbundle.egg-info → proofbundle-3.2.2}/PKG-INFO +1 -1
- {proofbundle-3.2.1 → proofbundle-3.2.2}/pyproject.toml +1 -1
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/__init__.py +1 -1
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/cli.py +33 -4
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/decision.py +117 -16
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/outcome.py +22 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/public_transparency.py +115 -2
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/renewal.py +116 -15
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/sdjwt.py +53 -19
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/trust_pack.py +110 -17
- {proofbundle-3.2.1 → proofbundle-3.2.2/src/proofbundle.egg-info}/PKG-INFO +1 -1
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/SOURCES.txt +3 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_verify.py +69 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_public_transparency.py +136 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_renewal.py +109 -0
- proofbundle-3.2.2/tests/test_schema_parity.py +190 -0
- proofbundle-3.2.2/tests/test_sdjwt_resource_budget.py +193 -0
- proofbundle-3.2.2/tests/test_trust_pack_crypto_agility.py +361 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/LICENSE +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/README.md +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/setup.cfg +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/_integration.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/_strict_json.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/_provenance.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/promptfoo.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/samples.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_chia.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_chia_add.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_markovian.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_ots.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_rfc3161.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/beacon.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/bundle.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/canonical.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/checkpoint.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/demo.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/dsse.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/emit.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/errors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/evalclaim.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/evidence_pack.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/experimental/__init__.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/experimental/enclave.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/hashalg.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/hf_evals.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/intoto.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/kbjwt.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/merkle.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/persample.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/decision-receipt-template-v1.json +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/research-preview-v1.json +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/strict-eval-authenticated-root-template-v1.json +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/strict-eval-template-v1.json +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/strict-prereg-template-v1.json +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policy.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policy_profiles.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/pqsig.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/prereg.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/py.typed +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/run_ledger.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/sdjwt_issue.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/sdjwt_vc.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/signature.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/statuslist.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/subject_binding.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/tlogproof.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/verification_summary.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/requires.txt +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_adapters.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_adversarial.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchor_hardening_321.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchor_longevity_property.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchor_target_trustedtime.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_chia.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_chia_add.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_chia_claims.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_generic.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_markovian.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_ots.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_rfc3161.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_beacon.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_branch_base_check.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_bundle.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_canonical.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_checkpoint.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_checkpoint_external_vectors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_checkpoint_quorum_property.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_claims_hygiene.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cli.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cli_eval.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cli_require_anchor.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_conformance.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_content_root_property.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cosignature.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cosignature_mldsa.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_anchors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_cli.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_evidence.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_fuzz.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_hardening.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_policy.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_schema.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_validator_api.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_demo.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_docs_truth.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_dsse_adversarial.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_dup_key_reject.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_ed25519_semantics.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_eee.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_emit.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_eval_evidence_class.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_evalclaim.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_evidence_pack.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_examples.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_experimental_enclave.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_fork_pr_secret_isolation.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_fuzz_parsers.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_hashalg.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_hf_entry_verify_eee_digest.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_hf_evals.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_inspect_hook.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_claims_hygiene.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_content_root_migration.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_eval_result.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_examples.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_spec_diff.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_svr.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_kbjwt.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_lens_review_fixes_3_1_3.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_merkle.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_merkle_consistency_property.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_merkle_property.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_mldsa_acvp_vectors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_official_vectors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_ok_semantics.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_ots_external_vectors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_outcome_verify.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_oversized_int_guard.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_persample.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_pinned_key_validation.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_explain_lint.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_lifecycle_purpose.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_profiles.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_templates.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_pqsig.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_predicate_type_enforcement.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_prereg.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_promptfoo.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_provenance.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_rekor_interop.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_release_integrity_gate.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_renewal_policy.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_renewal_signed.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_root_authenticity.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_run_ledger.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_schema.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_adversarial.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_duplicate_cnf.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_reference.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_vc.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_verify_binding.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwtvc_external_vectors.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_signature.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_statuslist.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_subject_binding.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_tlogproof.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_tree_context_authenticity.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_trust_pack.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_trust_policy.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_verification_summary.py +0 -0
- {proofbundle-3.2.1 → proofbundle-3.2.2}/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.2"
|
|
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.2"
|
|
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
|
|
@@ -1198,7 +1198,8 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
|
|
|
1198
1198
|
rp_trust = merged
|
|
1199
1199
|
result = verify_decision_receipt(env, pub, strict=args.strict, expected_audience=args.aud,
|
|
1200
1200
|
expected_nonce=args.nonce, policy=policy, anchors=anchors,
|
|
1201
|
-
rp_trust=rp_trust
|
|
1201
|
+
rp_trust=rp_trust,
|
|
1202
|
+
require_derived_subject=args.require_derived_subject)
|
|
1202
1203
|
except (ProofBundleError, OSError, ValueError) as exc:
|
|
1203
1204
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1204
1205
|
return 2
|
|
@@ -1210,7 +1211,7 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
|
|
|
1210
1211
|
report = {k: result[k] for k in (
|
|
1211
1212
|
"ok", "crypto_ok", "structure_ok", "predicate_type_ok", "signer_trusted", "policy_ok",
|
|
1212
1213
|
"evidence_bound", "audience_ok", "nonce_ok", "freshness_ok", "anchors_ok",
|
|
1213
|
-
"action_outcome_proven", "warnings", "errors",
|
|
1214
|
+
"action_outcome_proven", "subject_binding", "subject_derived_ok", "warnings", "errors",
|
|
1214
1215
|
)}
|
|
1215
1216
|
print(json.dumps(report, indent=2))
|
|
1216
1217
|
else:
|
|
@@ -1228,6 +1229,10 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
|
|
|
1228
1229
|
print(f"ANCHORS: {'OK' if result['anchors_ok'] else 'FAIL'}")
|
|
1229
1230
|
if result["action_outcome_proven"] is False:
|
|
1230
1231
|
print("ASSURANCE: actionOutcome=executed is self-asserted (no signed outcomeRef)")
|
|
1232
|
+
if result["subject_binding"] is not None:
|
|
1233
|
+
print(f"SUBJECT: {result['subject_binding']['mode']}")
|
|
1234
|
+
if result["subject_derived_ok"] is not None:
|
|
1235
|
+
print(f"SUBJECT_DERIVED: {'OK' if result['subject_derived_ok'] else 'FAIL'}")
|
|
1231
1236
|
for e in result["errors"]:
|
|
1232
1237
|
print(f" - {e}", file=sys.stderr)
|
|
1233
1238
|
for w in result["warnings"]:
|
|
@@ -1248,6 +1253,10 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
|
|
|
1248
1253
|
# like the eval verify path, never a silent exit 0 (lens 3 defect).
|
|
1249
1254
|
if result["audience_ok"] is False or result["nonce_ok"] is False:
|
|
1250
1255
|
return 2
|
|
1256
|
+
# Finding 05: a caller who explicitly requests --require-derived-subject is asking for the same kind of
|
|
1257
|
+
# fail-closed binding as --aud/--nonce — an EXTERNAL_ATTESTED subject then must not exit 0.
|
|
1258
|
+
if result["subject_derived_ok"] is False:
|
|
1259
|
+
return 2
|
|
1251
1260
|
# A supplied anchor that FAILS to verify (broken / root-mismatched / unknown type) is a tamper
|
|
1252
1261
|
# signal — fail-closed, never a silent exit 0 (fix-review Finding 3). None = no anchor supplied.
|
|
1253
1262
|
if result["anchors_ok"] is False:
|
|
@@ -1362,14 +1371,16 @@ def _cmd_outcome_verify(args: argparse.Namespace) -> int:
|
|
|
1362
1371
|
result = verify_outcome_receipt(
|
|
1363
1372
|
env, pub, strict=args.strict,
|
|
1364
1373
|
expected_decision_ref=args.expected_decision_ref, decision_maker_id=args.decision_maker_id,
|
|
1365
|
-
expected_audience=args.aud, expected_nonce=args.nonce
|
|
1374
|
+
expected_audience=args.aud, expected_nonce=args.nonce,
|
|
1375
|
+
require_derived_subject=args.require_derived_subject)
|
|
1366
1376
|
except (ProofBundleError, OSError, ValueError) as exc:
|
|
1367
1377
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1368
1378
|
return 2
|
|
1369
1379
|
if args.json:
|
|
1370
1380
|
report = {k: result[k] for k in (
|
|
1371
1381
|
"ok", "crypto_ok", "structure_ok", "predicate_type_ok", "decision_bound", "role_separation_ok",
|
|
1372
|
-
"execution_proven", "audience_ok", "nonce_ok", "
|
|
1382
|
+
"execution_proven", "audience_ok", "nonce_ok", "subject_binding", "subject_derived_ok",
|
|
1383
|
+
"warnings", "errors",
|
|
1373
1384
|
)}
|
|
1374
1385
|
print(json.dumps(report, indent=2))
|
|
1375
1386
|
else:
|
|
@@ -1385,6 +1396,10 @@ def _cmd_outcome_verify(args: argparse.Namespace) -> int:
|
|
|
1385
1396
|
print(f"NONCE: {'OK' if result['nonce_ok'] else 'MISMATCH'}")
|
|
1386
1397
|
if result["execution_proven"] is False:
|
|
1387
1398
|
print("ASSURANCE: status=executed is self-asserted (no effectDigest/actualActionDigest)")
|
|
1399
|
+
if result["subject_binding"] is not None:
|
|
1400
|
+
print(f"SUBJECT: {result['subject_binding']['mode']}")
|
|
1401
|
+
if result["subject_derived_ok"] is not None:
|
|
1402
|
+
print(f"SUBJECT_DERIVED: {'OK' if result['subject_derived_ok'] else 'FAIL'}")
|
|
1388
1403
|
for e in result["errors"]:
|
|
1389
1404
|
print(f" - {e}", file=sys.stderr)
|
|
1390
1405
|
for w in result["warnings"]:
|
|
@@ -1403,6 +1418,10 @@ def _cmd_outcome_verify(args: argparse.Namespace) -> int:
|
|
|
1403
1418
|
return 2
|
|
1404
1419
|
if result["audience_ok"] is False or result["nonce_ok"] is False:
|
|
1405
1420
|
return 2
|
|
1421
|
+
# Finding 05: a caller who explicitly requests --require-derived-subject is asking for the same kind of
|
|
1422
|
+
# fail-closed binding as --aud/--nonce — an EXTERNAL_ATTESTED subject then must not exit 0.
|
|
1423
|
+
if result["subject_derived_ok"] is False:
|
|
1424
|
+
return 2
|
|
1406
1425
|
return 0
|
|
1407
1426
|
|
|
1408
1427
|
|
|
@@ -1888,6 +1907,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1888
1907
|
d_verify.add_argument("--bitcoin-header", action="append", default=None, metavar="HEIGHT:MERKLEROOT_HEX",
|
|
1889
1908
|
help="WP-A1: a relying-party-supplied Bitcoin block header (internal byte order) "
|
|
1890
1909
|
"that confirms an OpenTimestamps statement anchor — frozen is never trusted")
|
|
1910
|
+
d_verify.add_argument("--require-derived-subject", dest="require_derived_subject", action="store_true",
|
|
1911
|
+
help="Finding 05: fail closed (exit 2) unless the Statement subject is a DERIVED "
|
|
1912
|
+
"commitment to the predicate (subject_binding.classify_subject) — rejects a "
|
|
1913
|
+
"self-attested/rehung subject_sha256 override. Default off: an "
|
|
1914
|
+
"EXTERNAL_ATTESTED subject is still warned, never silent")
|
|
1891
1915
|
d_verify.set_defaults(func=_cmd_decision_verify)
|
|
1892
1916
|
|
|
1893
1917
|
d_inspect = dsub.add_parser("inspect", help="print a decision receipt's predicate (no crypto verification)")
|
|
@@ -1927,6 +1951,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1927
1951
|
o_verify.add_argument("--strict", action="store_true", help="enforce strict-v0.1 required fields")
|
|
1928
1952
|
o_verify.add_argument("--aud", default=None, help="expected audience (checks validity.audience against replay)")
|
|
1929
1953
|
o_verify.add_argument("--nonce", default=None, help="expected nonce (checks validity.nonce against replay)")
|
|
1954
|
+
o_verify.add_argument("--require-derived-subject", dest="require_derived_subject", action="store_true",
|
|
1955
|
+
help="Finding 05: fail closed (exit 2) unless the Statement subject is a DERIVED "
|
|
1956
|
+
"commitment to the predicate (subject_binding.classify_subject) — rejects a "
|
|
1957
|
+
"self-attested/rehung subject_sha256 override. Default off: an "
|
|
1958
|
+
"EXTERNAL_ATTESTED subject is still warned, never silent")
|
|
1930
1959
|
o_verify.set_defaults(func=_cmd_outcome_verify)
|
|
1931
1960
|
|
|
1932
1961
|
o_inspect = osub.add_parser("inspect", help="print an outcome receipt's predicate (no crypto verification)")
|
|
@@ -16,6 +16,7 @@ from typing import Any
|
|
|
16
16
|
|
|
17
17
|
from ._strict_json import loads_strict
|
|
18
18
|
from .errors import BundleFormatError, ProofBundleError
|
|
19
|
+
from .subject_binding import nested_closure_violations
|
|
19
20
|
|
|
20
21
|
DECISION_RECEIPT_PREDICATE_TYPE = "https://b7n0de.com/proofbundle/predicates/decision-receipt/v0.1"
|
|
21
22
|
DECISION_SCHEMA_VERSION = "0.1.0"
|
|
@@ -53,6 +54,35 @@ _ALLOWED_TOP = set(_REQUIRED_ALWAYS) | set(_OPTIONAL)
|
|
|
53
54
|
# Time-bearing paths that, if present, MUST be RFC3339-Z.
|
|
54
55
|
_TIME_PATHS = ("decidedAt", "recordedAt")
|
|
55
56
|
|
|
57
|
+
# Nested schema closure (Finding 04 / subject_binding.nested_closure_violations): a top-level
|
|
58
|
+
# additionalProperties:false does NOT, by itself, close these nested objects — an undeclared key inside one
|
|
59
|
+
# of them (e.g. `decision: {..., "sneaky": 1}`) previously rode along silently. Every path here was verified
|
|
60
|
+
# against the golden examples (examples/decision_receipt_{allow,deny,escalate}.json,
|
|
61
|
+
# decision_receipt_with_eval_ref.intoto.json) AND the `decision init` CLI template so this closure can never
|
|
62
|
+
# reject the repo's own reference material. `decisionMaker.version` is DELIBERATELY left undeclared — it is
|
|
63
|
+
# the versioned extensions container the module's own docstring describes as "the ONLY way to add fields".
|
|
64
|
+
_NESTED_ALLOWED: dict[str, tuple[str, ...]] = {
|
|
65
|
+
"decisionMaker": ("id", "version"),
|
|
66
|
+
"agent": ("id", "version", "configurationDigest"),
|
|
67
|
+
"principal": ("id", "authnContextRef"),
|
|
68
|
+
"principal.authnContextRef": ("uri", "digest"),
|
|
69
|
+
"proposedAction": ("actionType", "target", "method", "parametersDigest", "parametersRef",
|
|
70
|
+
"parametersSchemaRef"),
|
|
71
|
+
"proposedAction.target": ("name", "uri", "digest"),
|
|
72
|
+
"proposedAction.parametersSchemaRef": ("uri", "digest"),
|
|
73
|
+
"policyBoundary": ("policyEngine", "policyId", "policyDigest", "decisionPath", "bundleRevision",
|
|
74
|
+
"validFrom", "validUntil"),
|
|
75
|
+
"evidenceRefs[]": ("relation", "digest", "artifactDigest", "uri", "predicateType"),
|
|
76
|
+
"decision": ("verdict", "reasonCodes", "humanReadableSummary", "obligations", "allowedScope"),
|
|
77
|
+
"notChecked[]": ("field", "reason", "impact"),
|
|
78
|
+
"decisionChangeConditions[]": ("conditionType", "description", "requiredEvidenceType"),
|
|
79
|
+
"actionOutcome": ("status", "performedAt", "outcomeRef"),
|
|
80
|
+
"actionOutcome.outcomeRef": ("uri", "digest"),
|
|
81
|
+
"privacy": ("rawInputsIncluded", "redactionProfile", "erased", "masked"),
|
|
82
|
+
"validity": ("audience", "nonce", "expiresAt"),
|
|
83
|
+
"inputSnapshot[]": ("name", "uri", "digest", "mediaType"),
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
|
|
57
87
|
class DecisionReceiptError(ProofBundleError):
|
|
58
88
|
"""A Decision Receipt predicate is malformed (fail-closed)."""
|
|
@@ -101,6 +131,13 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
|
|
|
101
131
|
if sv is not None and (not isinstance(sv, str) or not _SEMVER_0_1_X.match(sv)):
|
|
102
132
|
errors.append(f"schemaVersion must be a 0.1.x string, got {sv!r}")
|
|
103
133
|
|
|
134
|
+
# decisionId (Finding 04, analogous to trustPackId in trust_pack.py): the required-field loop above only
|
|
135
|
+
# checks PRESENCE — an int, a bool or an empty string previously satisfied "decisionId in predicate" and
|
|
136
|
+
# passed silently.
|
|
137
|
+
did = predicate.get("decisionId")
|
|
138
|
+
if "decisionId" in predicate and not (isinstance(did, str) and did):
|
|
139
|
+
errors.append(f"decisionId must be a non-empty string, got {did!r}")
|
|
140
|
+
|
|
104
141
|
# decisionType enum
|
|
105
142
|
dt = predicate.get("decisionType")
|
|
106
143
|
if dt is not None and (not isinstance(dt, str) or dt not in _DECISION_TYPES):
|
|
@@ -135,13 +172,19 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
|
|
|
135
172
|
elif "policyBoundary" in predicate:
|
|
136
173
|
errors.append("policyBoundary must be a JSON object")
|
|
137
174
|
|
|
138
|
-
# proposedAction inner shape (§6.2): actionType required + a digest OR a ref for the
|
|
175
|
+
# proposedAction inner shape (§6.2): actionType required + a digest OR a non-empty ref for the
|
|
176
|
+
# parameters. Finding 04: a bare `{}` parametersRef previously satisfied `isinstance(..., dict)`
|
|
177
|
+
# vacuously — `{}` is a dict but carries no actual reference.
|
|
139
178
|
pa = predicate.get("proposedAction")
|
|
140
179
|
if isinstance(pa, dict):
|
|
141
180
|
if not isinstance(pa.get("actionType"), str) or not pa.get("actionType"):
|
|
142
181
|
errors.append("proposedAction.actionType must be a non-empty string")
|
|
143
|
-
|
|
144
|
-
|
|
182
|
+
pref = pa.get("parametersRef")
|
|
183
|
+
pref_ok = isinstance(pref, dict) and bool(pref)
|
|
184
|
+
if "parametersRef" in pa and not pref_ok:
|
|
185
|
+
errors.append("proposedAction.parametersRef, when present, must be a non-empty object")
|
|
186
|
+
if not (_is_digest(pa.get("parametersDigest")) or pref_ok):
|
|
187
|
+
errors.append("proposedAction needs a sha256 parametersDigest or a non-empty parametersRef")
|
|
145
188
|
elif "proposedAction" in predicate:
|
|
146
189
|
errors.append("proposedAction must be a JSON object")
|
|
147
190
|
|
|
@@ -180,20 +223,28 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
|
|
|
180
223
|
elif "evidenceRefs" in predicate:
|
|
181
224
|
errors.append("evidenceRefs must be a list")
|
|
182
225
|
|
|
183
|
-
# actionOutcome (optional) — status enum
|
|
226
|
+
# actionOutcome (optional) — must be an object; status enum. Finding 04: a scalar (e.g. the bare
|
|
227
|
+
# string "executed") previously skipped every check silently (the `isinstance(ao, dict)` guard simply
|
|
228
|
+
# never entered the block, and nothing else objected to the wrong shape).
|
|
184
229
|
ao = predicate.get("actionOutcome")
|
|
185
230
|
if isinstance(ao, dict):
|
|
186
231
|
st = ao.get("status")
|
|
187
232
|
if not isinstance(st, str) or st not in _OUTCOME_STATUS:
|
|
188
233
|
errors.append(f"actionOutcome.status must be one of {sorted(_OUTCOME_STATUS)}, got {st!r}")
|
|
234
|
+
elif "actionOutcome" in predicate:
|
|
235
|
+
errors.append("actionOutcome must be a JSON object")
|
|
189
236
|
|
|
190
|
-
# validity — strict interactive requires audience + nonce when
|
|
237
|
+
# validity — must be an object; strict interactive mode additionally requires audience + nonce when
|
|
238
|
+
# present. Finding 04: a scalar validity previously passed the same way actionOutcome did.
|
|
191
239
|
val = predicate.get("validity")
|
|
192
|
-
if isinstance(val, dict)
|
|
193
|
-
if
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
240
|
+
if isinstance(val, dict):
|
|
241
|
+
if strict:
|
|
242
|
+
if not (isinstance(val.get("audience"), list) and val["audience"]):
|
|
243
|
+
errors.append("validity.audience (non-empty list) is required in strict mode when validity is present")
|
|
244
|
+
if not isinstance(val.get("nonce"), str) or not val.get("nonce"):
|
|
245
|
+
errors.append("validity.nonce is required in strict mode when validity is present")
|
|
246
|
+
elif "validity" in predicate:
|
|
247
|
+
errors.append("validity must be a JSON object")
|
|
197
248
|
|
|
198
249
|
# privacy inner shape: a bare {} must not pass strict — rawInputsIncluded is the field the policy's
|
|
199
250
|
# allow_raw_inputs gate reads, so it MUST be an explicit boolean (§5.5 privacy).
|
|
@@ -203,6 +254,11 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
|
|
|
203
254
|
elif strict and isinstance(priv, dict) and not isinstance(priv.get("rawInputsIncluded"), bool):
|
|
204
255
|
errors.append("privacy.rawInputsIncluded (boolean) is required in strict mode")
|
|
205
256
|
|
|
257
|
+
# Nested schema closure (Finding 04): additionalProperties:false at the TOP level does not, by itself,
|
|
258
|
+
# close these nested objects — an undeclared key inside decision/policyBoundary/proposedAction/
|
|
259
|
+
# decisionMaker/evidenceRefs[] (and their sub-objects) previously rode along silently.
|
|
260
|
+
errors.extend(nested_closure_violations(predicate, _NESTED_ALLOWED))
|
|
261
|
+
|
|
206
262
|
return errors
|
|
207
263
|
|
|
208
264
|
|
|
@@ -333,6 +389,7 @@ def _empty_result() -> dict:
|
|
|
333
389
|
"ok": None, "structure_ok": None, "crypto_ok": None, "signer_trusted": None,
|
|
334
390
|
"predicate_type_ok": None, "policy_ok": None, "evidence_bound": None, "audience_ok": None,
|
|
335
391
|
"nonce_ok": None, "freshness_ok": None, "anchors_ok": None, "action_outcome_proven": None,
|
|
392
|
+
"subject_binding": None, "subject_derived_ok": None,
|
|
336
393
|
"warnings": [], "errors": [],
|
|
337
394
|
}
|
|
338
395
|
|
|
@@ -340,7 +397,7 @@ def _empty_result() -> dict:
|
|
|
340
397
|
def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool = False,
|
|
341
398
|
expected_audience: str | None = None, expected_nonce: str | None = None,
|
|
342
399
|
policy: dict | None = None, anchors: list | None = None,
|
|
343
|
-
rp_trust: dict | None = None) -> dict:
|
|
400
|
+
rp_trust: dict | None = None, require_derived_subject: bool = False) -> dict:
|
|
344
401
|
"""Verify a DSSE-signed Decision Receipt. Crypto first, then structure over the EXACT signed bytes (never
|
|
345
402
|
re-serialized). Returns the snake_case structured result; each check independent, non-applicable = None.
|
|
346
403
|
|
|
@@ -348,14 +405,21 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
|
|
|
348
405
|
verdict (crypto AND structure AND predicate-type AND every applicable trust check). The individual
|
|
349
406
|
fields describe the payload only AFTER authentication: when `crypto_ok` is False the bytes are
|
|
350
407
|
unauthenticated, so the trust-derived fields (`audience_ok`, `nonce_ok`, `evidence_bound`,
|
|
351
|
-
`signer_trusted`, `policy_ok`, `action_outcome_proven`) stay None — and
|
|
352
|
-
False when anchors were supplied — and an error is recorded, so a consumer
|
|
353
|
-
`audience_ok` without checking `crypto_ok`/`ok` cannot read a claim about bytes nobody
|
|
354
|
-
CLI gates its exit code on `crypto_ok` first (and reports `ok`).
|
|
408
|
+
`signer_trusted`, `policy_ok`, `action_outcome_proven`, `subject_derived_ok`) stay None — and
|
|
409
|
+
`anchors_ok` is None, or False when anchors were supplied — and an error is recorded, so a consumer
|
|
410
|
+
that reads e.g. `audience_ok` without checking `crypto_ok`/`ok` cannot read a claim about bytes nobody
|
|
411
|
+
signed. The CLI gates its exit code on `crypto_ok` first (and reports `ok`).
|
|
355
412
|
|
|
356
413
|
hash_binding (§7.1): the received payload MUST equal its own RFC-8785 canonicalization; a deviation is a
|
|
357
414
|
fail-closed error (only checked when rfc8785 is importable, so plain verify stays dependency-free).
|
|
358
415
|
|
|
416
|
+
Subject binding (Finding 05, mirrors outcome.py): `build_decision_statement` allows a caller to
|
|
417
|
+
OVERRIDE `subject_sha256`, self-attested and NOT cross-checked there. This verify path now classifies
|
|
418
|
+
the binding via `subject_binding.classify_subject` so a subject-rehang is never a ZERO-signal event: an
|
|
419
|
+
`EXTERNAL_ATTESTED` subject is ALWAYS warned (`subject_binding`/`warnings`); `require_derived_subject`
|
|
420
|
+
(opt-in, default False, preserving the documented override use) makes it a hard fail-closed error via
|
|
421
|
+
`subject_derived_ok`.
|
|
422
|
+
|
|
359
423
|
Detached anchors (Fix 2 / proofbundle#7): `anchors` is the DETACHED anchor evidence for the decision
|
|
360
424
|
statement's OWN content root (sha256 over the exact signed payload bytes). It is NOT part of the signed
|
|
361
425
|
predicate — an anchor cannot live inside the bytes whose hash it commits. It is verified against the
|
|
@@ -455,6 +519,42 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
|
|
|
455
519
|
"nonce mismatch or absent validity.nonce — requested replay binding cannot be enforced "
|
|
456
520
|
"(replay?, fail-closed)")
|
|
457
521
|
|
|
522
|
+
# Subject binding (Finding 05, release-review #4 parity with outcome.py): classify whether the subject
|
|
523
|
+
# genuinely commits to the predicate so a consumer never gets ZERO signal on a subject-rehang override.
|
|
524
|
+
# An EXTERNAL_ATTESTED subject is ALWAYS warned; require_derived_subject makes it a hard fail-closed
|
|
525
|
+
# error (opt-in, preserves the documented build_decision_statement override use).
|
|
526
|
+
if isinstance(statement, dict) and r["crypto_ok"]:
|
|
527
|
+
if _rfc8785_available():
|
|
528
|
+
from . import subject_binding as _sb # noqa: PLC0415
|
|
529
|
+
try:
|
|
530
|
+
_cls = _sb.classify_subject(statement)
|
|
531
|
+
except Exception: # noqa: BLE001
|
|
532
|
+
_cls = None
|
|
533
|
+
if _cls is not None:
|
|
534
|
+
r["subject_binding"] = {"mode": _cls["mode"], "matches": _cls["matches"]}
|
|
535
|
+
if not _cls["matches"]:
|
|
536
|
+
r["warnings"].append(
|
|
537
|
+
"subject is EXTERNAL_ATTESTED — it does not commit to this predicate (subject-rehang); "
|
|
538
|
+
"trust it only via a policy that pins the external attester")
|
|
539
|
+
if require_derived_subject:
|
|
540
|
+
r["subject_derived_ok"] = _cls["matches"]
|
|
541
|
+
if not _cls["matches"]:
|
|
542
|
+
r["errors"].append(
|
|
543
|
+
"require_derived_subject: subject is not a DERIVED commitment to the predicate "
|
|
544
|
+
"(fail-closed)")
|
|
545
|
+
elif require_derived_subject:
|
|
546
|
+
# classify_subject raised (e.g. a predicate the JCS canonicalizer rejects). Do NOT silently
|
|
547
|
+
# pass the gate on a coincidence elsewhere — make the fail-closed explicit.
|
|
548
|
+
r["subject_derived_ok"] = False
|
|
549
|
+
r["errors"].append(
|
|
550
|
+
"require_derived_subject: could not classify the subject binding (canonicalization raised) "
|
|
551
|
+
"— fail-closed")
|
|
552
|
+
elif require_derived_subject:
|
|
553
|
+
r["subject_derived_ok"] = False
|
|
554
|
+
r["errors"].append(
|
|
555
|
+
"require_derived_subject but RFC-8785 canonicalizer unavailable — cannot verify subject "
|
|
556
|
+
"derivation (install proofbundle[eval]), fail-closed")
|
|
557
|
+
|
|
458
558
|
# Detached anchors (Fix 2): verify anchor evidence for the statement's OWN content root — sha256 over the
|
|
459
559
|
# EXACT signed payload bytes (never re-canonicalized), computed only once the bytes are authentic+canonical.
|
|
460
560
|
_dr_section = policy.get("decision_receipt") if isinstance(policy, dict) else None
|
|
@@ -526,5 +626,6 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
|
|
|
526
626
|
r["crypto_ok"] and r["structure_ok"] and r["predicate_type_ok"]
|
|
527
627
|
and r["policy_ok"] is not False and r["signer_trusted"] is not False
|
|
528
628
|
and r["audience_ok"] is not False and r["nonce_ok"] is not False
|
|
529
|
-
and r["evidence_bound"] is not False and r["anchors_ok"] is not False
|
|
629
|
+
and r["evidence_bound"] is not False and r["anchors_ok"] is not False
|
|
630
|
+
and r["subject_derived_ok"] is not False)
|
|
530
631
|
return r
|
|
@@ -23,6 +23,7 @@ from typing import Any
|
|
|
23
23
|
|
|
24
24
|
from ._strict_json import loads_strict
|
|
25
25
|
from .errors import BundleFormatError, ProofBundleError
|
|
26
|
+
from .subject_binding import nested_closure_violations
|
|
26
27
|
|
|
27
28
|
ACTION_OUTCOME_PREDICATE_TYPE = "https://b7n0de.com/proofbundle/predicates/action-outcome/v0.1"
|
|
28
29
|
OUTCOME_SCHEMA_VERSION = "0.1.0"
|
|
@@ -47,6 +48,15 @@ _ALLOWED_TOP = set(_REQUIRED_ALWAYS) | set(_OPTIONAL)
|
|
|
47
48
|
_DIGEST_FIELDS = ("requestedActionDigest", "actualActionDigest", "responseDigest", "effectDigest")
|
|
48
49
|
_TIME_PATHS = ("performedAt", "recordedAt")
|
|
49
50
|
|
|
51
|
+
# Nested schema closure (Finding 04 / subject_binding.nested_closure_violations): a top-level
|
|
52
|
+
# additionalProperties:false does NOT, by itself, close traceContext/validity — an undeclared key inside
|
|
53
|
+
# one of them previously rode along silently. Verified against `outcome init`'s CLI template
|
|
54
|
+
# (traceContext={"traceparent": ""}) and tests/test_outcome_verify.py's validity={"audience", "nonce"}.
|
|
55
|
+
_NESTED_ALLOWED: dict[str, tuple[str, ...]] = {
|
|
56
|
+
"traceContext": ("traceparent",),
|
|
57
|
+
"validity": ("audience", "nonce"),
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
|
|
51
61
|
class OutcomeReceiptError(ProofBundleError):
|
|
52
62
|
"""An Action Outcome Receipt predicate is malformed (fail-closed)."""
|
|
@@ -125,6 +135,18 @@ def validate_outcome_predicate(predicate: Any, *, strict: bool = False) -> list[
|
|
|
125
135
|
if "traceContext" in predicate and not isinstance(tc, dict):
|
|
126
136
|
errors.append("traceContext must be an object")
|
|
127
137
|
|
|
138
|
+
# validity (Finding 04): previously had NO structural check at all — a scalar validity (e.g. the bare
|
|
139
|
+
# string "n-1") silently reached verify_outcome_receipt, which only guards with
|
|
140
|
+
# `_val if isinstance(_val, dict) else {}` and so treated it as an absent validity object without ever
|
|
141
|
+
# flagging the malformed shape.
|
|
142
|
+
val = predicate.get("validity")
|
|
143
|
+
if "validity" in predicate and not isinstance(val, dict):
|
|
144
|
+
errors.append("validity must be an object")
|
|
145
|
+
|
|
146
|
+
# Nested schema closure (Finding 04): additionalProperties:false at the TOP level does not, by itself,
|
|
147
|
+
# close traceContext/validity — an undeclared key inside either previously rode along silently.
|
|
148
|
+
errors.extend(nested_closure_violations(predicate, _NESTED_ALLOWED))
|
|
149
|
+
|
|
128
150
|
return errors
|
|
129
151
|
|
|
130
152
|
|
|
@@ -23,6 +23,7 @@ general SCITT/RFC-9943 conformance claim without interop vectors, and never that
|
|
|
23
23
|
"""
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
from dataclasses import dataclass
|
|
26
27
|
from typing import Any
|
|
27
28
|
|
|
28
29
|
from .errors import ProofBundleError
|
|
@@ -39,6 +40,79 @@ class PublicTransparencyError(ProofBundleError):
|
|
|
39
40
|
"""A public-transparency policy is malformed (fail-closed)."""
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ConsistencyVerificationResult:
|
|
45
|
+
"""A relying party's (or independent monitor's) OWN result of checking an RFC 9162 §2.1.4 consistency
|
|
46
|
+
proof (``merkle.verify_consistency``) between two checkpoints of the SAME log: the *old* (first,
|
|
47
|
+
smaller) checkpoint the caller previously trusted, and the *new* (second, larger) checkpoint currently
|
|
48
|
+
being evaluated by ``evaluate_public_transparency``.
|
|
49
|
+
|
|
50
|
+
Finding 10: a bare ``consistency_confirmed: bool`` is an UNBOUND plaintext claim — it says nothing
|
|
51
|
+
about WHICH checkpoint pair was checked, so a ``True`` computed for one (old, new) pair is trivially
|
|
52
|
+
replayable onto a different, unrelated ``new`` checkpoint. Carrying the checkpoint identities INSIDE
|
|
53
|
+
the result lets ``evaluate_public_transparency`` bind ``new_origin`` / ``new_tree_size`` /
|
|
54
|
+
``new_root_b64`` to the EXACT checkpoint under evaluation before it ever accepts ``confirmed``.
|
|
55
|
+
|
|
56
|
+
``proof_digest`` / ``verifier_version`` / ``policy_digest`` are provenance (which consistency-proof
|
|
57
|
+
bytes were actually checked, by which verifier build, under which policy) — evidence the check really
|
|
58
|
+
ran, not merely a hand-typed ``confirmed=True`` (No-Fake omission floor); ``validate()`` requires them
|
|
59
|
+
non-empty. ``validate()`` also rejects a STRUCTURALLY impossible pair regardless of ``confirmed`` — in
|
|
60
|
+
particular the same tree size claiming two different roots, which is the signature of a log SPLIT VIEW
|
|
61
|
+
(a fork), never a valid consistency claim (RFC 9162: equal size implies equal root)."""
|
|
62
|
+
|
|
63
|
+
old_origin: str
|
|
64
|
+
old_tree_size: int
|
|
65
|
+
old_root_b64: str
|
|
66
|
+
new_origin: str
|
|
67
|
+
new_tree_size: int
|
|
68
|
+
new_root_b64: str
|
|
69
|
+
proof_digest: str
|
|
70
|
+
verifier_version: str
|
|
71
|
+
policy_digest: str
|
|
72
|
+
confirmed: bool
|
|
73
|
+
|
|
74
|
+
def validate(self) -> list[str]:
|
|
75
|
+
"""Fail-closed structural validation (empty list = valid). Never raises on malformed field types —
|
|
76
|
+
every comparison is guarded so a caller-constructed or deserialized result cannot crash this."""
|
|
77
|
+
errors: list[str] = []
|
|
78
|
+
|
|
79
|
+
def _str(name: str, val: Any) -> bool:
|
|
80
|
+
ok = isinstance(val, str) and val != ""
|
|
81
|
+
if not ok:
|
|
82
|
+
errors.append(f"{name} must be a non-empty string")
|
|
83
|
+
return ok
|
|
84
|
+
|
|
85
|
+
def _size(name: str, val: Any) -> bool:
|
|
86
|
+
ok = isinstance(val, int) and not isinstance(val, bool) and val >= 0
|
|
87
|
+
if not ok:
|
|
88
|
+
errors.append(f"{name} must be a non-negative integer")
|
|
89
|
+
return ok
|
|
90
|
+
|
|
91
|
+
origin_ok = _str("old_origin", self.old_origin) and _str("new_origin", self.new_origin)
|
|
92
|
+
root_ok = _str("old_root_b64", self.old_root_b64) and _str("new_root_b64", self.new_root_b64)
|
|
93
|
+
_str("proof_digest", self.proof_digest)
|
|
94
|
+
_str("verifier_version", self.verifier_version)
|
|
95
|
+
_str("policy_digest", self.policy_digest)
|
|
96
|
+
size_ok = _size("old_tree_size", self.old_tree_size) and _size("new_tree_size", self.new_tree_size)
|
|
97
|
+
if not isinstance(self.confirmed, bool):
|
|
98
|
+
errors.append("confirmed must be a boolean")
|
|
99
|
+
|
|
100
|
+
if size_ok and self.old_tree_size > self.new_tree_size:
|
|
101
|
+
errors.append(
|
|
102
|
+
"old_tree_size exceeds new_tree_size — a consistency proof only orders old -> new, "
|
|
103
|
+
"never the reverse")
|
|
104
|
+
if size_ok and root_ok and self.old_tree_size == self.new_tree_size \
|
|
105
|
+
and self.old_root_b64 != self.new_root_b64:
|
|
106
|
+
errors.append(
|
|
107
|
+
"old_tree_size == new_tree_size but the roots differ — a split view / fork, never a "
|
|
108
|
+
"valid consistency claim (RFC 9162: identical tree size implies an identical root)")
|
|
109
|
+
if origin_ok and self.old_origin != self.new_origin:
|
|
110
|
+
errors.append(
|
|
111
|
+
"old_origin and new_origin differ — a consistency proof is only meaningful within one "
|
|
112
|
+
"log's own history")
|
|
113
|
+
return errors
|
|
114
|
+
|
|
115
|
+
|
|
42
116
|
def validate_public_transparency_policy(policy: Any) -> list[str]:
|
|
43
117
|
"""Fail-closed validation of a public-transparency policy object (empty = valid)."""
|
|
44
118
|
errors: list[str] = []
|
|
@@ -72,13 +146,25 @@ def evaluate_public_transparency(
|
|
|
72
146
|
signed_note: str, policy: dict, *, log_vkey: str | None = None,
|
|
73
147
|
witness_vkeys: list | None = None, expected_root_b64: str | None = None,
|
|
74
148
|
expected_tree_size: int | None = None, consistency_confirmed: bool | None = None,
|
|
149
|
+
consistency_result: "ConsistencyVerificationResult | None" = None,
|
|
150
|
+
strict_consistency: bool = False,
|
|
75
151
|
) -> dict:
|
|
76
152
|
"""Evaluate a signed checkpoint against a public-transparency policy → unified named-status verdict.
|
|
77
153
|
|
|
78
154
|
``consistency_confirmed`` is the relying party's OWN result of a consistency-proof check (append-only, no
|
|
79
155
|
fork) computed elsewhere; when the policy requires consistency it MUST be True, else FAIL. It is passed in
|
|
80
156
|
(not computed here) because a consistency proof is between two checkpoints the relying party holds — this
|
|
81
|
-
layer records the verdict, fail-closed. Read PUBLIC_TRANSPARENCY — never an individual status alone.
|
|
157
|
+
layer records the verdict, fail-closed. Read PUBLIC_TRANSPARENCY — never an individual status alone.
|
|
158
|
+
|
|
159
|
+
``consistency_result`` (finding 10, additive): a typed ``ConsistencyVerificationResult`` instead of the
|
|
160
|
+
bare ``consistency_confirmed`` boolean. Preferred, because it BINDS the confirmation to this exact
|
|
161
|
+
checkpoint — ``new_origin`` / ``new_tree_size`` / ``new_root_b64`` are checked against the values this
|
|
162
|
+
call itself parses from ``signed_note`` BEFORE ``confirmed`` is ever accepted, and its own
|
|
163
|
+
``.validate()`` rejects a structurally impossible (e.g. split-view) pair regardless of ``confirmed``.
|
|
164
|
+
When both are supplied, ``consistency_result`` takes precedence. ``strict_consistency=True`` refuses
|
|
165
|
+
the unbound bare-boolean path entirely (CONSISTENCY FAILs if only ``consistency_confirmed`` is given) —
|
|
166
|
+
additive: the default (``strict_consistency=False``) preserves every existing caller's behavior
|
|
167
|
+
exactly."""
|
|
82
168
|
from . import checkpoint as cp # noqa: PLC0415
|
|
83
169
|
|
|
84
170
|
perrs = validate_public_transparency_policy(policy)
|
|
@@ -150,8 +236,35 @@ def evaluate_public_transparency(
|
|
|
150
236
|
errors.append("checkpoint tree size does not equal the relying party's expected tree size")
|
|
151
237
|
|
|
152
238
|
# CONSISTENCY — required-and-confirmed → PASS; required-and-not-confirmed → FAIL (fail-closed).
|
|
239
|
+
# finding 10: a typed consistency_result (preferred) is BOUND to this exact checkpoint before its
|
|
240
|
+
# `confirmed` is accepted; a bare consistency_confirmed boolean is an unbound plaintext claim, and
|
|
241
|
+
# strict_consistency=True refuses it entirely.
|
|
153
242
|
if bool(policy.get("requireConsistencyProof")):
|
|
154
|
-
if
|
|
243
|
+
if consistency_result is not None:
|
|
244
|
+
cerrs = consistency_result.validate()
|
|
245
|
+
if cerrs:
|
|
246
|
+
statuses["CONSISTENCY"] = "FAIL"
|
|
247
|
+
errors.extend(f"consistency result invalid: {e}" for e in cerrs)
|
|
248
|
+
elif not (parsed_ok and consistency_result.new_origin == origin
|
|
249
|
+
and consistency_result.new_tree_size == tree_size
|
|
250
|
+
and consistency_result.new_root_b64 == root):
|
|
251
|
+
statuses["CONSISTENCY"] = "FAIL"
|
|
252
|
+
errors.append(
|
|
253
|
+
"consistency result does not bind to this checkpoint (new_origin/new_tree_size/"
|
|
254
|
+
"new_root_b64 do not match the checkpoint being evaluated) — wrong checkpoint pair, "
|
|
255
|
+
"fail-closed")
|
|
256
|
+
elif consistency_result.confirmed is True:
|
|
257
|
+
statuses["CONSISTENCY"] = "PASS"
|
|
258
|
+
else:
|
|
259
|
+
statuses["CONSISTENCY"] = "FAIL"
|
|
260
|
+
errors.append("consistency result is bound to this checkpoint but confirmed is not True")
|
|
261
|
+
elif strict_consistency:
|
|
262
|
+
statuses["CONSISTENCY"] = "FAIL"
|
|
263
|
+
errors.append(
|
|
264
|
+
"requireConsistencyProof with strict_consistency=True needs a typed "
|
|
265
|
+
"ConsistencyVerificationResult — a bare consistency_confirmed boolean is an unbound "
|
|
266
|
+
"plaintext claim and is rejected (fail-closed)")
|
|
267
|
+
elif consistency_confirmed is True:
|
|
155
268
|
statuses["CONSISTENCY"] = "PASS"
|
|
156
269
|
else:
|
|
157
270
|
statuses["CONSISTENCY"] = "FAIL"
|