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/verify.py ADDED
@@ -0,0 +1,683 @@
1
+ # SPDX-FileCopyrightText: 2026 Deyan Paroushev
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ End-to-end verification of actproof receipts.
5
+
6
+ The audit-facing module. Given a receipt, this module answers the question:
7
+ "is this an honest commitment to a manifest hash, made at the time the
8
+ receipt claims, anchored to the chain the receipt names, validated against
9
+ the catalogue the receipt pins?"
10
+
11
+ Six checks
12
+ ----------
13
+
14
+ Each check is independent and produces a ``CheckResult`` with a name,
15
+ a status (PASS / FAIL / SKIP / ERROR), an optional diagnostic message,
16
+ and an elapsed-seconds measurement. Checks are run in a fixed order; the
17
+ orchestrator does NOT short-circuit on failure (so the caller sees the
18
+ full picture, not just the first thing that went wrong).
19
+
20
+ 1. **receipt_profile_supported** - the ``receipt_profile`` field is one
21
+ the verifier knows about. v1 verifiers accept ``actproof-jcs-v1``
22
+ only; future v2 verifiers will additionally accept
23
+ ``actproof-scitt-cose-v2``.
24
+
25
+ 2. **manifest_hash_match** - recompute the canonical hash of
26
+ ``receipt.manifest`` and compare to ``receipt.manifest_hash``.
27
+ Catches manifest-substitution attacks. Always runs; needs no network.
28
+
29
+ 3. **note_payload_reproducible** - check that the
30
+ ``receipt.anchor.note_payload_b64`` decodes to the canonical JSON
31
+ payload that ``build_note_payload(manifest_hash, batching_profile)``
32
+ would produce for this receipt. Catches "the receipt's anchor record
33
+ does not match what would actually be on-chain" mismatches. Always
34
+ runs.
35
+
36
+ 4. **catalogue_conformance** - run ``validate_manifest`` against a
37
+ catalogue. Skipped if no catalogue is provided. The catalogue MUST be
38
+ loaded at the git commit named in ``receipt.manifest.catalogue.git_commit``;
39
+ the verifier does NOT fetch the catalogue automatically (that is the
40
+ caller's responsibility, because git-commit fetching depends on the
41
+ caller's environment).
42
+
43
+ 5. **anchor_on_chain** - query an Algorand indexer for the transaction
44
+ identified by ``receipt.anchor.txid``. Compare the on-chain note
45
+ bytes to what the receipt claims they should be. Skipped if no
46
+ indexer client is provided OR if the receipt is a draft (txid empty).
47
+
48
+ 6. **timestamp_signature** - verify the RFC 3161 token's signature via
49
+ ``tsp_client.TSPVerifier``. Confirms the token is well-formed and the
50
+ imprint inside the token equals the receipt's ``manifest_hash``
51
+ bytes. Skipped if ``tsp_client`` is not available.
52
+
53
+ What this module does NOT do
54
+ ----------------------------
55
+
56
+ * **Does not fetch the catalogue.** Pass a pre-loaded ``Catalogue``. The
57
+ ``receipt.manifest.catalogue.git_commit`` value tells you WHICH commit
58
+ to load; loading itself depends on the caller's environment (network
59
+ access, git installation, actproof-events checkout location).
60
+
61
+ * **Does not validate the TSA certificate chain against the EU Trusted
62
+ List.** ``tsp_client.TSPVerifier`` validates the token structurally
63
+ and checks signature self-consistency; it does NOT verify the TSA's
64
+ cert chains up to a trust anchor in the EUTL or in any other
65
+ jurisdiction's trust list. Full EUTL chain validation is a future
66
+ enhancement (v0.2.x). Users needing legal-grade qualified timestamp
67
+ proof should re-verify the token against their jurisdiction's trust
68
+ list separately.
69
+
70
+ * **Does not check whether the issuer's address is one the verifier
71
+ recognises.** The receipt records WHICH Algorand address signed the
72
+ anchor transaction (via ``receipt.anchor`` - but actually it doesn't
73
+ even record that directly; the sender is encoded in the transaction
74
+ itself, retrievable from the indexer). Verifying that the signing
75
+ address belongs to the named issuer is a trust-root concern outside
76
+ the scope of this library. Operators establish that link via signed
77
+ publication, address registries, or DNS TXT records.
78
+
79
+ * **Does not check transaction confirmation depth.** The receipt claims
80
+ a block round; the indexer confirms the transaction is at that round.
81
+ But Algorand blocks have very strong finality (1 round = ~3.3s and
82
+ effectively final under non-byzantine conditions), so depth checking
83
+ is overkill for normal use. Users with paranoid requirements can
84
+ inspect ``receipt.anchor.block_round`` against ``algod_client.status()``
85
+ themselves.
86
+
87
+ API
88
+ ---
89
+
90
+ * ``verify_receipt(receipt, *, ...) -> VerificationResult``
91
+ * ``CheckResult`` - one check's outcome (name, status, detail, elapsed).
92
+ * ``CheckStatus`` - enum: PASS, FAIL, SKIP, ERROR.
93
+ * ``VerificationResult`` - bundle of (receipt, ok, checks).
94
+ * ``VerificationError`` - raised if a check encounters an unrecoverable
95
+ setup problem (e.g. malformed receipt structure). Does NOT raise on a
96
+ failed check; that's reported in the result.
97
+ """
98
+
99
+ from __future__ import annotations
100
+
101
+ import base64
102
+ import logging
103
+ import time
104
+ from dataclasses import dataclass
105
+ from enum import Enum
106
+ from typing import Any, Optional
107
+
108
+ from actproof.anchor import build_note_payload
109
+ from actproof.canonical import hash_canonical
110
+ from actproof.catalogue import (
111
+ Catalogue,
112
+ ValidationIssue,
113
+ validate_manifest,
114
+ )
115
+ from actproof.manifest import (
116
+ BATCHING_PROFILE_SINGLE,
117
+ Manifest,
118
+ RECEIPT_PROFILE_V1,
119
+ hash_manifest_hex,
120
+ manifest_to_dict,
121
+ )
122
+ from actproof.receipt import (
123
+ ALGORAND_MAINNET,
124
+ ALGORAND_TESTNET,
125
+ ARC2_DAPP_NAME,
126
+ ARC2_FORMAT_VERSION_JSON,
127
+ ARC2_NOTE_FORMAT,
128
+ Receipt,
129
+ )
130
+
131
+
132
+ logger = logging.getLogger(__name__)
133
+
134
+
135
+ # ─────────────────────────────────────────────────────────────────
136
+ # OPTIONAL IMPORTS: tsp_client (for timestamp signature check) and
137
+ # algosdk indexer (for on-chain anchor check)
138
+ # ─────────────────────────────────────────────────────────────────
139
+
140
+ try:
141
+ from actproof.timestamp import TSPVerifier as _TSPVerifier
142
+ _TSP_VERIFIER_AVAILABLE: bool = True
143
+ except Exception: # noqa: BLE001
144
+ _TSP_VERIFIER_AVAILABLE = False
145
+ _TSPVerifier = None # type: ignore[assignment,misc]
146
+
147
+ try:
148
+ from algosdk.v2client.indexer import IndexerClient
149
+ _INDEXER_AVAILABLE: bool = True
150
+ _INDEXER_ERROR: Optional[str] = None
151
+ except Exception as exc: # noqa: BLE001
152
+ _INDEXER_AVAILABLE = False
153
+ _INDEXER_ERROR = str(exc)
154
+ IndexerClient = None # type: ignore[assignment,misc]
155
+
156
+
157
+ __all__ = [
158
+ "verify_receipt",
159
+ "CheckResult",
160
+ "CheckStatus",
161
+ "VerificationResult",
162
+ "VerificationError",
163
+ "DEFAULT_INDEXER_URL_MAINNET",
164
+ "DEFAULT_INDEXER_URL_TESTNET",
165
+ "SUPPORTED_RECEIPT_PROFILES",
166
+ ]
167
+
168
+
169
+ # ─────────────────────────────────────────────────────────────────
170
+ # CONSTANTS
171
+ # ─────────────────────────────────────────────────────────────────
172
+
173
+ DEFAULT_INDEXER_URL_MAINNET: str = "https://mainnet-idx.algonode.cloud"
174
+ """Default Algorand mainnet indexer endpoint (Algonode, free)."""
175
+
176
+ DEFAULT_INDEXER_URL_TESTNET: str = "https://testnet-idx.algonode.cloud"
177
+ """Default Algorand testnet indexer endpoint (Algonode, free)."""
178
+
179
+ SUPPORTED_RECEIPT_PROFILES: frozenset[str] = frozenset({RECEIPT_PROFILE_V1})
180
+ """Receipt profiles this verifier knows how to parse. v1 verifier accepts
181
+ ``actproof-jcs-v1`` only. A future v2 verifier will additionally accept
182
+ ``actproof-scitt-cose-v2``."""
183
+
184
+
185
+ # ─────────────────────────────────────────────────────────────────
186
+ # EXCEPTIONS
187
+ # ─────────────────────────────────────────────────────────────────
188
+
189
+ class VerificationError(RuntimeError):
190
+ """Raised when verification cannot proceed due to a structural problem.
191
+
192
+ Not raised for failed checks (those are reported as FAIL in the result).
193
+ Raised only when the verifier cannot even attempt the checks, e.g.
194
+ if the receipt is so malformed that no checks make sense.
195
+ """
196
+
197
+
198
+ # ─────────────────────────────────────────────────────────────────
199
+ # RESULT TYPES
200
+ # ─────────────────────────────────────────────────────────────────
201
+
202
+ class CheckStatus(str, Enum):
203
+ """The outcome of a single verification check."""
204
+ PASS = "pass"
205
+ FAIL = "fail"
206
+ SKIP = "skip"
207
+ ERROR = "error"
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class CheckResult:
212
+ """The outcome of a single verification check.
213
+
214
+ Attributes:
215
+ name: Stable check identifier. One of: ``receipt_profile_supported``,
216
+ ``manifest_hash_match``, ``note_payload_reproducible``,
217
+ ``catalogue_conformance``, ``anchor_on_chain``,
218
+ ``timestamp_signature``.
219
+ status: ``CheckStatus.PASS``, ``FAIL``, ``SKIP``, or ``ERROR``.
220
+ detail: Human-readable diagnostic. ``None`` for trivial passes;
221
+ present on FAIL, SKIP, and ERROR to explain what happened.
222
+ elapsed_seconds: Wall-clock time spent on the check.
223
+ """
224
+ name: str
225
+ status: CheckStatus
226
+ detail: Optional[str] = None
227
+ elapsed_seconds: Optional[float] = None
228
+
229
+ @property
230
+ def ok(self) -> bool:
231
+ """``True`` if status is PASS or SKIP (no failure)."""
232
+ return self.status in (CheckStatus.PASS, CheckStatus.SKIP)
233
+
234
+ @property
235
+ def failed(self) -> bool:
236
+ """``True`` if status is FAIL or ERROR."""
237
+ return self.status in (CheckStatus.FAIL, CheckStatus.ERROR)
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class VerificationResult:
242
+ """End-to-end verification outcome.
243
+
244
+ Attributes:
245
+ receipt: The receipt that was verified.
246
+ checks: Tuple of per-check ``CheckResult`` in execution order.
247
+ ok: ``True`` if every check is PASS or SKIP. ``False`` if any
248
+ check is FAIL or ERROR.
249
+ """
250
+ receipt: Receipt
251
+ checks: tuple[CheckResult, ...]
252
+ ok: bool
253
+
254
+ def failed_checks(self) -> tuple[CheckResult, ...]:
255
+ """Subset of checks with status FAIL or ERROR."""
256
+ return tuple(c for c in self.checks if c.failed)
257
+
258
+ def get_check(self, name: str) -> Optional[CheckResult]:
259
+ """Find a check by name, or None if not present."""
260
+ for c in self.checks:
261
+ if c.name == name:
262
+ return c
263
+ return None
264
+
265
+
266
+ # ─────────────────────────────────────────────────────────────────
267
+ # INDIVIDUAL CHECKS
268
+ # ─────────────────────────────────────────────────────────────────
269
+
270
+ def _check_receipt_profile(receipt: Receipt) -> CheckResult:
271
+ """Confirm the receipt_profile is one this verifier supports."""
272
+ started = time.monotonic()
273
+ profile = receipt.receipt_profile
274
+ if profile not in SUPPORTED_RECEIPT_PROFILES:
275
+ return CheckResult(
276
+ name="receipt_profile_supported",
277
+ status=CheckStatus.FAIL,
278
+ detail=(
279
+ f"Unsupported receipt_profile {profile!r}. This verifier "
280
+ f"supports: {sorted(SUPPORTED_RECEIPT_PROFILES)}."
281
+ ),
282
+ elapsed_seconds=time.monotonic() - started,
283
+ )
284
+ return CheckResult(
285
+ name="receipt_profile_supported",
286
+ status=CheckStatus.PASS,
287
+ elapsed_seconds=time.monotonic() - started,
288
+ )
289
+
290
+
291
+ def _check_manifest_hash(receipt: Receipt) -> CheckResult:
292
+ """Recompute the canonical hash of receipt.manifest and compare."""
293
+ started = time.monotonic()
294
+ try:
295
+ recomputed_hex = hash_manifest_hex(receipt.manifest)
296
+ except Exception as exc: # noqa: BLE001
297
+ return CheckResult(
298
+ name="manifest_hash_match",
299
+ status=CheckStatus.ERROR,
300
+ detail=f"Hash recomputation raised: {exc}",
301
+ elapsed_seconds=time.monotonic() - started,
302
+ )
303
+
304
+ expected = f"sha256:{recomputed_hex}"
305
+ if receipt.manifest_hash != expected:
306
+ return CheckResult(
307
+ name="manifest_hash_match",
308
+ status=CheckStatus.FAIL,
309
+ detail=(
310
+ f"Receipt declares manifest_hash={receipt.manifest_hash!r} "
311
+ f"but recomputed canonical hash is {expected!r}. The "
312
+ f"manifest may have been tampered with after issuance."
313
+ ),
314
+ elapsed_seconds=time.monotonic() - started,
315
+ )
316
+ return CheckResult(
317
+ name="manifest_hash_match",
318
+ status=CheckStatus.PASS,
319
+ elapsed_seconds=time.monotonic() - started,
320
+ )
321
+
322
+
323
+ def _check_note_payload_reproducible(receipt: Receipt) -> CheckResult:
324
+ """Check that the receipt's on-chain note payload can be rebuilt
325
+ deterministically from manifest_hash + batching_profile."""
326
+ started = time.monotonic()
327
+ try:
328
+ # Strip the "sha256:" prefix to get raw hex.
329
+ hex_part = receipt.manifest_hash
330
+ if hex_part.startswith("sha256:"):
331
+ hex_part = hex_part[len("sha256:"):]
332
+ manifest_hash_bytes = bytes.fromhex(hex_part)
333
+
334
+ # Reconstruct the canonical payload bytes.
335
+ expected_payload = build_note_payload(
336
+ manifest_hash_bytes,
337
+ batching_profile=receipt.batching_profile,
338
+ )
339
+ expected_b64 = base64.b64encode(expected_payload).decode("ascii")
340
+
341
+ actual_b64 = receipt.anchor.note_payload_b64
342
+ if actual_b64 != expected_b64:
343
+ return CheckResult(
344
+ name="note_payload_reproducible",
345
+ status=CheckStatus.FAIL,
346
+ detail=(
347
+ f"Receipt anchor.note_payload_b64 does not match "
348
+ f"what build_note_payload(manifest_hash) would produce. "
349
+ f"Receipt says payload encodes a different hash, "
350
+ f"profile, or version than the manifest implies."
351
+ ),
352
+ elapsed_seconds=time.monotonic() - started,
353
+ )
354
+ return CheckResult(
355
+ name="note_payload_reproducible",
356
+ status=CheckStatus.PASS,
357
+ elapsed_seconds=time.monotonic() - started,
358
+ )
359
+ except Exception as exc: # noqa: BLE001
360
+ return CheckResult(
361
+ name="note_payload_reproducible",
362
+ status=CheckStatus.ERROR,
363
+ detail=f"Reconstruction raised: {exc}",
364
+ elapsed_seconds=time.monotonic() - started,
365
+ )
366
+
367
+
368
+ def _check_catalogue_conformance(
369
+ receipt: Receipt,
370
+ catalogue: Optional[Catalogue],
371
+ ) -> CheckResult:
372
+ """Run validate_manifest against the catalogue, if one is provided."""
373
+ started = time.monotonic()
374
+ if catalogue is None:
375
+ return CheckResult(
376
+ name="catalogue_conformance",
377
+ status=CheckStatus.SKIP,
378
+ detail=(
379
+ "No catalogue provided. Pass catalogue=Catalogue(...) to "
380
+ "actproof.verify_receipt() to enable this check. The "
381
+ "catalogue must be loaded at the git commit named in "
382
+ "receipt.manifest.catalogue.git_commit."
383
+ ),
384
+ elapsed_seconds=time.monotonic() - started,
385
+ )
386
+
387
+ try:
388
+ issues = validate_manifest(receipt.manifest, catalogue)
389
+ except Exception as exc: # noqa: BLE001
390
+ return CheckResult(
391
+ name="catalogue_conformance",
392
+ status=CheckStatus.ERROR,
393
+ detail=f"validate_manifest raised: {exc}",
394
+ elapsed_seconds=time.monotonic() - started,
395
+ )
396
+
397
+ if issues:
398
+ codes = ", ".join(sorted({i.code for i in issues}))
399
+ return CheckResult(
400
+ name="catalogue_conformance",
401
+ status=CheckStatus.FAIL,
402
+ detail=(
403
+ f"Manifest failed catalogue validation. "
404
+ f"{len(issues)} issue(s) found with codes: {codes}. "
405
+ f"First issue: {issues[0].message}"
406
+ ),
407
+ elapsed_seconds=time.monotonic() - started,
408
+ )
409
+ return CheckResult(
410
+ name="catalogue_conformance",
411
+ status=CheckStatus.PASS,
412
+ elapsed_seconds=time.monotonic() - started,
413
+ )
414
+
415
+
416
+ def _build_default_indexer_client(network: str) -> Any:
417
+ """Construct an IndexerClient for the given network identifier."""
418
+ if not _INDEXER_AVAILABLE:
419
+ raise VerificationError(
420
+ f"py-algorand-sdk indexer not available: {_INDEXER_ERROR}"
421
+ )
422
+ if network == ALGORAND_MAINNET:
423
+ url = DEFAULT_INDEXER_URL_MAINNET
424
+ elif network == ALGORAND_TESTNET:
425
+ url = DEFAULT_INDEXER_URL_TESTNET
426
+ else:
427
+ # Betanet or custom; user must pass their own client.
428
+ raise VerificationError(
429
+ f"No default indexer for network {network!r}. "
430
+ f"Pass indexer_client=IndexerClient(...) explicitly."
431
+ )
432
+ return IndexerClient("", url)
433
+
434
+
435
+ def _check_anchor_on_chain(
436
+ receipt: Receipt,
437
+ indexer_client: Optional[Any],
438
+ ) -> CheckResult:
439
+ """Query the Algorand indexer for the transaction; compare note bytes."""
440
+ started = time.monotonic()
441
+
442
+ # Skip if the receipt is a draft (no txid).
443
+ if not receipt.anchor.txid:
444
+ return CheckResult(
445
+ name="anchor_on_chain",
446
+ status=CheckStatus.SKIP,
447
+ detail=(
448
+ "Receipt is a draft (anchor.txid is empty). The manifest "
449
+ "was prepared but not submitted to the chain."
450
+ ),
451
+ elapsed_seconds=time.monotonic() - started,
452
+ )
453
+
454
+ # Get or build an indexer client.
455
+ if indexer_client is None:
456
+ try:
457
+ indexer_client = _build_default_indexer_client(receipt.anchor.network)
458
+ except VerificationError as exc:
459
+ return CheckResult(
460
+ name="anchor_on_chain",
461
+ status=CheckStatus.SKIP,
462
+ detail=str(exc),
463
+ elapsed_seconds=time.monotonic() - started,
464
+ )
465
+
466
+ # Query the transaction.
467
+ try:
468
+ response = indexer_client.transaction(receipt.anchor.txid)
469
+ except Exception as exc: # noqa: BLE001
470
+ return CheckResult(
471
+ name="anchor_on_chain",
472
+ status=CheckStatus.ERROR,
473
+ detail=(
474
+ f"Indexer transaction lookup failed for "
475
+ f"{receipt.anchor.txid}: {exc}"
476
+ ),
477
+ elapsed_seconds=time.monotonic() - started,
478
+ )
479
+
480
+ # Extract the transaction's note field. Indexer returns note as base64.
481
+ txn = response.get("transaction", {})
482
+ on_chain_note_b64 = txn.get("note", "")
483
+ if not on_chain_note_b64:
484
+ return CheckResult(
485
+ name="anchor_on_chain",
486
+ status=CheckStatus.FAIL,
487
+ detail=(
488
+ f"Transaction {receipt.anchor.txid} exists but has no "
489
+ f"note field. The transaction is not an actproof anchor."
490
+ ),
491
+ elapsed_seconds=time.monotonic() - started,
492
+ )
493
+
494
+ try:
495
+ on_chain_note_bytes = base64.b64decode(on_chain_note_b64)
496
+ except Exception as exc: # noqa: BLE001
497
+ return CheckResult(
498
+ name="anchor_on_chain",
499
+ status=CheckStatus.ERROR,
500
+ detail=f"Cannot decode on-chain note bytes: {exc}",
501
+ elapsed_seconds=time.monotonic() - started,
502
+ )
503
+
504
+ # Strip the ARC-2 prefix and compare to the receipt's note_payload_b64.
505
+ expected_prefix = (
506
+ f"{ARC2_DAPP_NAME}:{ARC2_FORMAT_VERSION_JSON}"
507
+ ).encode("ascii")
508
+ if not on_chain_note_bytes.startswith(expected_prefix):
509
+ return CheckResult(
510
+ name="anchor_on_chain",
511
+ status=CheckStatus.FAIL,
512
+ detail=(
513
+ f"On-chain note does not start with the actproof ARC-2 "
514
+ f"prefix {expected_prefix!r}. Got prefix "
515
+ f"{on_chain_note_bytes[:32]!r}."
516
+ ),
517
+ elapsed_seconds=time.monotonic() - started,
518
+ )
519
+
520
+ on_chain_payload = on_chain_note_bytes[len(expected_prefix):]
521
+ on_chain_payload_b64 = base64.b64encode(on_chain_payload).decode("ascii")
522
+
523
+ if on_chain_payload_b64 != receipt.anchor.note_payload_b64:
524
+ return CheckResult(
525
+ name="anchor_on_chain",
526
+ status=CheckStatus.FAIL,
527
+ detail=(
528
+ f"On-chain note payload does NOT match the receipt's "
529
+ f"declared payload. The receipt's anchor record disagrees "
530
+ f"with what is actually on-chain."
531
+ ),
532
+ elapsed_seconds=time.monotonic() - started,
533
+ )
534
+
535
+ return CheckResult(
536
+ name="anchor_on_chain",
537
+ status=CheckStatus.PASS,
538
+ elapsed_seconds=time.monotonic() - started,
539
+ )
540
+
541
+
542
+ def _check_timestamp_signature(receipt: Receipt) -> CheckResult:
543
+ """Verify the RFC 3161 token's signature and imprint."""
544
+ started = time.monotonic()
545
+ if not _TSP_VERIFIER_AVAILABLE:
546
+ return CheckResult(
547
+ name="timestamp_signature",
548
+ status=CheckStatus.SKIP,
549
+ detail=(
550
+ "tsp_client is not available (transitive dependency "
551
+ "conflict; see actproof.timestamp for details). Install "
552
+ "or repair tsp-client to enable this check."
553
+ ),
554
+ elapsed_seconds=time.monotonic() - started,
555
+ )
556
+
557
+ # Decode the token bytes and the expected imprint.
558
+ try:
559
+ token_bytes = base64.b64decode(receipt.trusted_timestamp.token_b64)
560
+ except Exception as exc: # noqa: BLE001
561
+ return CheckResult(
562
+ name="timestamp_signature",
563
+ status=CheckStatus.ERROR,
564
+ detail=f"Cannot base64-decode token: {exc}",
565
+ elapsed_seconds=time.monotonic() - started,
566
+ )
567
+
568
+ try:
569
+ hex_part = receipt.manifest_hash
570
+ if hex_part.startswith("sha256:"):
571
+ hex_part = hex_part[len("sha256:"):]
572
+ expected_imprint = bytes.fromhex(hex_part)
573
+ except Exception as exc: # noqa: BLE001
574
+ return CheckResult(
575
+ name="timestamp_signature",
576
+ status=CheckStatus.ERROR,
577
+ detail=f"Cannot decode manifest_hash hex: {exc}",
578
+ elapsed_seconds=time.monotonic() - started,
579
+ )
580
+
581
+ # Verify the token via tsp_client.
582
+ try:
583
+ verifier = _TSPVerifier()
584
+ verifier.verify(token_bytes, message_digest=expected_imprint)
585
+ except Exception as exc: # noqa: BLE001
586
+ return CheckResult(
587
+ name="timestamp_signature",
588
+ status=CheckStatus.FAIL,
589
+ detail=(
590
+ f"RFC 3161 token verification failed: {exc}. The token "
591
+ f"is malformed, the signature is invalid, or the imprint "
592
+ f"inside the token does not match the receipt's "
593
+ f"manifest_hash."
594
+ ),
595
+ elapsed_seconds=time.monotonic() - started,
596
+ )
597
+
598
+ return CheckResult(
599
+ name="timestamp_signature",
600
+ status=CheckStatus.PASS,
601
+ elapsed_seconds=time.monotonic() - started,
602
+ )
603
+
604
+
605
+ # ─────────────────────────────────────────────────────────────────
606
+ # PUBLIC ORCHESTRATOR
607
+ # ─────────────────────────────────────────────────────────────────
608
+
609
+ def verify_receipt(
610
+ receipt: Receipt,
611
+ *,
612
+ catalogue: Optional[Catalogue] = None,
613
+ indexer_client: Optional[Any] = None,
614
+ skip_anchor_check: bool = False,
615
+ skip_timestamp_check: bool = False,
616
+ skip_catalogue_check: bool = False,
617
+ ) -> VerificationResult:
618
+ """Verify a receipt end-to-end.
619
+
620
+ Runs the six checks in order, collecting all results. Does NOT short-
621
+ circuit on a failed check; the caller sees the full breakdown.
622
+
623
+ Args:
624
+ receipt: The receipt to verify.
625
+ catalogue: Optional pre-loaded ``Catalogue``. If ``None`` (or
626
+ ``skip_catalogue_check=True``), the catalogue_conformance check
627
+ is skipped. The caller is responsible for loading the catalogue
628
+ at the git commit named in ``receipt.manifest.catalogue.git_commit``.
629
+ indexer_client: Optional ``algosdk.v2client.indexer.IndexerClient``.
630
+ If ``None``, a default Algonode public indexer is used based on
631
+ the receipt's ``network`` field. If the receipt is a draft
632
+ (no txid), the on-chain check is skipped regardless.
633
+ skip_anchor_check: Explicitly skip the on-chain anchor check.
634
+ skip_timestamp_check: Explicitly skip the timestamp signature check.
635
+ skip_catalogue_check: Explicitly skip the catalogue conformance
636
+ check (alternative to passing ``catalogue=None``).
637
+
638
+ Returns:
639
+ ``VerificationResult`` with per-check status and an overall ``ok``.
640
+
641
+ Raises:
642
+ VerificationError: Only for unrecoverable structural issues; failed
643
+ checks are reported in the result, never raised.
644
+ """
645
+ checks: list[CheckResult] = []
646
+
647
+ checks.append(_check_receipt_profile(receipt))
648
+ checks.append(_check_manifest_hash(receipt))
649
+ checks.append(_check_note_payload_reproducible(receipt))
650
+
651
+ if skip_catalogue_check:
652
+ checks.append(CheckResult(
653
+ name="catalogue_conformance",
654
+ status=CheckStatus.SKIP,
655
+ detail="Explicitly skipped via skip_catalogue_check=True.",
656
+ ))
657
+ else:
658
+ checks.append(_check_catalogue_conformance(receipt, catalogue))
659
+
660
+ if skip_anchor_check:
661
+ checks.append(CheckResult(
662
+ name="anchor_on_chain",
663
+ status=CheckStatus.SKIP,
664
+ detail="Explicitly skipped via skip_anchor_check=True.",
665
+ ))
666
+ else:
667
+ checks.append(_check_anchor_on_chain(receipt, indexer_client))
668
+
669
+ if skip_timestamp_check:
670
+ checks.append(CheckResult(
671
+ name="timestamp_signature",
672
+ status=CheckStatus.SKIP,
673
+ detail="Explicitly skipped via skip_timestamp_check=True.",
674
+ ))
675
+ else:
676
+ checks.append(_check_timestamp_signature(receipt))
677
+
678
+ ok = all(c.ok for c in checks)
679
+ return VerificationResult(
680
+ receipt=receipt,
681
+ checks=tuple(checks),
682
+ ok=ok,
683
+ )