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.
- {proofbundle-1.9.2/src/proofbundle.egg-info → proofbundle-2.0.0b2}/PKG-INFO +7 -1
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/README.md +1 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/pyproject.toml +12 -1
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/__init__.py +1 -1
- proofbundle-2.0.0b2/src/proofbundle/anchors.py +185 -0
- proofbundle-2.0.0b2/src/proofbundle/anchors_ots.py +91 -0
- proofbundle-2.0.0b2/src/proofbundle/anchors_rfc3161.py +90 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/cli.py +192 -0
- proofbundle-2.0.0b2/src/proofbundle/experimental/__init__.py +28 -0
- proofbundle-2.0.0b2/src/proofbundle/experimental/enclave.py +196 -0
- proofbundle-2.0.0b2/src/proofbundle/intoto.py +468 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/statuslist.py +6 -2
- {proofbundle-1.9.2 → proofbundle-2.0.0b2/src/proofbundle.egg-info}/PKG-INFO +7 -1
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/SOURCES.txt +17 -1
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/requires.txt +7 -0
- proofbundle-2.0.0b2/tests/test_anchors_generic.py +111 -0
- proofbundle-2.0.0b2/tests/test_anchors_ots.py +122 -0
- proofbundle-2.0.0b2/tests/test_anchors_rfc3161.py +115 -0
- proofbundle-2.0.0b2/tests/test_branch_base_check.py +57 -0
- proofbundle-2.0.0b2/tests/test_experimental_enclave.py +207 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_fuzz_parsers.py +7 -0
- proofbundle-2.0.0b2/tests/test_intoto_claims_hygiene.py +53 -0
- proofbundle-2.0.0b2/tests/test_intoto_eval_result.py +172 -0
- proofbundle-2.0.0b2/tests/test_intoto_examples.py +86 -0
- proofbundle-2.0.0b2/tests/test_intoto_spec_diff.py +38 -0
- proofbundle-2.0.0b2/tests/test_intoto_svr.py +137 -0
- proofbundle-2.0.0b2/tests/test_verify_matrix.py +82 -0
- proofbundle-1.9.2/src/proofbundle/intoto.py +0 -182
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/LICENSE +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/setup.cfg +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/_integration.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/_provenance.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/promptfoo.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/adapters/samples.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/beacon.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/bundle.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/checkpoint.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/demo.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/dsse.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/emit.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/errors.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/evalclaim.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/hf_evals.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/kbjwt.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/merkle.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/persample.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/prereg.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/py.typed +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt_issue.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/signature.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle/tlogproof.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_adapters.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_adversarial.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_beacon.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_bundle.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_checkpoint.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_claims_hygiene.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cli.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cli_eval.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cosignature.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_cosignature_mldsa.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_demo.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_docs_truth.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_eee.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_emit.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_evalclaim.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_examples.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_hf_evals.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_inspect_hook.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_intoto.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_kbjwt.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_merkle.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_merkle_property.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_persample.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_prereg.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_promptfoo.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_provenance.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_rekor_interop.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_schema.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_sdjwt_reference.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_signature.py +0 -0
- {proofbundle-1.9.2 → proofbundle-2.0.0b2}/tests/test_statuslist.py +0 -0
- {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:
|
|
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 = "
|
|
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.
|
|
@@ -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
|
+
}
|