actproof 0.2.0__py3-none-any.whl
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.
- actproof/__init__.py +234 -0
- actproof/anchor.py +586 -0
- actproof/canonical.py +369 -0
- actproof/catalogue.py +1031 -0
- actproof/cli.py +593 -0
- actproof/manifest.py +728 -0
- actproof/receipt.py +678 -0
- actproof/signers/__init__.py +89 -0
- actproof/signers/google_kms.py +392 -0
- actproof/signers/interface.py +298 -0
- actproof/signers/mnemonic.py +153 -0
- actproof/timestamp.py +527 -0
- actproof/verify.py +683 -0
- actproof-0.2.0.dist-info/METADATA +295 -0
- actproof-0.2.0.dist-info/RECORD +18 -0
- actproof-0.2.0.dist-info/WHEEL +4 -0
- actproof-0.2.0.dist-info/entry_points.txt +2 -0
- actproof-0.2.0.dist-info/licenses/LICENSE +21 -0
actproof/timestamp.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Deyan Paroushev
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
Acquire RFC 3161 trusted timestamp tokens with QTSP failover.
|
|
5
|
+
|
|
6
|
+
A timestamp token is a TSA's signed assertion that a given hash existed at a
|
|
7
|
+
specific moment in time. For actproof, that hash is typically the
|
|
8
|
+
``manifest_hash`` of a commitment. The token plus the TSA's certificate
|
|
9
|
+
chain produce non-repudiable proof of existence: anyone who later receives
|
|
10
|
+
the receipt can verify the token's signature, extract the timestamp, and
|
|
11
|
+
confirm "this hash was already known by <date>" without needing to trust
|
|
12
|
+
either the issuer or the actproof platform.
|
|
13
|
+
|
|
14
|
+
This module owns the **acquisition** half: build an RFC 3161 TimeStampReq,
|
|
15
|
+
POST it to a TSA, parse the TimeStampResp, validate the response is
|
|
16
|
+
well-formed, extract metadata, return a typed ``TimestampToken``. The
|
|
17
|
+
**verification** half (validating the token's CMS signature against the
|
|
18
|
+
TSA's certificate chain end-to-end) lives in ``actproof.verify`` (v0.0.9).
|
|
19
|
+
|
|
20
|
+
QTSP failover chain
|
|
21
|
+
-------------------
|
|
22
|
+
|
|
23
|
+
``DEFAULT_TSA_CHAIN`` lists six TSAs in priority order:
|
|
24
|
+
|
|
25
|
+
1. **Sectigo Qualified** - `timestamp.sectigo.com/qualified`
|
|
26
|
+
2. **QuoVadis EU** - `ts.quovadisglobal.com/eu` (DigiCert eIDAS QTSP)
|
|
27
|
+
3. **Izenpe TSA** - `tsa.izenpe.com` (Basque government QTSP)
|
|
28
|
+
4. **Belgium TSA** - `tsa.belgium.be/connect` (Belgian government QTSP)
|
|
29
|
+
5. **DigiCert** - `timestamp.digicert.com` (public, high availability)
|
|
30
|
+
6. **freetsa.org** - `freetsa.org/tsr` (public, free, no SLA)
|
|
31
|
+
|
|
32
|
+
The first four are EU-qualified TSP candidates. Whether a given TSA holds
|
|
33
|
+
qualified status at any given moment depends on the EU Trusted List
|
|
34
|
+
(eIDAS Regulation 910/2014); actproof does not assert qualification, it
|
|
35
|
+
records which TSA actually returned the token. Verifiers can check current
|
|
36
|
+
qualified status against the EUTL themselves.
|
|
37
|
+
|
|
38
|
+
The four EU candidates are tried first because their tokens have stronger
|
|
39
|
+
legal weight in EU jurisdictions. The two public fallbacks exist because
|
|
40
|
+
free public TSAs are sometimes the only reachable option (corporate firewalls
|
|
41
|
+
blocking exotic endpoints, EU TSAs down for maintenance, etc.). A token from
|
|
42
|
+
DigiCert is still a valid RFC 3161 token; it just doesn't carry an eIDAS
|
|
43
|
+
qualified signature.
|
|
44
|
+
|
|
45
|
+
Counter-timestamping (acquiring a second token from a different TSA over
|
|
46
|
+
the same imprint, for defense against a single TSA failure or compromise)
|
|
47
|
+
is a v0.0.7+ concern; this module produces single tokens.
|
|
48
|
+
|
|
49
|
+
Acquisition flow
|
|
50
|
+
----------------
|
|
51
|
+
|
|
52
|
+
::
|
|
53
|
+
|
|
54
|
+
from actproof.canonical import hash_canonical
|
|
55
|
+
from actproof.manifest import manifest_to_dict
|
|
56
|
+
from actproof.timestamp import acquire_timestamp_token
|
|
57
|
+
|
|
58
|
+
manifest_dict = manifest_to_dict(my_manifest)
|
|
59
|
+
imprint_bytes = hash_canonical(manifest_dict) # 32 bytes
|
|
60
|
+
|
|
61
|
+
result = acquire_timestamp_token(imprint_bytes)
|
|
62
|
+
print(result.token.tsa_name) # e.g. "QuoVadis EU"
|
|
63
|
+
print(result.token.timestamp) # ISO 8601 UTC
|
|
64
|
+
print(result.attempts) # which TSAs succeeded/failed
|
|
65
|
+
|
|
66
|
+
API
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
* ``acquire_timestamp_token(imprint, *, ...) -> AcquisitionResult``
|
|
70
|
+
* ``TimestampAuthority`` - one TSA endpoint description.
|
|
71
|
+
* ``TSAAttempt`` - record of one acquisition attempt (success or failure).
|
|
72
|
+
* ``AcquisitionResult`` - the bundle of (token, attempts) returned.
|
|
73
|
+
* ``TimestampError`` - raised when all TSAs in the chain fail.
|
|
74
|
+
* ``DEFAULT_TSA_CHAIN`` - the production failover chain.
|
|
75
|
+
* ``DEFAULT_TIMEOUT_SECONDS`` - default per-TSA timeout.
|
|
76
|
+
* ``SUPPORTED_HASH_ALGORITHMS`` - set of accepted ``hash_alg`` values.
|
|
77
|
+
|
|
78
|
+
Network usage
|
|
79
|
+
-------------
|
|
80
|
+
|
|
81
|
+
This module reaches out over HTTP(S) to TSA endpoints. In production
|
|
82
|
+
``acquire_timestamp_token`` is the first network-touching call in an
|
|
83
|
+
anchor workflow. Tests in this package mock the underlying ``tsp_client``
|
|
84
|
+
calls so the test suite does not require network access.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
from __future__ import annotations
|
|
88
|
+
|
|
89
|
+
import base64
|
|
90
|
+
import hashlib
|
|
91
|
+
import logging
|
|
92
|
+
import time
|
|
93
|
+
from dataclasses import dataclass
|
|
94
|
+
from datetime import datetime, timezone
|
|
95
|
+
from typing import Any, Callable, Optional, Sequence
|
|
96
|
+
|
|
97
|
+
import requests
|
|
98
|
+
|
|
99
|
+
from actproof.receipt import TimestampToken
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
from tsp_client import SigningSettings, TSPSigner, TSPVerifier
|
|
103
|
+
from tsp_client.algorithms import DigestAlgorithm
|
|
104
|
+
_TSP_CLIENT_AVAILABLE: bool = True
|
|
105
|
+
_TSP_CLIENT_ERROR: Optional[str] = None
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
# tsp_client is a hard dependency in pyproject.toml, but transitive
|
|
108
|
+
# version conflicts (e.g. cryptography vs pyOpenSSL) can make it fail
|
|
109
|
+
# to import in some environments, sometimes with errors other than
|
|
110
|
+
# ImportError (AttributeError from a removed C-binding symbol, for
|
|
111
|
+
# example). Catch broadly so the rest of actproof remains usable;
|
|
112
|
+
# defer failure to call time. Tests can still monkeypatch the
|
|
113
|
+
# placeholder symbols below.
|
|
114
|
+
_TSP_CLIENT_AVAILABLE = False
|
|
115
|
+
_TSP_CLIENT_ERROR = str(exc)
|
|
116
|
+
|
|
117
|
+
class _UnavailableTSPSigner:
|
|
118
|
+
"""Placeholder. Real ``TSPSigner`` is provided by tsp_client when
|
|
119
|
+
available; in environments where the import fails, this stub stays
|
|
120
|
+
in place. Tests can monkeypatch ``sign`` to swap in fakes."""
|
|
121
|
+
def sign(self, message_digest: bytes, signing_settings: Any) -> bytes:
|
|
122
|
+
raise TimestampError(
|
|
123
|
+
f"tsp-client is required but cannot be imported: "
|
|
124
|
+
f"{_TSP_CLIENT_ERROR}. "
|
|
125
|
+
f"Install or repair with: pip install 'tsp-client==0.2.1'"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
class _UnavailableTSPVerifier:
|
|
129
|
+
def verify(self, token: bytes, message_digest: bytes) -> Any:
|
|
130
|
+
raise TimestampError(
|
|
131
|
+
f"tsp-client is required but cannot be imported: "
|
|
132
|
+
f"{_TSP_CLIENT_ERROR}."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
class _PlaceholderSigningSettings:
|
|
136
|
+
"""Placeholder settings constructor; accepts arbitrary kwargs."""
|
|
137
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
138
|
+
self.kwargs = kwargs
|
|
139
|
+
|
|
140
|
+
class _PlaceholderDigestAlgorithm:
|
|
141
|
+
"""Placeholder enum-like; values are string sentinels."""
|
|
142
|
+
SHA224 = "SHA224"
|
|
143
|
+
SHA256 = "SHA256"
|
|
144
|
+
SHA384 = "SHA384"
|
|
145
|
+
SHA512 = "SHA512"
|
|
146
|
+
SHA3_224 = "SHA3_224"
|
|
147
|
+
SHA3_256 = "SHA3_256"
|
|
148
|
+
SHA3_384 = "SHA3_384"
|
|
149
|
+
SHA3_512 = "SHA3_512"
|
|
150
|
+
|
|
151
|
+
SigningSettings = _PlaceholderSigningSettings # type: ignore[assignment,misc]
|
|
152
|
+
TSPSigner = _UnavailableTSPSigner # type: ignore[assignment,misc]
|
|
153
|
+
TSPVerifier = _UnavailableTSPVerifier # type: ignore[assignment,misc]
|
|
154
|
+
DigestAlgorithm = _PlaceholderDigestAlgorithm # type: ignore[assignment,misc]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
logger = logging.getLogger(__name__)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = [
|
|
161
|
+
"TimestampAuthority",
|
|
162
|
+
"TSAAttempt",
|
|
163
|
+
"AcquisitionResult",
|
|
164
|
+
"TimestampError",
|
|
165
|
+
"acquire_timestamp_token",
|
|
166
|
+
"DEFAULT_TSA_CHAIN",
|
|
167
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
168
|
+
"SUPPORTED_HASH_ALGORITHMS",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ─────────────────────────────────────────────────────────────────
|
|
173
|
+
# CONSTANTS
|
|
174
|
+
# ─────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
DEFAULT_TIMEOUT_SECONDS: float = 10.0
|
|
177
|
+
"""Default per-TSA timeout when acquiring a token."""
|
|
178
|
+
|
|
179
|
+
# Algorithm name → tsp_client DigestAlgorithm enum
|
|
180
|
+
_DIGEST_MAP: dict[str, DigestAlgorithm] = {
|
|
181
|
+
"sha-256": DigestAlgorithm.SHA256,
|
|
182
|
+
"sha-384": DigestAlgorithm.SHA384,
|
|
183
|
+
"sha-512": DigestAlgorithm.SHA512,
|
|
184
|
+
"sha-224": DigestAlgorithm.SHA224,
|
|
185
|
+
"sha3-256": DigestAlgorithm.SHA3_256,
|
|
186
|
+
"sha3-384": DigestAlgorithm.SHA3_384,
|
|
187
|
+
"sha3-512": DigestAlgorithm.SHA3_512,
|
|
188
|
+
"sha3-224": DigestAlgorithm.SHA3_224,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
SUPPORTED_HASH_ALGORITHMS: frozenset[str] = frozenset(_DIGEST_MAP.keys())
|
|
192
|
+
"""Hash algorithm names accepted by ``acquire_timestamp_token``."""
|
|
193
|
+
|
|
194
|
+
# Expected digest length in bytes, per algorithm.
|
|
195
|
+
_EXPECTED_DIGEST_BYTES: dict[str, int] = {
|
|
196
|
+
"sha-224": 28,
|
|
197
|
+
"sha-256": 32,
|
|
198
|
+
"sha-384": 48,
|
|
199
|
+
"sha-512": 64,
|
|
200
|
+
"sha3-224": 28,
|
|
201
|
+
"sha3-256": 32,
|
|
202
|
+
"sha3-384": 48,
|
|
203
|
+
"sha3-512": 64,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ─────────────────────────────────────────────────────────────────
|
|
208
|
+
# EXCEPTIONS
|
|
209
|
+
# ─────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
class TimestampError(RuntimeError):
|
|
212
|
+
"""Raised when all TSAs in the failover chain fail to produce a token.
|
|
213
|
+
|
|
214
|
+
When ``raise_on_failure=False`` is passed to ``acquire_timestamp_token``,
|
|
215
|
+
callers receive an ``AcquisitionResult`` with ``token=None`` instead of
|
|
216
|
+
this exception, so they can inspect the attempts list and decide what to
|
|
217
|
+
do (retry later, fall through to a different operational mode, etc.).
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ─────────────────────────────────────────────────────────────────
|
|
222
|
+
# DATA CLASSES
|
|
223
|
+
# ─────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class TimestampAuthority:
|
|
227
|
+
"""One TSA endpoint description.
|
|
228
|
+
|
|
229
|
+
Attributes:
|
|
230
|
+
name: Human-readable TSA name. Recorded in the returned
|
|
231
|
+
``TimestampToken.tsa_name`` for receipts.
|
|
232
|
+
url: HTTP(S) endpoint URL. RFC 3161 TSAs commonly speak HTTP only
|
|
233
|
+
(the protocol carries its own cryptographic protection via the
|
|
234
|
+
TSToken's signature); this is normal.
|
|
235
|
+
profile: Free-form profile tag describing what kind of TSA this is.
|
|
236
|
+
Common values: ``"eu-qualified-candidate"`` for EU TSPs that
|
|
237
|
+
*may* hold qualified status (verify against EUTL),
|
|
238
|
+
``"public"`` for free or fallback TSAs, ``"configured"`` for
|
|
239
|
+
user-supplied endpoints.
|
|
240
|
+
"""
|
|
241
|
+
name: str
|
|
242
|
+
url: str
|
|
243
|
+
profile: str = "public"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class TSAAttempt:
|
|
248
|
+
"""Record of one attempt against a TSA. Returned in the attempts list
|
|
249
|
+
regardless of success or failure, for diagnostics.
|
|
250
|
+
|
|
251
|
+
Attributes:
|
|
252
|
+
tsa_name: The TSA's name.
|
|
253
|
+
tsa_url: The TSA's URL.
|
|
254
|
+
profile: The TSA's profile tag.
|
|
255
|
+
ok: ``True`` if a valid token was returned; ``False`` if any error.
|
|
256
|
+
error: Error message text on failure, ``None`` on success.
|
|
257
|
+
elapsed_seconds: Wall-clock time spent on this attempt.
|
|
258
|
+
"""
|
|
259
|
+
tsa_name: str
|
|
260
|
+
tsa_url: str
|
|
261
|
+
profile: str
|
|
262
|
+
ok: bool
|
|
263
|
+
error: Optional[str] = None
|
|
264
|
+
elapsed_seconds: Optional[float] = None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True)
|
|
268
|
+
class AcquisitionResult:
|
|
269
|
+
"""The return of ``acquire_timestamp_token``: token (or None) plus attempts.
|
|
270
|
+
|
|
271
|
+
Attributes:
|
|
272
|
+
token: The acquired ``TimestampToken``, or ``None`` if every TSA in
|
|
273
|
+
the chain failed.
|
|
274
|
+
attempts: Tuple of per-TSA ``TSAAttempt`` records, in the order
|
|
275
|
+
they were tried.
|
|
276
|
+
"""
|
|
277
|
+
token: Optional[TimestampToken]
|
|
278
|
+
attempts: tuple[TSAAttempt, ...]
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def ok(self) -> bool:
|
|
282
|
+
"""``True`` if a token was acquired, ``False`` otherwise."""
|
|
283
|
+
return self.token is not None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ─────────────────────────────────────────────────────────────────
|
|
287
|
+
# DEFAULT TSA CHAIN
|
|
288
|
+
# ─────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
DEFAULT_TSA_CHAIN: tuple[TimestampAuthority, ...] = (
|
|
291
|
+
# EU-qualified candidates first. Whether a given TSA holds qualified
|
|
292
|
+
# status at any given moment depends on the EU Trusted List; verifiers
|
|
293
|
+
# check EUTL separately. Listing as candidate means: probable QTSP, but
|
|
294
|
+
# this module does not assert qualification.
|
|
295
|
+
TimestampAuthority(
|
|
296
|
+
name="Sectigo Qualified",
|
|
297
|
+
url="http://timestamp.sectigo.com/qualified",
|
|
298
|
+
profile="eu-qualified-candidate",
|
|
299
|
+
),
|
|
300
|
+
TimestampAuthority(
|
|
301
|
+
name="QuoVadis EU",
|
|
302
|
+
url="http://ts.quovadisglobal.com/eu",
|
|
303
|
+
profile="eu-qualified-candidate",
|
|
304
|
+
),
|
|
305
|
+
TimestampAuthority(
|
|
306
|
+
name="Izenpe TSA",
|
|
307
|
+
url="http://tsa.izenpe.com",
|
|
308
|
+
profile="eu-qualified-candidate",
|
|
309
|
+
),
|
|
310
|
+
TimestampAuthority(
|
|
311
|
+
name="Belgium TSA",
|
|
312
|
+
url="http://tsa.belgium.be/connect",
|
|
313
|
+
profile="eu-qualified-candidate",
|
|
314
|
+
),
|
|
315
|
+
# Public high-availability fallbacks. Valid RFC 3161 tokens but no
|
|
316
|
+
# eIDAS qualified signature.
|
|
317
|
+
TimestampAuthority(
|
|
318
|
+
name="DigiCert",
|
|
319
|
+
url="http://timestamp.digicert.com",
|
|
320
|
+
profile="public",
|
|
321
|
+
),
|
|
322
|
+
TimestampAuthority(
|
|
323
|
+
name="freetsa.org",
|
|
324
|
+
url="https://freetsa.org/tsr",
|
|
325
|
+
profile="public",
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ─────────────────────────────────────────────────────────────────
|
|
331
|
+
# INTERNAL HELPERS
|
|
332
|
+
# ─────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
def _utc_iso(value: Optional[datetime]) -> Optional[str]:
|
|
335
|
+
"""Convert a datetime to ISO 8601 UTC string with Z suffix."""
|
|
336
|
+
if value is None:
|
|
337
|
+
return None
|
|
338
|
+
if value.tzinfo is None:
|
|
339
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
340
|
+
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _validate_imprint(imprint: bytes, hash_alg: str) -> None:
|
|
344
|
+
"""Confirm the imprint bytes have the right length for hash_alg."""
|
|
345
|
+
expected = _EXPECTED_DIGEST_BYTES.get(hash_alg)
|
|
346
|
+
if expected is None:
|
|
347
|
+
return # unknown algorithm; let tsp_client complain
|
|
348
|
+
if len(imprint) != expected:
|
|
349
|
+
raise TimestampError(
|
|
350
|
+
f"imprint length {len(imprint)} bytes does not match "
|
|
351
|
+
f"{hash_alg} digest length {expected} bytes"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _digest_enum(hash_alg: str) -> DigestAlgorithm:
|
|
356
|
+
"""Map a hash_alg name to the tsp_client DigestAlgorithm enum."""
|
|
357
|
+
try:
|
|
358
|
+
return _DIGEST_MAP[hash_alg]
|
|
359
|
+
except KeyError as exc:
|
|
360
|
+
raise TimestampError(
|
|
361
|
+
f"Unsupported hash algorithm {hash_alg!r}. "
|
|
362
|
+
f"Supported: {sorted(SUPPORTED_HASH_ALGORITHMS)}"
|
|
363
|
+
) from exc
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _build_transport(timeout_seconds: float) -> Callable[..., requests.Response]:
|
|
367
|
+
"""Build a transport callable for tsp_client's SigningSettings.
|
|
368
|
+
|
|
369
|
+
The transport is a function that takes a URL plus kwargs and returns a
|
|
370
|
+
``requests.Response``. We set sensible connect/read timeouts so a
|
|
371
|
+
misbehaving TSA does not stall the whole chain.
|
|
372
|
+
"""
|
|
373
|
+
connect_timeout = min(2.0, max(0.5, timeout_seconds / 2.0))
|
|
374
|
+
read_timeout = max(1.0, timeout_seconds)
|
|
375
|
+
|
|
376
|
+
def _transport(url: str, **kwargs: Any) -> requests.Response:
|
|
377
|
+
kwargs.setdefault("timeout", (connect_timeout, read_timeout))
|
|
378
|
+
return requests.post(url, **kwargs)
|
|
379
|
+
|
|
380
|
+
return _transport
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _extract_token_metadata(
|
|
384
|
+
token_bytes: bytes,
|
|
385
|
+
verified: Any,
|
|
386
|
+
imprint: bytes,
|
|
387
|
+
hash_alg: str,
|
|
388
|
+
tsa: TimestampAuthority,
|
|
389
|
+
) -> TimestampToken:
|
|
390
|
+
"""Build a ``TimestampToken`` from a TSPVerifier.verify result."""
|
|
391
|
+
tst_info = getattr(verified, "tst_info", {}) or {}
|
|
392
|
+
gen_time = tst_info.get("gen_time")
|
|
393
|
+
policy_oid = tst_info.get("policy")
|
|
394
|
+
|
|
395
|
+
return TimestampToken(
|
|
396
|
+
tsa_url=tsa.url,
|
|
397
|
+
tsa_name=tsa.name,
|
|
398
|
+
token_b64=base64.b64encode(token_bytes).decode("ascii"),
|
|
399
|
+
policy_oid=str(policy_oid) if policy_oid else None,
|
|
400
|
+
hash_alg=hash_alg,
|
|
401
|
+
imprint_hex=imprint.hex(),
|
|
402
|
+
timestamp=_utc_iso(gen_time) or "",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ─────────────────────────────────────────────────────────────────
|
|
407
|
+
# PUBLIC API
|
|
408
|
+
# ─────────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
def acquire_timestamp_token(
|
|
411
|
+
imprint: bytes,
|
|
412
|
+
*,
|
|
413
|
+
hash_alg: str = "sha-256",
|
|
414
|
+
chain: Optional[Sequence[TimestampAuthority]] = None,
|
|
415
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
416
|
+
raise_on_failure: bool = True,
|
|
417
|
+
) -> AcquisitionResult:
|
|
418
|
+
"""Acquire an RFC 3161 timestamp token over ``imprint``.
|
|
419
|
+
|
|
420
|
+
Iterates through ``chain`` in order. Each TSA is tried with the
|
|
421
|
+
specified timeout. On the first success, returns immediately with
|
|
422
|
+
the token plus the attempts so far. If all TSAs fail, either raises
|
|
423
|
+
``TimestampError`` (default) or returns an ``AcquisitionResult`` with
|
|
424
|
+
``token=None`` and the full attempts list.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
imprint: The raw digest bytes to be timestamped. Typically the
|
|
428
|
+
SHA-256 of a canonical manifest dict (32 bytes). The length
|
|
429
|
+
must match the algorithm declared in ``hash_alg``.
|
|
430
|
+
hash_alg: Name of the algorithm used to produce ``imprint``. Must
|
|
431
|
+
be one of ``SUPPORTED_HASH_ALGORITHMS``. Default ``"sha-256"``.
|
|
432
|
+
chain: Optional sequence of ``TimestampAuthority`` to try in order.
|
|
433
|
+
Defaults to ``DEFAULT_TSA_CHAIN``.
|
|
434
|
+
timeout_seconds: Per-TSA timeout. Default 10.0 seconds.
|
|
435
|
+
raise_on_failure: If ``True`` (default), raise ``TimestampError``
|
|
436
|
+
when no TSA succeeds. If ``False``, return ``AcquisitionResult``
|
|
437
|
+
with ``token=None`` instead.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
``AcquisitionResult`` with the token (or None) and per-TSA attempts.
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
TimestampError: If ``imprint`` has the wrong length for ``hash_alg``,
|
|
444
|
+
or if ``hash_alg`` is not supported, or if all TSAs fail and
|
|
445
|
+
``raise_on_failure=True``.
|
|
446
|
+
"""
|
|
447
|
+
_validate_imprint(imprint, hash_alg)
|
|
448
|
+
digest_alg = _digest_enum(hash_alg)
|
|
449
|
+
|
|
450
|
+
if chain is None:
|
|
451
|
+
chain = DEFAULT_TSA_CHAIN
|
|
452
|
+
if not chain:
|
|
453
|
+
raise TimestampError("Empty TSA chain: pass at least one TimestampAuthority")
|
|
454
|
+
|
|
455
|
+
transport = _build_transport(timeout_seconds)
|
|
456
|
+
signer = TSPSigner()
|
|
457
|
+
verifier = TSPVerifier()
|
|
458
|
+
|
|
459
|
+
attempts: list[TSAAttempt] = []
|
|
460
|
+
|
|
461
|
+
for tsa in chain:
|
|
462
|
+
started = time.monotonic()
|
|
463
|
+
try:
|
|
464
|
+
settings = SigningSettings(
|
|
465
|
+
tsp_server=tsa.url,
|
|
466
|
+
digest_algorithm=digest_alg,
|
|
467
|
+
transport=transport,
|
|
468
|
+
)
|
|
469
|
+
token_bytes = signer.sign(message_digest=imprint, signing_settings=settings)
|
|
470
|
+
verified = verifier.verify(token_bytes, message_digest=imprint)
|
|
471
|
+
elapsed = time.monotonic() - started
|
|
472
|
+
|
|
473
|
+
token = _extract_token_metadata(
|
|
474
|
+
token_bytes=token_bytes,
|
|
475
|
+
verified=verified,
|
|
476
|
+
imprint=imprint,
|
|
477
|
+
hash_alg=hash_alg,
|
|
478
|
+
tsa=tsa,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
attempts.append(
|
|
482
|
+
TSAAttempt(
|
|
483
|
+
tsa_name=tsa.name,
|
|
484
|
+
tsa_url=tsa.url,
|
|
485
|
+
profile=tsa.profile,
|
|
486
|
+
ok=True,
|
|
487
|
+
error=None,
|
|
488
|
+
elapsed_seconds=elapsed,
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
logger.info(
|
|
493
|
+
"RFC 3161 timestamp acquired from %s in %.2fs",
|
|
494
|
+
tsa.name, elapsed,
|
|
495
|
+
)
|
|
496
|
+
return AcquisitionResult(token=token, attempts=tuple(attempts))
|
|
497
|
+
|
|
498
|
+
except Exception as exc: # noqa: BLE001
|
|
499
|
+
# Catch broadly because TSP errors come from many places:
|
|
500
|
+
# tsp_client.exceptions, requests exceptions, ASN.1 parsing,
|
|
501
|
+
# TSA returning HTTP 5xx, etc. Record and move to next TSA.
|
|
502
|
+
elapsed = time.monotonic() - started
|
|
503
|
+
attempts.append(
|
|
504
|
+
TSAAttempt(
|
|
505
|
+
tsa_name=tsa.name,
|
|
506
|
+
tsa_url=tsa.url,
|
|
507
|
+
profile=tsa.profile,
|
|
508
|
+
ok=False,
|
|
509
|
+
error=str(exc),
|
|
510
|
+
elapsed_seconds=elapsed,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
logger.warning(
|
|
514
|
+
"RFC 3161 timestamp attempt failed for %s: %s",
|
|
515
|
+
tsa.url, exc,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# All TSAs failed.
|
|
519
|
+
if raise_on_failure:
|
|
520
|
+
attempt_summary = "; ".join(
|
|
521
|
+
f"{a.tsa_name}: {a.error}" for a in attempts
|
|
522
|
+
)
|
|
523
|
+
raise TimestampError(
|
|
524
|
+
f"All {len(attempts)} TSAs in the chain failed: {attempt_summary}"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
return AcquisitionResult(token=None, attempts=tuple(attempts))
|