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

e129ad88bbde3518f05e73a0167d3e54accaee10

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-06T22:19:16Z

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.
 README.md                  |  64 +++++++++++++----
 evidence_seal/cli.py       | 107 +++++++++++++++++++++++++++-
 evidence_seal/manifest.py  |   5 +-
 evidence_seal/timestamp.py | 171 +++++++++++++++++++++++++++++++++++++++++++++
 pyproject.toml             |   3 +-
 tests/test_cli.py          |  30 ++++++++
 tests/test_timestamp.py    | 124 ++++++++++++++++++++++++++++++++
 7 files changed, 485 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md
index 3181a81..6443e8d 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,12 @@ directory is byte-for-byte what was sealed, and names anything that changed.
   forms an append-only history; reordering or removing one is detectable.
 - **Attribution** *(optional)* — sign a manifest with an ed25519 key so a named
   party attests "I collected this," not just "it is unchanged."
+- **Trusted time** *(optional)* — obtain an RFC 3161 timestamp from an
+  independent authority so the seal is provably *not backdated*.
 
 The core (`seal`, `verify`, `chain`) is **pure standard library** — no
-dependencies. Signing needs `cryptography` (`pip install evidence-seal[sign]`).
+dependencies. Signing needs `cryptography` (`evidence-seal[sign]`) and
+timestamping needs `asn1crypto` (`evidence-seal[timestamp]`).
 
 ## Install
 
@@ -27,7 +30,7 @@ dependencies. Signing needs `cryptography` (`pip install evidence-seal[sign]`).
 git clone https://github.com/audit-labs/evidence-seal
 cd evidence-seal
 python -m venv .venv && source .venv/bin/activate
-pip install -e ".[sign]"   # drop [sign] for the zero-dependency core
+pip install -e ".[sign,timestamp]"   # or drop the extras for the zero-dependency core
 ```
 
 ## Usage
@@ -72,6 +75,31 @@ evidence-seal verify ./pkg --pubkey acme.pub                # require this signe
 Without `--pubkey`, a present signature is still checked for validity; with it,
 the signer's key must also match, proving *identity* and not just integrity.
 
+### Timestamping (trusted time)
+
+A signature says *who*; a timestamp says *when*, attested by an independent
+Time-Stamp Authority rather than the sealer's own clock. The TSA timestamps the
+manifest `id`, so one token vouches for the whole package.
+
+```bash
+# One step: request, POST to a TSA, and bind the token in
+evidence-seal timestamp submit pkg.manifest.json --tsa https://freetsa.org/tsr
+
+# Or split it — build a request, submit it however you like, then apply
+evidence-seal timestamp request pkg.manifest.json --out pkg.tsq
+curl -sS -H 'Content-Type: application/timestamp-query' \
+  --data-binary @pkg.tsq https://freetsa.org/tsr -o pkg.tsr
+evidence-seal timestamp apply pkg.manifest.json --token pkg.tsr
+
+evidence-seal timestamp verify pkg.manifest.json
+# -> timestamp OK: timestamped at 2026-08-06T09:00:00Z
+```
+
+`apply` refuses any token whose imprint is not this manifest's `id`. Because the
+`id` moves if a single byte changes, a token can never be transplanted onto
+tampered evidence — re-sealing after a change orphans the timestamp. A present
+timestamp is also checked automatically during `verify`.
+
 ## The manifest
 
 Canonical JSON, sorted keys — diff-friendly and reproducible:
@@ -87,14 +115,17 @@ Canonical JSON, sorted keys — diff-friendly and reproducible:
   "file_count": 8,
   "files": [ { "path": "iam_users.csv", "sha256": "309b0e45…", "bytes": 412 } ],
   "id": "08b846a0…",
-  "signature": { "algorithm": "ed25519", "public_key": "1bea5f1d…", "value": "2d7c2d63…" }
+  "signature": { "algorithm": "ed25519", "public_key": "1bea5f1d…", "value": "2d7c2d63…" },
+  "timestamp": { "format": "rfc3161", "gen_time": "2026-08-06T09:00:00Z", "imprint": "08b846a0…", "token": "MIIB…" }
 }
 ```
 
 - **`root`** — Merkle root over all `(path, sha256)` leaves; one value that
   changes if any file, name, or byte changes.
