Commit e129ad88bb
Verified · cmc
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 | 17 | forms an append-only history; reordering or removing one is detectable. |
| 18 | 18 | - **Attribution** *(optional)* — sign a manifest with an ed25519 key so a named |
| 19 | 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 | |
| 21 | 23 | The core (`seal`, `verify`, `chain`) is **pure standard library** — no |
| 22 | dependencies. Signing needs `cryptography` (`pip install evidence-seal[sign]`). | |
| 24 | dependencies. Signing needs `cryptography` (`evidence-seal[sign]`) and | |
| 25 | timestamping needs `asn1crypto` (`evidence-seal[timestamp]`). | |
| 23 | 26 | |
| 24 | 27 | ## Install |
| 25 | 28 | |
| @@ -27,7 +30,7 @@ dependencies. Signing needs `cryptography` (`pip install evidence-seal[sign]`). | ||
| 27 | 30 | git clone https://github.com/audit-labs/evidence-seal |
| 28 | 31 | cd evidence-seal |
| 29 | 32 | python -m venv .venv && source .venv/bin/activate |
| 30 | pip install -e ".[sign]" # drop [sign] for the zero-dependency core | |
| 33 | pip install -e ".[sign,timestamp]" # or drop the extras for the zero-dependency core | |
| 31 | 34 | ``` |
| 32 | 35 | |
| 33 | 36 | ## Usage |
| @@ -72,6 +75,31 @@ evidence-seal verify ./pkg --pubkey acme.pub # require this signe | ||
| 72 | 75 | Without `--pubkey`, a present signature is still checked for validity; with it, |
| 73 | 76 | the signer's key must also match, proving *identity* and not just integrity. |
| 74 | 77 | |
| 78 | ### Timestamping (trusted time) | |
| 79 | ||
| 80 | A signature says *who*; a timestamp says *when*, attested by an independent | |
| 81 | Time-Stamp Authority rather than the sealer's own clock. The TSA timestamps the | |
| 82 | manifest `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 | |
| 86 | evidence-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 | |
| 89 | evidence-seal timestamp request pkg.manifest.json --out pkg.tsq | |
| 90 | curl -sS -H 'Content-Type: application/timestamp-query' \ | |
| 91 | --data-binary @pkg.tsq https://freetsa.org/tsr -o pkg.tsr | |
| 92 | evidence-seal timestamp apply pkg.manifest.json --token pkg.tsr | |
| 93 | ||
| 94 | evidence-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 | |
| 100 | tampered evidence — re-sealing after a change orphans the timestamp. A present | |
| 101 | timestamp is also checked automatically during `verify`. | |
| 102 | ||
| 75 | 103 | ## The manifest |
| 76 | 104 | |
| 77 | 105 | Canonical JSON, sorted keys — diff-friendly and reproducible: |
| @@ -87,14 +115,17 @@ Canonical JSON, sorted keys — diff-friendly and reproducible: | ||
| 87 | 115 | "file_count": 8, |
| 88 | 116 | "files": [ { "path": "iam_users.csv", "sha256": "309b0e45…", "bytes": 412 } ], |
| 89 | 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 | 123 | - **`root`** — Merkle root over all `(path, sha256)` leaves; one value that |
| 95 | 124 | changes if any file, name, or byte changes. |
| 96 | - **`id`** — SHA-256 of the manifest's canonical form (excluding `id` and | |
| 97 | `signature`); makes it self-verifying and chainable. | |
| 125 | - **`id`** — SHA-256 of the manifest's canonical form (excluding `id`, | |
| 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 | 129 | - **`ignore`** — glob patterns skipped at seal time; `verify` reuses them so it |
| 99 | 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 | 136 | | `0` | Intact / valid. | |
| 106 | 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 | |
| 109 | 140 | Fail a pipeline on `1`; treat `2` as a misconfiguration to fix. |
| 110 | 141 | |
| 111 | 142 | ## Threat model |
| 112 | 143 | |
| 113 | `evidence-seal` proves a directory matches a manifest, and (when signed) who | |
| 114 | produced that manifest. It does **not** prove *when* something was sealed beyond | |
| 115 | the self-reported `created_at`, and an unsigned manifest can be regenerated by | |
| 116 | anyone with the files. For strong "sealed at time T by party P" guarantees, | |
| 117 | sign the manifest and retain the public key out of band; optionally submit the | |
| 118 | manifest `id` to an external timestamping authority. Private keys are written | |
| 119 | unencrypted — store them accordingly. | |
| 144 | `evidence-seal` proves a directory matches a manifest, who produced it (when | |
| 145 | signed), and that it existed by a given time (when timestamped). An *unsigned, | |
| 146 | untimestamped* manifest can be regenerated by anyone with the files, and its | |
| 147 | `created_at` is self-reported. For a strong "sealed at time T by party P" | |
| 148 | guarantee, **sign** the manifest (retain the public key out of band) and | |
| 149 | **timestamp** it with a trusted TSA. | |
| 150 | ||
| 151 | Two 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 | 159 | ## Development |
| 122 | 160 | |
evidence_seal/cli.py +104 −3
| @@ -111,9 +111,16 @@ def _cmd_verify(args) -> int: | ||
| 111 | 111 | status = FAILED |
| 112 | 112 | |
| 113 | 113 | # Verify a signature when present, or when the caller supplied a key to trust. |
| 114 | if manifest.get("signature") or args.pubkey: | |
| 115 | sig_ok = _verify_sig_cli(manifest, args.pubkey) | |
| 116 | if not sig_ok: | |
| 114 | if (manifest.get("signature") or args.pubkey) and not _verify_sig_cli(manifest, args.pubkey): | |
| 115 | status = FAILED | |
| 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 | 124 | status = FAILED |
| 118 | 125 | |
| 119 | 126 | if status == OK: |
| @@ -175,6 +182,72 @@ def _cmd_sign(args) -> int: | ||
| 175 | 182 | return OK |
| 176 | 183 | |
| 177 | 184 | |
| 185 | def _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 | ||
| 206 | def _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 | ||
| 220 | def _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 | ||
| 238 | def _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 | ||
| 178 | 251 | def _build_parser() -> argparse.ArgumentParser: |
| 179 | 252 | parser = argparse.ArgumentParser( |
| 180 | 253 | prog="evidence-seal", |
| @@ -213,9 +286,37 @@ def _build_parser() -> argparse.ArgumentParser: | ||
| 213 | 286 | p_sign.add_argument("--out", help="write here instead of overwriting the manifest") |
| 214 | 287 | p_sign.set_defaults(func=_cmd_sign) |
| 215 | 288 | |
| 289 | _add_timestamp_commands(sub) | |
| 216 | 290 | return parser |
| 217 | 291 | |
| 218 | 292 | |
| 293 | def _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 | ||
| 219 | 320 | def main(argv: list[str] | None = None) -> int: |
| 220 | 321 | parser = _build_parser() |
| 221 | 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__ | ||
| 19 | 19 | from .hashing import hash_bytes, hash_file, merkle_root |
| 20 | 20 | |
| 21 | 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. | |
| 23 | _ID_EXCLUDED = ("id", "signature") | |
| 22 | # cover itself; a signature and a timestamp are both applied *to* the id | |
| 23 | # afterwards, so they sit outside it and compose independently of each other. | |
| 24 | _ID_EXCLUDED = ("id", "signature", "timestamp") | |
| 24 | 25 | |
| 25 | 26 | |
| 26 | 27 | def iter_files( |
evidence_seal/timestamp.py added +171
| @@ -0,0 +1,171 @@ | ||
| 1 | """Optional RFC 3161 trusted timestamping of manifests. | |
| 2 | ||
| 3 | A signature proves *who* sealed the evidence; a timestamp proves the seal | |
| 4 | existed *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 | |
| 7 | whole package at a point in time. | |
| 8 | ||
| 9 | This module builds RFC 3161 requests, submits them to a TSA, and binds the | |
| 10 | returned token into the manifest. It needs the ``asn1crypto`` package (install | |
| 11 | ``evidence-seal[timestamp]``); the core seal/verify path never imports it. | |
| 12 | ||
| 13 | The 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 | |
| 15 | an existing signature. | |
| 16 | """ | |
| 17 | ||
| 18 | from __future__ import annotations | |
| 19 | ||
| 20 | import base64 | |
| 21 | import secrets | |
| 22 | import urllib.request | |
| 23 | from datetime import timezone | |
| 24 | from pathlib import Path | |
| 25 | ||
| 26 | _TSA_CONTENT_TYPE = "application/timestamp-query" | |
| 27 | _TSA_ACCEPT = "application/timestamp-reply" | |
| 28 | ||
| 29 | ||
| 30 | def _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 | ||
| 41 | def 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 | ||
| 64 | def 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 | ||
| 79 | def _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 | ||
| 102 | def 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 | ||
| 118 | def 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 | ||
| 143 | def 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 | ||
| 169 | def 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 | 16 | [project.optional-dependencies] |
| 17 | 17 | sign = ["cryptography>=42.0"] |
| 18 | dev = ["pytest>=8.0", "ruff>=0.5", "cryptography>=42.0"] | |
| 18 | timestamp = ["asn1crypto>=1.5"] | |
| 19 | dev = ["pytest>=8.0", "ruff>=0.5", "cryptography>=42.0", "asn1crypto>=1.5"] | |
| 19 | 20 | |
| 20 | 21 | [project.scripts] |
| 21 | 22 | evidence-seal = "evidence_seal.cli:main" |
tests/test_cli.py +30
| @@ -72,3 +72,33 @@ def test_verify_rejects_wrong_signer(pkg, tmp_path): | ||
| 72 | 72 | manifest = tmp_path / "m.json" |
| 73 | 73 | main(["seal", str(pkg), "--out", str(manifest), "--sign", str(priv)]) |
| 74 | 74 | assert main(["verify", str(pkg), "--manifest", str(manifest), "--pubkey", str(other_pub)]) == 1 |
| 75 | ||
| 76 | ||
| 77 | def 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 | ||
| 100 | def 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 | ||
| 3 | These build tokens locally (acting as a TSA) so the whole flow is exercised | |
| 4 | offline — no network and no real Time-Stamp Authority. | |
| 5 | """ | |
| 6 | ||
| 7 | from datetime import datetime, timezone | |
| 8 | ||
| 9 | import pytest | |
| 10 | ||
| 11 | pytest.importorskip("asn1crypto") | |
| 12 | ||
| 13 | from asn1crypto import algos, cms, core, tsp | |
| 14 | ||
| 15 | from evidence_seal.manifest import build_manifest, compute_id | |
| 16 | from evidence_seal.timestamp import ( | |
| 17 | apply_timestamp, | |
| 18 | build_request, | |
| 19 | parse_token, | |
| 20 | verify_timestamp, | |
| 21 | ) | |
| 22 | ||
| 23 | ||
| 24 | def 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 | |
| 64 | def 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 | ||
| 71 | def 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 | ||
| 78 | def 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 | ||
| 86 | def 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 | ||
| 94 | def 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 | ||
| 99 | def 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 | ||
| 105 | def 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 | ||
| 112 | def 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 | ||
| 121 | def test_verify_unstamped_manifest(manifest): | |
| 122 | ok, message = verify_timestamp(manifest) | |
| 123 | assert not ok | |
| 124 | assert "not timestamped" in message | |