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