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
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(-) @@ -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 @@ -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:]) @@ -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( new file mode 100644 @@ -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() @@ -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" @@ -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 new file mode 100644 @@ -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