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: tests/test_timestamp.py · raw
1"""Tests for RFC 3161 timestamping (skipped if asn1crypto is absent).
2
3These build tokens locally (acting as a TSA) so the whole flow is exercised
4offline — no network and no real Time-Stamp Authority.
5"""
6
7from datetime import datetime, timezone
8
9import pytest
10
11pytest.importorskip("asn1crypto")
12
13from asn1crypto import algos, cms, core, tsp
14
15from evidence_seal.manifest import build_manifest, compute_id
16from evidence_seal.timestamp import (
17 apply_timestamp,
18 build_request,
19 parse_token,
20 verify_timestamp,
21)
22
23
24def issue_token(imprint_hex: str, gen_time=None, as_response=True) -> bytes:
25 """Mint a timestamp token over *imprint_hex*, as a local TSA would."""
26 gen_time = gen_time or datetime(2026, 8, 6, 9, 0, tzinfo=timezone.utc)
27 tst = tsp.TSTInfo(
28 {
29 "version": "v1",
30 "policy": "1.2.3.4.5",
31 "message_imprint": tsp.MessageImprint(
32 {
33 "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
34 "hashed_message": bytes.fromhex(imprint_hex),
35 }
36 ),
37 "serial_number": 7,
38 "gen_time": gen_time,
39 }
40 )
41 token = cms.ContentInfo(
42 {
43 "content_type": "signed_data",
44 "content": cms.SignedData(
45 {
46 "version": "v3",
47 "digest_algorithms": [],
48 "encap_content_info": cms.EncapsulatedContentInfo(
49 {"content_type": "tst_info", "content": core.ParsableOctetString(tst.dump())}
50 ),
51 "signer_infos": [],
52 }
53 ),
54 }
55 )
56 if not as_response:
57 return token.dump()
58 return tsp.TimeStampResp(
59 {"status": {"status": "granted"}, "time_stamp_token": token}
60 ).dump()
61
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
161@pytest.fixture
162def manifest(tmp_path):
163 d = tmp_path / "pkg"
164 d.mkdir()
165 (d / "a.csv").write_text("x\n", encoding="utf-8")
166 return build_manifest(d)
167
168
169def test_build_request_carries_the_id(manifest):
170 der = build_request(manifest["id"])
171 req = tsp.TimeStampReq.load(der)
172 assert req["message_imprint"]["hashed_message"].native.hex() == manifest["id"]
173 assert req["message_imprint"]["hash_algorithm"]["algorithm"].native == "sha256"
174
175
176def test_parse_token_fields(manifest):
177 fields = parse_token(issue_token(manifest["id"]))
178 assert fields["imprint"] == manifest["id"]
179 assert fields["imprint_algorithm"] == "sha256"
180 assert fields["gen_time"] == "2026-08-06T09:00:00Z"
181 assert fields["serial_number"] == "7"
182
183
184def test_apply_and_verify_roundtrip(manifest):
185 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
186 assert stamped["timestamp"]["gen_time"] == "2026-08-06T09:00:00Z"
187 ok, message = verify_timestamp(stamped)
188 assert ok
189 assert "2026-08-06T09:00:00Z" in message
190
191
192def test_apply_accepts_bare_token(manifest):
193 stamped = apply_timestamp(manifest, issue_token(manifest["id"], as_response=False))
194 assert verify_timestamp(stamped)[0]
195
196
197def test_apply_refuses_token_for_other_id(manifest):
198 wrong = "0" * 64
199 token = issue_token(wrong)
200 with pytest.raises(ValueError, match="does not timestamp this manifest"):
201 apply_timestamp(manifest, token)
202
203
204def test_timestamp_does_not_change_manifest_id(manifest):
205 before = manifest["id"]
206 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
207 # The timestamp block is excluded from the id, so the id is unchanged.
208 assert compute_id(stamped) == before
209
210
211def test_verify_fails_if_id_changed_after_stamping(manifest):
212 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
213 # Simulate a re-seal after tampering: the id moves, orphaning the timestamp.
214 stamped["id"] = "1" * 64
215 ok, message = verify_timestamp(stamped)
216 assert not ok
217 assert "does not match the manifest id" in message
218
219
220def test_verify_unstamped_manifest(manifest):
221 ok, message = verify_timestamp(manifest)
222 assert not ok
223 assert "not timestamped" in message
224
225
226# --- full CMS signature verification (needs cryptography) ---
227
228
229def test_full_signature_verifies(manifest):
230 pytest.importorskip("cryptography")
231 from evidence_seal.timestamp import verify_token_signature
232
233 token, cert_pem = issue_signed_token(manifest["id"])
234 ok, message = verify_token_signature(token, cert_pem)
235 assert ok
236 assert "valid" in message
237 assert "Test TSA" in message
238
239
240def test_full_signature_via_verify_timestamp(manifest):
241 pytest.importorskip("cryptography")
242 token, cert_pem = issue_signed_token(manifest["id"])
243 stamped = apply_timestamp(manifest, token)
244 ok, message = verify_timestamp(stamped, tsa_cert=cert_pem)
245 assert ok
246 assert "TSA signature valid" in message
247
248
249def test_full_signature_wrong_cert_rejected(manifest):
250 pytest.importorskip("cryptography")
251 from evidence_seal.timestamp import verify_token_signature
252
253 token, _cert = issue_signed_token(manifest["id"])
254 _other_token, other_cert = issue_signed_token(manifest["id"])
255 # A different cert (different key, but same serial in our helper) must fail
256 # either the signer match or the cryptographic check.
257 ok, message = verify_token_signature(token, other_cert)
258 assert not ok
259 assert "TSA signature" in message
260
261
262def test_full_signature_missing_eku_rejected(manifest):
263 pytest.importorskip("cryptography")
264 from evidence_seal.timestamp import verify_token_signature
265
266 token, cert_pem = issue_signed_token(manifest["id"], timestamping_eku=False)
267 ok, message = verify_token_signature(token, cert_pem)
268 assert not ok
269 assert "timeStamping" in message
270
271
272def test_full_signature_gen_time_outside_validity_rejected(manifest):
273 import datetime
274
275 pytest.importorskip("cryptography")
276 from evidence_seal.timestamp import verify_token_signature
277
278 # gen_time in 2026 but the cert is only valid in 2020.
279 token, cert_pem = issue_signed_token(
280 manifest["id"],
281 validity=(
282 datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
283 datetime.datetime(2021, 1, 1, tzinfo=datetime.timezone.utc),
284 ),
285 )
286 ok, message = verify_token_signature(token, cert_pem)
287 assert not ok
288 assert "validity window" in message
289
290
291def test_full_signature_tampered_content_rejected(manifest):
292 pytest.importorskip("cryptography")
293 from evidence_seal.timestamp import verify_token_signature
294
295 # A token whose imprint is for a different id: the signature is valid over
296 # its own content, but apply_timestamp would refuse it, and here we confirm
297 # the signature itself is bound to the (wrong) content it was issued for.
298 token, cert_pem = issue_signed_token("a" * 64)
299 ok, _message = verify_token_signature(token, cert_pem)
300 assert ok # signature is valid for its own content...
301 # ...but it does not bind to this manifest, which apply enforces.
302 with pytest.raises(ValueError, match="does not timestamp"):
303 apply_timestamp(manifest, token)