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
fbe8823be33c8323452c1044dcc33c38a8e7c921
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-06T22:26:36Z
README.md | 24 ++++-- evidence_seal/cli.py | 12 ++- evidence_seal/timestamp.py | 185 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_timestamp.py | 177 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 385 insertions(+), 13 deletions(-) @@ -93,12 +93,24 @@ evidence-seal timestamp apply pkg.manifest.json --token pkg.tsr evidence-seal timestamp verify pkg.manifest.json # -> timestamp OK: timestamped at 2026-08-06T09:00:00Z + +# Full verification: also check the token's CMS signature against the TSA cert +evidence-seal timestamp verify pkg.manifest.json --tsa-cert freetsa.pem +# -> timestamp OK: timestamped at 2026-08-06T09:00:00Z; TSA signature valid (CN=…) +evidence-seal verify ./pkg --tsa-cert freetsa.pem # same check inside a full verify ``` `apply` refuses any token whose imprint is not this manifest's `id`. Because the `id` moves if a single byte changes, a token can never be transplanted onto tampered evidence — re-sealing after a change orphans the timestamp. A present -timestamp is also checked automatically during `verify`. +timestamp is checked automatically during `verify`. + +With `--tsa-cert`, the token's RFC 3161 CMS signature is verified against the +supplied certificate: the certificate must carry the timeStamping extended key +usage, identify the token's signer, be valid at `gen_time`, and its key must +verify the signature over the timestamped content. This authenticates the token +against a TSA certificate you trust; establishing that the certificate itself +chains to a known root is left to you (supply a cert you already trust). ## The manifest @@ -150,10 +162,12 @@ guarantee, **sign** the manifest (retain the public key out of band) and Two limits to be honest about: -- **`timestamp verify` checks the binding, not the TSA's signature.** It proves - the stored token timestamps this manifest's `id`; it does not by itself verify - the TSA's own signature and certificate chain. Validate the token against the - TSA's certificate out of band (e.g. `openssl ts -verify`) for full assurance. +- **`timestamp verify` checks the binding; `--tsa-cert` adds signature + verification but not chain-of-trust.** Without a cert, verification proves the + stored token timestamps this manifest's `id`. With `--tsa-cert`, it also + verifies the token's CMS signature, the timeStamping EKU, the signer match, + and validity at `gen_time`. It does **not** verify that the certificate chains + to a trusted root — supply a TSA certificate you already trust. - Private keys are written **unencrypted** — store them accordingly. ## Development @@ -118,7 +118,8 @@ def _cmd_verify(args) -> int: if manifest.get("timestamp"): from .timestamp import verify_timestamp - ts_ok, ts_message = verify_timestamp(manifest) + cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None + ts_ok, ts_message = verify_timestamp(manifest, tsa_cert=cert) print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}") if not ts_ok: status = FAILED @@ -240,10 +241,11 @@ def _cmd_ts_verify(args) -> int: try: manifest = load_manifest(args.manifest) + cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None except (FileNotFoundError, ValueError) as exc: print(f"error: cannot read manifest: {exc}", file=sys.stderr) return USAGE - ok, message = verify_timestamp(manifest) + ok, message = verify_timestamp(manifest, tsa_cert=cert) print(f"timestamp {'OK' if ok else 'FAIL'}: {message}") return OK if ok else FAILED @@ -269,6 +271,9 @@ def _build_parser() -> argparse.ArgumentParser: p_verify.add_argument("directory") p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)") p_verify.add_argument("--pubkey", metavar="PEM", help="require a signature by this public key") + p_verify.add_argument( + "--tsa-cert", metavar="CERT", help="verify the embedded timestamp against this TSA certificate" + ) p_verify.set_defaults(func=_cmd_verify) p_chain = sub.add_parser("chain", help="verify manifests link oldest -> newest") @@ -314,6 +319,9 @@ def _add_timestamp_commands(sub) -> None: p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp") p_tsv.add_argument("manifest") + p_tsv.add_argument( + "--tsa-cert", metavar="CERT", help="also verify the token's CMS signature against this cert" + ) p_tsv.set_defaults(func=_cmd_ts_verify) @@ -38,6 +38,16 @@ def _require_asn1(): return algos, cms, core, tsp +def _require_crypto(): + try: + import cryptography # noqa: F401 + except ImportError as exc: # pragma: no cover - exercised via a clear message + raise RuntimeError( + "verifying a TSA signature requires the 'cryptography' package — " + "install evidence-seal[sign,timestamp]" + ) from exc + + def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes: """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id. @@ -140,12 +150,13 @@ def apply_timestamp(manifest: dict, token_or_response_der: bytes) -> dict: return stamped -def verify_timestamp(manifest: dict) -> tuple[bool, str]: - """Verify a manifest's embedded timestamp binds to its id. +def verify_timestamp(manifest: dict, tsa_cert: bytes | None = None) -> tuple[bool, str]: + """Verify a manifest's embedded timestamp. - Returns ``(ok, message)``. This checks that the stored token timestamps the - current manifest id; verifying the TSA's own signature and certificate chain - is a separate, out-of-band step (see the README threat model). + Returns ``(ok, message)``. Always checks that the stored token timestamps the + current manifest id. When *tsa_cert* (PEM or DER bytes) is given, the token's + RFC 3161 CMS signature is also verified against that certificate — proving + the timestamp really was issued by that authority. """ block = manifest.get("timestamp") if not block: @@ -163,7 +174,169 @@ def verify_timestamp(manifest: dict) -> tuple[bool, str]: return False, "timestamp does not match the manifest id" tsa = f" by {fields['tsa']}" if fields["tsa"] else "" - return True, f"timestamped at {fields['gen_time']}{tsa}" + bound = f"timestamped at {fields['gen_time']}{tsa}" + + if tsa_cert is None: + return True, bound + + sig_ok, sig_message = verify_token_signature(token_der, tsa_cert) + return sig_ok, f"{bound}; {sig_message}" + + +# --------------------------------------------------------------------------- # +# Full RFC 3161 CMS signature verification +# --------------------------------------------------------------------------- # + +# asn1crypto hash names -> cryptography hash classes for the algorithms a TSA +# realistically signs with. +_HASHES = { + "sha1": "SHA1", + "sha224": "SHA224", + "sha256": "SHA256", + "sha384": "SHA384", + "sha512": "SHA512", +} + + +def _load_certificate(cert_bytes: bytes): + from cryptography import x509 + + try: + return x509.load_pem_x509_certificate(cert_bytes) + except ValueError: + return x509.load_der_x509_certificate(cert_bytes) + + +def _hash_instance(name: str): + from cryptography.hazmat.primitives import hashes + + if name not in _HASHES: + raise ValueError(f"unsupported digest algorithm: {name}") + return getattr(hashes, _HASHES[name])() + + +def _verify_raw(public_key, signature: bytes, data: bytes, sig_algo: str, hash_name: str) -> None: + """Verify *signature* over *data*, raising on any failure.""" + from cryptography.hazmat.primitives.asymmetric import ec, padding + + if sig_algo == "rsassa_pkcs1v15": + public_key.verify(signature, data, padding.PKCS1v15(), _hash_instance(hash_name)) + elif sig_algo == "rsassa_pss": + digest = _hash_instance(hash_name) + public_key.verify( + signature, + data, + padding.PSS(mgf=padding.MGF1(digest), salt_length=padding.PSS.DIGEST_LENGTH), + digest, + ) + elif sig_algo == "ecdsa": + public_key.verify(signature, data, ec.ECDSA(_hash_instance(hash_name))) + elif sig_algo in ("ed25519", "ed448"): + public_key.verify(signature, data) + else: + raise ValueError(f"unsupported signature algorithm: {sig_algo}") + + +def _signer_matches_cert(signer_info, cert) -> bool: + """True if the SignerInfo identifies the supplied certificate.""" + sid = signer_info["sid"] + if sid.name == "issuer_and_serial_number": + return sid.chosen["serial_number"].native == cert.serial_number + # subject_key_identifier: compare against the cert's SKI extension. + from cryptography import x509 + + try: + ski = cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value + except x509.ExtensionNotFound: + return False + return sid.chosen.native == ski.digest + + +def _has_timestamping_eku(cert) -> bool: + from cryptography import x509 + from cryptography.x509.oid import ExtendedKeyUsageOID + + try: + eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value + except x509.ExtensionNotFound: + return False + return ExtendedKeyUsageOID.TIME_STAMPING in eku + + +def _signed_attr(signed_attrs, attr_type): + for attr in signed_attrs: + if attr["type"].native == attr_type: + return attr["values"][0].native + return None + + +def verify_token_signature(token_or_response_der: bytes, cert_bytes: bytes) -> tuple[bool, str]: + """Verify a token's CMS signature against a TSA certificate. + + Checks, in order: the certificate carries the timeStamping extended key + usage; it identifies the token's signer; ``gen_time`` falls within its + validity window; the signed message digest matches the timestamped content; + and the signature verifies. Returns ``(ok, message)``. + + This authenticates the token against the certificate you supply. Establishing + that the certificate itself is trusted (chain to a known root) is left to the + caller — supply a TSA certificate you already trust. + """ + import hashlib + + _require_asn1() + _require_crypto() + + try: + token, tst, _status = _extract(token_or_response_der) + signed_data = token["content"] + signer_info = signed_data["signer_infos"][0] + except Exception as exc: + return False, f"TSA signature: token is unreadable ({exc})" + + try: + cert = _load_certificate(cert_bytes) + except ValueError as exc: + return False, f"TSA signature: cannot load certificate ({exc})" + + if not _has_timestamping_eku(cert): + return False, "TSA signature: certificate lacks the timeStamping extended key usage" + if not _signer_matches_cert(signer_info, cert): + return False, "TSA signature: certificate does not match the token's signer" + + gen_time = tst["gen_time"].native.astimezone(timezone.utc) + if not (cert.not_valid_before_utc <= gen_time <= cert.not_valid_after_utc): + return False, "TSA signature: gen_time is outside the certificate validity window" + + econtent = signed_data["encap_content_info"]["content"].parsed.dump() + digest_name = signer_info["digest_algorithm"]["algorithm"].native + signed_attrs = signer_info["signed_attrs"] + + if signed_attrs.native is not None: + recorded = _signed_attr(signed_attrs, "message_digest") + if recorded is None or recorded != hashlib.new(digest_name, econtent).digest(): + return False, "TSA signature: signed message digest does not match the token content" + signed_bytes = signed_attrs.untag().dump() + else: + signed_bytes = econtent + + sig_algo = signer_info["signature_algorithm"].signature_algo + # For rsassa_pkcs1v15 the OID carries no hash, and asn1crypto raises rather + # than returning None — fall back to the SignerInfo digest algorithm. + try: + hash_name = signer_info["signature_algorithm"].hash_algo or digest_name + except ValueError: + hash_name = digest_name + try: + _verify_raw( + cert.public_key(), signer_info["signature"].native, signed_bytes, sig_algo, hash_name + ) + except ValueError as exc: + return False, f"TSA signature: {exc}" + except Exception as exc: + return False, f"TSA signature is INVALID ({type(exc).__name__})" + + return True, f"TSA signature valid ({cert.subject.rfc4514_string()})" def load_der(path: str | Path) -> bytes: @@ -60,6 +60,104 @@ def issue_token(imprint_hex: str, gen_time=None, as_response=True) -> bytes: ).dump() +def issue_signed_token(imprint_hex, gen_time=None, timestamping_eku=True, validity=None): + """Mint a genuinely CMS-signed token; return ``(response_der, cert_pem)``. + + Acts as a real (self-signed) TSA so full signature verification can be + exercised offline. + """ + import datetime + + from asn1crypto import x509 as a1x509 + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding, rsa + from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + + gen_time = gen_time or datetime.datetime(2026, 8, 6, 9, 0, tzinfo=datetime.timezone.utc) + not_before, not_after = validity or ( + datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + datetime.datetime(2030, 1, 1, tzinfo=datetime.timezone.utc), + ) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test TSA")]) + builder = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(4242) + .not_valid_before(not_before) + .not_valid_after(not_after) + ) + if timestamping_eku: + builder = builder.add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.TIME_STAMPING]), critical=True + ) + cert = builder.sign(key, hashes.SHA256()) + cert_der = cert.public_bytes(serialization.Encoding.DER) + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + + imprint = tsp.MessageImprint( + { + "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}), + "hashed_message": bytes.fromhex(imprint_hex), + } + ) + tst = tsp.TSTInfo( + { + "version": "v1", + "policy": "1.2.3.4.5", + "message_imprint": imprint, + "serial_number": 7, + "gen_time": gen_time, + } + ) + econtent = tst.dump() + + import hashlib + + signed_attrs = cms.CMSAttributes( + [ + cms.CMSAttribute({"type": "content_type", "values": ["tst_info"]}), + cms.CMSAttribute( + {"type": "message_digest", "values": [core.OctetString(hashlib.sha256(econtent).digest())]} + ), + ] + ) + signature = key.sign(signed_attrs.untag().dump(), padding.PKCS1v15(), hashes.SHA256()) + signer_info = cms.SignerInfo( + { + "version": "v1", + "sid": cms.SignerIdentifier( + { + "issuer_and_serial_number": cms.IssuerAndSerialNumber( + {"issuer": a1x509.Certificate.load(cert_der).issuer, "serial_number": 4242} + ) + } + ), + "digest_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}), + "signed_attrs": signed_attrs, + "signature_algorithm": algos.SignedDigestAlgorithm({"algorithm": "rsassa_pkcs1v15"}), + "signature": signature, + } + ) + signed_data = cms.SignedData( + { + "version": "v3", + "digest_algorithms": [algos.DigestAlgorithm({"algorithm": "sha256"})], + "encap_content_info": cms.EncapsulatedContentInfo( + {"content_type": "tst_info", "content": core.ParsableOctetString(econtent)} + ), + "certificates": [a1x509.Certificate.load(cert_der)], + "signer_infos": [signer_info], + } + ) + token = cms.ContentInfo({"content_type": "signed_data", "content": signed_data}) + response = tsp.TimeStampResp({"status": {"status": "granted"}, "time_stamp_token": token}) + return response.dump(), cert_pem + + @pytest.fixture def manifest(tmp_path): d = tmp_path / "pkg" @@ -122,3 +220,82 @@ def test_verify_unstamped_manifest(manifest): ok, message = verify_timestamp(manifest) assert not ok assert "not timestamped" in message + + +# --- full CMS signature verification (needs cryptography) --- + + +def test_full_signature_verifies(manifest): + pytest.importorskip("cryptography") + from evidence_seal.timestamp import verify_token_signature + + token, cert_pem = issue_signed_token(manifest["id"]) + ok, message = verify_token_signature(token, cert_pem) + assert ok + assert "valid" in message and "Test TSA" in message + + +def test_full_signature_via_verify_timestamp(manifest): + pytest.importorskip("cryptography") + token, cert_pem = issue_signed_token(manifest["id"]) + stamped = apply_timestamp(manifest, token) + ok, message = verify_timestamp(stamped, tsa_cert=cert_pem) + assert ok + assert "TSA signature valid" in message + + +def test_full_signature_wrong_cert_rejected(manifest): + pytest.importorskip("cryptography") + from evidence_seal.timestamp import verify_token_signature + + token, _cert = issue_signed_token(manifest["id"]) + _other_token, other_cert = issue_signed_token(manifest["id"]) + # A different cert (different key, but same serial in our helper) must fail + # either the signer match or the cryptographic check. + ok, message = verify_token_signature(token, other_cert) + assert not ok + assert "TSA signature" in message + + +def test_full_signature_missing_eku_rejected(manifest): + pytest.importorskip("cryptography") + from evidence_seal.timestamp import verify_token_signature + + token, cert_pem = issue_signed_token(manifest["id"], timestamping_eku=False) + ok, message = verify_token_signature(token, cert_pem) + assert not ok + assert "timeStamping" in message + + +def test_full_signature_gen_time_outside_validity_rejected(manifest): + import datetime + + pytest.importorskip("cryptography") + from evidence_seal.timestamp import verify_token_signature + + # gen_time in 2026 but the cert is only valid in 2020. + token, cert_pem = issue_signed_token( + manifest["id"], + validity=( + datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc), + datetime.datetime(2021, 1, 1, tzinfo=datetime.timezone.utc), + ), + ) + ok, message = verify_token_signature(token, cert_pem) + assert not ok + assert "validity window" in message + + +def test_full_signature_tampered_content_rejected(manifest): + pytest.importorskip("cryptography") + from evidence_seal.timestamp import verify_token_signature + + # A token whose imprint is for a different id: the signature is valid over + # its own content, but apply_timestamp would refuse it, and here we confirm + # the signature itself is bound to the (wrong) content it was issued for. + token, cert_pem = issue_signed_token("a" * 64) + ok, _message = verify_token_signature(token, cert_pem) + assert ok # signature is valid for its own content... + # ...but it does not bind to this manifest, which apply enforces. + with pytest.raises(ValueError, match="does not timestamp"): + apply_timestamp(manifest, token)