-- **`id`** — SHA-256 of the manifest's canonical form (excluding `id` and
-  `signature`); makes it self-verifying and chainable.
+- **`id`** — SHA-256 of the manifest's canonical form (excluding `id`,
+  `signature`, and `timestamp`); makes it self-verifying and chainable. Because
+  the signature and the timestamp both attest *to* the id, they sit outside it
+  and compose in any order.
 - **`ignore`** — glob patterns skipped at seal time; `verify` reuses them so it
   never false-flags an intentionally excluded file.
 
@@ -104,19 +135,26 @@ Canonical JSON, sorted keys — diff-friendly and reproducible:
 | --- | --- |
 | `0` | Intact / valid. |
 | `1` | Tamper detected, chain broken, or signature invalid. |
-| `2` | Usage error (missing directory, bad `--meta`, missing `cryptography`). |
+| `2` | Usage error (missing directory, bad `--meta`, missing optional dependency). |
 
 Fail a pipeline on `1`; treat `2` as a misconfiguration to fix.
 
 ## Threat model
 
-`evidence-seal` proves a directory matches a manifest, and (when signed) who
-produced that manifest. It does **not** prove *when* something was sealed beyond
-the self-reported `created_at`, and an unsigned manifest can be regenerated by
-anyone with the files. For strong "sealed at time T by party P" guarantees,
-sign the manifest and retain the public key out of band; optionally submit the
-manifest `id` to an external timestamping authority. Private keys are written
-unencrypted — store them accordingly.
+`evidence-seal` proves a directory matches a manifest, who produced it (when
+signed), and that it existed by a given time (when timestamped). An *unsigned,
+untimestamped* manifest can be regenerated by anyone with the files, and its
+`created_at` is self-reported. For a strong "sealed at time T by party P"
+guarantee, **sign** the manifest (retain the public key out of band) and
+**timestamp** it with a trusted TSA.
+
+Two limits to be honest about:
+
+- **`timestamp verify` checks the binding, not the TSA's signature.** It proves
+  the stored token timestamps this manifest's `id`; it does not by itself verify
+  the TSA's own signature and certificate chain. Validate the token against the
+  TSA's certificate out of band (e.g. `openssl ts -verify`) for full assurance.
+- Private keys are written **unencrypted** — store them accordingly.
 
 ## Development
 
diff --git a/evidence_seal/cli.py b/evidence_seal/cli.py
index 227d673..4263e3e 100644
--- a/evidence_seal/cli.py
+++ b/evidence_seal/cli.py
@@ -111,9 +111,16 @@ def _cmd_verify(args) -> int:
         status = FAILED
 
     # Verify a signature when present, or when the caller supplied a key to trust.
-    if manifest.get("signature") or args.pubkey:
-        sig_ok = _verify_sig_cli(manifest, args.pubkey)
-        if not sig_ok:
+    if (manifest.get("signature") or args.pubkey) and not _verify_sig_cli(manifest, args.pubkey):
+        status = FAILED
+
+    # Verify an embedded timestamp when present.
+    if manifest.get("timestamp"):
+        from .timestamp import verify_timestamp
+
+        ts_ok, ts_message = verify_timestamp(manifest)
+        print(f"  timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}")
+        if not ts_ok:
             status = FAILED
 
     if status == OK:
@@ -175,6 +182,72 @@ def _cmd_sign(args) -> int:
     return OK
 
 
