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
v0.1.0: 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 with pytest.raises(ValueError, match="does not timestamp this manifest"):
200 apply_timestamp(manifest, issue_token(wrong))
201
202
203def test_timestamp_does_not_change_manifest_id(manifest):
204 before = manifest["id"]
205 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
206 # The timestamp block is excluded from the id, so the id is unchanged.
207 assert compute_id(stamped) == before
208
209
210def test_verify_fails_if_id_changed_after_stamping(manifest):
211 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
212 # Simulate a re-seal after tampering: the id moves, orphaning the timestamp.
213 stamped["id"] = "1" * 64
214 ok, message = verify_timestamp(stamped)
215 assert not ok
216 assert "does not match the manifest id" in message
217
218
219def test_verify_unstamped_manifest(manifest):
220 ok, message = verify_timestamp(manifest)
221 assert not ok
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)