audit-labs/evidence-seal

Tamper-evident seals and chain of custody for audit evidence.

clone: git clone https://gitbay.org/audit-labs/evidence-seal.git

main: evidence_seal/timestamp.py · raw

  1"""Optional RFC 3161 trusted timestamping of manifests.
  2
  3A signature proves *who* sealed the evidence; a timestamp proves the seal
  4existed *by* a certain time — attested by an independent Time-Stamp Authority
  5(TSA), not by the sealer's own clock. The TSA timestamps the manifest's ``id``
  6(itself the hash of every file and all metadata), so one token vouches for the
  7whole package at a point in time.
  8
  9This module builds RFC 3161 requests, submits them to a TSA, and binds the
 10returned token into the manifest. It needs the ``asn1crypto`` package (install
 11``evidence-seal[timestamp]``); the core seal/verify path never imports it.
 12
 13The bound token is stored under a ``timestamp`` key, which — like ``signature``
 14— is excluded from the manifest id, so timestamping never invalidates the id or
 15an existing signature.
 16"""
 17
 18from __future__ import annotations
 19
 20import base64
 21import secrets
 22import urllib.request
 23from datetime import timezone
 24from pathlib import Path
 25
 26_TSA_CONTENT_TYPE = "application/timestamp-query"
 27_TSA_ACCEPT = "application/timestamp-reply"
 28
 29
 30def _require_asn1():
 31    try:
 32        from asn1crypto import algos, cms, core, tsp
 33    except ImportError as exc:  # pragma: no cover - exercised via a clear message
 34        raise RuntimeError(
 35            "timestamping requires the 'asn1crypto' package — "
 36            "install evidence-seal[timestamp]"
 37        ) from exc
 38    return algos, cms, core, tsp
 39
 40
 41def _require_crypto():
 42    try:
 43        import cryptography  # noqa: F401
 44    except ImportError as exc:  # pragma: no cover - exercised via a clear message
 45        raise RuntimeError(
 46            "verifying a TSA signature requires the 'cryptography' package — "
 47            "install evidence-seal[sign,timestamp]"
 48        ) from exc
 49
 50
 51def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes:
 52    """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id.
 53
 54    The message imprint is the manifest id (a SHA-256 digest) carried directly
 55    as the hashed message, so the TSA timestamps exactly what the id commits to.
 56    """
 57    algos, _cms, core, tsp = _require_asn1()
 58    request = tsp.TimeStampReq(
 59        {
 60            "version": 1,
 61            "message_imprint": tsp.MessageImprint(
 62                {
 63                    "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
 64                    "hashed_message": bytes.fromhex(manifest_id_hex),
 65                }
 66            ),
 67            "nonce": core.Integer(secrets.randbits(64)),
 68            "cert_req": cert_req,
 69        }
 70    )
 71    return request.dump()
 72
 73
 74def submit(request_der: bytes, tsa_url: str, timeout: float = 30.0) -> bytes:
 75    """POST a TimeStampReq to a TSA and return the raw TimeStampResp bytes.
 76
 77    Network call — the caller is responsible for choosing a trusted TSA URL.
 78    """
 79    req = urllib.request.Request(
 80        tsa_url,
 81        data=request_der,
 82        headers={"Content-Type": _TSA_CONTENT_TYPE, "Accept": _TSA_ACCEPT},
 83        method="POST",
 84    )
 85    with urllib.request.urlopen(req, timeout=timeout) as response:
 86        return response.read()
 87
 88
 89def _extract(token_or_response_der: bytes):
 90    """Return ``(token_contentinfo, tst_info)`` from a token or a TimeStampResp.
 91
 92    Accepts either a bare RFC 3161 token (a CMS ContentInfo) or a full
 93    TimeStampResp (what a TSA returns and a ``.tsr`` file holds).
 94    """
 95    _algos, cms, _core, tsp = _require_asn1()
 96
 97    def _tst_of(content_info):
 98        return content_info["content"]["encap_content_info"]["content"].parsed
 99