+def _cmd_ts_request(args) -> int:
+    from .timestamp import build_request
+
+    try:
+        manifest = load_manifest(args.manifest)
+        request = build_request(manifest["id"])
+    except (RuntimeError, FileNotFoundError, ValueError, KeyError, OSError) as exc:
+        print(f"error: {exc}", file=sys.stderr)
+        return USAGE
+    out = args.out or f"{args.manifest}.tsq"
+    Path(out).write_bytes(request)
+    print(f"wrote timestamp request for id {manifest['id'][:16]}… -> {out}", file=sys.stderr)
+    print("submit it to a TSA, e.g.:", file=sys.stderr)
+    print(
+        f"  curl -sS -H 'Content-Type: application/timestamp-query' "
+        f"--data-binary @{out} <TSA_URL> -o {out.removesuffix('.tsq')}.tsr",
+        file=sys.stderr,
+    )
+    return OK
+
+
+def _cmd_ts_apply(args) -> int:
+    from .timestamp import apply_timestamp, load_der
+
+    try:
+        manifest = load_manifest(args.manifest)
+        stamped = apply_timestamp(manifest, load_der(args.token))
+    except (RuntimeError, FileNotFoundError, ValueError, OSError) as exc:
+        print(f"error: {exc}", file=sys.stderr)
+        return USAGE
+    write_manifest(stamped, args.out or args.manifest)
+    print(f"timestamped {args.out or args.manifest} at {stamped['timestamp']['gen_time']}", file=sys.stderr)
+    return OK
+
+
+def _cmd_ts_submit(args) -> int:
+    from .timestamp import apply_timestamp, build_request, submit
+
+    try:
+        manifest = load_manifest(args.manifest)
+        response = submit(build_request(manifest["id"]), args.tsa, timeout=args.timeout)
+        stamped = apply_timestamp(manifest, response)
+    except ValueError as exc:
+        print(f"error: {exc}", file=sys.stderr)
+        return FAILED
+    except (RuntimeError, FileNotFoundError, KeyError, OSError) as exc:
+        print(f"error: {exc}", file=sys.stderr)
+        return USAGE
+    write_manifest(stamped, args.out or args.manifest)
+    print(f"timestamped by {args.tsa} at {stamped['timestamp']['gen_time']}", file=sys.stderr)
+    return OK
+
+
+def _cmd_ts_verify(args) -> int:
+    from .timestamp import verify_timestamp
+
+    try:
+        manifest = load_manifest(args.manifest)
+    except (FileNotFoundError, ValueError) as exc:
+        print(f"error: cannot read manifest: {exc}", file=sys.stderr)
+        return USAGE
+    ok, message = verify_timestamp(manifest)
+    print(f"timestamp {'OK' if ok else 'FAIL'}: {message}")
+    return OK if ok else FAILED
+
+
 def _build_parser() -> argparse.ArgumentParser:
     parser = argparse.ArgumentParser(
         prog="evidence-seal",
@@ -213,9 +286,37 @@ def _build_parser() -> argparse.ArgumentParser:
     p_sign.add_argument("--out", help="write here instead of overwriting the manifest")
     p_sign.set_defaults(func=_cmd_sign)
 
+    _add_timestamp_commands(sub)
     return parser
 
 
+def _add_timestamp_commands(sub) -> None:
+    p_ts = sub.add_parser("timestamp", help="RFC 3161 trusted timestamping of a manifest")
+    ts = p_ts.add_subparsers(dest="ts_action", required=True)
+
+    p_req = ts.add_parser("request", help="write a TimeStampReq (.tsq) for the manifest id")
+    p_req.add_argument("manifest")
+    p_req.add_argument("--out", help="request path (default: <manifest>.tsq)")
+    p_req.set_defaults(func=_cmd_ts_request)
+
+    p_apply = ts.add_parser("apply", help="bind a TSA response/token into the manifest")
+    p_apply.add_argument("manifest")
+    p_apply.add_argument("--token", required=True, metavar="TSR", help="TSA response or token (DER)")
+    p_apply.add_argument("--out", help="write here instead of overwriting the manifest")
+    p_apply.set_defaults(func=_cmd_ts_apply)
+
+    p_submit = ts.add_parser("submit", help="request, POST to a TSA, and bind in one step")
+    p_submit.add_argument("manifest")
+    p_submit.add_argument("--tsa", required=True, metavar="URL", help="RFC 3161 TSA endpoint")
+    p_submit.add_argument("--timeout", type=float, default=30.0, help="network timeout (seconds)")
+    p_submit.add_argument("--out", help="write here instead of overwriting the manifest")
+    p_submit.set_defaults(func=_cmd_ts_submit)
+
+    p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp")
+    p_tsv.add_argument("manifest")
+    p_tsv.set_defaults(func=_cmd_ts_verify)
+
+
 def main(argv: list[str] | None = None) -> int:
     parser = _build_parser()
     args = parser.parse_args(argv if argv is not None else sys.argv[1:])
diff --git a/evidence_seal/manifest.py b/evidence_seal/manifest.py
index 1debae6..f29230c 100644
--- a/evidence_seal/manifest.py
+++ b/evidence_seal/manifest.py
@@ -19,8 +19,9 @@ from . import ALGORITHM, MANIFEST_VERSION, __version__
 from .hashing import hash_bytes, hash_file, merkle_root
 
 # Keys excluded from the canonical bytes the id is computed over. The id cannot
-# cover itself, and a signature is applied *to* the id afterwards.
-_ID_EXCLUDED = ("id", "signature")
+# cover itself; a signature and a timestamp are both applied *to* the id
+# afterwards, so they sit outside it and compose independently of each other.
+_ID_EXCLUDED = ("id", "signature", "timestamp")
 
 
 def iter_files(
diff --git a/evidence_seal/timestamp.py b/evidence_seal/timestamp.py
new file mode 100644
index 0000000..b8b8f4a
--- /dev/null
+++ b/evidence_seal/timestamp.py
@@ -0,0 +1,171 @@
+"""Optional RFC 3161 trusted timestamping of manifests.
+
+A signature proves *who* sealed the evidence; a timestamp proves the seal
+existed *by* a certain time — attested by an independent Time-Stamp Authority
+(TSA), not by the sealer's own clock. The TSA timestamps the manifest's ``id``
+(itself the hash of every file and all metadata), so one token vouches for the
+whole package at a point in time.
+
+This module builds RFC 3161 requests, submits them to a TSA, and binds the
+returned token into the manifest. It needs the ``asn1crypto`` package (install
+``evidence-seal[timestamp]``); the core seal/verify path never imports it.
+
+The bound token is stored under a ``timestamp`` key, which — like ``signature``
+— is excluded from the manifest id, so timestamping never invalidates the id or
+an existing signature.
+"""
+
+from __future__ import annotations
+
+import base64
+import secrets
+import urllib.request
+from datetime import timezone
+from pathlib import Path
+
+_TSA_CONTENT_TYPE = "application/timestamp-query"
+_TSA_ACCEPT = "application/timestamp-reply"
+
+
+def _require_asn1():
+    try:
+        from asn1crypto import algos, cms, core, tsp
+    except ImportError as exc:  # pragma: no cover - exercised via a clear message
+        raise RuntimeError(
+            "timestamping requires the 'asn1crypto' package — "
+            "install evidence-seal[timestamp]"
+        ) from exc
+    return algos, cms, core, tsp
+
+
+def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes:
+    """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id.
+
+    The message imprint is the manifest id (a SHA-256 digest) carried directly
+    as the hashed message, so the TSA timestamps exactly what the id commits to.
+    """
+    algos, _cms, core, tsp = _require_asn1()
+    request = tsp.TimeStampReq(
+        {
+            "version": 1,
+            "message_imprint": tsp.MessageImprint(
+                {
+                    "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
+                    "hashed_message": bytes.fromhex(manifest_id_hex),
+                }
+            ),
+            "nonce": core.Integer(secrets.randbits(64)),
+            "cert_req": cert_req,
+        }
+    )
+    return request.dump()
+
+
+def submit(request_der: bytes, tsa_url: str, timeout: float = 30.0) -> bytes:
+    """POST a TimeStampReq to a TSA and return the raw TimeStampResp bytes.
+
+    Network call — the caller is responsible for choosing a trusted TSA URL.
+    """
+    req = urllib.request.Request(
+        tsa_url,
+        data=request_der,
+        headers={"Content-Type": _TSA_CONTENT_TYPE, "Accept": _TSA_ACCEPT},
+        method="POST",
+    )
+    with urllib.request.urlopen(req, timeout=timeout) as response:
+        return response.read()
+
+
+def _extract(token_or_response_der: bytes):
+    """Return ``(token_contentinfo, tst_info)`` from a token or a TimeStampResp.
+
+    Accepts either a bare RFC 3161 token (a CMS ContentInfo) or a full
+    TimeStampResp (what a TSA returns and a ``.tsr`` file holds).
+    """
+    _algos, cms, _core, tsp = _require_asn1()
+
+    def _tst_of(content_info):
+        return content_info["content"]["encap_content_info"]["content"].parsed
+
+    # Try a full response first; fall back to a bare token.
+    try:
+        response = tsp.TimeStampResp.load(token_or_response_der)
+        status = response["status"]["status"].native
+        token = response["time_stamp_token"]
+        tst = _tst_of(token)  # forces a parse; raises if this was not a response
+    except Exception:
+        token = cms.ContentInfo.load(token_or_response_der)
+        return token, _tst_of(token), None
+    return token, tst, status
+
+
+def parse_token(token_or_response_der: bytes) -> dict:
+    """Extract the human-facing fields from a timestamp token."""
+    _token, tst, _status = _extract(token_or_response_der)
+    gen_time = tst["gen_time"].native.astimezone(timezone.utc)
+    tsa = None
+    if tst["tsa"].native is not None:
+        tsa = str(tst["tsa"].native)
+    return {
+        "imprint": tst["message_imprint"]["hashed_message"].native.hex(),
+        "imprint_algorithm": tst["message_imprint"]["hash_algorithm"]["algorithm"].native,
+        "gen_time": gen_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
+        "serial_number": str(tst["serial_number"].native),
+        "tsa": tsa,
+    }
+
+
+def apply_timestamp(manifest: dict, token_or_response_der: bytes) -> dict:
+    """Bind a TSA token into *manifest* after checking it timestamps its id.
+
+    Raises ``ValueError`` if the token's message imprint does not equal the
+    manifest id — a token for anything else must never be attached.
+    """
+    token, _tst, _status = _extract(token_or_response_der)
+    fields = parse_token(token_or_response_der)
+
+    if fields["imprint_algorithm"] != "sha256" or fields["imprint"] != manifest.get("id"):
+        raise ValueError("token does not timestamp this manifest's id")
+
+    stamped = dict(manifest)
+    stamped["timestamp"] = {
+        "format": "rfc3161",
+        "imprint_algorithm": fields["imprint_algorithm"],
+        "imprint": fields["imprint"],
+        "gen_time": fields["gen_time"],
+        "serial_number": fields["serial_number"],
+        "tsa": fields["tsa"],
+        "token": base64.b64encode(token.dump()).decode(),
+    }
+    return stamped
+
+
+def verify_timestamp(manifest: dict) -> tuple[bool, str]:
+    """Verify a manifest's embedded timestamp binds to its id.
+
+    Returns ``(ok, message)``. This checks that the stored token timestamps the
+    current manifest id; verifying the TSA's own signature and certificate chain
+    is a separate, out-of-band step (see the README threat model).
+    """
+    block = manifest.get("timestamp")
+    if not block:
+        return False, "manifest is not timestamped"
+    if block.get("format") != "rfc3161":
+        return False, f"unsupported timestamp format: {block.get('format')}"
+
+    try:
+        token_der = base64.b64decode(block["token"])
+        fields = parse_token(token_der)
+    except Exception as exc:
+        return False, f"timestamp token is unreadable: {exc}"
+
+    if fields["imprint_algorithm"] != "sha256" or fields["imprint"] != manifest.get("id"):
+        return False, "timestamp does not match the manifest id"
+
+    tsa = f" by {fields['tsa']}" if fields["tsa"] else ""
+    return True, f"timestamped at {fields['gen_time']}{tsa}"
+
+
+def load_der(path: str | Path) -> bytes:
+    """Read a DER file (a ``.tsq`` request or ``.tsr`` response)."""
+    return Path(path).read_bytes()
diff --git a/pyproject.toml b/pyproject.toml
index d2dc218..06a1178 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,8 @@ dependencies = []
 
 [project.optional-dependencies]
 sign = ["cryptography>=42.0"]
-dev = ["pytest>=8.0", "ruff>=0.5", "cryptography>=42.0"]
+timestamp = ["asn1crypto>=1.5"]
+dev = ["pytest>=8.0", "ruff>=0.5", "cryptography>=42.0", "asn1crypto>=1.5"]
 
 [project.scripts]
 evidence-seal = "evidence_seal.cli:main"
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 6b59da1..6d3ebb8 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -72,3 +72,33 @@ def test_verify_rejects_wrong_signer(pkg, tmp_path):
     manifest = tmp_path / "m.json"
     main(["seal", str(pkg), "--out", str(manifest), "--sign", str(priv)])
     assert main(["verify", str(pkg), "--manifest", str(manifest), "--pubkey", str(other_pub)]) == 1
+
+
+def test_timestamp_request_apply_verify(pkg, tmp_path):
+    pytest.importorskip("asn1crypto")
+    import json
+
+    from tests.test_timestamp import issue_token
+
+    manifest = tmp_path / "m.json"
+    main(["seal", str(pkg), "--out", str(manifest)])
+
+    req = tmp_path / "m.tsq"
+    assert main(["timestamp", "request", str(manifest), "--out", str(req)]) == OK
+    assert req.exists() and req.stat().st_size > 0
+
+    manifest_id = json.loads(manifest.read_text())["id"]
+    tsr = tmp_path / "resp.tsr"
+    tsr.write_bytes(issue_token(manifest_id))
+
+    assert main(["timestamp", "apply", str(manifest), "--token", str(tsr)]) == OK
+    assert main(["timestamp", "verify", str(manifest)]) == OK
+    # A timestamp is checked as part of a normal verify too.
+    assert main(["verify", str(pkg), "--manifest", str(manifest)]) == OK
+
+
+def test_timestamp_verify_unstamped_is_failure(pkg, tmp_path):
+    pytest.importorskip("asn1crypto")
+    manifest = tmp_path / "m.json"
+    main(["seal", str(pkg), "--out", str(manifest)])
+    assert main(["timestamp", "verify", str(manifest)]) == 1
diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py
new file mode 100644
index 0000000..56ce082
--- /dev/null
+++ b/tests/test_timestamp.py
@@ -0,0 +1,124 @@
+"""Tests for RFC 3161 timestamping (skipped if asn1crypto is absent).
+
+These build tokens locally (acting as a TSA) so the whole flow is exercised
+offline — no network and no real Time-Stamp Authority.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+
+pytest.importorskip("asn1crypto")
+
+from asn1crypto import algos, cms, core, tsp
+
+from evidence_seal.manifest import build_manifest, compute_id
+from evidence_seal.timestamp import (
+    apply_timestamp,
+    build_request,
+    parse_token,
+    verify_timestamp,
+)
+
+
+def issue_token(imprint_hex: str, gen_time=None, as_response=True) -> bytes:
+    """Mint a timestamp token over *imprint_hex*, as a local TSA would."""
+    gen_time = gen_time or datetime(2026, 8, 6, 9, 0, tzinfo=timezone.utc)
+    tst = tsp.TSTInfo(
+        {
+            "version": "v1",
+            "policy": "1.2.3.4.5",
+            "message_imprint": tsp.MessageImprint(
+                {
+                    "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
+                    "hashed_message": bytes.fromhex(imprint_hex),
+                }
+            ),
+            "serial_number": 7,
+            "gen_time": gen_time,
+        }
+    )
+    token = cms.ContentInfo(
+        {
+            "content_type": "signed_data",
+            "content": cms.SignedData(
+                {
+                    "version": "v3",
+                    "digest_algorithms": [],
+                    "encap_content_info": cms.EncapsulatedContentInfo(
+                        {"content_type": "tst_info", "content": core.ParsableOctetString(tst.dump())}
+                    ),
+                    "signer_infos": [],
+                }
+            ),
+        }
+    )
+    if not as_response:
+        return token.dump()
+    return tsp.TimeStampResp(
+        {"status": {"status": "granted"}, "time_stamp_token": token}
+    ).dump()
+
+
+@pytest.fixture
+def manifest(tmp_path):
+    d = tmp_path / "pkg"
+    d.mkdir()
+    (d / "a.csv").write_text("x\n", encoding="utf-8")
+    return build_manifest(d)
+
+
+def test_build_request_carries_the_id(manifest):
+    der = build_request(manifest["id"])
+    req = tsp.TimeStampReq.load(der)
+    assert req["message_imprint"]["hashed_message"].native.hex() == manifest["id"]
+    assert req["message_imprint"]["hash_algorithm"]["algorithm"].native == "sha256"
+
+
+def test_parse_token_fields(manifest):
+    fields = parse_token(issue_token(manifest["id"]))
+    assert fields["imprint"] == manifest["id"]
+    assert fields["imprint_algorithm"] == "sha256"
+    assert fields["gen_time"] == "2026-08-06T09:00:00Z"
+    assert fields["serial_number"] == "7"
+
+
+def test_apply_and_verify_roundtrip(manifest):
+    stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
+    assert stamped["timestamp"]["gen_time"] == "2026-08-06T09:00:00Z"
+    ok, message = verify_timestamp(stamped)
+    assert ok
+    assert "2026-08-06T09:00:00Z" in message
+
+
+def test_apply_accepts_bare_token(manifest):
+    stamped = apply_timestamp(manifest, issue_token(manifest["id"], as_response=False))
+    assert verify_timestamp(stamped)[0]
+
+
+def test_apply_refuses_token_for_other_id(manifest):
+    wrong = "0" * 64
+    with pytest.raises(ValueError, match="does not timestamp this manifest"):
+        apply_timestamp(manifest, issue_token(wrong))
+
+
+def test_timestamp_does_not_change_manifest_id(manifest):
+    before = manifest["id"]
+    stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
+    # The timestamp block is excluded from the id, so the id is unchanged.
+    assert compute_id(stamped) == before
+
+
+def test_verify_fails_if_id_changed_after_stamping(manifest):
+    stamped = apply_timestamp(manifest, issue_token(manifest["id"]))
+    # Simulate a re-seal after tampering: the id moves, orphaning the timestamp.
+    stamped["id"] = "1" * 64
+    ok, message = verify_timestamp(stamped)
+    assert not ok
+    assert "does not match the manifest id" in message
+
+
+def test_verify_unstamped_manifest(manifest):
+    ok, message = verify_timestamp(manifest)
+    assert not ok
+    assert "not timestamped" in message