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/receipt.py
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Deyan Paroushev
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
Receipt: the artifact that travels outside the issuing platform.
|
|
5
|
+
|
|
6
|
+
A receipt wraps everything an independent verifier needs to confirm a
|
|
7
|
+
commitment was made and anchored: the canonical manifest, its hash, the
|
|
8
|
+
on-chain anchor record (network, txid, block round, the ARC-2 note payload),
|
|
9
|
+
the RFC 3161 trusted timestamp token, and the two profile discriminators
|
|
10
|
+
(``receipt_profile`` and ``batching_profile``). The receipt is the file
|
|
11
|
+
recipients receive, the file gets attached to compliance reports, the file
|
|
12
|
+
ends up in auditors' archives.
|
|
13
|
+
|
|
14
|
+
Two artifacts
|
|
15
|
+
-------------
|
|
16
|
+
|
|
17
|
+
This module models two distinct files an issuer produces per commitment:
|
|
18
|
+
|
|
19
|
+
1. ``Receipt`` - the **public** artifact. Contains no plaintext emails or
|
|
20
|
+
internal identifiers. The manifest already uses hashed emails for
|
|
21
|
+
recipients; the receipt inherits that property. Safe to publish, attach
|
|
22
|
+
to outgoing email, or stash on a public webpage.
|
|
23
|
+
|
|
24
|
+
2. ``IssuerEvidence`` - the **private** addendum the issuer keeps locally.
|
|
25
|
+
Carries the plaintext-to-hash mapping for recipient emails (so the
|
|
26
|
+
issuer can later identify "which auditor was on this anchor?") plus any
|
|
27
|
+
internal user identifiers. Linked to its public receipt by
|
|
28
|
+
``manifest_hash``.
|
|
29
|
+
|
|
30
|
+
The two files are stored separately. The library reads and writes each
|
|
31
|
+
independently; the issuer's workflow is "anchor → write receipt JSON →
|
|
32
|
+
write issuer evidence JSON" as two operations.
|
|
33
|
+
|
|
34
|
+
Verification flow (implemented in v0.0.9)
|
|
35
|
+
-----------------------------------------
|
|
36
|
+
|
|
37
|
+
A verifier holding a receipt JSON file performs:
|
|
38
|
+
|
|
39
|
+
1. Read the receipt with ``read_receipt(path)``.
|
|
40
|
+
2. Recompute ``manifest_hash`` from ``receipt.manifest`` via
|
|
41
|
+
``hash_manifest_hex(manifest)``. Compare to ``receipt.manifest_hash``.
|
|
42
|
+
This is the JCS-canonical hash check.
|
|
43
|
+
3. Validate the manifest against the catalogue at the pinned
|
|
44
|
+
``catalogue_git_commit`` (the verifier fetches the catalogue at that
|
|
45
|
+
commit).
|
|
46
|
+
4. Look up ``receipt.anchor.txid`` on ``receipt.anchor.network``. Fetch
|
|
47
|
+
the transaction's note bytes. Strip the ``actproof:j`` ARC-2 prefix.
|
|
48
|
+
Compare to the base64-decoded ``receipt.anchor.note_payload_b64``.
|
|
49
|
+
5. Verify ``receipt.trusted_timestamp.token_b64`` is a valid RFC 3161
|
|
50
|
+
token issued by the named TSA, whose imprint equals
|
|
51
|
+
``receipt.manifest_hash``.
|
|
52
|
+
|
|
53
|
+
The verifier returns a structured ``VerificationResult`` (lands in v0.0.9).
|
|
54
|
+
This module is concerned with reading, writing, and structurally validating
|
|
55
|
+
receipts; the cryptographic verification lives elsewhere.
|
|
56
|
+
|
|
57
|
+
Reserved forward-compat slots
|
|
58
|
+
-----------------------------
|
|
59
|
+
|
|
60
|
+
The ``receipt_profile`` field is a discriminator. The v1 value
|
|
61
|
+
``"actproof-jcs-v1"`` declares the JSON canonical layout this module
|
|
62
|
+
implements. A future v2 will introduce ``"actproof-scitt-cose-v2"`` once
|
|
63
|
+
RFC 9943 (SCITT Receipts) leaves AUTH48 and publishes. That format will
|
|
64
|
+
add a ``cose_sign1_b64`` field carrying a COSE_Sign1 signature over the
|
|
65
|
+
manifest hash, and a ``scitt_transparent_statement`` pointer to a SCITT
|
|
66
|
+
transparency service. Those fields are not present in v1 receipts; the
|
|
67
|
+
``receipt_profile`` discriminator lets verifiers route to the right parser.
|
|
68
|
+
|
|
69
|
+
JSON file format
|
|
70
|
+
----------------
|
|
71
|
+
|
|
72
|
+
Receipts are written as pretty-printed JSON (2-space indent) for human
|
|
73
|
+
readability. The verifier does NOT depend on receipt-file formatting:
|
|
74
|
+
the manifest within the receipt is re-canonicalised before hashing, so
|
|
75
|
+
whitespace in the receipt file is meaningless. Only the canonical manifest
|
|
76
|
+
bytes (computed by the verifier) feed into the hash check.
|
|
77
|
+
|
|
78
|
+
API
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
* ``build_receipt(*, manifest, anchor, trusted_timestamp, ...) -> Receipt``
|
|
82
|
+
* ``receipt_to_dict(r) -> dict``
|
|
83
|
+
* ``receipt_from_dict(d) -> Receipt``
|
|
84
|
+
* ``read_receipt(path) -> Receipt``
|
|
85
|
+
* ``write_receipt(path, receipt) -> None``
|
|
86
|
+
* ``build_issuer_evidence(*, manifest_hash, ...) -> IssuerEvidence``
|
|
87
|
+
* ``issuer_evidence_to_dict(ev) -> dict``
|
|
88
|
+
* ``issuer_evidence_from_dict(d) -> IssuerEvidence``
|
|
89
|
+
* ``read_issuer_evidence(path) -> IssuerEvidence``
|
|
90
|
+
* ``write_issuer_evidence(path, evidence) -> None``
|
|
91
|
+
|
|
92
|
+
Exceptions
|
|
93
|
+
~~~~~~~~~~
|
|
94
|
+
|
|
95
|
+
* ``ReceiptError`` - raised on I/O or parse problems.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
from __future__ import annotations
|
|
99
|
+
|
|
100
|
+
import json
|
|
101
|
+
from dataclasses import dataclass
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
104
|
+
|
|
105
|
+
from actproof.manifest import (
|
|
106
|
+
BATCHING_PROFILE_SINGLE,
|
|
107
|
+
RECEIPT_PROFILE_V1,
|
|
108
|
+
Manifest,
|
|
109
|
+
hash_manifest_hex,
|
|
110
|
+
manifest_from_dict,
|
|
111
|
+
manifest_to_dict,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"AnchorRecord",
|
|
116
|
+
"TimestampToken",
|
|
117
|
+
"Receipt",
|
|
118
|
+
"PlaintextRecipient",
|
|
119
|
+
"IssuerEvidence",
|
|
120
|
+
"ReceiptError",
|
|
121
|
+
"build_receipt",
|
|
122
|
+
"receipt_to_dict",
|
|
123
|
+
"receipt_from_dict",
|
|
124
|
+
"read_receipt",
|
|
125
|
+
"write_receipt",
|
|
126
|
+
"build_issuer_evidence",
|
|
127
|
+
"issuer_evidence_to_dict",
|
|
128
|
+
"issuer_evidence_from_dict",
|
|
129
|
+
"read_issuer_evidence",
|
|
130
|
+
"write_issuer_evidence",
|
|
131
|
+
"ALGORAND_MAINNET",
|
|
132
|
+
"ALGORAND_TESTNET",
|
|
133
|
+
"ALGORAND_BETANET",
|
|
134
|
+
"ARC2_NOTE_FORMAT",
|
|
135
|
+
"ARC2_DAPP_NAME",
|
|
136
|
+
"ARC2_FORMAT_VERSION_JSON",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ─────────────────────────────────────────────────────────────────
|
|
141
|
+
# CONSTANTS
|
|
142
|
+
# ─────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
ALGORAND_MAINNET: str = "algorand-mainnet"
|
|
145
|
+
ALGORAND_TESTNET: str = "algorand-testnet"
|
|
146
|
+
ALGORAND_BETANET: str = "algorand-betanet"
|
|
147
|
+
|
|
148
|
+
_VALID_NETWORKS: frozenset[str] = frozenset(
|
|
149
|
+
{ALGORAND_MAINNET, ALGORAND_TESTNET, ALGORAND_BETANET}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
ARC2_NOTE_FORMAT: str = "arc-2"
|
|
153
|
+
"""The note format identifier per Algorand ARC-2."""
|
|
154
|
+
|
|
155
|
+
ARC2_DAPP_NAME: str = "actproof"
|
|
156
|
+
"""The dapp name actproof uses in the ARC-2 note prefix."""
|
|
157
|
+
|
|
158
|
+
ARC2_FORMAT_VERSION_JSON: str = "j"
|
|
159
|
+
"""The ARC-2 format version for disclosed-mode JSON payloads."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ─────────────────────────────────────────────────────────────────
|
|
163
|
+
# EXCEPTIONS
|
|
164
|
+
# ─────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
class ReceiptError(ValueError):
|
|
167
|
+
"""Raised when a receipt cannot be read, parsed, or has structural problems.
|
|
168
|
+
|
|
169
|
+
Subclass of ``ValueError`` so callers can catch ``ValueError`` for unified
|
|
170
|
+
handling, or ``ReceiptError`` specifically.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ─────────────────────────────────────────────────────────────────
|
|
175
|
+
# DATA CLASSES
|
|
176
|
+
# ─────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class AnchorRecord:
|
|
180
|
+
"""The on-chain anchor commitment for one manifest.
|
|
181
|
+
|
|
182
|
+
Carries everything a verifier needs to look up the transaction on the
|
|
183
|
+
right network and confirm the note bytes contain the expected manifest
|
|
184
|
+
hash. For unanchored draft receipts, ``block_round`` and ``confirmed_at``
|
|
185
|
+
are ``None`` and ``txid`` may be empty.
|
|
186
|
+
|
|
187
|
+
Attributes:
|
|
188
|
+
network: One of ``"algorand-mainnet"``, ``"algorand-testnet"``,
|
|
189
|
+
``"algorand-betanet"``. Module-level constants
|
|
190
|
+
``ALGORAND_MAINNET`` etc. spell these out.
|
|
191
|
+
txid: 52-character base32 Algorand transaction ID. Empty for draft
|
|
192
|
+
receipts (anchor not yet submitted).
|
|
193
|
+
block_round: Algorand round in which the transaction confirmed,
|
|
194
|
+
or ``None`` if not yet confirmed.
|
|
195
|
+
confirmed_at: ISO 8601 UTC timestamp of confirmation, or ``None``.
|
|
196
|
+
note_format: ARC-2 note format identifier. Always ``"arc-2"`` in v1.
|
|
197
|
+
note_dapp_name: ARC-2 dapp name prefix. Always ``"actproof"`` in v1.
|
|
198
|
+
note_format_version: ARC-2 format version. Always ``"j"`` (JSON
|
|
199
|
+
disclosed mode) in v1.
|
|
200
|
+
note_payload_b64: Base64 (standard, with padding) encoding of the
|
|
201
|
+
note payload bytes - the part AFTER the ARC-2 prefix
|
|
202
|
+
``"actproof:j"``. Storing this lets a verifier reconstruct the
|
|
203
|
+
full on-chain note for byte-comparison without re-deriving from
|
|
204
|
+
the manifest hash.
|
|
205
|
+
"""
|
|
206
|
+
network: str
|
|
207
|
+
txid: str
|
|
208
|
+
block_round: Optional[int]
|
|
209
|
+
confirmed_at: Optional[str]
|
|
210
|
+
note_format: str
|
|
211
|
+
note_dapp_name: str
|
|
212
|
+
note_format_version: str
|
|
213
|
+
note_payload_b64: str
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class TimestampToken:
|
|
218
|
+
"""An RFC 3161 trusted timestamp token over the manifest hash.
|
|
219
|
+
|
|
220
|
+
The TSA's signed assertion that the manifest hash existed at a given
|
|
221
|
+
moment, vouched for by a certified TSA whose certificate chain anchors
|
|
222
|
+
in a trust list (EU Trust List, Adobe AATL, etc.). Storing the raw
|
|
223
|
+
DER-encoded token bytes lets any verifier re-check the signature.
|
|
224
|
+
|
|
225
|
+
Attributes:
|
|
226
|
+
tsa_url: HTTP(S) URL of the TSA the token came from. Useful for
|
|
227
|
+
diagnostics but NOT load-bearing for verification (the TSA
|
|
228
|
+
identity is inside the token itself).
|
|
229
|
+
tsa_name: Human-readable TSA name (e.g. ``"QuoVadis EU"``).
|
|
230
|
+
token_b64: Base64 (standard, with padding) of the DER-encoded
|
|
231
|
+
RFC 3161 TSToken. Load-bearing - this is what gets verified.
|
|
232
|
+
policy_oid: Policy OID under which the TSA issued the token. May
|
|
233
|
+
be ``None`` if the TSA did not assert a policy.
|
|
234
|
+
hash_alg: Hash algorithm used for the timestamp imprint, lowercase
|
|
235
|
+
(e.g. ``"sha-256"``). Must match the algorithm used to compute
|
|
236
|
+
the manifest hash.
|
|
237
|
+
imprint_hex: Lowercase hex of the imprint (typically the
|
|
238
|
+
``manifest_hash`` raw bytes). The verifier checks this matches
|
|
239
|
+
the receipt's ``manifest_hash`` value.
|
|
240
|
+
timestamp: ISO 8601 UTC timestamp extracted from the token for
|
|
241
|
+
convenience. Authoritative value is inside ``token_b64``.
|
|
242
|
+
"""
|
|
243
|
+
tsa_url: str
|
|
244
|
+
tsa_name: str
|
|
245
|
+
token_b64: str
|
|
246
|
+
policy_oid: Optional[str]
|
|
247
|
+
hash_alg: str
|
|
248
|
+
imprint_hex: str
|
|
249
|
+
timestamp: str
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class Receipt:
|
|
254
|
+
"""A public actproof receipt. Contains no plaintext PII.
|
|
255
|
+
|
|
256
|
+
Bundles the canonical manifest, its content hash, the on-chain anchor
|
|
257
|
+
record, the RFC 3161 timestamp token, and the profile discriminators
|
|
258
|
+
that tell a verifier how to parse what it is reading.
|
|
259
|
+
|
|
260
|
+
Attributes:
|
|
261
|
+
receipt_profile: Discriminator for receipt layout. v1 is
|
|
262
|
+
``"actproof-jcs-v1"``. The constant ``RECEIPT_PROFILE_V1``
|
|
263
|
+
from ``actproof.manifest`` spells this out.
|
|
264
|
+
issued_at: ISO 8601 UTC timestamp when the receipt was produced.
|
|
265
|
+
Typically equals ``manifest.issued_at``.
|
|
266
|
+
manifest: The full ``Manifest`` object. The verifier re-canonicalises
|
|
267
|
+
this to recompute the hash check.
|
|
268
|
+
manifest_hash: ``"sha256:..."`` of the canonical manifest bytes.
|
|
269
|
+
Cached at receipt-issue time and stored explicitly so verifiers
|
|
270
|
+
can spot manifest-substitution attacks at a glance.
|
|
271
|
+
anchor: On-chain anchor record.
|
|
272
|
+
trusted_timestamp: RFC 3161 token over the manifest hash.
|
|
273
|
+
batching_profile: ``"single_attestation_anchor_v1"`` for v1. The
|
|
274
|
+
constant ``BATCHING_PROFILE_SINGLE`` from ``actproof.manifest``
|
|
275
|
+
spells this out.
|
|
276
|
+
"""
|
|
277
|
+
receipt_profile: str
|
|
278
|
+
issued_at: str
|
|
279
|
+
manifest: Manifest
|
|
280
|
+
manifest_hash: str
|
|
281
|
+
anchor: AnchorRecord
|
|
282
|
+
trusted_timestamp: TimestampToken
|
|
283
|
+
batching_profile: str
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class PlaintextRecipient:
|
|
288
|
+
"""A recipient's plaintext email paired with the hash carried in the manifest.
|
|
289
|
+
|
|
290
|
+
Stored only inside ``IssuerEvidence``, never inside a public ``Receipt``.
|
|
291
|
+
|
|
292
|
+
Attributes:
|
|
293
|
+
email_plaintext: The recipient's email address as the issuer entered it.
|
|
294
|
+
email_hash: ``"sha256:..."`` computed via ``hash_email(email_plaintext)``.
|
|
295
|
+
Must equal one of the ``email_hash`` values in the corresponding
|
|
296
|
+
manifest's ``recipients`` list.
|
|
297
|
+
"""
|
|
298
|
+
email_plaintext: str
|
|
299
|
+
email_hash: str
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass(frozen=True)
|
|
303
|
+
class IssuerEvidence:
|
|
304
|
+
"""The issuer's private addendum to a public receipt.
|
|
305
|
+
|
|
306
|
+
Carries plaintext recipient emails (so the issuer can later answer
|
|
307
|
+
"which auditor received this anchor?") and any internal user
|
|
308
|
+
identifiers. Linked to its public receipt by ``manifest_hash``.
|
|
309
|
+
|
|
310
|
+
NOT distributed. Lives in the issuer's vault, document management
|
|
311
|
+
system, or compliance archive. If the issuer is later asked by an
|
|
312
|
+
auditor "show me the original recipient list," they pull the
|
|
313
|
+
``IssuerEvidence`` file from local storage.
|
|
314
|
+
|
|
315
|
+
Attributes:
|
|
316
|
+
receipt_profile: Same value as the linked ``Receipt.receipt_profile``,
|
|
317
|
+
so verifiers can confirm the addendum matches the receipt's profile.
|
|
318
|
+
manifest_hash: Same value as the linked ``Receipt.manifest_hash``.
|
|
319
|
+
This is what binds the addendum to the receipt.
|
|
320
|
+
plaintext_recipients: Tuple of plaintext-to-hash mappings, one per
|
|
321
|
+
recipient. Each ``email_hash`` MUST be present in the linked
|
|
322
|
+
manifest's recipients list (the verifier can check this).
|
|
323
|
+
issuer_user_id: Optional internal user identifier (Auth0 sub,
|
|
324
|
+
internal SSO ID, etc.) of the person at the issuing organisation
|
|
325
|
+
who actually clicked "anchor". Useful for internal audit; never
|
|
326
|
+
published.
|
|
327
|
+
internal_notes: Optional free-form text the issuer attaches at
|
|
328
|
+
anchor time (e.g. "reviewed by legal team 2026-05-13",
|
|
329
|
+
internal case reference).
|
|
330
|
+
"""
|
|
331
|
+
receipt_profile: str
|
|
332
|
+
manifest_hash: str
|
|
333
|
+
plaintext_recipients: tuple[PlaintextRecipient, ...]
|
|
334
|
+
issuer_user_id: Optional[str]
|
|
335
|
+
internal_notes: Optional[str]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ─────────────────────────────────────────────────────────────────
|
|
339
|
+
# CONSTRUCTION
|
|
340
|
+
# ─────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def build_receipt(
|
|
343
|
+
*,
|
|
344
|
+
manifest: Manifest,
|
|
345
|
+
anchor: AnchorRecord,
|
|
346
|
+
trusted_timestamp: TimestampToken,
|
|
347
|
+
issued_at: Optional[str] = None,
|
|
348
|
+
receipt_profile: str = RECEIPT_PROFILE_V1,
|
|
349
|
+
batching_profile: str = BATCHING_PROFILE_SINGLE,
|
|
350
|
+
) -> Receipt:
|
|
351
|
+
"""Construct a ``Receipt`` from a manifest plus anchor and timestamp.
|
|
352
|
+
|
|
353
|
+
Computes ``manifest_hash`` from the canonical manifest bytes. Defaults
|
|
354
|
+
``issued_at`` to the manifest's ``issued_at`` value if not provided.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
manifest: The committed manifest.
|
|
358
|
+
anchor: The on-chain anchor record.
|
|
359
|
+
trusted_timestamp: The RFC 3161 timestamp token.
|
|
360
|
+
issued_at: When the receipt was produced. Defaults to
|
|
361
|
+
``manifest.issued_at``.
|
|
362
|
+
receipt_profile: Receipt layout discriminator. Defaults to v1.
|
|
363
|
+
batching_profile: Batching discriminator. Defaults to single.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
A populated ``Receipt`` instance.
|
|
367
|
+
"""
|
|
368
|
+
if issued_at is None:
|
|
369
|
+
issued_at = manifest.issued_at
|
|
370
|
+
|
|
371
|
+
manifest_hash = "sha256:" + hash_manifest_hex(manifest)
|
|
372
|
+
|
|
373
|
+
return Receipt(
|
|
374
|
+
receipt_profile=receipt_profile,
|
|
375
|
+
issued_at=issued_at,
|
|
376
|
+
manifest=manifest,
|
|
377
|
+
manifest_hash=manifest_hash,
|
|
378
|
+
anchor=anchor,
|
|
379
|
+
trusted_timestamp=trusted_timestamp,
|
|
380
|
+
batching_profile=batching_profile,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def build_issuer_evidence(
|
|
385
|
+
*,
|
|
386
|
+
manifest_hash: str,
|
|
387
|
+
plaintext_recipients: Sequence[PlaintextRecipient] = (),
|
|
388
|
+
issuer_user_id: Optional[str] = None,
|
|
389
|
+
internal_notes: Optional[str] = None,
|
|
390
|
+
receipt_profile: str = RECEIPT_PROFILE_V1,
|
|
391
|
+
) -> IssuerEvidence:
|
|
392
|
+
"""Construct an ``IssuerEvidence`` addendum linked to a public receipt.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
manifest_hash: ``"sha256:..."`` of the public receipt's manifest.
|
|
396
|
+
Must match the linked ``Receipt.manifest_hash``.
|
|
397
|
+
plaintext_recipients: Plaintext-to-hash mappings for recipients.
|
|
398
|
+
issuer_user_id: Optional internal user identifier.
|
|
399
|
+
internal_notes: Optional free-form notes.
|
|
400
|
+
receipt_profile: Receipt profile discriminator (must match the
|
|
401
|
+
linked receipt's). Defaults to v1.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
A populated ``IssuerEvidence`` instance.
|
|
405
|
+
"""
|
|
406
|
+
return IssuerEvidence(
|
|
407
|
+
receipt_profile=receipt_profile,
|
|
408
|
+
manifest_hash=manifest_hash,
|
|
409
|
+
plaintext_recipients=tuple(plaintext_recipients),
|
|
410
|
+
issuer_user_id=issuer_user_id,
|
|
411
|
+
internal_notes=internal_notes,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ─────────────────────────────────────────────────────────────────
|
|
416
|
+
# SERIALISATION: AnchorRecord, TimestampToken
|
|
417
|
+
# ─────────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
def _anchor_to_dict(a: AnchorRecord) -> dict[str, Any]:
|
|
420
|
+
return {
|
|
421
|
+
"network": a.network,
|
|
422
|
+
"txid": a.txid,
|
|
423
|
+
"block_round": a.block_round,
|
|
424
|
+
"confirmed_at": a.confirmed_at,
|
|
425
|
+
"note_format": a.note_format,
|
|
426
|
+
"note_dapp_name": a.note_dapp_name,
|
|
427
|
+
"note_format_version": a.note_format_version,
|
|
428
|
+
"note_payload_b64": a.note_payload_b64,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _anchor_from_dict(d: Mapping[str, Any]) -> AnchorRecord:
|
|
433
|
+
try:
|
|
434
|
+
return AnchorRecord(
|
|
435
|
+
network=d["network"],
|
|
436
|
+
txid=d["txid"],
|
|
437
|
+
block_round=d["block_round"],
|
|
438
|
+
confirmed_at=d["confirmed_at"],
|
|
439
|
+
note_format=d["note_format"],
|
|
440
|
+
note_dapp_name=d["note_dapp_name"],
|
|
441
|
+
note_format_version=d["note_format_version"],
|
|
442
|
+
note_payload_b64=d["note_payload_b64"],
|
|
443
|
+
)
|
|
444
|
+
except (KeyError, TypeError, AttributeError) as exc:
|
|
445
|
+
raise ReceiptError(f"Cannot parse anchor record: {exc}") from exc
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _timestamp_to_dict(t: TimestampToken) -> dict[str, Any]:
|
|
449
|
+
return {
|
|
450
|
+
"tsa_url": t.tsa_url,
|
|
451
|
+
"tsa_name": t.tsa_name,
|
|
452
|
+
"token_b64": t.token_b64,
|
|
453
|
+
"policy_oid": t.policy_oid,
|
|
454
|
+
"hash_alg": t.hash_alg,
|
|
455
|
+
"imprint_hex": t.imprint_hex,
|
|
456
|
+
"timestamp": t.timestamp,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _timestamp_from_dict(d: Mapping[str, Any]) -> TimestampToken:
|
|
461
|
+
try:
|
|
462
|
+
return TimestampToken(
|
|
463
|
+
tsa_url=d["tsa_url"],
|
|
464
|
+
tsa_name=d["tsa_name"],
|
|
465
|
+
token_b64=d["token_b64"],
|
|
466
|
+
policy_oid=d["policy_oid"],
|
|
467
|
+
hash_alg=d["hash_alg"],
|
|
468
|
+
imprint_hex=d["imprint_hex"],
|
|
469
|
+
timestamp=d["timestamp"],
|
|
470
|
+
)
|
|
471
|
+
except (KeyError, TypeError, AttributeError) as exc:
|
|
472
|
+
raise ReceiptError(f"Cannot parse timestamp token: {exc}") from exc
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# ─────────────────────────────────────────────────────────────────
|
|
476
|
+
# SERIALISATION: Receipt
|
|
477
|
+
# ─────────────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
def receipt_to_dict(r: Receipt) -> dict[str, Any]:
|
|
480
|
+
"""Convert a ``Receipt`` to a plain dict for JSON serialisation.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
r: The receipt to serialise.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
A nested dict with all receipt fields.
|
|
487
|
+
"""
|
|
488
|
+
return {
|
|
489
|
+
"receipt_profile": r.receipt_profile,
|
|
490
|
+
"issued_at": r.issued_at,
|
|
491
|
+
"manifest": manifest_to_dict(r.manifest),
|
|
492
|
+
"manifest_hash": r.manifest_hash,
|
|
493
|
+
"anchor": _anchor_to_dict(r.anchor),
|
|
494
|
+
"trusted_timestamp": _timestamp_to_dict(r.trusted_timestamp),
|
|
495
|
+
"batching_profile": r.batching_profile,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def receipt_from_dict(d: Mapping[str, Any]) -> Receipt:
|
|
500
|
+
"""Parse a dict back into a ``Receipt``.
|
|
501
|
+
|
|
502
|
+
Used when reading a receipt from disk or from an external source.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
d: A dict matching the receipt JSON layout.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
A reconstructed ``Receipt``.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
ReceiptError: If a required field is missing or has the wrong type.
|
|
512
|
+
"""
|
|
513
|
+
try:
|
|
514
|
+
return Receipt(
|
|
515
|
+
receipt_profile=d["receipt_profile"],
|
|
516
|
+
issued_at=d["issued_at"],
|
|
517
|
+
manifest=manifest_from_dict(d["manifest"]),
|
|
518
|
+
manifest_hash=d["manifest_hash"],
|
|
519
|
+
anchor=_anchor_from_dict(d["anchor"]),
|
|
520
|
+
trusted_timestamp=_timestamp_from_dict(d["trusted_timestamp"]),
|
|
521
|
+
batching_profile=d["batching_profile"],
|
|
522
|
+
)
|
|
523
|
+
except (KeyError, TypeError, AttributeError) as exc:
|
|
524
|
+
raise ReceiptError(f"Cannot parse receipt: {exc}") from exc
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ─────────────────────────────────────────────────────────────────
|
|
528
|
+
# SERIALISATION: IssuerEvidence
|
|
529
|
+
# ─────────────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
def issuer_evidence_to_dict(ev: IssuerEvidence) -> dict[str, Any]:
|
|
532
|
+
"""Convert an ``IssuerEvidence`` to a plain dict for JSON serialisation.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
ev: The evidence to serialise.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
A nested dict.
|
|
539
|
+
"""
|
|
540
|
+
return {
|
|
541
|
+
"receipt_profile": ev.receipt_profile,
|
|
542
|
+
"manifest_hash": ev.manifest_hash,
|
|
543
|
+
"plaintext_recipients": [
|
|
544
|
+
{
|
|
545
|
+
"email_plaintext": p.email_plaintext,
|
|
546
|
+
"email_hash": p.email_hash,
|
|
547
|
+
}
|
|
548
|
+
for p in ev.plaintext_recipients
|
|
549
|
+
],
|
|
550
|
+
"issuer_user_id": ev.issuer_user_id,
|
|
551
|
+
"internal_notes": ev.internal_notes,
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def issuer_evidence_from_dict(d: Mapping[str, Any]) -> IssuerEvidence:
|
|
556
|
+
"""Parse a dict back into an ``IssuerEvidence``.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
d: A dict matching the issuer evidence JSON layout.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
A reconstructed ``IssuerEvidence``.
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
ReceiptError: If a required field is missing or has the wrong type.
|
|
566
|
+
"""
|
|
567
|
+
try:
|
|
568
|
+
recipients = tuple(
|
|
569
|
+
PlaintextRecipient(
|
|
570
|
+
email_plaintext=p["email_plaintext"],
|
|
571
|
+
email_hash=p["email_hash"],
|
|
572
|
+
)
|
|
573
|
+
for p in d.get("plaintext_recipients", [])
|
|
574
|
+
)
|
|
575
|
+
return IssuerEvidence(
|
|
576
|
+
receipt_profile=d["receipt_profile"],
|
|
577
|
+
manifest_hash=d["manifest_hash"],
|
|
578
|
+
plaintext_recipients=recipients,
|
|
579
|
+
issuer_user_id=d.get("issuer_user_id"),
|
|
580
|
+
internal_notes=d.get("internal_notes"),
|
|
581
|
+
)
|
|
582
|
+
except (KeyError, TypeError, AttributeError) as exc:
|
|
583
|
+
raise ReceiptError(f"Cannot parse issuer evidence: {exc}") from exc
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ─────────────────────────────────────────────────────────────────
|
|
587
|
+
# FILESYSTEM I/O
|
|
588
|
+
# ─────────────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
def read_receipt(path: Path) -> Receipt:
|
|
591
|
+
"""Read a receipt from a JSON file on disk.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
path: Path to the receipt JSON file.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
The parsed ``Receipt``.
|
|
598
|
+
|
|
599
|
+
Raises:
|
|
600
|
+
ReceiptError: If the file cannot be read, parsed as JSON, or
|
|
601
|
+
interpreted as a receipt.
|
|
602
|
+
"""
|
|
603
|
+
try:
|
|
604
|
+
raw_bytes = path.read_bytes()
|
|
605
|
+
except OSError as exc:
|
|
606
|
+
raise ReceiptError(f"Cannot read receipt file {path}: {exc}") from exc
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
data = json.loads(raw_bytes)
|
|
610
|
+
except json.JSONDecodeError as exc:
|
|
611
|
+
raise ReceiptError(f"Receipt file {path} is not valid JSON: {exc}") from exc
|
|
612
|
+
|
|
613
|
+
return receipt_from_dict(data)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def write_receipt(path: Path, receipt: Receipt) -> None:
|
|
617
|
+
"""Write a receipt to disk as pretty-printed JSON.
|
|
618
|
+
|
|
619
|
+
The 2-space indent makes the file human-readable; the verifier does NOT
|
|
620
|
+
depend on file formatting (the manifest within is re-canonicalised
|
|
621
|
+
before hashing).
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
path: Destination file path.
|
|
625
|
+
receipt: The receipt to write.
|
|
626
|
+
|
|
627
|
+
Raises:
|
|
628
|
+
ReceiptError: If the file cannot be written.
|
|
629
|
+
"""
|
|
630
|
+
try:
|
|
631
|
+
data = receipt_to_dict(receipt)
|
|
632
|
+
text = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
|
|
633
|
+
path.write_text(text + "\n", encoding="utf-8")
|
|
634
|
+
except OSError as exc:
|
|
635
|
+
raise ReceiptError(f"Cannot write receipt to {path}: {exc}") from exc
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def read_issuer_evidence(path: Path) -> IssuerEvidence:
|
|
639
|
+
"""Read an issuer evidence addendum from disk.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
path: Path to the issuer evidence JSON file.
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
The parsed ``IssuerEvidence``.
|
|
646
|
+
|
|
647
|
+
Raises:
|
|
648
|
+
ReceiptError: If the file cannot be read or parsed.
|
|
649
|
+
"""
|
|
650
|
+
try:
|
|
651
|
+
raw_bytes = path.read_bytes()
|
|
652
|
+
except OSError as exc:
|
|
653
|
+
raise ReceiptError(f"Cannot read evidence file {path}: {exc}") from exc
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
data = json.loads(raw_bytes)
|
|
657
|
+
except json.JSONDecodeError as exc:
|
|
658
|
+
raise ReceiptError(f"Evidence file {path} is not valid JSON: {exc}") from exc
|
|
659
|
+
|
|
660
|
+
return issuer_evidence_from_dict(data)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def write_issuer_evidence(path: Path, evidence: IssuerEvidence) -> None:
|
|
664
|
+
"""Write issuer evidence to disk as pretty-printed JSON.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
path: Destination file path.
|
|
668
|
+
evidence: The evidence to write.
|
|
669
|
+
|
|
670
|
+
Raises:
|
|
671
|
+
ReceiptError: If the file cannot be written.
|
|
672
|
+
"""
|
|
673
|
+
try:
|
|
674
|
+
data = issuer_evidence_to_dict(evidence)
|
|
675
|
+
text = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
|
|
676
|
+
path.write_text(text + "\n", encoding="utf-8")
|
|
677
|
+
except OSError as exc:
|
|
678
|
+
raise ReceiptError(f"Cannot write evidence to {path}: {exc}") from exc
|