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

v1.0.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())