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

feat: full TSA-signature verification with --tsa-cert

verify and timestamp verify accept --tsa-cert to check the token's RFC 3161 CMS
signature, not just its binding to the manifest id. Verification confirms the
certificate carries the timeStamping EKU, identifies the token's signer, is
valid at gen_time, that the signed message digest matches the timestamped
content, and that the signature verifies (RSA PKCS1v15/PSS, ECDSA, Ed25519/448).
Chain-of-trust to a root is left to the caller. Tests mint genuinely CMS-signed
tokens locally. 44 tests, ruff clean.
 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(-)

diff --git a/README.md b/README.md
index 6443e8d..db16d5a 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/evidence_seal/cli.py b/evidence_seal/cli.py
index 4263e3e..de7c630 100644
--- a/evidence_seal/cli.py
+++ b/evidence_seal/cli.py
@@ -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)
 
 
diff --git a/evidence_seal/timestamp.py b/evidence_seal/timestamp.py
index b8b8f4a..b06adf3 100644
--- a/evidence_seal/timestamp.py
+++ b/evidence_seal/timestamp.py
@@ -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:
diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py
index 56ce082..5e66536 100644
--- a/tests/test_timestamp.py
+++ b/tests/test_timestamp.py
@@ -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)