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
v0.1.0: evidence_seal/cli.py · raw
1"""Command-line interface for evidence-seal."""
2
3from __future__ import annotations
4
5import argparse
6import sys
7from pathlib import Path
8
9from . import __version__
10from .manifest import (
11 build_manifest,
12 load_manifest,
13 verify_chain,
14 verify_manifest,
15 write_manifest,
16)
17
18# Exit codes: 0 = intact/valid, 1 = tamper/verification failure, 2 = usage error.
19OK, FAILED, USAGE = 0, 1, 2
20
21
22def _default_manifest_path(directory: Path) -> Path:
23 return directory.parent / f"{directory.name}.manifest.json"
24
25
26def _manifest_exclude(directory: Path, manifest_path: Path) -> set[str]:
27 """If the manifest lives inside the sealed dir, it must not seal itself."""
28 try:
29 return {manifest_path.resolve().relative_to(directory.resolve()).as_posix()}
30 except ValueError:
31 return set()
32
33
34def _parse_meta(pairs: list[str]) -> dict:
35 meta = {}
36 for pair in pairs or []:
37 if "=" not in pair:
38 raise ValueError(f"metadata must be key=value, got {pair!r}")
39 key, value = pair.split("=", 1)
40 meta[key.strip()] = value.strip()
41 return meta
42
43
44def _cmd_seal(args) -> int:
45 directory = Path(args.directory)
46 manifest_path = Path(args.out) if args.out else _default_manifest_path(directory)
47
48 previous = None
49 if args.prev:
50 previous = load_manifest(args.prev).get("id")
51
52 try:
53 manifest = build_manifest(
54 directory,
55 metadata=_parse_meta(args.meta),
56 previous=previous,
57 ignore=args.ignore,
58 exclude=_manifest_exclude(directory, manifest_path),
59 )
60 except (FileNotFoundError, ValueError) as exc:
61 print(f"error: {exc}", file=sys.stderr)
62 return USAGE
63
64 if args.sign:
65 from .signing import sign_manifest
66
67 try:
68 manifest = sign_manifest(manifest, args.sign)
69 except (RuntimeError, OSError) as exc:
70 print(f"error: {exc}", file=sys.stderr)
71 return USAGE
72
73 write_manifest(manifest, manifest_path)
74 signed = " (signed)" if "signature" in manifest else ""
75 print(f"sealed {manifest['file_count']} files{signed}", file=sys.stderr)
76 print(f" root {manifest['root']}", file=sys.stderr)
77 print(f" id {manifest['id']}", file=sys.stderr)
78 print(f" -> {manifest_path}", file=sys.stderr)
79 return OK
80
81
82def _cmd_verify(args) -> int:
83 directory = Path(args.directory)
84 manifest_path = Path(args.manifest) if args.manifest else _default_manifest_path(directory)
85 try:
86 manifest = load_manifest(manifest_path)
87 except (FileNotFoundError, ValueError) as exc:
88 print(f"error: cannot read manifest: {exc}", file=sys.stderr)
89 return USAGE
90
91 result = verify_manifest(
92 directory,
93 manifest,
94 ignore=manifest.get("ignore"),
95 exclude=_manifest_exclude(directory, manifest_path),
96 )
97
98 for path in result.modified:
99 print(f" MODIFIED {path}")
100 for path in result.added:
101 print(f" ADDED {path}")
102 for path in result.removed:
103 print(f" REMOVED {path}")
104 if not result.id_ok:
105 print(" MANIFEST id does not re-derive — the manifest itself was altered")
106 if not result.root_ok:
107 print(" MANIFEST Merkle root does not match the file list")
108
109 status = OK
110 if not result.intact:
111 status = FAILED
112
113 # Verify a signature when present, or when the caller supplied a key to trust.
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 cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None
122 ts_ok, ts_message = verify_timestamp(manifest, tsa_cert=cert)
123 print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}")
124 if not ts_ok:
125 status = FAILED
126
127 if status == OK:
128 print(f"intact — {result.checked} files match the seal")
129 else:
130 print("TAMPERED — the directory does not match its seal", file=sys.stderr)
131 return status
132
133
134def _verify_sig_cli(manifest: dict, pubkey_path: str | None) -> bool:
135 from .signing import load_public_hex, verify_signature
136
137 expected = load_public_hex(pubkey_path) if pubkey_path else None
138 ok, message = verify_signature(manifest, expected)
139 print(f" signature {'OK' if ok else 'FAIL'}: {message}")
140 return ok
141
142
143def _cmd_chain(args) -> int:
144 try:
145 manifests = [load_manifest(p) for p in args.manifests]
146 except (FileNotFoundError, ValueError) as exc:
147 print(f"error: cannot read manifest: {exc}", file=sys.stderr)
148 return USAGE
149
150 result = verify_chain(manifests)
151 if result.ok:
152 print(f"chain intact — {result.length} seals link correctly")
153 return OK
154 where = "" if result.broken_at is None else f" at position {result.broken_at}"
155 print(f"CHAIN BROKEN{where}: {result.reason}", file=sys.stderr)
156 return FAILED
157
158
159def _cmd_keygen(args) -> int:
160 from .signing import generate_keypair
161
162 try:
163 generate_keypair(args.private, args.public)
164 except (RuntimeError, OSError) as exc:
165 print(f"error: {exc}", file=sys.stderr)
166 return USAGE
167 print(f"wrote private key {args.private}", file=sys.stderr)
168 print(f"wrote public key {args.public}", file=sys.stderr)
169 return OK
170
171
172def _cmd_sign(args) -> int:
173 from .signing import sign_manifest
174
175 try:
176 manifest = load_manifest(args.manifest)
177 signed = sign_manifest(manifest, args.key)
178 except (RuntimeError, FileNotFoundError, ValueError, OSError) as exc:
179 print(f"error: {exc}", file=sys.stderr)
180 return USAGE
181 write_manifest(signed, args.out or args.manifest)
182 print(f"signed {args.out or args.manifest}", file=sys.stderr)
183 return OK
184
185
186def _cmd_ts_request(args) -> int:
187 from .timestamp import build_request
188
189 try:
190 manifest = load_manifest(args.manifest)
191 request = build_request(manifest["id"])
192 except (RuntimeError, FileNotFoundError, ValueError, KeyError, OSError) as exc:
193 print(f"error: {exc}", file=sys.stderr)
194 return USAGE
195 out = args.out or f"{args.manifest}.tsq"
196 Path(out).write_bytes(request)
197 print(f"wrote timestamp request for id {manifest['id'][:16]}… -> {out}", file=sys.stderr)
198 print("submit it to a TSA, e.g.:", file=sys.stderr)
199 print(
200 f" curl -sS -H 'Content-Type: application/timestamp-query' "
201 f"--data-binary @{out} <TSA_URL> -o {out.removesuffix('.tsq')}.tsr",
202 file=sys.stderr,
203 )
204 return OK
205
206
207def _cmd_ts_apply(args) -> int:
208 from .timestamp import apply_timestamp, load_der
209
210 try:
211 manifest = load_manifest(args.manifest)
212 stamped = apply_timestamp(manifest, load_der(args.token))
213 except (RuntimeError, FileNotFoundError, ValueError, OSError) as exc:
214 print(f"error: {exc}", file=sys.stderr)
215 return USAGE
216 write_manifest(stamped, args.out or args.manifest)
217 print(f"timestamped {args.out or args.manifest} at {stamped['timestamp']['gen_time']}", file=sys.stderr)
218 return OK
219
220
221def _cmd_ts_submit(args) -> int:
222 from .timestamp import apply_timestamp, build_request, submit
223
224 try:
225 manifest = load_manifest(args.manifest)
226 response = submit(build_request(manifest["id"]), args.tsa, timeout=args.timeout)
227 stamped = apply_timestamp(manifest, response)
228 except ValueError as exc:
229 print(f"error: {exc}", file=sys.stderr)
230 return FAILED
231 except (RuntimeError, FileNotFoundError, KeyError, OSError) as exc:
232 print(f"error: {exc}", file=sys.stderr)
233 return USAGE
234 write_manifest(stamped, args.out or args.manifest)
235 print(f"timestamped by {args.tsa} at {stamped['timestamp']['gen_time']}", file=sys.stderr)
236 return OK
237
238
239def _cmd_ts_verify(args) -> int:
240 from .timestamp import verify_timestamp
241
242 try:
243 manifest = load_manifest(args.manifest)
244 cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None
245 except (FileNotFoundError, ValueError) as exc:
246 print(f"error: cannot read manifest: {exc}", file=sys.stderr)
247 return USAGE
248 ok, message = verify_timestamp(manifest, tsa_cert=cert)
249 print(f"timestamp {'OK' if ok else 'FAIL'}: {message}")
250 return OK if ok else FAILED
251
252
253def _build_parser() -> argparse.ArgumentParser:
254 parser = argparse.ArgumentParser(
255 prog="evidence-seal",
256 description="Tamper-evident seals for audit evidence packages.",
257 )
258 parser.add_argument("--version", action="version", version=f"evidence-seal {__version__}")
259 sub = parser.add_subparsers(dest="command", required=True)
260
261 p_seal = sub.add_parser("seal", help="seal a directory into a manifest")
262 p_seal.add_argument("directory")
263 p_seal.add_argument("--out", help="manifest path (default: <dir>.manifest.json alongside)")
264 p_seal.add_argument("--prev", help="prior manifest to chain from (records its id as previous)")
265 p_seal.add_argument("--meta", action="append", metavar="KEY=VALUE", help="provenance metadata")
266 p_seal.add_argument("--ignore", action="append", metavar="GLOB", help="skip matching paths")
267 p_seal.add_argument("--sign", metavar="PRIVATE_KEY", help="also sign with an ed25519 key")
268 p_seal.set_defaults(func=_cmd_seal)
269
270 p_verify = sub.add_parser("verify", help="verify a directory against its manifest")
271 p_verify.add_argument("directory")
272 p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)")
273 p_verify.add_argument("--pubkey", metavar="PEM", help="require a signature by this public key")
274 p_verify.add_argument(
275 "--tsa-cert", metavar="CERT", help="verify the embedded timestamp against this TSA certificate"
276 )
277 p_verify.set_defaults(func=_cmd_verify)
278
279 p_chain = sub.add_parser("chain", help="verify manifests link oldest -> newest")
280 p_chain.add_argument("manifests", nargs="+", help="manifest files, oldest first")
281 p_chain.set_defaults(func=_cmd_chain)
282
283 p_keygen = sub.add_parser("keygen", help="generate an ed25519 keypair")
284 p_keygen.add_argument("--private", default="evidence-seal.key")
285 p_keygen.add_argument("--public", default="evidence-seal.pub")
286 p_keygen.set_defaults(func=_cmd_keygen)
287
288 p_sign = sub.add_parser("sign", help="sign an existing manifest")
289 p_sign.add_argument("manifest")
290 p_sign.add_argument("--key", required=True, metavar="PRIVATE_KEY")
291 p_sign.add_argument("--out", help="write here instead of overwriting the manifest")
292 p_sign.set_defaults(func=_cmd_sign)
293
294 _add_timestamp_commands(sub)
295 return parser
296
297
298def _add_timestamp_commands(sub) -> None:
299 p_ts = sub.add_parser("timestamp", help="RFC 3161 trusted timestamping of a manifest")
300 ts = p_ts.add_subparsers(dest="ts_action", required=True)
301
302 p_req = ts.add_parser("request", help="write a TimeStampReq (.tsq) for the manifest id")
303 p_req.add_argument("manifest")
304 p_req.add_argument("--out", help="request path (default: <manifest>.tsq)")
305 p_req.set_defaults(func=_cmd_ts_request)
306
307 p_apply = ts.add_parser("apply", help="bind a TSA response/token into the manifest")
308 p_apply.add_argument("manifest")
309 p_apply.add_argument("--token", required=True, metavar="TSR", help="TSA response or token (DER)")
310 p_apply.add_argument("--out", help="write here instead of overwriting the manifest")
311 p_apply.set_defaults(func=_cmd_ts_apply)
312
313 p_submit = ts.add_parser("submit", help="request, POST to a TSA, and bind in one step")
314 p_submit.add_argument("manifest")
315 p_submit.add_argument("--tsa", required=True, metavar="URL", help="RFC 3161 TSA endpoint")
316 p_submit.add_argument("--timeout", type=float, default=30.0, help="network timeout (seconds)")
317 p_submit.add_argument("--out", help="write here instead of overwriting the manifest")
318 p_submit.set_defaults(func=_cmd_ts_submit)
319
320 p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp")
321 p_tsv.add_argument("manifest")
322 p_tsv.add_argument(
323 "--tsa-cert", metavar="CERT", help="also verify the token's CMS signature against this cert"
324 )
325 p_tsv.set_defaults(func=_cmd_ts_verify)
326
327
328def main(argv: list[str] | None = None) -> int:
329 parser = _build_parser()
330 args = parser.parse_args(argv if argv is not None else sys.argv[1:])
331 return args.func(args)
332
333
334if __name__ == "__main__":
335 raise SystemExit(main())