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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|