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/__init__.py +234 -0
- actproof/anchor.py +586 -0
- actproof/canonical.py +369 -0
- actproof/catalogue.py +1031 -0
- actproof/cli.py +593 -0
- actproof/manifest.py +728 -0
- actproof/receipt.py +678 -0
- actproof/signers/__init__.py +89 -0
- actproof/signers/google_kms.py +392 -0
- actproof/signers/interface.py +298 -0
- actproof/signers/mnemonic.py +153 -0
- actproof/timestamp.py +527 -0
- actproof/verify.py +683 -0
- actproof-0.2.0.dist-info/METADATA +295 -0
- actproof-0.2.0.dist-info/RECORD +18 -0
- actproof-0.2.0.dist-info/WHEEL +4 -0
- actproof-0.2.0.dist-info/entry_points.txt +2 -0
- actproof-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|