proofbundle 1.9.2__tar.gz → 2.0.0b2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {proofbundle-1.9.2/src/proofbundle.egg-info → proofbundle-2.0.0b2}/PKG-INFO +7 -1
  2. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/README.md +1 -0
  3. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/pyproject.toml +12 -1
  4. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/__init__.py +1 -1
  5. proofbundle-2.0.0b2/src/proofbundle/anchors.py +185 -0
  6. proofbundle-2.0.0b2/src/proofbundle/anchors_ots.py +91 -0
  7. proofbundle-2.0.0b2/src/proofbundle/anchors_rfc3161.py +90 -0
  8. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/cli.py +192 -0
  9. proofbundle-2.0.0b2/src/proofbundle/experimental/__init__.py +28 -0
  10. proofbundle-2.0.0b2/src/proofbundle/experimental/enclave.py +196 -0
  11. proofbundle-2.0.0b2/src/proofbundle/intoto.py +468 -0
  12. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/statuslist.py +6 -2
  13. {proofbundle-1.9.2 → proofbundle-2.0.0b2/src/proofbundle.egg-info}/PKG-INFO +7 -1
  14. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/SOURCES.txt +17 -1
  15. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/requires.txt +7 -0
  16. proofbundle-2.0.0b2/tests/test_anchors_generic.py +111 -0
  17. proofbundle-2.0.0b2/tests/test_anchors_ots.py +122 -0
  18. proofbundle-2.0.0b2/tests/test_anchors_rfc3161.py +115 -0
  19. proofbundle-2.0.0b2/tests/test_branch_base_check.py +57 -0
  20. proofbundle-2.0.0b2/tests/test_experimental_enclave.py +207 -0
  21. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_fuzz_parsers.py +7 -0
  22. proofbundle-2.0.0b2/tests/test_intoto_claims_hygiene.py +53 -0
  23. proofbundle-2.0.0b2/tests/test_intoto_eval_result.py +172 -0
  24. proofbundle-2.0.0b2/tests/test_intoto_examples.py +86 -0
  25. proofbundle-2.0.0b2/tests/test_intoto_spec_diff.py +38 -0
  26. proofbundle-2.0.0b2/tests/test_intoto_svr.py +137 -0
  27. proofbundle-2.0.0b2/tests/test_verify_matrix.py +82 -0
  28. proofbundle-1.9.2/src/proofbundle/intoto.py +0 -182
  29. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/LICENSE +0 -0
  30. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/setup.cfg +0 -0
  31. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/_inspect_registry.py +0 -0
  32. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/_integration.py +0 -0
  33. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/__init__.py +0 -0
  34. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/_provenance.py +0 -0
  35. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/eee.py +0 -0
  36. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/inspect_ai.py +0 -0
  37. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/lm_eval.py +0 -0
  38. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/promptfoo.py +0 -0
  39. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/samples.py +0 -0
  40. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/beacon.py +0 -0
  41. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/bundle.py +0 -0
  42. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/checkpoint.py +0 -0
  43. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/demo.py +0 -0
  44. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/dsse.py +0 -0
  45. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/eee_eval_schema.json +0 -0
  46. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/emit.py +0 -0
  47. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/errors.py +0 -0
  48. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/evalclaim.py +0 -0
  49. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/hf_evals.py +0 -0
  50. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/inspect_hook.py +0 -0
  51. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/kbjwt.py +0 -0
  52. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/merkle.py +0 -0
  53. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/persample.py +0 -0
  54. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/prereg.py +0 -0
  55. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/py.typed +0 -0
  56. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/pytest_plugin.py +0 -0
  57. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt.py +0 -0
  58. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt_issue.py +0 -0
  59. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/signature.py +0 -0
  60. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/tlogproof.py +0 -0
  61. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  62. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/entry_points.txt +0 -0
  63. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/top_level.txt +0 -0
  64. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_adapters.py +0 -0
  65. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_adversarial.py +0 -0
  66. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_beacon.py +0 -0
  67. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_bundle.py +0 -0
  68. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_bundle_robustness.py +0 -0
  69. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_checkpoint.py +0 -0
  70. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_claims_hygiene.py +0 -0
  71. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cli.py +0 -0
  72. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cli_eval.py +0 -0
  73. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cosignature.py +0 -0
  74. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cosignature_mldsa.py +0 -0
  75. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_demo.py +0 -0
  76. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_docs_truth.py +0 -0
  77. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_eee.py +0 -0
  78. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_emit.py +0 -0
  79. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_eval_claim_schema.py +0 -0
  80. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_evalclaim.py +0 -0
  81. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_examples.py +0 -0
  82. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_hf_evals.py +0 -0
  83. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_inspect_hook.py +0 -0
  84. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_intoto.py +0 -0
  85. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_intoto_dsse.py +0 -0
  86. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_kbjwt.py +0 -0
  87. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_merkle.py +0 -0
  88. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_merkle_property.py +0 -0
  89. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_persample.py +0 -0
  90. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_prereg.py +0 -0
  91. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_promptfoo.py +0 -0
  92. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_provenance.py +0 -0
  93. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_pytest_plugin.py +0 -0
  94. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_rekor_interop.py +0 -0
  95. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_rfc6962_external_vectors.py +0 -0
  96. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_schema.py +0 -0
  97. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_sdjwt_issue.py +0 -0
  98. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_sdjwt_reference.py +0 -0
  99. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_signature.py +0 -0
  100. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_statuslist.py +0 -0
  101. {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_tlogproof.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 1.9.2
3
+ Version: 2.0.0b2
4
4
  Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
5
5
  Author: Konrad Gruszka
6
6
  License: MIT
@@ -27,7 +27,12 @@ Requires-Dist: cryptography>=42
27
27
  Provides-Extra: sdjwt
28
28
  Provides-Extra: eval
29
29
  Requires-Dist: rfc8785>=0.1.4; extra == "eval"
30
+ Provides-Extra: anchors
31
+ Requires-Dist: rfc3161-client>=1.0.6; extra == "anchors"
32
+ Requires-Dist: opentimestamps>=0.4.5; extra == "anchors"
33
+ Requires-Dist: rfc8785>=0.1.4; extra == "anchors"
30
34
  Provides-Extra: adapters
35
+ Provides-Extra: experimental
31
36
  Provides-Extra: pq
32
37
  Requires-Dist: cryptography>=48; extra == "pq"
33
38
  Provides-Extra: pytest
@@ -165,6 +170,7 @@ flowchart LR
165
170
  | Honest comparison to Rekor / in-toto / OMS / ValiChord | [INTEROP.md](INTEROP.md) |
166
171
  | Regulatory mapping (and what to never claim) | [COMPLIANCE.md](COMPLIANCE.md) |
167
172
  | Funders / role fit | [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md) |
173
+ | **Preview:** TEE-attestation bridge (v2.0 beta) | [docs/EXPERIMENTAL_ENCLAVE.md](docs/EXPERIMENTAL_ENCLAVE.md) |
168
174
 
169
175
  ## Install
170
176
 
@@ -117,6 +117,7 @@ flowchart LR
117
117
  | Honest comparison to Rekor / in-toto / OMS / ValiChord | [INTEROP.md](INTEROP.md) |
118
118
  | Regulatory mapping (and what to never claim) | [COMPLIANCE.md](COMPLIANCE.md) |
119
119
  | Funders / role fit | [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md) |
120
+ | **Preview:** TEE-attestation bridge (v2.0 beta) | [docs/EXPERIMENTAL_ENCLAVE.md](docs/EXPERIMENTAL_ENCLAVE.md) |
120
121
 
121
122
  ## Install
122
123
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "1.9.2"
7
+ version = "2.0.0b2"
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"
@@ -44,8 +44,19 @@ sdjwt = []
44
44
  # path (verify_bundle / decode_eval_claim) never canonicalizes — it checks stored bytes — so the
45
45
  # verifier stays dependency-free. `pip install proofbundle[eval]` adds emit-side canonicalization.
46
46
  eval = ["rfc8785>=0.1.4"]
47
+ # EXPERIMENTAL: external time anchors (proofbundle.anchors) — RFC 3161 TSA + OpenTimestamps. Opt-in and
48
+ # clearly experimental (API/format may change). rfc3161-client (Trail of Bits) is deliberately offline —
49
+ # it verifies an RFC 3161 token against bundled roots, no network in the verify path. opentimestamps is
50
+ # the OTS proof library. rfc8785 gives the receipt its canonical (JCS) root. The base install stays
51
+ # anchor-free (only cryptography); a receipt with no anchors verifies unchanged.
52
+ anchors = ["rfc3161-client>=1.0.6", "opentimestamps>=0.4.5", "rfc8785>=0.1.4"]
47
53
  # The lm-eval adapter reads exported results.json (no import) → pure stdlib.
48
54
  adapters = []
55
+ # EXPERIMENTAL (v2.0 preview): the TEE-attestation bridge (proofbundle.experimental.enclave).
56
+ # Opt-in and clearly unstable — API/wire-format may change or be removed without deprecation.
57
+ # No new runtime dependency (pure stdlib + the core `cryptography`); the extra exists to signal
58
+ # intent and gate the preview, mirroring the `sdjwt` extra. `pip install "proofbundle[experimental]"`.
59
+ experimental = []
49
60
  # ML-DSA-44 (FIPS 204) cosignature verification (C2SP tlog-cosignature type 0x06). Needs a
50
61
  # cryptography build with OpenSSL 3.5+ (default PyPI wheels since 48.0.0). Verify-only by default;
51
62
  # the emit helper needs the same. Ed25519 paths never require this.
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "1.9.2"
16
+ __version__ = "2.0.0b2"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -0,0 +1,185 @@
1
+ """Generic external time-anchor layer for proofbundle receipts (EXPERIMENTAL; `[anchors]` extra).
2
+
3
+ An **anchor** is external evidence that a target existed at (or before) a time — something the receipt's
4
+ own Ed25519 + Merkle structure cannot establish on its own, because a self-emitted timestamp is only
5
+ producer-clock testimony. Two targets, **never mixed**:
6
+
7
+ * ``preRegistration`` — "the commitment existed BEFORE the run" (backdating protection; the point raised
8
+ in in-toto/attestation#565).
9
+ * ``receipt`` — "the receipt existed from time T" (publication proof).
10
+
11
+ Each ``anchors[]`` entry is ``{type, target, canonicalRoot, proof, anchoredAt}``:
12
+
13
+ * ``type`` — ``rfc3161-tsa`` | ``opentimestamps`` | ``<extension>/vN``.
14
+ * ``target`` — ``receipt`` | ``preRegistration`` (see above).
15
+ * ``canonicalRoot`` — base64 of the canonical root of the target: for ``receipt`` the RFC 8785 (JCS)
16
+ sha256 of the receipt bundle; for ``preRegistration`` the sha256 of the raw protocol bytes (the
17
+ receipt's ``prereg_sha256``). The anchor timestamps THIS root.
18
+ * ``proof`` — base64 of the type-specific proof (an RFC 3161 token, an OpenTimestamps proof, ...).
19
+ * ``anchoredAt`` — RFC 3339 Z, INFORMATIVE only (the trusted time comes from the proof, not this field).
20
+
21
+ **Verify contract (fail-closed).** Missing/empty ``anchors`` → SKIP (never FAIL — consistent with
22
+ in-toto's Monotonic Principle: deny only when an attestation is present and wrong). Present → fail-closed:
23
+ a root mismatch, an unknown type, or a broken proof is a FAIL, never silent. ``require`` (CLI
24
+ ``--require-anchor <type|any>``) turns "no verifying anchor" into a FAIL.
25
+
26
+ **Cross-target safety.** ``canonicalRoot`` is compared to the root of the anchor's OWN ``target`` — a
27
+ ``preRegistration`` anchor can never validate a ``receipt`` target and vice versa (the roots differ).
28
+
29
+ **Lean core.** This module is pure dispatch + schema; the RFC 3161 / OpenTimestamps verifiers lazy-import
30
+ their libraries and are only needed with the ``[anchors]`` extra. The base install pulls only
31
+ ``cryptography``; a bundle with no anchors verifies unchanged. Anchoring writes a NEW file — a network
32
+ error while stamping never corrupts the local receipt.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import binascii
38
+ import hashlib
39
+ from typing import Callable, Optional
40
+
41
+ from .errors import BundleFormatError
42
+
43
+ ANCHOR_TARGETS = ("receipt", "preRegistration")
44
+ _ANCHOR_KEYS = {"type", "target", "canonicalRoot", "proof", "anchoredAt", "frozen"}
45
+
46
+ # type name -> verifier callable:
47
+ # (proof: bytes, canonical_root: bytes, *, frozen: dict, now: Optional[int]) -> {"ok": bool, "detail": str}
48
+ _VERIFIERS: dict[str, Callable] = {}
49
+
50
+
51
+ def register_anchor_type(type_name: str, verifier: Callable) -> None:
52
+ """Register a verifier for an anchor ``type``. A third party ships its own type this way (see
53
+ docs/ANCHORS.md). The verifier MUST be fail-closed: return ``{"ok": False, ...}`` on any doubt,
54
+ never raise for an ordinary bad proof."""
55
+ if not type_name or not isinstance(type_name, str) or not callable(verifier):
56
+ raise BundleFormatError("register_anchor_type needs a non-empty name and a callable verifier")
57
+ _VERIFIERS[type_name] = verifier
58
+
59
+
60
+ def registered_anchor_types() -> tuple:
61
+ _ensure_builtin_types()
62
+ return tuple(sorted(_VERIFIERS))
63
+
64
+
65
+ def _ensure_builtin_types() -> None:
66
+ """Lazily register the built-in anchor verifiers (rfc3161-tsa, opentimestamps). Each needs the
67
+ ``[anchors]`` extra; if a library is absent the type stays UNREGISTERED — which the verify path
68
+ treats as an unknown type → FAIL (fail-closed), exactly the behaviour we want without the extra."""
69
+ if "rfc3161-tsa" not in _VERIFIERS:
70
+ try:
71
+ from . import anchors_rfc3161 # noqa: PLC0415
72
+ _VERIFIERS["rfc3161-tsa"] = anchors_rfc3161.verify_rfc3161
73
+ except Exception: # extra missing / import failure → leave unregistered (fail-closed)
74
+ pass
75
+ if "opentimestamps" not in _VERIFIERS:
76
+ try:
77
+ from . import anchors_ots # noqa: PLC0415
78
+ _VERIFIERS["opentimestamps"] = anchors_ots.verify_opentimestamps
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ def _b64d(value, field: str) -> bytes:
84
+ if not isinstance(value, str):
85
+ raise BundleFormatError(f"anchor {field} must be a base64 string")
86
+ try:
87
+ return base64.b64decode(value, validate=True)
88
+ except (ValueError, binascii.Error) as exc:
89
+ raise BundleFormatError(f"anchor {field} is not valid base64") from exc
90
+
91
+
92
+ def receipt_canonical_root(bundle: dict) -> bytes:
93
+ """The RFC 8785 (JCS) sha256 of the receipt bundle — the canonical root a ``receipt`` anchor stamps.
94
+ Uses a real RFC 8785 canonicalizer (the ``[anchors]``/``[eval]`` extra); never a home-grown sort."""
95
+ try:
96
+ import rfc8785 # noqa: PLC0415
97
+ except ImportError as exc: # pragma: no cover - guarded by the extra
98
+ raise BundleFormatError(
99
+ "receipt anchoring needs the RFC 8785 canonicalizer — install proofbundle[anchors]") from exc
100
+ return hashlib.sha256(rfc8785.dumps(bundle)).digest()
101
+
102
+
103
+ def prereg_canonical_root(prereg_sha256_hex: str) -> bytes:
104
+ """The canonical root a ``preRegistration`` anchor stamps: the sha256 (raw bytes) of the eval
105
+ protocol file, i.e. the receipt's ``prereg_sha256``."""
106
+ if not isinstance(prereg_sha256_hex, str) or len(prereg_sha256_hex) != 64:
107
+ raise BundleFormatError("prereg canonical root needs a 64-char hex sha256")
108
+ try:
109
+ return bytes.fromhex(prereg_sha256_hex)
110
+ except ValueError as exc:
111
+ raise BundleFormatError("prereg_sha256 is not valid hex") from exc
112
+
113
+
114
+ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None) -> dict:
115
+ """Verify ONE anchor entry, fail-closed. ``target_roots`` maps a target name to its canonical root
116
+ bytes (only the targets that exist for this receipt). Returns ``{ok, type, target, detail}``."""
117
+ _ensure_builtin_types()
118
+ if not isinstance(anchor, dict):
119
+ raise BundleFormatError("each anchor must be a JSON object")
120
+ unknown = set(anchor) - _ANCHOR_KEYS
121
+ if unknown:
122
+ raise BundleFormatError(f"anchor has unknown field(s) {sorted(unknown)}")
123
+ atype = anchor.get("type")
124
+ target = anchor.get("target")
125
+ out = {"ok": False, "warn": False, "status": "fail", "type": atype, "target": target, "detail": ""}
126
+ if target not in ANCHOR_TARGETS:
127
+ out["detail"] = f"anchor target must be one of {ANCHOR_TARGETS}"
128
+ return out
129
+ if not isinstance(atype, str) or atype not in _VERIFIERS:
130
+ # Unknown type is a FAIL, not a SKIP — an anchor we cannot check must never pass silently.
131
+ out["detail"] = (f"no verifier registered for anchor type {atype!r} "
132
+ "(install proofbundle[anchors] or register the extension type)")
133
+ return out
134
+ expected_root = target_roots.get(target)
135
+ if expected_root is None:
136
+ out["detail"] = f"the receipt has no {target} target to anchor against"
137
+ return out
138
+ canonical_root = _b64d(anchor.get("canonicalRoot"), "canonicalRoot")
139
+ if canonical_root != expected_root:
140
+ # cross-target safety: a preRegistration anchor's root never equals the receipt root, and v.v.
141
+ out["detail"] = f"canonicalRoot does not match the {target} root (cross-target or tampered)"
142
+ return out
143
+ proof = _b64d(anchor.get("proof"), "proof")
144
+ try:
145
+ res = _VERIFIERS[atype](proof, canonical_root, frozen=anchor.get("frozen") or {}, now=now)
146
+ except Exception as exc: # a verifier must be fail-closed; if it raises, treat as FAIL, never pass
147
+ out["detail"] = f"anchor verifier error (fail-closed): {exc}"
148
+ return out
149
+ out["ok"] = bool(res.get("ok"))
150
+ out["warn"] = bool(res.get("warn"))
151
+ out["status"] = res.get("status") or ("pass" if out["ok"] else ("warn" if out["warn"] else "fail"))
152
+ out["detail"] = res.get("detail", "")
153
+ return out
154
+
155
+
156
+ def verify_anchors(anchors, *, target_roots: dict, require: Optional[str] = None,
157
+ now: Optional[int] = None) -> dict:
158
+ """Verify a receipt's ``anchors``. Missing/empty → SKIP (unless ``require`` is set → FAIL). Present →
159
+ fail-closed PASS/FAIL over every entry. ``require`` is ``None`` | ``'any'`` | a type string; when set,
160
+ at least one anchor of that type (or any) must verify. Returns ``{status, detail, results}`` with
161
+ ``status`` in {PASS, FAIL, SKIP}."""
162
+ if not anchors:
163
+ if require:
164
+ return {"status": "FAIL", "detail": f"--require-anchor {require} set but the receipt has no anchors",
165
+ "results": []}
166
+ return {"status": "SKIP", "detail": "no external time anchors present", "results": []}
167
+ if not isinstance(anchors, list):
168
+ raise BundleFormatError("anchors must be a list")
169
+ results = [verify_anchor(a, target_roots=target_roots, now=now) for a in anchors]
170
+ if require: # a warn/pending anchor never SATISFIES a requirement — only a real verified one
171
+ want = None if require == "any" else require
172
+ matched = [r for r in results if r["ok"] and (want is None or r["type"] == want)]
173
+ if not matched:
174
+ return {"status": "FAIL",
175
+ "detail": f"--require-anchor {require}: no verifying anchor of that type",
176
+ "results": results}
177
+ hard_fail = any(not r["ok"] and not r["warn"] for r in results)
178
+ if hard_fail:
179
+ status = "FAIL" # a broken/unbound/unknown anchor is never silent
180
+ elif any(r["warn"] for r in results):
181
+ status = "WARN" # e.g. a PENDING OpenTimestamps proof — not a full anchor yet
182
+ else:
183
+ status = "PASS"
184
+ detail = f"{sum(r['ok'] for r in results)}/{len(results)} anchor(s) verified"
185
+ return {"status": status, "detail": detail, "results": results}
@@ -0,0 +1,91 @@
1
+ """OpenTimestamps anchor (EXPERIMENTAL; the `[anchors]` extra).
2
+
3
+ Honest lifecycle (this is where OTS is easy to over-claim):
4
+
5
+ * A fresh stamp is submitted to public calendars and is **PENDING** — it commits your root but is not
6
+ yet anchored in Bitcoin. A pending proof is a WARN, never a full-strength anchor.
7
+ * ``ots upgrade`` embeds the Bitcoin block-header path; only then is the proof **upgraded** and
8
+ self-contained (no calendar needed to verify).
9
+ * Verifying an upgraded proof still needs the Bitcoin **block header** for the attested height — per the
10
+ documented client path, a local (pruned) Bitcoin node. There is no documented "header file instead of
11
+ a node" mode, and we do not claim one. If the caller supplies the block header (its Merkle root) in the
12
+ anchor's ``frozen`` block, we verify against it offline; otherwise we report, honestly, that the proof
13
+ is upgraded but Bitcoin verification needs a node/header — we never silently PASS it.
14
+
15
+ ``proof`` is the serialized detached OTS proof; ``canonicalRoot`` is the exact bytes that were stamped.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+
22
+ def _classify(timestamp):
23
+ """Return (has_bitcoin, bitcoin_heights, has_pending) over all attestations in the proof."""
24
+ from opentimestamps.core.notary import ( # noqa: PLC0415
25
+ BitcoinBlockHeaderAttestation, PendingAttestation,
26
+ )
27
+ heights, has_pending = [], False
28
+ for _msg, att in timestamp.all_attestations():
29
+ if isinstance(att, BitcoinBlockHeaderAttestation):
30
+ heights.append(att.height)
31
+ elif isinstance(att, PendingAttestation):
32
+ has_pending = True
33
+ return (bool(heights), heights, has_pending)
34
+
35
+
36
+ def verify_opentimestamps(proof: bytes, canonical_root: bytes, *, frozen: dict,
37
+ now: Optional[int] = None) -> dict:
38
+ """Fail-closed OTS verify. Returns {ok, detail, warn, status}. A pending proof is warn (status
39
+ 'pending'); an upgraded proof with no supplied block header is not-ok-but-not-warn honest report
40
+ (status 'upgraded_unverified'); an upgraded proof verified against a supplied header is ok (status
41
+ 'confirmed')."""
42
+ try:
43
+ from opentimestamps.core.serialize import BytesDeserializationContext # noqa: PLC0415
44
+ from opentimestamps.core.timestamp import DetachedTimestampFile # noqa: PLC0415
45
+ except ImportError:
46
+ return {"ok": False, "warn": False, "status": "no_lib",
47
+ "detail": "opentimestamps anchor needs proofbundle[anchors] (opentimestamps)"}
48
+ try:
49
+ dtf = DetachedTimestampFile.deserialize(BytesDeserializationContext(proof))
50
+ except Exception as exc: # any malformed proof → FAIL (fail-closed)
51
+ return {"ok": False, "warn": False, "status": "malformed",
52
+ "detail": f"OTS proof did not deserialize: {exc}"}
53
+ # structural binding: the proof must commit to EXACTLY the canonical root
54
+ if dtf.file_digest != canonical_root:
55
+ return {"ok": False, "warn": False, "status": "unbound",
56
+ "detail": "OTS proof does not commit to the target canonical root"}
57
+ has_bitcoin, heights, has_pending = _classify(dtf.timestamp)
58
+ if not has_bitcoin:
59
+ if has_pending:
60
+ return {"ok": False, "warn": True, "status": "pending",
61
+ "detail": "OTS proof is PENDING (submitted to calendars, not yet on Bitcoin) — "
62
+ "run `ots upgrade`; not a full anchor yet"}
63
+ return {"ok": False, "warn": False, "status": "empty",
64
+ "detail": "OTS proof has no Bitcoin or pending attestation"}
65
+ # upgraded: to verify offline we need the block's Merkle root for the attested height, supplied by a
66
+ # trusted (local pruned) Bitcoin node — proofbundle never fetches it. BitcoinBlockHeaderAttestation's
67
+ # own check is exactly `attestation_message == block_header.hashMerkleRoot`; we do that comparison
68
+ # directly against the supplied root (equivalent, and avoids reconstructing a full CBlockHeader).
69
+ headers = frozen.get("bitcoinBlockHeaderMerkleRootsByHeight") or {}
70
+ for msg, att in dtf.timestamp.all_attestations():
71
+ height = getattr(att, "height", None)
72
+ if height is None:
73
+ continue
74
+ merkle_root_hex = headers.get(str(height))
75
+ if not merkle_root_hex:
76
+ continue
77
+ try:
78
+ expected = bytes.fromhex(merkle_root_hex)
79
+ except ValueError:
80
+ return {"ok": False, "warn": False, "status": "bad_header",
81
+ "detail": f"supplied Bitcoin block merkle root for height {height} is not valid hex"}
82
+ if msg == expected:
83
+ return {"ok": True, "warn": False, "status": "confirmed",
84
+ "detail": f"OTS proof confirmed: committed in the Bitcoin block at height {height} "
85
+ "(merkle root supplied by a trusted node)"}
86
+ return {"ok": False, "warn": False, "status": "block_mismatch",
87
+ "detail": f"OTS Bitcoin attestation at height {height} does not match the supplied "
88
+ "block merkle root"}
89
+ return {"ok": False, "warn": False, "status": "upgraded_unverified",
90
+ "detail": f"OTS proof is upgraded (Bitcoin height {heights}) but no block header was supplied "
91
+ "— offline verification needs a local (pruned) Bitcoin node; not claiming a pass"}
@@ -0,0 +1,90 @@
1
+ """RFC 3161 Time-Stamp Authority anchor (EXPERIMENTAL; the `[anchors]` extra).
2
+
3
+ Verification is OFFLINE (Trail of Bits ``rfc3161-client`` — deliberately no network in verify): an RFC
4
+ 3161 token is checked against the TSA certificate chain **frozen into the anchor at emit time**. Freezing
5
+ matters because a TSA can rotate its certificate (FreeTSA rotated in March 2026); an old token is then
6
+ only re-verifiable against the chain that was current when it was issued. The frozen material lives in the
7
+ anchor's ``frozen`` block as base64 DER, so no PEM files travel with a receipt.
8
+
9
+ ``proof`` is the base64 DER of the RFC 3161 response; ``canonicalRoot`` is the exact bytes that were
10
+ timestamped (the anchor layer has already matched it to the receipt's target root). Creating an anchor
11
+ (``create_rfc3161_anchor``) does the network call to the TSA and writes a NEW anchor object; a network
12
+ error there never touches the local receipt.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ from typing import Optional
18
+
19
+
20
+ def _load_der_cert(b64: str):
21
+ from cryptography import x509 # noqa: PLC0415
22
+ return x509.load_der_x509_certificate(base64.b64decode(b64))
23
+
24
+
25
+ def verify_rfc3161(proof: bytes, canonical_root: bytes, *, frozen: dict, now: Optional[int] = None) -> dict:
26
+ """Fail-closed offline verify of an RFC 3161 token against the frozen chain. Returns {ok, detail}."""
27
+ try:
28
+ import rfc3161_client as tsp # noqa: PLC0415
29
+ except ImportError:
30
+ return {"ok": False, "detail": "rfc3161-tsa anchor needs proofbundle[anchors] (rfc3161-client)"}
31
+ roots = frozen.get("rootCertsDerB64") or []
32
+ if not roots:
33
+ return {"ok": False, "detail": "frozen chain is missing rootCertsDerB64 (cannot verify offline)"}
34
+ try:
35
+ response = tsp.decode_timestamp_response(proof)
36
+ builder = tsp.VerifierBuilder()
37
+ for rb in roots:
38
+ builder = builder.add_root_certificate(_load_der_cert(rb))
39
+ for ib in frozen.get("intermediateCertsDerB64", []) or []:
40
+ builder = builder.add_intermediate_certificate(_load_der_cert(ib))
41
+ tsa_b64 = frozen.get("tsaCertDerB64")
42
+ if tsa_b64:
43
+ builder = builder.tsa_certificate(_load_der_cert(tsa_b64))
44
+ builder.build().verify_message(response, canonical_root)
45
+ except Exception as exc: # any verify failure is a FAIL, never a silent pass (fail-closed)
46
+ return {"ok": False, "detail": f"RFC 3161 token did not verify against the frozen chain: {exc}"}
47
+ return {"ok": True, "detail": "RFC 3161 token verified offline against the frozen TSA chain"}
48
+
49
+
50
+ def create_rfc3161_anchor(canonical_root: bytes, target: str, *, tsa_url: str,
51
+ root_certs_der: list, tsa_cert_der: Optional[bytes] = None,
52
+ intermediate_certs_der: Optional[list] = None,
53
+ anchored_at: Optional[str] = None, timeout: int = 30) -> dict:
54
+ """Stamp ``canonical_root`` at ``tsa_url`` and return a NEW anchor object with the chain frozen in.
55
+
56
+ Network call (POST an RFC 3161 query). The caller supplies the TSA's root cert(s) (and, for TSAs that
57
+ do not embed it, the TSA cert) so the chain can be frozen for offline re-verification. This function
58
+ only builds and returns the anchor dict — writing it into a receipt is the caller's job, so a network
59
+ failure here never corrupts the local receipt.
60
+ """
61
+ import urllib.request # noqa: PLC0415
62
+
63
+ import rfc3161_client as tsp # noqa: PLC0415
64
+ request = tsp.TimestampRequestBuilder().data(canonical_root).cert_request().build()
65
+ http = urllib.request.Request(
66
+ tsa_url, data=request.as_bytes(), method="POST",
67
+ headers={"Content-Type": "application/timestamp-query",
68
+ "Accept": "application/timestamp-reply"})
69
+ with urllib.request.urlopen(http, timeout=timeout) as resp:
70
+ token = resp.read()
71
+ # sanity: the response must be granted and verify against the supplied chain before we freeze it
72
+ frozen: dict = {
73
+ "rootCertsDerB64": [base64.b64encode(c).decode("ascii") for c in root_certs_der],
74
+ }
75
+ if tsa_cert_der:
76
+ frozen["tsaCertDerB64"] = base64.b64encode(tsa_cert_der).decode("ascii")
77
+ if intermediate_certs_der:
78
+ frozen["intermediateCertsDerB64"] = [base64.b64encode(c).decode("ascii")
79
+ for c in intermediate_certs_der]
80
+ check = verify_rfc3161(token, canonical_root, frozen=frozen)
81
+ if not check["ok"]:
82
+ raise RuntimeError(f"refusing to build anchor: fresh token did not verify — {check['detail']}")
83
+ return {
84
+ "type": "rfc3161-tsa",
85
+ "target": target,
86
+ "canonicalRoot": base64.b64encode(canonical_root).decode("ascii"),
87
+ "proof": base64.b64encode(token).decode("ascii"),
88
+ "anchoredAt": anchored_at,
89
+ "frozen": frozen,
90
+ }