Commit fbe8823be3

fbe8823be33c8323452c1044dcc33c38a8e7c921

parent: e129ad88bb

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-06 22:26 UTC

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.

Layout: unified · split

README.md +19 −5
@@ -93,12 +93,24 @@ evidence-seal timestamp apply pkg.manifest.json --token pkg.tsr
9393
9494evidence-seal timestamp verify pkg.manifest.json
9595# -> timestamp OK: timestamped at 2026-08-06T09:00:00Z
96
97# Full verification: also check the token's CMS signature against the TSA cert
98evidence-seal timestamp verify pkg.manifest.json --tsa-cert freetsa.pem
99# -> timestamp OK: timestamped at 2026-08-06T09:00:00Z; TSA signature valid (CN=…)
100evidence-seal verify ./pkg --tsa-cert freetsa.pem # same check inside a full verify
96101```
97102
98103`apply` refuses any token whose imprint is not this manifest's `id`. Because the
99104`id` moves if a single byte changes, a token can never be transplanted onto
100105tampered evidence — re-sealing after a change orphans the timestamp. A present
101timestamp is also checked automatically during `verify`.
106timestamp is checked automatically during `verify`.
107
108With `--tsa-cert`, the token's RFC 3161 CMS signature is verified against the
109supplied certificate: the certificate must carry the timeStamping extended key
110usage, identify the token's signer, be valid at `gen_time`, and its key must
111verify the signature over the timestamped content. This authenticates the token
112against a TSA certificate you trust; establishing that the certificate itself
113chains to a known root is left to you (supply a cert you already trust).
102114
103115## The manifest
104116
@@ -150,10 +162,12 @@ guarantee, **sign** the manifest (retain the public key out of band) and
150162
151163Two limits to be honest about:
152164
153- **`timestamp verify` checks the binding, not the TSA's signature.** It proves
154 the stored token timestamps this manifest's `id`; it does not by itself verify
155 the TSA's own signature and certificate chain. Validate the token against the
156 TSA's certificate out of band (e.g. `openssl ts -verify`) for full assurance.
165- **`timestamp verify` checks the binding; `--tsa-cert` adds signature
166 verification but not chain-of-trust.** Without a cert, verification proves the
167 stored token timestamps this manifest's `id`. With `--tsa-cert`, it also
168 verifies the token's CMS signature, the timeStamping EKU, the signer match,
169 and validity at `gen_time`. It does **not** verify that the certificate chains
170 to a trusted root — supply a TSA certificate you already trust.
157171- Private keys are written **unencrypted** — store them accordingly.
158172
159173## Development
evidence_seal/cli.py +10 −2
@@ -118,7 +118,8 @@ def _cmd_verify(args) -> int:
118118 if manifest.get("timestamp"):
119119 from .timestamp import verify_timestamp
120120
121 ts_ok, ts_message = verify_timestamp(manifest)
121 cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None
122 ts_ok, ts_message = verify_timestamp(manifest, tsa_cert=cert)
122123 print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}")
123124 if not ts_ok:
124125 status = FAILED
@@ -240,10 +241,11 @@ def _cmd_ts_verify(args) -> int:
240241
241242 try:
242243 manifest = load_manifest(args.manifest)
244 cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None
243245 except (FileNotFoundError, ValueError) as exc:
244246 print(f"error: cannot read manifest: {exc}", file=sys.stderr)
245247 return USAGE
246 ok, message = verify_timestamp(manifest)
248 ok, message = verify_timestamp(manifest, tsa_cert=cert)
247249 print(f"timestamp {'OK' if ok else 'FAIL'}: {message}")
248250 return OK if ok else FAILED
249251
@@ -269,6 +271,9 @@ def _build_parser() -> argparse.ArgumentParser:
269271 p_verify.add_argument("directory")
270272 p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)")
271273 p_verify.add_argument("--pubkey", metavar="PEM", help="require a signature by this public key")
274 p_verify.add_argument(
275 "--tsa-cert", metavar="CERT", help="verify the embedded timestamp against this TSA certificate"
276 )
272277 p_verify.set_defaults(func=_cmd_verify)
273278
274279 p_chain = sub.add_parser("chain", help="verify manifests link oldest -> newest")
@@ -314,6 +319,9 @@ def _add_timestamp_commands(sub) -> None:
314319
315320 p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp")
316321 p_tsv.add_argument("manifest")
322 p_tsv.add_argument(
323 "--tsa-cert", metavar="CERT", help="also verify the token's CMS signature against this cert"
324 )
317325 p_tsv.set_defaults(func=_cmd_ts_verify)
318326
319327
evidence_seal/timestamp.py +179 −6
@@ -38,6 +38,16 @@ def _require_asn1():
3838 return algos, cms, core, tsp
3939
4040
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
4151def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes:
4252 """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id.
4353
@@ -140,12 +150,13 @@ def apply_timestamp(manifest: dict, token_or_response_der: bytes) -> dict:
140150 return stamped
141151
142152
143def verify_timestamp(manifest: dict) -> tuple[bool, str]:
144 """Verify a manifest's embedded timestamp binds to its id.
153def verify_timestamp(manifest: dict, tsa_cert: bytes | None = None) -> tuple[bool, str]:
154 """Verify a manifest's embedded timestamp.
145155
146 Returns ``(ok, message)``. This checks that the stored token timestamps the
147 current manifest id; verifying the TSA's own signature and certificate chain
148 is a separate, out-of-band step (see the README threat model).
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.
149160 """
150161 block = manifest.get("timestamp")
151162 if not block:
@@ -163,7 +174,169 @@ def verify_timestamp(manifest: dict) -> tuple[bool, str]:
163174 return False, "timestamp does not match the manifest id"
164175
165176 tsa = f" by {fields['tsa']}" if fields["tsa"] else ""
166 return True, f"timestamped at {fields['gen_time']}{tsa}"
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()})"
167340
168341
169342def load_der(path: str | Path) -> bytes:
tests/test_timestamp.py +177
@@ -60,6 +60,104 @@ def issue_token(imprint_hex: str, gen_time=None, as_response=True) -> bytes:
6060 ).dump()
6161
6262
63def issue_signed_token(imprint_hex, gen_time=None, timestamping_eku=True, validity=None):
64 """Mint a genuinely CMS-signed token; return ``(response_der, cert_pem)``.
65
66 Acts as a real (self-signed) TSA so full signature verification can be
67 exercised offline.
68 """
69 import datetime
70
71 from asn1crypto import x509 as a1x509
72 from cryptography import x509
73 from cryptography.hazmat.primitives import hashes, serialization
74 from cryptography.hazmat.primitives.asymmetric import padding, rsa
75 from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
76
77 gen_time = gen_time or datetime.datetime(2026, 8, 6, 9, 0, tzinfo=datetime.timezone.utc)
78 not_before, not_after = validity or (
79 datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
80 datetime.datetime(2030, 1, 1, tzinfo=datetime.timezone.utc),
81 )
82 key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
83 name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test TSA")])
84 builder = (
85 x509.CertificateBuilder()
86 .subject_name(name)
87 .issuer_name(name)
88 .public_key(key.public_key())
89 .serial_number(4242)
90 .not_valid_before(not_before)
91 .not_valid_after(not_after)
92 )
93 if timestamping_eku:
94 builder = builder.add_extension(
95 x509.ExtendedKeyUsage([ExtendedKeyUsageOID.TIME_STAMPING]), critical=True
96 )
97 cert = builder.sign(key, hashes.SHA256())
98 cert_der = cert.public_bytes(serialization.Encoding.DER)
99 cert_pem = cert.public_bytes(serialization.Encoding.PEM)
100
101 imprint = tsp.MessageImprint(
102 {
103 "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
104 "hashed_message": bytes.fromhex(imprint_hex),
105 }
106 )
107 tst = tsp.TSTInfo(
108 {
109 "version": "v1",
110 "policy": "1.2.3.4.5",
111 "message_imprint": imprint,
112 "serial_number": 7,
113 "gen_time": gen_time,
114 }
115 )
116 econtent = tst.dump()
117
118 import hashlib
119
120 signed_attrs = cms.CMSAttributes(
121 [
122 cms.CMSAttribute({"type": "content_type", "values": ["tst_info"]}),
123 cms.CMSAttribute(
124 {"type": "message_digest", "values": [core.OctetString(hashlib.sha256(econtent).digest())]}
125 ),
126 ]
127 )
128 signature = key.sign(signed_attrs.untag().dump(), padding.PKCS1v15(), hashes.SHA256())
129 signer_info = cms.SignerInfo(
130 {
131 "version": "v1",
132 "sid": cms.SignerIdentifier(
133 {
134 "issuer_and_serial_number": cms.IssuerAndSerialNumber(
135 {"issuer": a1x509.Certificate.load(cert_der).issuer, "serial_number": 4242}
136 )
137 }
138 ),
139 "digest_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
140 "signed_attrs": signed_attrs,
141 "signature_algorithm": algos.SignedDigestAlgorithm({"algorithm": "rsassa_pkcs1v15"}),
142 "signature": signature,
143 }
144 )
145 signed_data = cms.SignedData(
146 {
147 "version": "v3",
148 "digest_algorithms": [algos.DigestAlgorithm({"algorithm": "sha256"})],
149 "encap_content_info": cms.EncapsulatedContentInfo(
150 {"content_type": "tst_info", "content": core.ParsableOctetString(econtent)}
151 ),
152 "certificates": [a1x509.Certificate.load(cert_der)],
153 "signer_infos": [signer_info],
154 }
155 )
156 token = cms.ContentInfo({"content_type": "signed_data", "content": signed_data})
157 response = tsp.TimeStampResp({"status": {"status": "granted"}, "time_stamp_token": token})
158 return response.dump(), cert_pem
159
160
63161@pytest.fixture
64162def manifest(tmp_path):
65163 d = tmp_path / "pkg"
@@ -122,3 +220,82 @@ def test_verify_unstamped_manifest(manifest):
122220 ok, message = verify_timestamp(manifest)
123221 assert not ok
124222 assert "not timestamped" in message
223
224
225# --- full CMS signature verification (needs cryptography) ---
226
227
228def test_full_signature_verifies(manifest):
229 pytest.importorskip("cryptography")
230 from evidence_seal.timestamp import verify_token_signature
231
232 token, cert_pem = issue_signed_token(manifest["id"])
233 ok, message = verify_token_signature(token, cert_pem)
234 assert ok
235 assert "valid" in message and "Test TSA" in message
236
237
238def test_full_signature_via_verify_timestamp(manifest):
239 pytest.importorskip("cryptography")
240 token, cert_pem = issue_signed_token(manifest["id"])
241 stamped = apply_timestamp(manifest, token)
242 ok, message = verify_timestamp(stamped, tsa_cert=cert_pem)
243 assert ok
244 assert "TSA signature valid" in message
245
246
247def test_full_signature_wrong_cert_rejected(manifest):
248 pytest.importorskip("cryptography")
249 from evidence_seal.timestamp import verify_token_signature
250
251 token, _cert = issue_signed_token(manifest["id"])
252 _other_token, other_cert = issue_signed_token(manifest["id"])
253 # A different cert (different key, but same serial in our helper) must fail
254 # either the signer match or the cryptographic check.
255 ok, message = verify_token_signature(token, other_cert)
256 assert not ok
257 assert "TSA signature" in message
258
259
260def test_full_signature_missing_eku_rejected(manifest):
261 pytest.importorskip("cryptography")
262 from evidence_seal.timestamp import verify_token_signature
263
264 token, cert_pem = issue_signed_token(manifest["id"], timestamping_eku=False)
265 ok, message = verify_token_signature(token, cert_pem)
266 assert not ok
267 assert "timeStamping" in message
268
269
270def test_full_signature_gen_time_outside_validity_rejected(manifest):
271 import datetime
272
273 pytest.importorskip("cryptography")
274 from evidence_seal.timestamp import verify_token_signature
275
276 # gen_time in 2026 but the cert is only valid in 2020.
277 token, cert_pem = issue_signed_token(
278 manifest["id"],
279 validity=(
280 datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
281 datetime.datetime(2021, 1, 1, tzinfo=datetime.timezone.utc),
282 ),
283 )
284 ok, message = verify_token_signature(token, cert_pem)
285 assert not ok
286 assert "validity window" in message
287
288
289def test_full_signature_tampered_content_rejected(manifest):
290 pytest.importorskip("cryptography")
291 from evidence_seal.timestamp import verify_token_signature
292
293 # A token whose imprint is for a different id: the signature is valid over
294 # its own content, but apply_timestamp would refuse it, and here we confirm
295 # the signature itself is bound to the (wrong) content it was issued for.
296 token, cert_pem = issue_signed_token("a" * 64)
297 ok, _message = verify_token_signature(token, cert_pem)
298 assert ok # signature is valid for its own content...
299 # ...but it does not bind to this manifest, which apply enforces.
300 with pytest.raises(ValueError, match="does not timestamp"):
301 apply_timestamp(manifest, token)