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)