actproof 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
actproof/cli.py ADDED
@@ -0,0 +1,593 @@
1
+ # SPDX-FileCopyrightText: 2026 Deyan Paroushev
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ actproof command-line interface.
5
+
6
+ Three subcommands wired to the actproof Python API:
7
+
8
+ * ``actproof anchor`` - read a manifest JSON file, optionally acquire an
9
+ RFC 3161 timestamp, anchor the manifest hash to the Algorand ledger,
10
+ write the resulting receipt.
11
+
12
+ * ``actproof verify`` - read a receipt JSON file and run the six checks
13
+ defined in ``actproof.verify``. Prints per-check status. Exits
14
+ non-zero if any check fails.
15
+
16
+ * ``actproof validate`` - read a manifest JSON file and validate against
17
+ an actproof-events catalogue. Prints any validation issues. Exits
18
+ non-zero if the manifest does not conform.
19
+
20
+ Security note on mnemonics
21
+ --------------------------
22
+
23
+ For testing/demo anchoring, the mnemonic is read from the
24
+ ``ACTPROOF_MNEMONIC`` environment variable, NEVER from command-line
25
+ arguments. Command-line arguments leak to shell history (``~/.bash_history``,
26
+ ``~/.zsh_history``), to the kernel's process table (visible via ``ps aux``
27
+ to other users on shared systems), to docker layer metadata, and to log
28
+ aggregation systems. The env var path is the lower-risk channel and the
29
+ only path supported.
30
+
31
+ Production anchoring uses GCP KMS via ``--kms-resource``; the key never
32
+ leaves the HSM. AWS users implement their own signer subclass (see
33
+ ``actproof.signers.interface``).
34
+
35
+ Exit codes
36
+ ----------
37
+
38
+ * 0 - success
39
+ * 1 - operation failed (verification failed, anchor failed, validation
40
+ found issues)
41
+ * 2 - usage error (missing file, bad arguments, etc.) - Click default
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import base64
47
+ import json
48
+ import logging
49
+ import os
50
+ import sys
51
+ import warnings
52
+ from pathlib import Path
53
+ from typing import Any, Optional
54
+
55
+ import click
56
+
57
+
58
+ # ─────────────────────────────────────────────────────────────────
59
+ # VERSION (read lazily so the CLI works even before actproof is fully
60
+ # importable, e.g. for `actproof --version` early-exit cases)
61
+ # ─────────────────────────────────────────────────────────────────
62
+
63
+ def _get_version() -> str:
64
+ try:
65
+ from actproof import __version__
66
+ return __version__
67
+ except Exception: # noqa: BLE001
68
+ return "unknown"
69
+
70
+
71
+ # ─────────────────────────────────────────────────────────────────
72
+ # LOGGING SETUP
73
+ # ─────────────────────────────────────────────────────────────────
74
+
75
+ def _setup_logging(verbose: bool) -> None:
76
+ """Configure logging level based on the --verbose flag."""
77
+ level = logging.DEBUG if verbose else logging.WARNING
78
+ logging.basicConfig(
79
+ level=level,
80
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
81
+ stream=sys.stderr,
82
+ )
83
+
84
+
85
+ # ─────────────────────────────────────────────────────────────────
86
+ # THE MAIN GROUP
87
+ # ─────────────────────────────────────────────────────────────────
88
+
89
+ @click.group()
90
+ @click.version_option(version=_get_version(), prog_name="actproof")
91
+ @click.option(
92
+ "-v", "--verbose",
93
+ is_flag=True,
94
+ help="Enable DEBUG logging to stderr.",
95
+ )
96
+ @click.pass_context
97
+ def main(ctx: click.Context, verbose: bool) -> None:
98
+ """actproof: anchor signed JSON manifests; verify anchored receipts.
99
+
100
+ See ``actproof <command> --help`` for per-command help. The three
101
+ commands are ``anchor`` (commit), ``verify`` (audit), and ``validate``
102
+ (lint a manifest against a catalogue).
103
+ """
104
+ _setup_logging(verbose)
105
+ ctx.ensure_object(dict)
106
+ ctx.obj["verbose"] = verbose
107
+
108
+
109
+ # ─────────────────────────────────────────────────────────────────
110
+ # COMMAND: validate
111
+ # ─────────────────────────────────────────────────────────────────
112
+
113
+ @main.command()
114
+ @click.argument(
115
+ "manifest_path",
116
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
117
+ )
118
+ @click.option(
119
+ "--catalogue", "catalogue_path",
120
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
121
+ required=True,
122
+ help="Path to the actproof-events catalogue acts/ directory.",
123
+ )
124
+ @click.option(
125
+ "--git-commit",
126
+ required=True,
127
+ help=(
128
+ "Git commit SHA at which the catalogue was loaded. "
129
+ "Recorded in the validation context for reproducibility."
130
+ ),
131
+ )
132
+ @click.option(
133
+ "--source-uri",
134
+ required=True,
135
+ help=(
136
+ "Source URI of the catalogue "
137
+ "(e.g. https://github.com/deyan-paroushev/actproof-events)."
138
+ ),
139
+ )
140
+ @click.option(
141
+ "--json", "as_json",
142
+ is_flag=True,
143
+ help="Output validation result as JSON for scripting.",
144
+ )
145
+ def validate(
146
+ manifest_path: Path,
147
+ catalogue_path: Path,
148
+ git_commit: str,
149
+ source_uri: str,
150
+ as_json: bool,
151
+ ) -> None:
152
+ """Validate a manifest against an actproof-events catalogue.
153
+
154
+ Reads MANIFEST_PATH, loads the catalogue at the specified git commit,
155
+ runs all catalogue checks, prints any issues. Exits non-zero if the
156
+ manifest does not conform.
157
+ """
158
+ from actproof.catalogue import load_catalogue, validate_manifest
159
+ from actproof.manifest import manifest_from_dict
160
+
161
+ # Read the manifest.
162
+ try:
163
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
164
+ manifest = manifest_from_dict(data)
165
+ except Exception as exc: # noqa: BLE001
166
+ click.echo(
167
+ f"Error: Cannot read manifest {manifest_path}: {exc}",
168
+ err=True,
169
+ )
170
+ sys.exit(1)
171
+
172
+ # Load the catalogue.
173
+ try:
174
+ catalogue = load_catalogue(
175
+ acts_path=catalogue_path,
176
+ source_uri=source_uri,
177
+ git_commit=git_commit,
178
+ )
179
+ except Exception as exc: # noqa: BLE001
180
+ click.echo(
181
+ f"Error: Cannot load catalogue {catalogue_path}: {exc}",
182
+ err=True,
183
+ )
184
+ sys.exit(1)
185
+
186
+ # Validate.
187
+ issues = validate_manifest(manifest, catalogue)
188
+
189
+ # Output.
190
+ if as_json:
191
+ result = {
192
+ "ok": len(issues) == 0,
193
+ "manifest_path": str(manifest_path),
194
+ "issues": [
195
+ {"code": i.code, "field": i.field, "message": i.message}
196
+ for i in issues
197
+ ],
198
+ }
199
+ click.echo(json.dumps(result, indent=2))
200
+ else:
201
+ if not issues:
202
+ click.echo(click.style(f"OK ", fg="green", bold=True) + f"{manifest_path}")
203
+ click.echo(" No validation issues.")
204
+ else:
205
+ click.echo(
206
+ click.style(f"FAIL ", fg="red", bold=True)
207
+ + f"{manifest_path}"
208
+ )
209
+ click.echo(f" {len(issues)} issue(s) found:")
210
+ for issue in issues:
211
+ where = f" at {issue.field}" if issue.field else ""
212
+ click.echo(
213
+ f" - {click.style(issue.code, fg='yellow')}"
214
+ f"{where}: {issue.message}"
215
+ )
216
+
217
+ sys.exit(1 if issues else 0)
218
+
219
+
220
+ # ─────────────────────────────────────────────────────────────────
221
+ # COMMAND: verify
222
+ # ─────────────────────────────────────────────────────────────────
223
+
224
+ @main.command()
225
+ @click.argument(
226
+ "receipt_path",
227
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
228
+ )
229
+ @click.option(
230
+ "--catalogue", "catalogue_path",
231
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
232
+ help=(
233
+ "Optional path to the actproof-events catalogue acts/ directory. "
234
+ "If not provided, catalogue_conformance check is skipped."
235
+ ),
236
+ )
237
+ @click.option(
238
+ "--git-commit",
239
+ help=(
240
+ "Git commit SHA at which the catalogue was loaded. Required if "
241
+ "--catalogue is provided."
242
+ ),
243
+ )
244
+ @click.option(
245
+ "--source-uri",
246
+ help=(
247
+ "Source URI of the catalogue. Required if --catalogue is provided."
248
+ ),
249
+ )
250
+ @click.option(
251
+ "--skip-anchor", is_flag=True,
252
+ help="Skip the on-chain anchor check.",
253
+ )
254
+ @click.option(
255
+ "--skip-timestamp", is_flag=True,
256
+ help="Skip the RFC 3161 timestamp signature check.",
257
+ )
258
+ @click.option(
259
+ "--json", "as_json", is_flag=True,
260
+ help="Output verification result as JSON for scripting.",
261
+ )
262
+ def verify(
263
+ receipt_path: Path,
264
+ catalogue_path: Optional[Path],
265
+ git_commit: Optional[str],
266
+ source_uri: Optional[str],
267
+ skip_anchor: bool,
268
+ skip_timestamp: bool,
269
+ as_json: bool,
270
+ ) -> None:
271
+ """Verify a receipt end-to-end.
272
+
273
+ Reads RECEIPT_PATH and runs the six checks defined in actproof.verify.
274
+ Prints per-check status. Exits non-zero if any check fails.
275
+ """
276
+ from actproof.catalogue import load_catalogue
277
+ from actproof.receipt import read_receipt
278
+ from actproof.verify import verify_receipt
279
+
280
+ # Read the receipt.
281
+ try:
282
+ receipt = read_receipt(receipt_path)
283
+ except Exception as exc: # noqa: BLE001
284
+ click.echo(
285
+ f"Error: Cannot read receipt {receipt_path}: {exc}",
286
+ err=True,
287
+ )
288
+ sys.exit(1)
289
+
290
+ # Optionally load the catalogue.
291
+ catalogue = None
292
+ skip_catalogue = False
293
+ if catalogue_path is not None:
294
+ if not git_commit or not source_uri:
295
+ click.echo(
296
+ "Error: --git-commit and --source-uri are required when "
297
+ "--catalogue is provided.",
298
+ err=True,
299
+ )
300
+ sys.exit(2)
301
+ try:
302
+ catalogue = load_catalogue(
303
+ acts_path=catalogue_path,
304
+ source_uri=source_uri,
305
+ git_commit=git_commit,
306
+ )
307
+ except Exception as exc: # noqa: BLE001
308
+ click.echo(
309
+ f"Error: Cannot load catalogue {catalogue_path}: {exc}",
310
+ err=True,
311
+ )
312
+ sys.exit(1)
313
+ else:
314
+ skip_catalogue = True
315
+
316
+ # Run verification.
317
+ result = verify_receipt(
318
+ receipt,
319
+ catalogue=catalogue,
320
+ skip_anchor_check=skip_anchor,
321
+ skip_timestamp_check=skip_timestamp,
322
+ skip_catalogue_check=skip_catalogue,
323
+ )
324
+
325
+ # Output.
326
+ if as_json:
327
+ out = {
328
+ "ok": result.ok,
329
+ "receipt_path": str(receipt_path),
330
+ "manifest_hash": receipt.manifest_hash,
331
+ "checks": [
332
+ {
333
+ "name": c.name,
334
+ "status": c.status.value,
335
+ "detail": c.detail,
336
+ "elapsed_seconds": c.elapsed_seconds,
337
+ }
338
+ for c in result.checks
339
+ ],
340
+ }
341
+ click.echo(json.dumps(out, indent=2))
342
+ else:
343
+ header = (
344
+ click.style("OK ", fg="green", bold=True)
345
+ if result.ok
346
+ else click.style("FAIL ", fg="red", bold=True)
347
+ )
348
+ click.echo(header + f"{receipt_path}")
349
+ click.echo(f" manifest_hash: {receipt.manifest_hash}")
350
+ click.echo(f" Checks:")
351
+ for c in result.checks:
352
+ status_color = {
353
+ "pass": "green",
354
+ "fail": "red",
355
+ "skip": "yellow",
356
+ "error": "red",
357
+ }[c.status.value]
358
+ status_str = c.status.value.upper().rjust(5)
359
+ elapsed_ms = (c.elapsed_seconds or 0.0) * 1000
360
+ line = (
361
+ f" [{click.style(status_str, fg=status_color, bold=True)}]"
362
+ f" {c.name:<32} ({elapsed_ms:.2f} ms)"
363
+ )
364
+ click.echo(line)
365
+ if c.detail and c.status.value in ("fail", "error", "skip"):
366
+ # Wrap the detail to fit reasonable width.
367
+ detail = c.detail.replace("\n", " ")[:200]
368
+ click.echo(f" {detail}")
369
+
370
+ sys.exit(0 if result.ok else 1)
371
+
372
+
373
+ # ─────────────────────────────────────────────────────────────────
374
+ # COMMAND: anchor
375
+ # ─────────────────────────────────────────────────────────────────
376
+
377
+ _MNEMONIC_ENV_VAR = "ACTPROOF_MNEMONIC"
378
+
379
+
380
+ @main.command()
381
+ @click.argument(
382
+ "manifest_path",
383
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
384
+ )
385
+ @click.option(
386
+ "--mode",
387
+ type=click.Choice(["draft", "demo", "production"], case_sensitive=False),
388
+ required=True,
389
+ help=(
390
+ "Anchoring mode. draft = build the note bytes without submission. "
391
+ "demo = submit to testnet. production = submit to mainnet."
392
+ ),
393
+ )
394
+ @click.option(
395
+ "--kms-resource",
396
+ help=(
397
+ "GCP KMS Ed25519 key version resource path for production signing. "
398
+ "Mutually exclusive with the ACTPROOF_MNEMONIC env var."
399
+ ),
400
+ )
401
+ @click.option(
402
+ "--output", "output_path",
403
+ type=click.Path(dir_okay=False, path_type=Path),
404
+ required=True,
405
+ help="Path to write the resulting receipt JSON.",
406
+ )
407
+ @click.option(
408
+ "--evidence-output", "evidence_output_path",
409
+ type=click.Path(dir_okay=False, path_type=Path),
410
+ help=(
411
+ "Optional path to write the issuer evidence JSON (the private "
412
+ "addendum containing plaintext recipient emails). If omitted, "
413
+ "no issuer evidence file is produced."
414
+ ),
415
+ )
416
+ @click.option(
417
+ "--skip-timestamp", is_flag=True,
418
+ help=(
419
+ "Skip RFC 3161 timestamp acquisition. The receipt will contain a "
420
+ "placeholder TimestampToken; verifiers will fail the timestamp "
421
+ "signature check. Useful for offline tests only."
422
+ ),
423
+ )
424
+ @click.option(
425
+ "--wait/--no-wait", default=True,
426
+ help=(
427
+ "If --wait (default), poll algod until the transaction confirms. "
428
+ "If --no-wait, return immediately after submission with block_round=None."
429
+ ),
430
+ )
431
+ def anchor(
432
+ manifest_path: Path,
433
+ mode: str,
434
+ kms_resource: Optional[str],
435
+ output_path: Path,
436
+ evidence_output_path: Optional[Path],
437
+ skip_timestamp: bool,
438
+ wait: bool,
439
+ ) -> None:
440
+ """Anchor a manifest to the Algorand ledger and write a receipt.
441
+
442
+ Reads MANIFEST_PATH, builds a signer (from ACTPROOF_MNEMONIC env var
443
+ or --kms-resource), optionally acquires an RFC 3161 timestamp, anchors
444
+ the manifest hash to Algorand in the requested mode, writes the
445
+ resulting receipt to --output.
446
+ """
447
+ from actproof.anchor import AnchorMode, anchor_manifest
448
+ from actproof.manifest import manifest_from_dict, hash_manifest
449
+ from actproof.receipt import TimestampToken, build_receipt, write_receipt
450
+ from actproof.signers import GoogleKMSSigner, MnemonicSigner
451
+ from actproof.timestamp import acquire_timestamp_token
452
+
453
+ # Read the manifest.
454
+ try:
455
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
456
+ manifest = manifest_from_dict(data)
457
+ except Exception as exc: # noqa: BLE001
458
+ click.echo(
459
+ f"Error: Cannot read manifest {manifest_path}: {exc}",
460
+ err=True,
461
+ )
462
+ sys.exit(1)
463
+
464
+ # Build the signer.
465
+ mnemonic_value = os.environ.get(_MNEMONIC_ENV_VAR)
466
+ if kms_resource and mnemonic_value:
467
+ click.echo(
468
+ f"Error: Both --kms-resource and {_MNEMONIC_ENV_VAR} are set. "
469
+ f"Choose one.",
470
+ err=True,
471
+ )
472
+ sys.exit(2)
473
+
474
+ signer: Any = None
475
+ if kms_resource:
476
+ try:
477
+ signer = GoogleKMSSigner(kms_resource_name=kms_resource)
478
+ except Exception as exc: # noqa: BLE001
479
+ click.echo(f"Error: Cannot build GoogleKMSSigner: {exc}", err=True)
480
+ sys.exit(1)
481
+ elif mnemonic_value:
482
+ try:
483
+ with warnings.catch_warnings():
484
+ # The MnemonicSigner emits a UserWarning every time; for CLI
485
+ # use the warning would interleave with the normal output.
486
+ # The "do not use for production" warning is documented at
487
+ # the env var level (this command's --help) and at the
488
+ # MnemonicSigner class level (its docstring), so suppressing
489
+ # it here keeps CLI output clean.
490
+ warnings.simplefilter("ignore", category=UserWarning)
491
+ signer = MnemonicSigner(mnemonic_value)
492
+ except Exception as exc: # noqa: BLE001
493
+ click.echo(
494
+ f"Error: Cannot build MnemonicSigner from "
495
+ f"{_MNEMONIC_ENV_VAR}: {exc}",
496
+ err=True,
497
+ )
498
+ sys.exit(1)
499
+ else:
500
+ click.echo(
501
+ f"Error: Either --kms-resource or {_MNEMONIC_ENV_VAR} env "
502
+ f"var must be set. The CLI does NOT read mnemonics from "
503
+ f"command-line arguments (they would leak to shell history).",
504
+ err=True,
505
+ )
506
+ sys.exit(2)
507
+
508
+ # Compute the manifest hash.
509
+ manifest_hash_bytes = hash_manifest(manifest)
510
+
511
+ # Optionally acquire a trusted timestamp.
512
+ if skip_timestamp:
513
+ click.echo(
514
+ click.style(
515
+ "Note: --skip-timestamp set; using placeholder TimestampToken.",
516
+ fg="yellow",
517
+ ),
518
+ err=True,
519
+ )
520
+ token = TimestampToken(
521
+ tsa_url="", tsa_name="(placeholder)",
522
+ token_b64="",
523
+ policy_oid=None,
524
+ hash_alg="sha-256",
525
+ imprint_hex=manifest_hash_bytes.hex(),
526
+ timestamp="",
527
+ )
528
+ else:
529
+ try:
530
+ ts_result = acquire_timestamp_token(manifest_hash_bytes)
531
+ if not ts_result.ok:
532
+ click.echo(
533
+ "Error: RFC 3161 timestamp acquisition failed for all "
534
+ "TSAs in the chain.", err=True,
535
+ )
536
+ for a in ts_result.attempts:
537
+ click.echo(
538
+ f" - {a.tsa_name}: {a.error}", err=True,
539
+ )
540
+ sys.exit(1)
541
+ token = ts_result.token # type: ignore[assignment]
542
+ except Exception as exc: # noqa: BLE001
543
+ click.echo(
544
+ f"Error: timestamp acquisition raised: {exc}", err=True,
545
+ )
546
+ sys.exit(1)
547
+
548
+ # Build the AnchorMode.
549
+ mode_map = {
550
+ "draft": AnchorMode.DRAFT,
551
+ "demo": AnchorMode.DEMO,
552
+ "production": AnchorMode.PRODUCTION,
553
+ }
554
+ anchor_mode = mode_map[mode.lower()]
555
+
556
+ # Anchor.
557
+ try:
558
+ anchor_record = anchor_manifest(
559
+ manifest_hash_bytes,
560
+ signer=signer,
561
+ mode=anchor_mode,
562
+ wait_for_confirmation=wait,
563
+ )
564
+ except Exception as exc: # noqa: BLE001
565
+ click.echo(f"Error: anchor failed: {exc}", err=True)
566
+ sys.exit(1)
567
+
568
+ # Build and write the receipt.
569
+ receipt = build_receipt(
570
+ manifest=manifest,
571
+ anchor=anchor_record,
572
+ trusted_timestamp=token,
573
+ )
574
+ try:
575
+ write_receipt(output_path, receipt)
576
+ except Exception as exc: # noqa: BLE001
577
+ click.echo(f"Error: cannot write receipt to {output_path}: {exc}", err=True)
578
+ sys.exit(1)
579
+
580
+ # Output: human-readable summary.
581
+ click.echo(click.style("OK ", fg="green", bold=True) + f"{output_path}")
582
+ click.echo(f" Mode: {anchor_mode.value}")
583
+ click.echo(f" Manifest hash: {receipt.manifest_hash}")
584
+ if anchor_record.txid:
585
+ click.echo(f" Txid: {anchor_record.txid}")
586
+ click.echo(f" Network: {anchor_record.network}")
587
+ if anchor_record.block_round:
588
+ click.echo(f" Block round: {anchor_record.block_round}")
589
+ else:
590
+ click.echo(f" Txid: (draft - not submitted)")
591
+ if token.tsa_name and token.tsa_name != "(placeholder)":
592
+ click.echo(f" TSA: {token.tsa_name}")
593
+ click.echo(f" TSA time: {token.timestamp}")