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
93 93
94evidence-seal timestamp verify pkg.manifest.json 94evidence-seal timestamp verify pkg.manifest.json
95# -> timestamp OK: timestamped at 2026-08-06T09:00:00Z 95# -> 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
96``` 101```
97 102
98`apply` refuses any token whose imprint is not this manifest's `id`. Because the 103`apply` refuses any token whose imprint is not this manifest's `id`. Because the
99`id` moves if a single byte changes, a token can never be transplanted onto 104`id` moves if a single byte changes, a token can never be transplanted onto
100tampered evidence — re-sealing after a change orphans the timestamp. A present 105tampered 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).
102 114
103## The manifest 115## The manifest
104 116
@@ -150,10 +162,12 @@ guarantee, **sign** the manifest (retain the public key out of band) and
150 162
151Two limits to be honest about: 163Two limits to be honest about:
152 164
153- **`timestamp verify` checks the binding, not the TSA's signature.** It proves 165- **`timestamp verify` checks the binding; `--tsa-cert` adds signature
154 the stored token timestamps this manifest's `id`; it does not by itself verify 166 verification but not chain-of-trust.** Without a cert, verification proves the
155 the TSA's own signature and certificate chain. Validate the token against the 167 stored token timestamps this manifest's `id`. With `--tsa-cert`, it also
156 TSA's certificate out of band (e.g. `openssl ts -verify`) for full assurance. 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.
157- Private keys are written **unencrypted** — store them accordingly. 171- Private keys are written **unencrypted** — store them accordingly.
158 172
159## Development 173## Development
evidence_seal/cli.py +10 −2
@@ -118,7 +118,8 @@ def _cmd_verify(args) -> int:
118 if manifest.get("timestamp"): 118 if manifest.get("timestamp"):
119 from .timestamp import verify_timestamp 119 from .timestamp import verify_timestamp
120 120
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)
122 print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}") 123 print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}")
123 if not ts_ok: 124 if not ts_ok:
124 status = FAILED 125 status = FAILED
@@ -240,10 +241,11 @@ def _cmd_ts_verify(args) -> int:
240 241
241 try: 242 try:
242 manifest = load_manifest(args.manifest) 243 manifest = load_manifest(args.manifest)
244 cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None
243 except (FileNotFoundError, ValueError) as exc: 245 except (FileNotFoundError, ValueError) as exc:
244 print(f"error: cannot read manifest: {exc}", file=sys.stderr) 246 print(f"error: cannot read manifest: {exc}", file=sys.stderr)
245 return USAGE 247 return USAGE
246 ok, message = verify_timestamp(manifest) 248 ok, message = verify_timestamp(manifest, tsa_cert=cert)
247 print(f"timestamp {'OK' if ok else 'FAIL'}: {message}") 249 print(f"timestamp {'OK' if ok else 'FAIL'}: {message}")
248 return OK if ok else FAILED 250 return OK if ok else FAILED
249 251
@@ -269,6 +271,9 @@ def _build_parser() -> argparse.ArgumentParser:
269 p_verify.add_argument("directory") 271 p_verify.add_argument("directory")
270 p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)") 272 p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)")
271 p_verify.add_argument("--pubkey", metavar="PEM", help="require a signature by this public key") 273 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 )
272 p_verify.set_defaults(func=_cmd_verify) 277 p_verify.set_defaults(func=_cmd_verify)
273 278
274 p_chain = sub.add_parser("chain", help="verify manifests link oldest -> newest") 279 p_chain = sub.add_parser("chain", help="verify manifests link oldest -> newest")
@@ -314,6 +319,9 @@ def _add_timestamp_commands(sub) -> None:
314 319
315 p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp") 320 p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp")
316 p_tsv.add_argument("manifest") 321 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 )
317 p_tsv.set_defaults(func=_cmd_ts_verify) 325 p_tsv.set_defaults(func=_cmd_ts_verify)
318 326
319 327
evidence_seal/timestamp.py +179 −6
@@ -38,6 +38,16 @@ def _require_asn1():
38 return algos, cms, core, tsp 38 return algos, cms, core, tsp
39 39
40 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
41def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes: 51def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes:
42 """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id. 52 """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id.
43 53
@@ -140,12 +150,13 @@ def apply_timestamp(manifest: dict, token_or_response_der: bytes) -> dict:
140 return stamped 150 return stamped
141 151
142 152
143def verify_timestamp(manifest: dict) -> tuple[bool, str]: 153def verify_timestamp(manifest: dict, tsa_cert: bytes | None = None) -> tuple[bool, str]:
144 """Verify a manifest's embedded timestamp binds to its id. 154 """Verify a manifest's embedded timestamp.
145 155
146 Returns ``(ok, message)``. This checks that the stored token timestamps the 156 Returns ``(ok, message)``. Always checks that the stored token timestamps the
147 current manifest id; verifying the TSA's own signature and certificate chain 157 current manifest id. When *tsa_cert* (PEM or DER bytes) is given, the token's
148 is a separate, out-of-band step (see the README threat model). 158 RFC 3161 CMS signature is also verified against that certificate — proving
159 the timestamp really was issued by that authority.
149 """ 160 """
150 block = manifest.get("timestamp") 161 block = manifest.get("timestamp")
151 if not block: 162 if not block:
@@ -163,7 +174,169 @@ def verify_timestamp(manifest: dict) -> tuple[bool, str]:
163 return False, "timestamp does not match the manifest id" 174 return False, "timestamp does not match the manifest id"
164 175
165 tsa = f" by {fields['tsa']}" if fields["tsa"] else "" 176 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()})"
167 340
168 341
169def load_der(path: str | Path) -> bytes: 342def 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:
60 ).dump() 60 ).dump()
61 61
62 62
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
63@pytest.fixture 161@pytest.fixture
64def manifest(tmp_path): 162def manifest(tmp_path):
65 d = tmp_path / "pkg" 163 d = tmp_path / "pkg"
@@ -122,3 +220,82 @@ def test_verify_unstamped_manifest(manifest):
122 ok, message = verify_timestamp(manifest) 220 ok, message = verify_timestamp(manifest)
123 assert not ok 221 assert not ok
124 assert "not timestamped" in message 222 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)