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

v1.0.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)