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/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))