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())