100    # Try a full response first; fall back to a bare token.
101    try:
102        response = tsp.TimeStampResp.load(token_or_response_der)
103        status = response["status"]["status"].native
104        token = response["time_stamp_token"]
105        tst = _tst_of(token)  # forces a parse; raises if this was not a response
106    except Exception:
107        token = cms.ContentInfo.load(token_or_response_der)
108        return token, _tst_of(token), None
109    return token, tst, status
110
111
112def parse_token(token_or_response_der: bytes) -> dict:
113    """Extract the human-facing fields from a timestamp token."""
114    _token, tst, _status = _extract(token_or_response_der)
115    gen_time = tst["gen_time"].native.astimezone(timezone.utc)
116    tsa = None
117    if tst["tsa"].native is not None:
118        tsa = str(tst["tsa"].native)
119    return {
120        "imprint": tst["message_imprint"]["hashed_message"].native.hex(),
121        "imprint_algorithm": tst["message_imprint"]["hash_algorithm"]["algorithm"].native,
122        "gen_time": gen_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
123        "serial_number": str(tst["serial_number"].native),
124        "tsa": tsa,
125    }
126
127
128def apply_timestamp(manifest: dict, token_or_response_der: bytes) -> dict:
129    """Bind a TSA token into *manifest* after checking it timestamps its id.
130
131    Raises ``ValueError`` if the token's message imprint does not equal the
132    manifest id — a token for anything else must never be attached.
133    """
134    token, _tst, _status = _extract(token_or_response_der)
135    fields = parse_token(token_or_response_der)
136
137    if fields["imprint_algorithm"] != "sha256" or fields["imprint"] != manifest.get("id"):
138        raise ValueError("token does not timestamp this manifest's id")
139
140    stamped = dict(manifest)
141    stamped["timestamp"] = {
142        "format": "rfc3161",
143        "imprint_algorithm": fields["imprint_algorithm"],
144        "imprint": fields["imprint"],
145        "gen_time": fields["gen_time"],
146        "serial_number": fields["serial_number"],
147        "tsa": fields["tsa"],
148        "token": base64.b64encode(token.dump()).decode(),
149    }
150    return stamped
151
152
153def verify_timestamp(manifest: dict, tsa_cert: bytes | None = None) -> tuple[bool, str]:
154    """Verify a manifest's embedded timestamp.
155
156    Returns ``(ok, message)``. Always checks that the stored token timestamps the
157    current manifest id. When *tsa_cert* (PEM or DER bytes) is given, the token's
158    RFC 3161 CMS signature is also verified against that certificate — proving
159    the timestamp really was issued by that authority.
160    """
161    block = manifest.get("timestamp")
162    if not block:
163        return False, "manifest is not timestamped"
164    if block.get("format") != "rfc3161":
165        return False, f"unsupported timestamp format: {block.get('format')}"
166
167    try:
168        token_der = base64.b64decode(block["token"])
169        fields = parse_token(token_der)
170    except Exception as exc:
171        return False, f"timestamp token is unreadable: {exc}"
172
173    if fields["imprint_algorithm"] != "sha256" or fields["imprint"] != manifest.get("id"):
174        return False, "timestamp does not match the manifest id"
175
176    tsa = f" by {fields['tsa']}" if fields["tsa"] else ""
177    bound = f"timestamped at {fields['gen_time']}{tsa}"
178
179    if tsa_cert is None:
180        return True, bound
181
182    sig_ok, sig_message = verify_token_signature(token_der, tsa_cert)
183    return sig_ok, f"{bound}; {sig_message}"
184
185
186# --------------------------------------------------------------------------- #
187# Full RFC 3161 CMS signature verification
188# --------------------------------------------------------------------------- #
189
190# asn1crypto hash names -> cryptography hash classes for the algorithms a TSA
191# realistically signs with.
192_HASHES = {
193    "sha1": "SHA1",
194    "sha224": "SHA224",
195    "sha256": "SHA256",
196    "sha384": "SHA384",
197    "sha512": "SHA512",
198}
199
200
201def _load_certificate(cert_bytes: bytes):
202    from cryptography import x509
203
204    try:
205        return x509.load_pem_x509_certificate(cert_bytes)
206    except ValueError:
207        return x509.load_der_x509_certificate(cert_bytes)
208
209
210def _hash_instance(name: str):
211    from cryptography.hazmat.primitives import hashes
212
213    if name not in _HASHES:
214        raise ValueError(f"unsupported digest algorithm: {name}")
215    return getattr(hashes, _HASHES[name])()
216
217
218def _verify_raw(public_key, signature: bytes, data: bytes, sig_algo: str, hash_name: str) -> None:
219    """Verify *signature* over *data*, raising on any failure."""
220    from cryptography.hazmat.primitives.asymmetric import ec, padding
221
222    if sig_algo == "rsassa_pkcs1v15":
223        public_key.verify(signature, data, padding.PKCS1v15(), _hash_instance(hash_name))
224    elif sig_algo == "rsassa_pss":
225        digest = _hash_instance(hash_name)
226        public_key.verify(
227            signature,
228            data,
229            padding.PSS(mgf=padding.MGF1(digest), salt_length=padding.PSS.DIGEST_LENGTH),
230            digest,
231        )
232    elif sig_algo == "ecdsa":
233        public_key.verify(signature, data, ec.ECDSA(_hash_instance(hash_name)))
234    elif sig_algo in ("ed25519", "ed448"):
235        public_key.verify(signature, data)
236    else:
237        raise ValueError(f"unsupported signature algorithm: {sig_algo}")
238
239
240def _signer_matches_cert(signer_info, cert) -> bool:
241    """True if the SignerInfo identifies the supplied certificate."""
242    sid = signer_info["sid"]
243    if sid.name == "issuer_and_serial_number":
244        return sid.chosen["serial_number"].native == cert.serial_number
245    # subject_key_identifier: compare against the cert's SKI extension.
246    from cryptography import x509
247
248    try:
249        ski = cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
250    except x509.ExtensionNotFound:
251        return False
252    return sid.chosen.native == ski.digest
253
254
255def _has_timestamping_eku(cert) -> bool:
256    from cryptography import x509
257    from cryptography.x509.oid import ExtendedKeyUsageOID
258
259    try:
260        eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
261    except x509.ExtensionNotFound:
262        return False
263    return ExtendedKeyUsageOID.TIME_STAMPING in eku
264
265
266def _signed_attr(signed_attrs, attr_type):
267    for attr in signed_attrs:
268        if attr["type"].native == attr_type:
269            return attr["values"][0].native
270    return None
271
272
273def verify_token_signature(token_or_response_der: bytes, cert_bytes: bytes) -> tuple[bool, str]:
274    """Verify a token's CMS signature against a TSA certificate.
275
276    Checks, in order: the certificate carries the timeStamping extended key
277    usage; it identifies the token's signer; ``gen_time`` falls within its
278    validity window; the signed message digest matches the timestamped content;
279    and the signature verifies. Returns ``(ok, message)``.
280
281    This authenticates the token against the certificate you supply. Establishing
282    that the certificate itself is trusted (chain to a known root) is left to the
283    caller — supply a TSA certificate you already trust.
284    """
285    import hashlib
286
287    _require_asn1()
288    _require_crypto()
289
290    try:
291        token, tst, _status = _extract(token_or_response_der)
292        signed_data = token["content"]
293        signer_info = signed_data["signer_infos"][0]
294    except Exception as exc:
295        return False, f"TSA signature: token is unreadable ({exc})"
296
297    try:
298        cert = _load_certificate(cert_bytes)
299    except ValueError as exc:
300        return False, f"TSA signature: cannot load certificate ({exc})"
301
302    if not _has_timestamping_eku(cert):
303        return False, "TSA signature: certificate lacks the timeStamping extended key usage"
304    if not _signer_matches_cert(signer_info, cert):
305        return False, "TSA signature: certificate does not match the token's signer"
306
307    gen_time = tst["gen_time"].native.astimezone(timezone.utc)
308    if not (cert.not_valid_before_utc <= gen_time <= cert.not_valid_after_utc):
309        return False, "TSA signature: gen_time is outside the certificate validity window"
310
311    econtent = signed_data["encap_content_info"]["content"].parsed.dump()
312    digest_name = signer_info["digest_algorithm"]["algorithm"].native
313    signed_attrs = signer_info["signed_attrs"]
314
315    if signed_attrs.native is not None:
316        recorded = _signed_attr(signed_attrs, "message_digest")
317        if recorded is None or recorded != hashlib.new(digest_name, econtent).digest():
318            return False, "TSA signature: signed message digest does not match the token content"
319        signed_bytes = signed_attrs.untag().dump()
320    else:
321        signed_bytes = econtent
322
323    sig_algo = signer_info["signature_algorithm"].signature_algo
324    # For rsassa_pkcs1v15 the OID carries no hash, and asn1crypto raises rather
325    # than returning None — fall back to the SignerInfo digest algorithm.
326    try:
327        hash_name = signer_info["signature_algorithm"].hash_algo or digest_name
328    except ValueError:
329        hash_name = digest_name
330    try:
331        _verify_raw(
332            cert.public_key(), signer_info["signature"].native, signed_bytes, sig_algo, hash_name
333        )
334    except ValueError as exc:
335        return False, f"TSA signature: {exc}"
336    except Exception as exc:
337        return False, f"TSA signature is INVALID ({type(exc).__name__})"
338
339    return True, f"TSA signature valid ({cert.subject.rfc4514_string()})"
340
341
342def load_der(path: str | Path) -> bytes:
343    """Read a DER file (a ``.tsq`` request or ``.tsr`` response)."""
344    return Path(path).read_bytes()