Commit e129ad88bb

e129ad88bbde3518f05e73a0167d3e54accaee10

parent: 5a9365de64

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-06 22:19 UTC

feat: add RFC 3161 timestamp subcommand

evidence-seal timestamp {request,apply,submit,verify} obtains a trusted
timestamp over a manifest's id from an independent Time-Stamp Authority, proving
the seal is not backdated. request writes a DER TimeStampReq; submit POSTs it to
a --tsa and binds the token; apply ingests a TSA response/token; verify (and the
main verify command) confirm the token's imprint equals the manifest id.

apply refuses any token not bound to this id, so a token can never be
transplanted onto tampered evidence. The timestamp block is excluded from the id
(alongside signature), so seal/sign/timestamp compose in any order. Timestamping
uses asn1crypto via the [timestamp] extra; the core stays stdlib-only. Adds
offline tests that mint tokens locally. 38 tests, ruff clean.

Layout: unified · split

README.md +51 −13
@@ -17,9 +17,12 @@ directory is byte-for-byte what was sealed, and names anything that changed.
17 forms an append-only history; reordering or removing one is detectable. 17 forms an append-only history; reordering or removing one is detectable.
18- **Attribution** *(optional)* — sign a manifest with an ed25519 key so a named 18- **Attribution** *(optional)* — sign a manifest with an ed25519 key so a named
19 party attests "I collected this," not just "it is unchanged." 19 party attests "I collected this," not just "it is unchanged."
20- **Trusted time** *(optional)* — obtain an RFC 3161 timestamp from an
21 independent authority so the seal is provably *not backdated*.
20 22
21The core (`seal`, `verify`, `chain`) is **pure standard library** — no 23The core (`seal`, `verify`, `chain`) is **pure standard library** — no
22dependencies. Signing needs `cryptography` (`pip install evidence-seal[sign]`). 24dependencies. Signing needs `cryptography` (`evidence-seal[sign]`) and
25timestamping needs `asn1crypto` (`evidence-seal[timestamp]`).
23 26
24## Install 27## Install
25 28
@@ -27,7 +30,7 @@ dependencies. Signing needs `cryptography` (`pip install evidence-seal[sign]`).
27git clone https://github.com/audit-labs/evidence-seal 30git clone https://github.com/audit-labs/evidence-seal
28cd evidence-seal 31cd evidence-seal
29python -m venv .venv && source .venv/bin/activate 32python -m venv .venv && source .venv/bin/activate
30pip install -e ".[sign]" # drop [sign] for the zero-dependency core 33pip install -e ".[sign,timestamp]" # or drop the extras for the zero-dependency core
31``` 34```
32 35
33## Usage 36## Usage
@@ -72,6 +75,31 @@ evidence-seal verify ./pkg --pubkey acme.pub # require this signe
72Without `--pubkey`, a present signature is still checked for validity; with it, 75Without `--pubkey`, a present signature is still checked for validity; with it,
73the signer's key must also match, proving *identity* and not just integrity. 76the signer's key must also match, proving *identity* and not just integrity.
74 77
78### Timestamping (trusted time)
79
80A signature says *who*; a timestamp says *when*, attested by an independent
81Time-Stamp Authority rather than the sealer's own clock. The TSA timestamps the
82manifest `id`, so one token vouches for the whole package.
83
84```bash
85# One step: request, POST to a TSA, and bind the token in
86evidence-seal timestamp submit pkg.manifest.json --tsa https://freetsa.org/tsr
87
88# Or split it — build a request, submit it however you like, then apply
89evidence-seal timestamp request pkg.manifest.json --out pkg.tsq
90curl -sS -H 'Content-Type: application/timestamp-query' \
91 --data-binary @pkg.tsq https://freetsa.org/tsr -o pkg.tsr
92evidence-seal timestamp apply pkg.manifest.json --token pkg.tsr
93
94evidence-seal timestamp verify pkg.manifest.json
95# -> timestamp OK: timestamped at 2026-08-06T09:00:00Z
96```
97
98`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
100tampered evidence — re-sealing after a change orphans the timestamp. A present
101timestamp is also checked automatically during `verify`.
102
75## The manifest 103## The manifest
76 104
77Canonical JSON, sorted keys — diff-friendly and reproducible: 105Canonical JSON, sorted keys — diff-friendly and reproducible:
@@ -87,14 +115,17 @@ Canonical JSON, sorted keys — diff-friendly and reproducible:
87 "file_count": 8, 115 "file_count": 8,
88 "files": [ { "path": "iam_users.csv", "sha256": "309b0e45…", "bytes": 412 } ], 116 "files": [ { "path": "iam_users.csv", "sha256": "309b0e45…", "bytes": 412 } ],
89 "id": "08b846a0…", 117 "id": "08b846a0…",
90 "signature": { "algorithm": "ed25519", "public_key": "1bea5f1d…", "value": "2d7c2d63…" } 118 "signature": { "algorithm": "ed25519", "public_key": "1bea5f1d…", "value": "2d7c2d63…" },
119 "timestamp": { "format": "rfc3161", "gen_time": "2026-08-06T09:00:00Z", "imprint": "08b846a0…", "token": "MIIB…" }
91} 120}
92``` 121```
93 122
94- **`root`** — Merkle root over all `(path, sha256)` leaves; one value that 123- **`root`** — Merkle root over all `(path, sha256)` leaves; one value that
95 changes if any file, name, or byte changes. 124 changes if any file, name, or byte changes.
96- **`id`** — SHA-256 of the manifest's canonical form (excluding `id` and 125- **`id`** — SHA-256 of the manifest's canonical form (excluding `id`,
97 `signature`); makes it self-verifying and chainable. 126 `signature`, and `timestamp`); makes it self-verifying and chainable. Because
127 the signature and the timestamp both attest *to* the id, they sit outside it
128 and compose in any order.
98- **`ignore`** — glob patterns skipped at seal time; `verify` reuses them so it 129- **`ignore`** — glob patterns skipped at seal time; `verify` reuses them so it
99 never false-flags an intentionally excluded file. 130 never false-flags an intentionally excluded file.
100 131
@@ -104,19 +135,26 @@ Canonical JSON, sorted keys — diff-friendly and reproducible:
104| --- | --- | 135| --- | --- |
105| `0` | Intact / valid. | 136| `0` | Intact / valid. |
106| `1` | Tamper detected, chain broken, or signature invalid. | 137| `1` | Tamper detected, chain broken, or signature invalid. |
107| `2` | Usage error (missing directory, bad `--meta`, missing `cryptography`). | 138| `2` | Usage error (missing directory, bad `--meta`, missing optional dependency). |
108 139
109Fail a pipeline on `1`; treat `2` as a misconfiguration to fix. 140Fail a pipeline on `1`; treat `2` as a misconfiguration to fix.
110 141
111## Threat model 142## Threat model
112 143
113`evidence-seal` proves a directory matches a manifest, and (when signed) who 144`evidence-seal` proves a directory matches a manifest, who produced it (when
114produced that manifest. It does **not** prove *when* something was sealed beyond 145signed), and that it existed by a given time (when timestamped). An *unsigned,
115the self-reported `created_at`, and an unsigned manifest can be regenerated by 146untimestamped* manifest can be regenerated by anyone with the files, and its
116anyone with the files. For strong "sealed at time T by party P" guarantees, 147`created_at` is self-reported. For a strong "sealed at time T by party P"
117sign the manifest and retain the public key out of band; optionally submit the 148guarantee, **sign** the manifest (retain the public key out of band) and
118manifest `id` to an external timestamping authority. Private keys are written 149**timestamp** it with a trusted TSA.
119unencrypted — store them accordingly. 150
151Two limits to be honest about:
152
153- **`timestamp verify` checks the binding, not the TSA's signature.** It proves
154 the stored token timestamps this manifest's `id`; it does not by itself verify
155 the TSA's own signature and certificate chain. Validate the token against the
156 TSA's certificate out of band (e.g. `openssl ts -verify`) for full assurance.
157- Private keys are written **unencrypted** — store them accordingly.
120 158
121## Development 159## Development
122 160
evidence_seal/cli.py +104 −3
@@ -111,9 +111,16 @@ def _cmd_verify(args) -> int:
111 status = FAILED 111 status = FAILED
112 112
113 # Verify a signature when present, or when the caller supplied a key to trust. 113 # Verify a signature when present, or when the caller supplied a key to trust.
114 if manifest.get("signature") or args.pubkey: 114 if (manifest.get("signature") or args.pubkey) and not _verify_sig_cli(manifest, args.pubkey):
115 sig_ok = _verify_sig_cli(manifest, args.pubkey) 115 status = FAILED
116 if not sig_ok: 116
117 # Verify an embedded timestamp when present.
118 if manifest.get("timestamp"):
119 from .timestamp import verify_timestamp
120
121 ts_ok, ts_message = verify_timestamp(manifest)
122 print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}")
123 if not ts_ok:
117 status = FAILED 124 status = FAILED
118 125
119 if status == OK: 126 if status == OK:
@@ -175,6 +182,72 @@ def _cmd_sign(args) -> int:
175 return OK 182 return OK
176 183
177 184
185def _cmd_ts_request(args) -> int:
186 from .timestamp import build_request
187
188 try:
189 manifest = load_manifest(args.manifest)
190 request = build_request(manifest["id"])
191 except (RuntimeError, FileNotFoundError, ValueError, KeyError, OSError) as exc:
192 print(f"error: {exc}", file=sys.stderr)
193 return USAGE
194 out = args.out or f"{args.manifest}.tsq"
195 Path(out).write_bytes(request)
196 print(f"wrote timestamp request for id {manifest['id'][:16]}… -> {out}", file=sys.stderr)
197 print("submit it to a TSA, e.g.:", file=sys.stderr)
198 print(
199 f" curl -sS -H 'Content-Type: application/timestamp-query' "
200 f"--data-binary @{out} <TSA_URL> -o {out.removesuffix('.tsq')}.tsr",
201 file=sys.stderr,
202 )
203 return OK
204
205
206def _cmd_ts_apply(args) -> int:
207 from .timestamp import apply_timestamp, load_der
208
209 try:
210 manifest = load_manifest(args.manifest)
211 stamped = apply_timestamp(manifest, load_der(args.token))
212 except (RuntimeError, FileNotFoundError, ValueError, OSError) as exc:
213 print(f"error: {exc}", file=sys.stderr)
214 return USAGE
215 write_manifest(stamped, args.out or args.manifest)
216 print(f"timestamped {args.out or args.manifest} at {stamped['timestamp']['gen_time']}", file=sys.stderr)
217 return OK
218
219
220def _cmd_ts_submit(args) -> int:
221 from .timestamp import apply_timestamp, build_request, submit
222
223 try:
224 manifest = load_manifest(args.manifest)
225 response = submit(build_request(manifest["id"]), args.tsa, timeout=args.timeout)
226 stamped = apply_timestamp(manifest, response)
227 except ValueError as exc:
228 print(f"error: {exc}", file=sys.stderr)
229 return FAILED
230 except (RuntimeError, FileNotFoundError, KeyError, OSError) as exc:
231 print(f"error: {exc}", file=sys.stderr)
232 return USAGE
233 write_manifest(stamped, args.out or args.manifest)
234 print(f"timestamped by {args.tsa} at {stamped['timestamp']['gen_time']}", file=sys.stderr)
235 return OK
236
237
238def _cmd_ts_verify(args) -> int:
239 from .timestamp import verify_timestamp
240
241 try:
242 manifest = load_manifest(args.manifest)
243 except (FileNotFoundError, ValueError) as exc:
244 print(f"error: cannot read manifest: {exc}", file=sys.stderr)
245 return USAGE
246 ok, message = verify_timestamp(manifest)
247 print(f"timestamp {'OK' if ok else 'FAIL'}: {message}")
248 return OK if ok else FAILED
249
250
178def _build_parser() -> argparse.ArgumentParser: 251def _build_parser() -> argparse.ArgumentParser:
179 parser = argparse.ArgumentParser( 252 parser = argparse.ArgumentParser(
180 prog="evidence-seal", 253 prog="evidence-seal",
@@ -213,9 +286,37 @@ def _build_parser() -> argparse.ArgumentParser:
213 p_sign.add_argument("--out", help="write here instead of overwriting the manifest") 286 p_sign.add_argument("--out", help="write here instead of overwriting the manifest")
214 p_sign.set_defaults(func=_cmd_sign) 287 p_sign.set_defaults(func=_cmd_sign)
215 288
289 _add_timestamp_commands(sub)
216 return parser 290 return parser
217 291
218 292
293def _add_timestamp_commands(sub) -> None:
294 p_ts = sub.add_parser("timestamp", help="RFC 3161 trusted timestamping of a manifest")
295 ts = p_ts.add_subparsers(dest="ts_action", required=True)
296
297 p_req = ts.add_parser("request", help="write a TimeStampReq (.tsq) for the manifest id")
298 p_req.add_argument("manifest")
299 p_req.add_argument("--out", help="request path (default: <manifest>.tsq)")
300 p_req.set_defaults(func=_cmd_ts_request)
301
302 p_apply = ts.add_parser("apply", help="bind a TSA response/token into the manifest")
303 p_apply.add_argument("manifest")
304 p_apply.add_argument("--token", required=True, metavar="TSR", help="TSA response or token (DER)")
305 p_apply.add_argument("--out", help="write here instead of overwriting the manifest")
306 p_apply.set_defaults(func=_cmd_ts_apply)
307
308 p_submit = ts.add_parser("submit", help="request, POST to a TSA, and bind in one step")
309 p_submit.add_argument("manifest")
310 p_submit.add_argument("--tsa", required=True, metavar="URL", help="RFC 3161 TSA endpoint")
311 p_submit.add_argument("--timeout", type=float, default=30.0, help="network timeout (seconds)")
312 p_submit.add_argument("--out", help="write here instead of overwriting the manifest")
313 p_submit.set_defaults(func=_cmd_ts_submit)
314
315 p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp")
316 p_tsv.add_argument("manifest")
317 p_tsv.set_defaults(func=_cmd_ts_verify)
318
319
219def main(argv: list[str] | None = None) -> int: 320def main(argv: list[str] | None = None) -> int:
220 parser = _build_parser() 321 parser = _build_parser()
221 args = parser.parse_args(argv if argv is not None else sys.argv[1:]) 322 args = parser.parse_args(argv if argv is not None else sys.argv[1:])
evidence_seal/manifest.py +3 −2
@@ -19,8 +19,9 @@ from . import ALGORITHM, MANIFEST_VERSION, __version__
19from .hashing import hash_bytes, hash_file, merkle_root 19from .hashing import hash_bytes, hash_file, merkle_root
20 20
21# Keys excluded from the canonical bytes the id is computed over. The id cannot 21# Keys excluded from the canonical bytes the id is computed over. The id cannot
22# cover itself, and a signature is applied *to* the id afterwards. 22# cover itself; a signature and a timestamp are both applied *to* the id
23_ID_EXCLUDED = ("id", "signature") 23# afterwards, so they sit outside it and compose independently of each other.
24_ID_EXCLUDED = ("id", "signature", "timestamp")
24 25
25 26
26def iter_files( 27def iter_files(
evidence_seal/timestamp.py added +171
@@ -0,0 +1,171 @@
1"""Optional RFC 3161 trusted timestamping of manifests.
2
3A signature proves *who* sealed the evidence; a timestamp proves the seal
4existed *by* a certain time — attested by an independent Time-Stamp Authority
5(TSA), not by the sealer's own clock. The TSA timestamps the manifest's ``id``
6(itself the hash of every file and all metadata), so one token vouches for the
7whole package at a point in time.
8
9This module builds RFC 3161 requests, submits them to a TSA, and binds the
10returned token into the manifest. It needs the ``asn1crypto`` package (install
11``evidence-seal[timestamp]``); the core seal/verify path never imports it.
12
13The bound token is stored under a ``timestamp`` key, which — like ``signature``
14— is excluded from the manifest id, so timestamping never invalidates the id or
15an existing signature.
16"""
17
18from __future__ import annotations
19
20import base64
21import secrets
22import urllib.request
23from datetime import timezone
24from pathlib import Path
25
26_TSA_CONTENT_TYPE = "application/timestamp-query"
27_TSA_ACCEPT = "application/timestamp-reply"
28
29
30def _require_asn1():
31 try:
32 from asn1crypto import algos, cms, core, tsp
33 except ImportError as exc: # pragma: no cover - exercised via a clear message
34 raise RuntimeError(
35 "timestamping requires the 'asn1crypto' package — "
36 "install evidence-seal[timestamp]"
37 ) from exc
38 return algos, cms, core, tsp
39
40
41def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes:
42 """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id.
43
44 The message imprint is the manifest id (a SHA-256 digest) carried directly
45 as the hashed message, so the TSA timestamps exactly what the id commits to.
46 """
47 algos, _cms, core, tsp = _require_asn1()
48 request = tsp.TimeStampReq(
49 {
50 "version": 1,
51 "message_imprint": tsp.MessageImprint(
52 {
53 "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
54 "hashed_message": bytes.fromhex(manifest_id_hex),
55 }
56 ),
57 "nonce": core.Integer(secrets.randbits(64)),
58 "cert_req": cert_req,
59 }
60 )
61 return request.dump()
62
63
64def submit(request_der: bytes, tsa_url: str, timeout: float = 30.0) -> bytes:
65 """POST a TimeStampReq to a TSA and return the raw TimeStampResp bytes.
66
67 Network call — the caller is responsible for choosing a trusted TSA URL.
68 """
69 req = urllib.request.Request(
70 tsa_url,
71 data=request_der,
72 headers={"Content-Type": _TSA_CONTENT_TYPE, "Accept": _TSA_ACCEPT},
73 method="POST",
74 )
75 with urllib.request.urlopen(req, timeout=timeout) as response:
76 return response.read()
77
78
79def _extract(token_or_response_der: bytes):
80 """Return ``(token_contentinfo, tst_info)`` from a token or a TimeStampResp.
81
82 Accepts either a bare RFC 3161 token (a CMS ContentInfo) or a full
83 TimeStampResp (what a TSA returns and a ``.tsr`` file holds).
84 """
85 _algos, cms, _core, tsp = _require_asn1()
86
87 def _tst_of(content_info):
88 return content_info["content"]["encap_content_info"]["content"].parsed
89
90 # Try a full response first; fall back to a bare token.
91 try:
92 response = tsp.TimeStampResp.load(token_or_response_der)
93 status = response["status"]["status"].native
94 token = response["time_stamp_token"]
95 tst = _tst_of(token) # forces a parse; raises if this was not a response
96 except Exception:
97 token = cms.ContentInfo.load(token_or_response_der)
98 return token, _tst_of(token), None
99 return token, tst, status
100
101
102def parse_token(token_or_response_der: bytes) -> dict:
103 """Extract the human-facing fields from a timestamp token."""
104 _token, tst, _status = _extract(token_or_response_der)
105 gen_time = tst["gen_time"].native.astimezone(timezone.utc)
106 tsa = None
107 if tst["tsa"].native is not None:
108 tsa = str(tst["tsa"].native)
109 return {
110 "imprint": tst["message_imprint"]["hashed_message"].native.hex(),
111 "imprint_algorithm": tst["message_imprint"]["hash_algorithm"]["algorithm"].native,
112 "gen_time": gen_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
113 "serial_number": str(tst["serial_number"].native),
114 "tsa": tsa,
115 }
116
117
118def apply_timestamp(manifest: dict, token_or_response_der: bytes) -> dict:
119 """Bind a TSA token into *manifest* after checking it timestamps its id.
120
121 Raises ``ValueError`` if the token's message imprint does not equal the
122 manifest id — a token for anything else must never be attached.
123 """
124 token, _tst, _status = _extract(token_or_response_der)
125 fields = parse_token(token_or_response_der)
126
127 if fields["imprint_algorithm"] != "sha256" or fields["imprint"] != manifest.get("id"):
128 raise ValueError("token does not timestamp this manifest's id")
129
130 stamped = dict(manifest)
131 stamped["timestamp"] = {
132 "format": "rfc3161",
133 "imprint_algorithm": fields["imprint_algorithm"],
134 "imprint": fields["imprint"],
135 "gen_time": fields["gen_time"],
136 "serial_number": fields["serial_number"],
137 "tsa": fields["tsa"],
138 "token": base64.b64encode(token.dump()).decode(),
139 }
140 return stamped
141
142
143def verify_timestamp(manifest: dict) -> tuple[bool, str]:
144 """Verify a manifest's embedded timestamp binds to its id.
145
146 Returns ``(ok, message)``. This checks that the stored token timestamps the
147 current manifest id; verifying the TSA's own signature and certificate chain
148 is a separate, out-of-band step (see the README threat model).
149 """
150 block = manifest.get("timestamp")
151 if not block:
152 return False, "manifest is not timestamped"
153 if block.get("format") != "rfc3161":
154 return False, f"unsupported timestamp format: {block.get('format')}"
155
156 try:
157 token_der = base64.b64decode(block["token"])
158 fields = parse_token(token_der)
159 except Exception as exc:
160 return False, f"timestamp token is unreadable: {exc}"
161
162 if fields["imprint_algorithm"] != "sha256" or fields["imprint"] != manifest.get("id"):
163 return False, "timestamp does not match the manifest id"
164
165 tsa = f" by {fields['tsa']}" if fields["tsa"] else ""
166 return True, f"timestamped at {fields['gen_time']}{tsa}"
167
168
169def load_der(path: str | Path) -> bytes:
170 """Read a DER file (a ``.tsq`` request or ``.tsr`` response)."""
171 return Path(path).read_bytes()
pyproject.toml +2 −1
@@ -15,7 +15,8 @@ dependencies = []
15 15
16[project.optional-dependencies] 16[project.optional-dependencies]
17sign = ["cryptography>=42.0"] 17sign = ["cryptography>=42.0"]
18dev = ["pytest>=8.0", "ruff>=0.5", "cryptography>=42.0"] 18timestamp = ["asn1crypto>=1.5"]
19dev = ["pytest>=8.0", "ruff>=0.5", "cryptography>=42.0", "asn1crypto>=1.5"]
19 20
20[project.scripts] 21[project.scripts]
21evidence-seal = "evidence_seal.cli:main" 22evidence-seal = "evidence_seal.cli:main"
tests/test_cli.py +30
@@ -72,3 +72,33 @@ def test_verify_rejects_wrong_signer(pkg, tmp_path):
72 manifest = tmp_path / "m.json" 72 manifest = tmp_path / "m.json"
73 main(["seal", str(pkg), "--out", str(manifest), "--sign", str(priv)]) 73 main(["seal", str(pkg), "--out", str(manifest), "--sign", str(priv)])
74 assert main(["verify", str(pkg), "--manifest", str(manifest), "--pubkey", str(other_pub)]) == 1 74 assert main(["verify", str(pkg), "--manifest", str(manifest), "--pubkey", str(other_pub)]) == 1
75
76
77def test_timestamp_request_apply_verify(pkg, tmp_path):
78 pytest.importorskip("asn1crypto")
79 import json
80
81 from tests.test_timestamp import issue_token
82
83 manifest = tmp_path / "m.json"
84 main(["seal", str(pkg), "--out", str(manifest)])
85
86 req = tmp_path / "m.tsq"
87 assert main(["timestamp", "request", str(manifest), "--out", str(req)]) == OK
88 assert req.exists() and req.stat().st_size > 0
89
90 manifest_id = json.loads(manifest.read_text())["id"]
91 tsr = tmp_path / "resp.tsr"
92 tsr.write_bytes(issue_token(manifest_id))
93
94 assert main(["timestamp", "apply", str(manifest), "--token", str(tsr)]) == OK
95 assert main(["timestamp", "verify", str(manifest)]) == OK
96 # A timestamp is checked as part of a normal verify too.
97 assert main(["verify", str(pkg), "--manifest", str(manifest)]) == OK
98
99
100def test_timestamp_verify_unstamped_is_failure(pkg, tmp_path):
101 pytest.importorskip("asn1crypto")
102 manifest = tmp_path / "m.json"
103 main(["seal", str(pkg), "--out", str(manifest)])
104 assert main(["timestamp", "verify", str(manifest)]) == 1
tests/test_timestamp.py added +124
@@ -0,0 +1,124 @@
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
63@pytest.fixture
64def manifest(tmp_path):
65 d = tmp_path / "pkg"
66 d.mkdir()
67 (d / "a.csv").write_text("x\n", encoding="utf-8")
68 return build_manifest(d)
69
70
71def test_build_request_carries_the_id(manifest):
72 der = build_request(manifest["id"])
73 req = tsp.TimeStampReq.load(der)
74 assert req["message_imprint"]["hashed_message"].native.hex() == manifest["id"]
75 assert req["message_imprint"]["hash_algorithm"]["algorithm"].native == "sha256"
76
77
78def test_parse_token_fields(manifest):
79 fields = parse_token(issue_token(manifest["id"]))
80 assert fields["imprint"] == manifest["id"]
81 assert fields["imprint_algorithm"] == "sha256"
82 assert fields["gen_time"] == "2026-08-06T09:00:00Z"
83 assert fields["serial_number"] == "7"
84
85
86def test_apply_and_verify_roundtrip(manifest):
87 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
88 assert stamped["timestamp"]["gen_time"] == "2026-08-06T09:00:00Z"
89 ok, message = verify_timestamp(stamped)
90 assert ok
91 assert "2026-08-06T09:00:00Z" in message
92
93
94def test_apply_accepts_bare_token(manifest):
95 stamped = apply_timestamp(manifest, issue_token(manifest["id"], as_response=False))
96 assert verify_timestamp(stamped)[0]
97
98
99def test_apply_refuses_token_for_other_id(manifest):
100 wrong = "0" * 64
101 with pytest.raises(ValueError, match="does not timestamp this manifest"):
102 apply_timestamp(manifest, issue_token(wrong))
103
104
105def test_timestamp_does_not_change_manifest_id(manifest):
106 before = manifest["id"]
107 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
108 # The timestamp block is excluded from the id, so the id is unchanged.
109 assert compute_id(stamped) == before
110
111
112def test_verify_fails_if_id_changed_after_stamping(manifest):
113 stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
114 # Simulate a re-seal after tampering: the id moves, orphaning the timestamp.
115 stamped["id"] = "1" * 64
116 ok, message = verify_timestamp(stamped)
117 assert not ok
118 assert "does not match the manifest id" in message
119
120
121def test_verify_unstamped_manifest(manifest):
122 ok, message = verify_timestamp(manifest)
123 assert not ok
124 assert "not timestamped" in message