sci-protocol 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.
Files changed (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
pysci/timestamps.py ADDED
@@ -0,0 +1,867 @@
1
+ """RFC 3161 / RFC 5816 timestamp proofs (protocol §6.8).
2
+
3
+ * :func:`build_timestamp_request` -- a DER ``TimeStampReq`` with a SHA-256
4
+ imprint of the signature bytes and ``certReq`` true.
5
+ * :func:`verify_timestamp_file` -- frame concatenated DER values and verify
6
+ each proof independently.
7
+ * :func:`verify_timestamp_response` -- verify one complete DER value.
8
+ * :func:`check_response_matches_request` -- bind a fresh response to its request.
9
+
10
+ Per-proof results are ``verified``, ``untrusted`` (cryptographically valid but
11
+ not anchored to an accepted trust anchor), ``incomplete`` (revocation status
12
+ unavailable under the configured policy), or ``invalid``. A failed proof never
13
+ affects the publication's validity.
14
+
15
+ Trust policy: the signer certificate must carry a critical extended key usage
16
+ with only ``id-kp-timeStamping`` (RFC 3161 §2.3); every certificate in the
17
+ path must be valid at ``genTime``; CA certificates must assert
18
+ ``cA``; with :attr:`RevocationPolicy.REQUIRE_CURRENT_CRL` every non-anchor
19
+ certificate needs a CRL from its issuer that is current at the verification
20
+ time. A revocation invalidates the proof unless it is dated after
21
+ ``genTime`` with a benign reason (superseded, cessation of operation,
22
+ affiliation changed). MD5 and SHA-1 are not accepted for the CMS signature;
23
+ SHA-1 is accepted only for ESS ``SigningCertificate`` (v1) identifiers.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import contextlib
29
+ import hashlib
30
+ import itertools
31
+ import secrets
32
+ from collections.abc import Iterable, Sequence
33
+ from dataclasses import dataclass, field
34
+ from datetime import UTC, datetime, timedelta
35
+ from decimal import Decimal
36
+ from enum import StrEnum
37
+ from functools import cache
38
+ from importlib import resources
39
+ from typing import Any, ClassVar, Final
40
+
41
+ from asn1crypto import cms as a_cms
42
+ from asn1crypto import core as a_core
43
+ from asn1crypto import tsp as a_tsp
44
+ from cryptography import x509
45
+ from cryptography.exceptions import InvalidSignature
46
+ from cryptography.hazmat.primitives import hashes
47
+ from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, padding, rsa
48
+ from cryptography.x509.oid import ExtendedKeyUsageOID
49
+
50
+ from .der import encode_length, frame_values, validate_der_structure
51
+ from .errors import Issue, Outcome
52
+
53
+ __all__ = [
54
+ "FREETSA_CA_SHA256",
55
+ "FREETSA_URL",
56
+ "Accuracy",
57
+ "FileOutcome",
58
+ "ProofResult",
59
+ "ProofVerification",
60
+ "RevocationPolicy",
61
+ "TimestampFileVerification",
62
+ "TimestampRequest",
63
+ "TrustConfig",
64
+ "append_proof",
65
+ "build_timestamp_request",
66
+ "check_response_matches_request",
67
+ "freetsa_ca_certificate",
68
+ "freetsa_trust",
69
+ "verify_timestamp_file",
70
+ "verify_timestamp_response",
71
+ ]
72
+
73
+ FREETSA_URL: Final = "https://freetsa.org/tsr"
74
+ """Default public timestamp authority (FreeTSA)."""
75
+ FREETSA_CA_SHA256: Final = "2151b61137ffa86bf664691ba67e7da0b19f98c758e3d228d5d8ebf27e044438"
76
+ """SHA-256 of the bundled ``freetsa-cacert.pem`` (https://freetsa.org/files/cacert.pem)."""
77
+ FREETSA_CRL_URL: Final = "http://www.freetsa.org/crl/root_ca.crl"
78
+ TIMESTAMP_CONTENT_TYPE: Final = "application/timestamp-query"
79
+ TIMESTAMP_REPLY_TYPE: Final = "application/timestamp-reply"
80
+
81
+ _SHA256_OID: Final = "2.16.840.1.101.3.4.2.1"
82
+ _MAX_PATH: Final = 8
83
+ _BENIGN_REASONS: Final = frozenset(
84
+ {
85
+ x509.ReasonFlags.superseded,
86
+ x509.ReasonFlags.cessation_of_operation,
87
+ x509.ReasonFlags.affiliation_changed,
88
+ }
89
+ )
90
+ _HASHES: Final[dict[str, type[hashes.HashAlgorithm]]] = {
91
+ "sha224": hashes.SHA224,
92
+ "sha256": hashes.SHA256,
93
+ "sha384": hashes.SHA384,
94
+ "sha512": hashes.SHA512,
95
+ "sha512_224": hashes.SHA512_224,
96
+ "sha512_256": hashes.SHA512_256,
97
+ "sha3_224": hashes.SHA3_224,
98
+ "sha3_256": hashes.SHA3_256,
99
+ "sha3_384": hashes.SHA3_384,
100
+ "sha3_512": hashes.SHA3_512,
101
+ }
102
+ _ESS_HASHES: Final[dict[str, type[hashes.HashAlgorithm]]] = {"sha1": hashes.SHA1, **_HASHES}
103
+
104
+
105
+ class ProofResult(StrEnum):
106
+ VERIFIED = "verified"
107
+ UNTRUSTED = "untrusted"
108
+ INCOMPLETE = "incomplete"
109
+ INVALID = "invalid"
110
+
111
+
112
+ class FileOutcome(StrEnum):
113
+ """Aggregate result of a timestamp file."""
114
+
115
+ VALID = "valid"
116
+ """Framing is complete and every proof verified."""
117
+ PARTIAL = "partial"
118
+ """At least one proof verified, but another proof or the tail is unusable."""
119
+ UNTRUSTED = "untrusted"
120
+ """Framing is complete and every proof is valid but untrusted."""
121
+ INCOMPLETE = "incomplete"
122
+ """Framing is complete; no proof failed, but revocation status was unavailable."""
123
+ INVALID = "invalid"
124
+ """No proof verified (including empty or malformed-first files)."""
125
+
126
+
127
+ class RevocationPolicy(StrEnum):
128
+ REQUIRE_CURRENT_CRL = "require_current_crl"
129
+ """Every non-anchor certificate needs a current CRL from its issuer."""
130
+ CHECK_SUPPLIED_CRLS = "check_supplied_crls"
131
+ """Use matching supplied CRLs when present; missing CRLs are tolerated."""
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class TrustConfig:
136
+ """Local trust policy for timestamp verification."""
137
+
138
+ anchors: Sequence[x509.Certificate] = ()
139
+ intermediates: Sequence[x509.Certificate] = ()
140
+ crls: Sequence[x509.CertificateRevocationList] = ()
141
+ revocation_policy: RevocationPolicy = RevocationPolicy.REQUIRE_CURRENT_CRL
142
+
143
+ @classmethod
144
+ def from_pem(
145
+ cls,
146
+ anchors: Iterable[bytes] = (),
147
+ crls: Iterable[bytes] = (),
148
+ intermediates: Iterable[bytes] = (),
149
+ revocation_policy: RevocationPolicy = RevocationPolicy.REQUIRE_CURRENT_CRL,
150
+ ) -> TrustConfig:
151
+ """Load PEM certificates (each blob may hold several) and PEM/DER CRLs."""
152
+ return cls(
153
+ anchors=[c for blob in anchors for c in x509.load_pem_x509_certificates(blob)],
154
+ intermediates=[
155
+ c for blob in intermediates for c in x509.load_pem_x509_certificates(blob)
156
+ ],
157
+ crls=[_load_crl(blob) for blob in crls],
158
+ revocation_policy=revocation_policy,
159
+ )
160
+
161
+
162
+ def _load_crl(blob: bytes) -> x509.CertificateRevocationList:
163
+ if blob.lstrip().startswith(b"-----BEGIN"):
164
+ return x509.load_pem_x509_crl(blob)
165
+ return x509.load_der_x509_crl(blob)
166
+
167
+
168
+ @cache
169
+ def freetsa_ca_certificate() -> x509.Certificate:
170
+ """The bundled FreeTSA root CA certificate (hash-checked)."""
171
+ data = resources.files("pysci").joinpath("data/freetsa-cacert.pem").read_bytes()
172
+ if hashlib.sha256(data).hexdigest() != FREETSA_CA_SHA256:
173
+ raise RuntimeError("bundled FreeTSA CA certificate does not match its recorded hash")
174
+ return x509.load_pem_x509_certificate(data)
175
+
176
+
177
+ def freetsa_trust(
178
+ crls: Iterable[bytes] = (),
179
+ revocation_policy: RevocationPolicy = RevocationPolicy.CHECK_SUPPLIED_CRLS,
180
+ ) -> TrustConfig:
181
+ """Trust configuration anchored at the bundled FreeTSA root.
182
+
183
+ FreeTSA publishes its CRL at :data:`FREETSA_CRL_URL`; pass it in ``crls``
184
+ and use ``REQUIRE_CURRENT_CRL`` for a full revocation check online.
185
+ """
186
+ return TrustConfig(
187
+ anchors=[freetsa_ca_certificate()],
188
+ crls=[_load_crl(blob) for blob in crls],
189
+ revocation_policy=revocation_policy,
190
+ )
191
+
192
+
193
+ @dataclass(frozen=True, slots=True)
194
+ class Accuracy:
195
+ """TSTInfo accuracy (RFC 3161 §2.4.2)."""
196
+
197
+ seconds: int = 0
198
+ millis: int = 0
199
+ micros: int = 0
200
+
201
+ def total_seconds(self) -> Decimal:
202
+ return Decimal(self.seconds) + Decimal(self.millis) / 1000 + Decimal(self.micros) / 10**6
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class ProofVerification:
207
+ """Result for one complete DER value in a timestamp file."""
208
+
209
+ offset: int
210
+ length: int
211
+ result: ProofResult
212
+ issues: tuple[Issue, ...] = ()
213
+ gen_time: datetime | None = None
214
+ accuracy: Accuracy | None = None
215
+ tsa_name: str | None = None
216
+ signer_subject: str | None = None
217
+ policy: str | None = None
218
+ serial_number: int | None = None
219
+ nonce: int | None = None
220
+ revocation_checked: bool = False
221
+
222
+
223
+ @dataclass(frozen=True, slots=True)
224
+ class TimestampFileVerification:
225
+ """Result for a whole timestamp file."""
226
+
227
+ outcome: FileOutcome
228
+ framing_complete: bool
229
+ proofs: tuple[ProofVerification, ...]
230
+ stop_offset: int
231
+
232
+ @property
233
+ def framing(self) -> str:
234
+ return "complete" if self.framing_complete else "malformed"
235
+
236
+ @property
237
+ def verified(self) -> tuple[ProofVerification, ...]:
238
+ return tuple(p for p in self.proofs if p.result is ProofResult.VERIFIED)
239
+
240
+
241
+ @dataclass(frozen=True, slots=True)
242
+ class TimestampRequest:
243
+ """A DER ``TimeStampReq`` and the values needed to check its response."""
244
+
245
+ der: bytes
246
+ digest: bytes
247
+ nonce: int | None
248
+ cert_req: bool = True
249
+ policy: str | None = None
250
+
251
+
252
+ def build_timestamp_request(
253
+ signature: bytes,
254
+ *,
255
+ nonce: int | bool | None = True,
256
+ cert_req: bool = True,
257
+ policy: str | None = None,
258
+ ) -> TimestampRequest:
259
+ """Build an RFC 3161 request over SHA-256 of the exact signature bytes (§6.8).
260
+
261
+ ``nonce=True`` (default) draws a random 64-bit nonce; pass an int to fix
262
+ it or None/False to omit it. POST ``der`` with content type
263
+ ``application/timestamp-query`` (e.g. to :data:`FREETSA_URL`).
264
+ """
265
+ digest = hashlib.sha256(signature).digest()
266
+ nonce_value: int | None
267
+ if nonce is True:
268
+ nonce_value = secrets.randbits(63) | (1 << 62)
269
+ elif nonce is False or nonce is None:
270
+ nonce_value = None
271
+ else:
272
+ nonce_value = int(nonce)
273
+ request: dict[str, Any] = {
274
+ "version": "v1",
275
+ "message_imprint": {
276
+ "hash_algorithm": {"algorithm": "sha256"},
277
+ "hashed_message": digest,
278
+ },
279
+ "cert_req": cert_req,
280
+ }
281
+ if policy is not None:
282
+ request["req_policy"] = policy
283
+ if nonce_value is not None:
284
+ request["nonce"] = nonce_value
285
+ der = a_tsp.TimeStampReq(request).dump()
286
+ return TimestampRequest(der, digest, nonce_value, cert_req, policy)
287
+
288
+
289
+ # --------------------------------------------------------------------------- verification
290
+
291
+
292
+ class _TimeStampResp(a_core.Sequence): # type: ignore[misc]
293
+ """TimeStampResp with the token OPTIONAL, as in RFC 3161 §2.4.2."""
294
+
295
+ _fields: ClassVar[list[tuple[Any, ...]]] = [
296
+ ("status", a_tsp.PKIStatusInfo),
297
+ ("time_stamp_token", a_cms.ContentInfo, {"optional": True}),
298
+ ]
299
+
300
+
301
+ class _ProofError(Exception):
302
+ def __init__(
303
+ self,
304
+ code: str,
305
+ requirement: str,
306
+ observed: object = None,
307
+ result: ProofResult = ProofResult.INVALID,
308
+ ) -> None:
309
+ super().__init__(requirement)
310
+ self.result = result
311
+ self.issue = Issue(
312
+ code,
313
+ requirement,
314
+ section="6.8",
315
+ observed=None if observed is None else str(observed),
316
+ outcome=Outcome.INCOMPLETE if result is ProofResult.INCOMPLETE else Outcome.INVALID,
317
+ )
318
+
319
+
320
+ @dataclass(slots=True)
321
+ class _Token:
322
+ signed_data: Any
323
+ tst_info: Any
324
+ signer_info: Any
325
+ certificates: list[x509.Certificate] = field(default_factory=list)
326
+ signer: x509.Certificate | None = None
327
+
328
+
329
+ def verify_timestamp_response(
330
+ der: bytes,
331
+ signature: bytes,
332
+ trust: TrustConfig | None,
333
+ *,
334
+ at: datetime | None = None,
335
+ offset: int = 0,
336
+ ) -> ProofVerification:
337
+ """Verify one complete DER ``TimeStampResp`` against the signature bytes.
338
+
339
+ ``at`` is the verification time used for CRL currency (default: now).
340
+ With ``trust`` None or without anchors, a cryptographically valid proof is
341
+ reported as ``untrusted``.
342
+ """
343
+ now = at or datetime.now(UTC)
344
+ details: dict[str, Any] = {}
345
+ try:
346
+ token = _parse_response(der)
347
+ details = _token_details(token)
348
+ _check_imprint(token.tst_info, signature)
349
+ _check_signer(token, trust)
350
+ assert token.signer is not None
351
+ details["signer_subject"] = token.signer.subject.rfc4514_string()
352
+ _check_signature(token)
353
+ gen_time: datetime = details["gen_time"]
354
+ if gen_time > now + timedelta(minutes=5):
355
+ raise _ProofError(
356
+ "timestamp_future", "genTime must not be in the future", gen_time.isoformat()
357
+ )
358
+ assert token.signer is not None
359
+ _check_signer_certificate(token.signer, gen_time)
360
+ if trust is None or not trust.anchors:
361
+ raise _ProofError(
362
+ "timestamp_untrusted",
363
+ "no accepted trust anchor is configured",
364
+ result=ProofResult.UNTRUSTED,
365
+ )
366
+ path = _build_path(token.signer, token.certificates, trust, gen_time)
367
+ checked = _check_revocation(path, trust, gen_time, now)
368
+ except _ProofError as exc:
369
+ return ProofVerification(offset, len(der), exc.result, (exc.issue,), **details)
370
+ return ProofVerification(
371
+ offset, len(der), ProofResult.VERIFIED, (), revocation_checked=checked, **details
372
+ )
373
+
374
+
375
+ def _parse_response(der: bytes) -> _Token:
376
+ if not validate_der_structure(der):
377
+ raise _ProofError("timestamp_der", "value must be DER-encoded")
378
+ try:
379
+ response = _TimeStampResp.load(der, strict=True)
380
+ status = response["status"]["status"].native
381
+ token = response["time_stamp_token"]
382
+ except Exception as exc: # asn1crypto raises assorted exception types
383
+ raise _ProofError(
384
+ "timestamp_not_response", "value must be a TimeStampResp", type(exc).__name__
385
+ ) from None
386
+ if status not in ("granted", "granted_with_mods"):
387
+ raise _ProofError("timestamp_status", "status must be granted or grantedWithMods", status)
388
+ try:
389
+ if token.native is None:
390
+ raise _ProofError("timestamp_missing_token", "a TimeStampToken must be present")
391
+ if token["content_type"].native != "signed_data":
392
+ raise _ProofError("timestamp_cms", "token must be CMS SignedData")
393
+ signed_data = token["content"]
394
+ encap = signed_data["encap_content_info"]
395
+ if encap["content_type"].native != "tst_info":
396
+ raise _ProofError("timestamp_cms", "encapsulated content must be TSTInfo")
397
+ if not isinstance(encap["content"], a_core.ParsableOctetString):
398
+ raise _ProofError("timestamp_cms", "TSTInfo content must be present")
399
+ tst_info = encap["content"].parsed
400
+ _ = tst_info.native # force a full parse so malformed content fails here
401
+ signer_infos = signed_data["signer_infos"]
402
+ if len(signer_infos) != 1:
403
+ raise _ProofError(
404
+ "timestamp_signers", "token must contain exactly one signature", len(signer_infos)
405
+ )
406
+ certificates = []
407
+ for choice in signed_data["certificates"] or []:
408
+ if choice.name == "certificate":
409
+ certificates.append(x509.load_der_x509_certificate(choice.chosen.dump()))
410
+ return _Token(signed_data, tst_info, signer_infos[0], certificates)
411
+ except _ProofError:
412
+ raise
413
+ except Exception as exc:
414
+ raise _ProofError(
415
+ "timestamp_token_malformed", "TimeStampToken must be well formed", type(exc).__name__
416
+ ) from None
417
+
418
+
419
+ def _token_details(token: _Token) -> dict[str, Any]:
420
+ info = token.tst_info
421
+ if info["version"].native != "v1":
422
+ raise _ProofError("timestamp_version", "TSTInfo version must be 1")
423
+ accuracy = None
424
+ if info["accuracy"].native is not None:
425
+ raw = info["accuracy"].native
426
+ accuracy = Accuracy(raw["seconds"] or 0, raw["millis"] or 0, raw["micros"] or 0)
427
+ tsa = info["tsa"]
428
+ tsa_name = None
429
+ if tsa.native is not None:
430
+ chosen = tsa.chosen
431
+ tsa_name = (
432
+ chosen.human_friendly
433
+ if chosen.name == "directory_name" or hasattr(chosen, "human_friendly")
434
+ else str(tsa.native)
435
+ )
436
+ gen_time = info["gen_time"].native
437
+ if not isinstance(gen_time, datetime):
438
+ raise _ProofError("timestamp_gen_time", "genTime must be a GeneralizedTime")
439
+ return {
440
+ "gen_time": gen_time.astimezone(UTC),
441
+ "accuracy": accuracy,
442
+ "tsa_name": tsa_name,
443
+ "policy": info["policy"].dotted,
444
+ "serial_number": info["serial_number"].native,
445
+ "nonce": info["nonce"].native,
446
+ }
447
+
448
+
449
+ def _check_imprint(info: Any, signature: bytes) -> None:
450
+ imprint = info["message_imprint"]
451
+ algorithm = imprint["hash_algorithm"]
452
+ if algorithm["algorithm"].dotted != _SHA256_OID:
453
+ raise _ProofError(
454
+ "timestamp_imprint_algorithm",
455
+ "messageImprint must use SHA-256",
456
+ algorithm["algorithm"].native,
457
+ )
458
+ if algorithm["parameters"].native is not None:
459
+ raise _ProofError(
460
+ "timestamp_imprint_algorithm", "SHA-256 parameters must be absent or NULL"
461
+ )
462
+ if imprint["hashed_message"].native != hashlib.sha256(signature).digest():
463
+ raise _ProofError(
464
+ "timestamp_imprint_mismatch",
465
+ "messageImprint must equal SHA-256 of the verified signature bytes",
466
+ )
467
+
468
+
469
+ def _find_signer(token: _Token, extra: Sequence[x509.Certificate]) -> x509.Certificate:
470
+ sid = token.signer_info["sid"]
471
+ candidates = [*token.certificates, *extra]
472
+ if sid.name == "issuer_and_serial_number":
473
+ issuer = sid.chosen["issuer"].dump()
474
+ serial = sid.chosen["serial_number"].native
475
+ for cert in candidates:
476
+ if cert.serial_number == serial and cert.issuer.public_bytes() == issuer:
477
+ return cert
478
+ else:
479
+ key_id = sid.chosen.native
480
+ for cert in candidates:
481
+ try:
482
+ ski = cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
483
+ except x509.ExtensionNotFound:
484
+ continue
485
+ if ski.value.digest == key_id:
486
+ return cert
487
+ raise _ProofError(
488
+ "timestamp_signer_certificate",
489
+ "the signing certificate must be available (request certReq)",
490
+ )
491
+
492
+
493
+ def _check_signer(token: _Token, trust: TrustConfig | None) -> None:
494
+ token.signer = _find_signer(token, trust.intermediates if trust else ())
495
+ signer = token.signer
496
+ attrs = token.signer_info["signed_attrs"]
497
+ if not isinstance(attrs, a_cms.CMSAttributes) or len(attrs) == 0:
498
+ raise _ProofError("timestamp_signed_attrs", "signed attributes are required")
499
+ values: dict[str, list[Any]] = {}
500
+ for attr in attrs:
501
+ name = attr["type"].native
502
+ if name in values:
503
+ raise _ProofError(
504
+ "timestamp_signed_attrs", "signed attribute types must be unique", name
505
+ )
506
+ values[name] = list(attr["values"])
507
+ for required in ("content_type", "message_digest"):
508
+ if len(values.get(required, [])) != 1:
509
+ raise _ProofError(
510
+ "timestamp_signed_attrs", f"exactly one {required} attribute value is required"
511
+ )
512
+ if values["content_type"][0].native != "tst_info":
513
+ raise _ProofError("timestamp_signed_attrs", "content-type attribute must be id-ct-TSTInfo")
514
+ digest_name = token.signer_info["digest_algorithm"]["algorithm"].native
515
+ if digest_name not in _HASHES:
516
+ raise _ProofError(
517
+ "timestamp_digest_algorithm", "unsupported or weak CMS digest algorithm", digest_name
518
+ )
519
+ content = token.signed_data["encap_content_info"]["content"].contents
520
+ expected = hashlib.new(_hashlib_name(digest_name), content).digest()
521
+ if values["message_digest"][0].native != expected:
522
+ raise _ProofError(
523
+ "timestamp_message_digest", "message-digest attribute must match the TSTInfo"
524
+ )
525
+ _check_ess(values, signer)
526
+
527
+
528
+ def _hashlib_name(name: str) -> str:
529
+ return {"sha512_224": "sha512_224", "sha512_256": "sha512_256"}.get(name, name)
530
+
531
+
532
+ def _check_ess(values: dict[str, list[Any]], signer: x509.Certificate) -> None:
533
+ from cryptography.hazmat.primitives import serialization
534
+
535
+ cert_der = signer.public_bytes(serialization.Encoding.DER)
536
+ if "signing_certificate_v2" in values:
537
+ attribute = values["signing_certificate_v2"]
538
+ default_hash = "sha256"
539
+ elif "signing_certificate" in values:
540
+ attribute = values["signing_certificate"]
541
+ default_hash = "sha1"
542
+ else:
543
+ raise _ProofError(
544
+ "timestamp_ess", "ESS signing-certificate attribute (v1 or v2) is required"
545
+ )
546
+ if len(attribute) != 1 or len(attribute[0]["certs"]) == 0:
547
+ raise _ProofError("timestamp_ess", "ESS attribute must identify the signing certificate")
548
+ cert_id = attribute[0]["certs"][0]
549
+ hash_name = default_hash
550
+ if default_hash == "sha256" and cert_id["hash_algorithm"].native is not None:
551
+ hash_name = cert_id["hash_algorithm"]["algorithm"].native
552
+ if hash_name not in _ESS_HASHES:
553
+ raise _ProofError("timestamp_ess", "unsupported ESSCertID hash algorithm", hash_name)
554
+ actual = hashlib.new(_hashlib_name(hash_name), cert_der).digest()
555
+ if cert_id["cert_hash"].native != actual:
556
+ raise _ProofError(
557
+ "timestamp_ess_mismatch", "ESSCertID must identify the actual signing certificate"
558
+ )
559
+ issuer_serial = cert_id["issuer_serial"]
560
+ if issuer_serial.native is not None:
561
+ if issuer_serial["serial_number"].native != signer.serial_number:
562
+ raise _ProofError(
563
+ "timestamp_ess_mismatch",
564
+ "ESSCertID issuerSerial must match the signing certificate",
565
+ )
566
+ names = [n.chosen.dump() for n in issuer_serial["issuer"] if n.name == "directory_name"]
567
+ if names and signer.issuer.public_bytes() not in names:
568
+ raise _ProofError(
569
+ "timestamp_ess_mismatch", "ESSCertID issuer must match the signing certificate"
570
+ )
571
+
572
+
573
+ def _check_signature(token: _Token) -> None:
574
+ signer = token.signer
575
+ assert signer is not None
576
+ info = token.signer_info
577
+ attrs = info["signed_attrs"]
578
+ signed = b"\x31" + encode_length(len(attrs.contents)) + attrs.contents
579
+ signature = info["signature"].native
580
+ digest_name = info["digest_algorithm"]["algorithm"].native
581
+ algorithm = info["signature_algorithm"]
582
+ key = signer.public_key()
583
+ try:
584
+ algo = algorithm.signature_algo
585
+ if algo == "rsassa_pkcs1v15" and isinstance(key, rsa.RSAPublicKey):
586
+ sig_hash = (
587
+ algorithm.hash_algo
588
+ if algorithm["algorithm"].native != "rsassa_pkcs1v15"
589
+ else digest_name
590
+ )
591
+ key.verify(signature, signed, padding.PKCS1v15(), _hash(sig_hash))
592
+ elif algo == "rsassa_pss" and isinstance(key, rsa.RSAPublicKey):
593
+ params = algorithm["parameters"]
594
+ pss_hash = params["hash_algorithm"]["algorithm"].native
595
+ mgf_hash = params["mask_gen_algorithm"]["parameters"]["algorithm"].native
596
+ key.verify(
597
+ signature,
598
+ signed,
599
+ padding.PSS(padding.MGF1(_hash(mgf_hash)), params["salt_length"].native),
600
+ _hash(pss_hash),
601
+ )
602
+ elif algo == "ecdsa" and isinstance(key, ec.EllipticCurvePublicKey):
603
+ sig_hash = (
604
+ algorithm.hash_algo if algorithm["algorithm"].native != "ecdsa" else digest_name
605
+ )
606
+ key.verify(signature, signed, ec.ECDSA(_hash(sig_hash)))
607
+ elif (algo == "ed25519" and isinstance(key, ed25519.Ed25519PublicKey)) or (
608
+ algo == "ed448" and isinstance(key, ed448.Ed448PublicKey)
609
+ ):
610
+ key.verify(signature, signed)
611
+ else:
612
+ raise _ProofError(
613
+ "timestamp_signature_algorithm",
614
+ "unsupported signature algorithm for the signer key",
615
+ algo,
616
+ )
617
+ except InvalidSignature:
618
+ raise _ProofError(
619
+ "timestamp_signature", "CMS signature must verify with the signing certificate"
620
+ ) from None
621
+ except (ValueError, KeyError, TypeError) as exc:
622
+ raise _ProofError(
623
+ "timestamp_signature_algorithm", "unsupported signature parameters", type(exc).__name__
624
+ ) from None
625
+
626
+
627
+ def _hash(name: str) -> hashes.HashAlgorithm:
628
+ if name not in _HASHES:
629
+ raise _ProofError("timestamp_digest_algorithm", "unsupported or weak hash algorithm", name)
630
+ return _HASHES[name]()
631
+
632
+
633
+ def _check_signer_certificate(cert: x509.Certificate, gen_time: datetime) -> None:
634
+ try:
635
+ eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage)
636
+ except x509.ExtensionNotFound:
637
+ eku = None
638
+ if eku is None or not eku.critical or list(eku.value) != [ExtendedKeyUsageOID.TIME_STAMPING]:
639
+ raise _ProofError(
640
+ "timestamp_eku",
641
+ "TSA certificate needs a critical extended key usage of only id-kp-timeStamping",
642
+ )
643
+ try:
644
+ usage = cert.extensions.get_extension_for_class(x509.KeyUsage).value
645
+ if not (usage.digital_signature or usage.content_commitment):
646
+ raise _ProofError("timestamp_key_usage", "TSA key usage must permit signatures")
647
+ except x509.ExtensionNotFound:
648
+ pass
649
+ _check_validity(cert, gen_time)
650
+
651
+
652
+ def _check_validity(cert: x509.Certificate, when: datetime) -> None:
653
+ if not cert.not_valid_before_utc <= when <= cert.not_valid_after_utc:
654
+ raise _ProofError(
655
+ "timestamp_certificate_validity",
656
+ "certificates must be valid at genTime",
657
+ cert.subject.rfc4514_string(),
658
+ )
659
+
660
+
661
+ def _is_ca(cert: x509.Certificate) -> bool:
662
+ try:
663
+ constraints = cert.extensions.get_extension_for_class(x509.BasicConstraints).value
664
+ except x509.ExtensionNotFound:
665
+ return False
666
+ if not constraints.ca:
667
+ return False
668
+ try:
669
+ usage = cert.extensions.get_extension_for_class(x509.KeyUsage).value
670
+ except x509.ExtensionNotFound:
671
+ return True
672
+ return usage.key_cert_sign
673
+
674
+
675
+ def _issued_by(cert: x509.Certificate, issuer: x509.Certificate) -> bool:
676
+ try:
677
+ cert.verify_directly_issued_by(issuer)
678
+ except (ValueError, TypeError, InvalidSignature):
679
+ return False
680
+ return True
681
+
682
+
683
+ def _build_path(
684
+ signer: x509.Certificate,
685
+ bundled: Sequence[x509.Certificate],
686
+ trust: TrustConfig,
687
+ gen_time: datetime,
688
+ ) -> list[x509.Certificate]:
689
+ """Return [signer, ..., anchor]; raise untrusted if no anchored path exists."""
690
+ pool = [*bundled, *trust.intermediates]
691
+ path = [signer]
692
+ current = signer
693
+ for _ in range(_MAX_PATH):
694
+ for anchor in trust.anchors:
695
+ if anchor == current:
696
+ return path
697
+ for anchor in trust.anchors:
698
+ if anchor.subject == current.issuer and _issued_by(current, anchor):
699
+ path.append(anchor)
700
+ return path
701
+ step = next(
702
+ (
703
+ c
704
+ for c in pool
705
+ if c.subject == current.issuer
706
+ and c not in path
707
+ and _is_ca(c)
708
+ and _issued_by(current, c)
709
+ ),
710
+ None,
711
+ )
712
+ if step is None:
713
+ break
714
+ _check_validity(step, gen_time)
715
+ path.append(step)
716
+ current = step
717
+ raise _ProofError(
718
+ "timestamp_untrusted",
719
+ "certificate path does not reach an accepted trust anchor",
720
+ signer.issuer.rfc4514_string(),
721
+ ProofResult.UNTRUSTED,
722
+ )
723
+
724
+
725
+ def _crl_signed_by(crl: x509.CertificateRevocationList, issuer: x509.Certificate) -> bool:
726
+ key = issuer.public_key()
727
+ if not isinstance(
728
+ key,
729
+ rsa.RSAPublicKey
730
+ | ec.EllipticCurvePublicKey
731
+ | ed25519.Ed25519PublicKey
732
+ | ed448.Ed448PublicKey,
733
+ ):
734
+ return False
735
+ try:
736
+ return crl.is_signature_valid(key)
737
+ except (ValueError, TypeError):
738
+ return False
739
+
740
+
741
+ def _check_revocation(
742
+ path: list[x509.Certificate], trust: TrustConfig, gen_time: datetime, now: datetime
743
+ ) -> bool:
744
+ """Check every non-anchor certificate; return True if all had a current CRL."""
745
+ all_checked = True
746
+ for cert, issuer in itertools.pairwise(path):
747
+ crls = [
748
+ crl
749
+ for crl in trust.crls
750
+ if crl.issuer == issuer.subject
751
+ and _crl_signed_by(crl, issuer)
752
+ and crl.last_update_utc <= now
753
+ and crl.next_update_utc is not None
754
+ and now < crl.next_update_utc
755
+ ]
756
+ if not crls:
757
+ all_checked = False
758
+ if trust.revocation_policy is RevocationPolicy.REQUIRE_CURRENT_CRL:
759
+ raise _ProofError(
760
+ "timestamp_revocation_unavailable",
761
+ "a current CRL from the issuer is required",
762
+ cert.subject.rfc4514_string(),
763
+ ProofResult.INCOMPLETE,
764
+ )
765
+ continue
766
+ for crl in crls:
767
+ revoked = crl.get_revoked_certificate_by_serial_number(cert.serial_number)
768
+ if revoked is None:
769
+ continue
770
+ reason = None
771
+ with contextlib.suppress(x509.ExtensionNotFound):
772
+ reason = revoked.extensions.get_extension_for_class(x509.CRLReason).value.reason
773
+ if revoked.revocation_date_utc > gen_time and reason in _BENIGN_REASONS:
774
+ continue
775
+ raise _ProofError(
776
+ "timestamp_certificate_revoked",
777
+ "a certificate in the path is revoked",
778
+ cert.subject.rfc4514_string(),
779
+ )
780
+ return all_checked
781
+
782
+
783
+ # --------------------------------------------------------------------------- files
784
+
785
+
786
+ def verify_timestamp_file(
787
+ data: bytes,
788
+ signature: bytes,
789
+ trust: TrustConfig | None,
790
+ *,
791
+ at: datetime | None = None,
792
+ max_value_length: int | None = 1024 * 1024,
793
+ ) -> TimestampFileVerification:
794
+ """Frame and verify every complete proof in a timestamp file (§6.8).
795
+
796
+ The caller must first complete the manifest or revocation checks for the
797
+ signature (§6.8); this function binds proofs to ``signature`` only.
798
+ Values longer than ``max_value_length`` stop framing (a local bound).
799
+ """
800
+ framing = frame_values(data, max_value_length=max_value_length)
801
+ proofs = tuple(
802
+ verify_timestamp_response(
803
+ data[offset : offset + length], signature, trust, at=at, offset=offset
804
+ )
805
+ for offset, length in framing.values
806
+ )
807
+ results = [proof.result for proof in proofs]
808
+ if framing.complete and results and all(r is ProofResult.VERIFIED for r in results):
809
+ outcome = FileOutcome.VALID
810
+ elif ProofResult.VERIFIED in results:
811
+ outcome = FileOutcome.PARTIAL
812
+ elif (
813
+ framing.complete
814
+ and results
815
+ and all(r in (ProofResult.UNTRUSTED, ProofResult.INCOMPLETE) for r in results)
816
+ ):
817
+ outcome = (
818
+ FileOutcome.INCOMPLETE if ProofResult.INCOMPLETE in results else FileOutcome.UNTRUSTED
819
+ )
820
+ else:
821
+ outcome = FileOutcome.INVALID
822
+ return TimestampFileVerification(outcome, framing.complete, proofs, framing.stop_offset)
823
+
824
+
825
+ def check_response_matches_request(der: bytes, request: TimestampRequest) -> list[Issue]:
826
+ """Check a fresh TSA response against its request (status, imprint, nonce, certificate).
827
+
828
+ Trust is not evaluated here; use :func:`verify_timestamp_response`.
829
+ Returns an empty list when the response answers the request.
830
+ """
831
+ try:
832
+ token = _parse_response(der)
833
+ info = token.tst_info
834
+ if info["message_imprint"]["hashed_message"].native != request.digest:
835
+ raise _ProofError(
836
+ "timestamp_imprint_mismatch", "response imprint must match the request"
837
+ )
838
+ if info["message_imprint"]["hash_algorithm"]["algorithm"].dotted != _SHA256_OID:
839
+ raise _ProofError("timestamp_imprint_algorithm", "response must use SHA-256")
840
+ if request.nonce is not None and info["nonce"].native != request.nonce:
841
+ raise _ProofError("timestamp_nonce", "response nonce must match the request")
842
+ if request.policy is not None and info["policy"].dotted != request.policy:
843
+ raise _ProofError("timestamp_policy", "response policy must match the request")
844
+ if request.cert_req:
845
+ _find_signer(token, ())
846
+ except _ProofError as exc:
847
+ return [exc.issue]
848
+ return []
849
+
850
+
851
+ def append_proof(existing: bytes, response: bytes) -> bytes:
852
+ """Append a complete DER response to a timestamp file without duplicating it.
853
+
854
+ Raises:
855
+ ValueError: ``existing`` is not completely framed (preserve it and stop),
856
+ or ``response`` is not exactly one complete DER value.
857
+ """
858
+ if existing and not frame_values(existing, max_value_length=None).complete:
859
+ raise ValueError("existing timestamp file is malformed; refusing to append")
860
+ framed = frame_values(response, max_value_length=None)
861
+ if len(framed.values) != 1 or not framed.complete:
862
+ raise ValueError("response must be exactly one complete DER value")
863
+ current = frame_values(existing, max_value_length=None)
864
+ for offset, length in current.values:
865
+ if existing[offset : offset + length] == response:
866
+ return existing
867
+ return existing + response