proofbundle 2.0.0b1__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-2.0.0b1/src/proofbundle.egg-info → proofbundle-2.0.0b2}/PKG-INFO +24 -8
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/README.md +19 -7
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/pyproject.toml +7 -1
- {proofbundle-2.0.0b1 → 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-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/bundle.py +14 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/cli.py +153 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/evalclaim.py +8 -0
- proofbundle-2.0.0b2/src/proofbundle/intoto.py +468 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2/src/proofbundle.egg-info}/PKG-INFO +24 -8
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/SOURCES.txt +16 -1
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/requires.txt +5 -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_claims_hygiene.py +48 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cli_eval.py +25 -0
- proofbundle-2.0.0b2/tests/test_docs_truth.py +46 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_evalclaim.py +23 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_experimental_enclave.py +4 -2
- 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.0b1 → proofbundle-2.0.0b2}/tests/test_kbjwt.py +16 -0
- proofbundle-2.0.0b2/tests/test_verify_matrix.py +82 -0
- proofbundle-2.0.0b1/src/proofbundle/intoto.py +0 -182
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/LICENSE +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/setup.cfg +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/_integration.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/_provenance.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/promptfoo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/samples.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/beacon.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/checkpoint.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/demo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/dsse.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/emit.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/errors.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/experimental/__init__.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/experimental/enclave.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/hf_evals.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/kbjwt.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/merkle.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/persample.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/prereg.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/py.typed +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt_issue.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/signature.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/statuslist.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/tlogproof.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_adapters.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_adversarial.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_beacon.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_bundle.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_checkpoint.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cli.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cosignature.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cosignature_mldsa.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_demo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_eee.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_emit.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_examples.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_fuzz_parsers.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_hf_evals.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_inspect_hook.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_intoto.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_merkle.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_merkle_property.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_persample.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_prereg.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_promptfoo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_provenance.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_rekor_interop.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_schema.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_sdjwt_reference.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_signature.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_statuslist.py +0 -0
- {proofbundle-2.0.0b1 → 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: 2.0.
|
|
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,6 +27,10 @@ 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
|
|
31
35
|
Provides-Extra: experimental
|
|
32
36
|
Provides-Extra: pq
|
|
@@ -56,9 +60,11 @@ Dynamic: license-file
|
|
|
56
60
|
|
|
57
61
|
<h1>proofbundle</h1>
|
|
58
62
|
|
|
59
|
-
**
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
**AI eval results need receipts.**
|
|
64
|
+
|
|
65
|
+
Turn an AI evaluation result into one portable, offline-verifiable receipt. It proves *who signed
|
|
66
|
+
these exact bytes* and *that nothing changed since* — not that the number is true. Ed25519 + RFC 6962
|
|
67
|
+
Merkle, one file, no server, no network.
|
|
62
68
|
|
|
63
69
|
[](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
|
|
64
70
|
[](LICENSE)
|
|
@@ -109,6 +115,13 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
|
|
|
109
115
|
and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
|
|
110
116
|
**[THREAT_MODEL.md](THREAT_MODEL.md)**.
|
|
111
117
|
|
|
118
|
+
## In plain language
|
|
119
|
+
|
|
120
|
+
A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
|
|
121
|
+
number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
|
|
122
|
+
cash-register receipt does not show the meal was good — but without a receipt there is nothing to
|
|
123
|
+
check at all.
|
|
124
|
+
|
|
112
125
|
## How it fits together
|
|
113
126
|
|
|
114
127
|
```mermaid
|
|
@@ -133,13 +146,16 @@ flowchart LR
|
|
|
133
146
|
- **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
|
|
134
147
|
post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
|
|
135
148
|
- **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
|
|
136
|
-
nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root.
|
|
137
|
-
|
|
149
|
+
nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. With
|
|
150
|
+
such an auditor-supplied or beacon-bound challenge, 300 samples catch 1% sample-doctoring with 95%
|
|
151
|
+
confidence, regardless of run size — a challenge the issuer chose itself does not give this
|
|
152
|
+
guarantee.
|
|
138
153
|
- **Pre-registration** — `proofbundle prereg <plan>` commits to the protocol before the run, so
|
|
139
154
|
best-of-many publishing becomes visible.
|
|
140
155
|
- **Integrations** — opt-in inspect_ai end-of-task hook and pytest plugin (emit only when
|
|
141
156
|
`PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
|
|
142
|
-
[INTEGRATIONS.md](INTEGRATIONS.md)
|
|
157
|
+
[INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
|
|
158
|
+
[docs/INSPECT_HAPPY_PATH.md](docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
|
|
143
159
|
|
|
144
160
|
## Docs
|
|
145
161
|
|
|
@@ -170,7 +186,7 @@ Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 co
|
|
|
170
186
|
|
|
171
187
|
## Status & scope
|
|
172
188
|
|
|
173
|
-
Beta, SemVer-committed,
|
|
189
|
+
Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
|
|
174
190
|
is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
|
|
175
191
|
**not** a log service, a full in-toto client, a TEE, a consensus network, or a compliance product
|
|
176
192
|
by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
<h1>proofbundle</h1>
|
|
9
9
|
|
|
10
|
-
**
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
**AI eval results need receipts.**
|
|
11
|
+
|
|
12
|
+
Turn an AI evaluation result into one portable, offline-verifiable receipt. It proves *who signed
|
|
13
|
+
these exact bytes* and *that nothing changed since* — not that the number is true. Ed25519 + RFC 6962
|
|
14
|
+
Merkle, one file, no server, no network.
|
|
13
15
|
|
|
14
16
|
[](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
|
|
15
17
|
[](LICENSE)
|
|
@@ -60,6 +62,13 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
|
|
|
60
62
|
and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
|
|
61
63
|
**[THREAT_MODEL.md](THREAT_MODEL.md)**.
|
|
62
64
|
|
|
65
|
+
## In plain language
|
|
66
|
+
|
|
67
|
+
A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
|
|
68
|
+
number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
|
|
69
|
+
cash-register receipt does not show the meal was good — but without a receipt there is nothing to
|
|
70
|
+
check at all.
|
|
71
|
+
|
|
63
72
|
## How it fits together
|
|
64
73
|
|
|
65
74
|
```mermaid
|
|
@@ -84,13 +93,16 @@ flowchart LR
|
|
|
84
93
|
- **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
|
|
85
94
|
post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
|
|
86
95
|
- **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
|
|
87
|
-
nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root.
|
|
88
|
-
|
|
96
|
+
nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. With
|
|
97
|
+
such an auditor-supplied or beacon-bound challenge, 300 samples catch 1% sample-doctoring with 95%
|
|
98
|
+
confidence, regardless of run size — a challenge the issuer chose itself does not give this
|
|
99
|
+
guarantee.
|
|
89
100
|
- **Pre-registration** — `proofbundle prereg <plan>` commits to the protocol before the run, so
|
|
90
101
|
best-of-many publishing becomes visible.
|
|
91
102
|
- **Integrations** — opt-in inspect_ai end-of-task hook and pytest plugin (emit only when
|
|
92
103
|
`PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
|
|
93
|
-
[INTEGRATIONS.md](INTEGRATIONS.md)
|
|
104
|
+
[INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
|
|
105
|
+
[docs/INSPECT_HAPPY_PATH.md](docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
|
|
94
106
|
|
|
95
107
|
## Docs
|
|
96
108
|
|
|
@@ -121,7 +133,7 @@ Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 co
|
|
|
121
133
|
|
|
122
134
|
## Status & scope
|
|
123
135
|
|
|
124
|
-
Beta, SemVer-committed,
|
|
136
|
+
Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
|
|
125
137
|
is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
|
|
126
138
|
**not** a log service, a full in-toto client, a TEE, a consensus network, or a compliance product
|
|
127
139
|
by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proofbundle"
|
|
7
|
-
version = "2.0.
|
|
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,6 +44,12 @@ 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 = []
|
|
49
55
|
# EXPERIMENTAL (v2.0 preview): the TEE-attestation bridge (proofbundle.experimental.enclave).
|
|
@@ -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
|
+
}
|
|
@@ -153,6 +153,7 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
|
|
|
153
153
|
|
|
154
154
|
# 3. optional SD-JWT selective disclosure credential
|
|
155
155
|
sd = bundle.get("sd_jwt_vc")
|
|
156
|
+
kb_binding_checked = False # F4: did a KB-JWT (the aud/nonce carrier) actually get verified?
|
|
156
157
|
if sd is not None:
|
|
157
158
|
sd = _require_dict(sd, "sd_jwt_vc")
|
|
158
159
|
_reject_unknown(sd, _SD_KEYS, "sd_jwt_vc")
|
|
@@ -186,6 +187,7 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
|
|
|
186
187
|
if kb is not None:
|
|
187
188
|
kb_res = verify_key_binding(compact, expected_aud=expected_aud, expected_nonce=expected_nonce)
|
|
188
189
|
result.add("sd-jwt-key-binding", kb_res["ok"], kb_res["detail"])
|
|
190
|
+
kb_binding_checked = True
|
|
189
191
|
elif _issuer_requires_holder_binding(sd_part):
|
|
190
192
|
result.add(
|
|
191
193
|
"sd-jwt-key-binding", False,
|
|
@@ -206,6 +208,18 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
|
|
|
206
208
|
"binding is unverifiable, refusing (fail-closed; supply "
|
|
207
209
|
"sd_jwt_vc.issuer_public_key_b64)")
|
|
208
210
|
|
|
211
|
+
# F4 (v1.9.2, fail-closed): supplying expected_aud/expected_nonce asks for RFC 9901 §7.3
|
|
212
|
+
# replay/audience binding. A bundle with no verifiable KB-JWT (no sd_jwt_vc at all, or an
|
|
213
|
+
# sd_jwt_vc without a Key Binding JWT) carries nothing to bind to — returning OK anyway is a
|
|
214
|
+
# downgrade trap: the verifier believes the presentation was bound to its aud/nonce when it was
|
|
215
|
+
# not. Refuse the binding it asked for but cannot be enforced. Verifiers that pass no expected_*
|
|
216
|
+
# are unaffected (backward-compatible: the check only fires when a binding was actually requested).
|
|
217
|
+
if (expected_aud is not None or expected_nonce is not None) and not kb_binding_checked:
|
|
218
|
+
result.add(
|
|
219
|
+
"sd-jwt-key-binding", False,
|
|
220
|
+
"expected_aud/expected_nonce were supplied but the bundle carries no verifiable Key "
|
|
221
|
+
"Binding JWT — the requested replay/audience binding cannot be enforced (fail-closed)")
|
|
222
|
+
|
|
209
223
|
return result
|
|
210
224
|
|
|
211
225
|
|