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/manifest.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Deyan Paroushev
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
Canonical manifest envelope for actproof.
|
|
5
|
+
|
|
6
|
+
A manifest is the structured commitment an issuer makes. It declares which
|
|
7
|
+
catalogue entry it implements, who the issuer is, what is being claimed, what
|
|
8
|
+
evidence files back the claim, and which recipients are designated witnesses.
|
|
9
|
+
The manifest's canonical bytes (via RFC 8785 JCS) produce the hash that gets
|
|
10
|
+
anchored on the public ledger and timestamped by the QTSP. That hash is the
|
|
11
|
+
load-bearing artifact: identical input bytes must always produce identical
|
|
12
|
+
canonical bytes, on any platform, in any year, forever.
|
|
13
|
+
|
|
14
|
+
Wire shape
|
|
15
|
+
----------
|
|
16
|
+
|
|
17
|
+
When canonicalized, a manifest produces JSON of this shape:
|
|
18
|
+
|
|
19
|
+
::
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
"batching_profile": "single_attestation_anchor_v1",
|
|
23
|
+
"catalogue": {
|
|
24
|
+
"act_type_id": "op:eu.nis2.art20.management_body_approval.v1",
|
|
25
|
+
"entry_hash": "sha256:abc...",
|
|
26
|
+
"entry_version": 1,
|
|
27
|
+
"git_commit": "0123abcd...",
|
|
28
|
+
"schema_hash": "sha256:def...",
|
|
29
|
+
"source_uri": "https://github.com/deyan-paroushev/actproof-events"
|
|
30
|
+
},
|
|
31
|
+
"claim": {
|
|
32
|
+
"approving_body_name": "Board of Directors",
|
|
33
|
+
"decision_date": "2026-05-14",
|
|
34
|
+
"...": "..."
|
|
35
|
+
},
|
|
36
|
+
"evidence": [
|
|
37
|
+
{
|
|
38
|
+
"byte_size": 482991,
|
|
39
|
+
"filename_normalized": "minutes_2026-05-14.pdf",
|
|
40
|
+
"label": "signed_resolution_or_minutes",
|
|
41
|
+
"mime_type": "application/pdf",
|
|
42
|
+
"sha256": "sha256:..."
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"issued_at": "2026-05-14T08:23:11Z",
|
|
46
|
+
"issuer": {
|
|
47
|
+
"authority_label": "Management Body",
|
|
48
|
+
"org_name": "Sofia Tech Holdings AD"
|
|
49
|
+
},
|
|
50
|
+
"receipt_profile": "actproof-jcs-v1",
|
|
51
|
+
"recipients": [
|
|
52
|
+
{
|
|
53
|
+
"email_hash": "sha256:...",
|
|
54
|
+
"org_name": "Auditor AD",
|
|
55
|
+
"role": "external_auditor"
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"title": "NIS2 Article 20 management body approval, May 2026"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
After canonicalization (RFC 8785), every object's keys are sorted in Unicode
|
|
62
|
+
code-point order. The example above already shows the sorted form.
|
|
63
|
+
|
|
64
|
+
Design choices
|
|
65
|
+
--------------
|
|
66
|
+
|
|
67
|
+
**Label-bound evidence.** Each ``Evidence`` carries its catalogue-required
|
|
68
|
+
``label`` inline. This deviates from a naive design that would store evidence
|
|
69
|
+
hashes and labels in two separate lists and require the verifier to correlate
|
|
70
|
+
them. Label-bound evidence means the manifest itself states "this file
|
|
71
|
+
satisfies this required label," which is what a downstream verifier actually
|
|
72
|
+
needs to check. (Pattern carried forward from the architectural review.)
|
|
73
|
+
|
|
74
|
+
**Catalogue pinning.** The ``catalogue`` block pins five things: which
|
|
75
|
+
entry (``act_type_id``), which version (``entry_version``), where it lives
|
|
76
|
+
(``source_uri``), exactly which version of the spec repo (``git_commit``),
|
|
77
|
+
the SHA-256 of the entry JSON bytes (``entry_hash``), and the SHA-256 of
|
|
78
|
+
the schema JSON bytes (``schema_hash``). Together these make the receipt
|
|
79
|
+
independently re-verifiable: anyone can fetch the catalogue at the pinned
|
|
80
|
+
commit, recompute the entry hash, and confirm that what was used was what
|
|
81
|
+
the receipt claims.
|
|
82
|
+
|
|
83
|
+
**Hashed recipient emails.** The public manifest contains only
|
|
84
|
+
``email_hash`` for each recipient (SHA-256 of normalised plaintext). The
|
|
85
|
+
plaintext email lives only in the issuer-side receipt. A public verifier
|
|
86
|
+
can confirm that a particular email was designated by computing the hash
|
|
87
|
+
and comparing; a public observer cannot enumerate recipients without
|
|
88
|
+
already knowing the plaintext.
|
|
89
|
+
|
|
90
|
+
**Frozen dataclasses, no Pydantic.** This module uses ``@dataclass(frozen=True)``
|
|
91
|
+
throughout. Frozen for immutability (a manifest's identity is its hash; mutating
|
|
92
|
+
fields after construction would invalidate that). No Pydantic so the library
|
|
93
|
+
stays lightweight; validation is a separate explicit function that callers
|
|
94
|
+
invoke when they want it.
|
|
95
|
+
|
|
96
|
+
API
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
Construction:
|
|
100
|
+
|
|
101
|
+
* ``build_manifest(*, ...) -> Manifest`` - construct from keyword arguments.
|
|
102
|
+
* ``manifest_from_dict(d) -> Manifest`` - parse from a dict (e.g. from a receipt).
|
|
103
|
+
|
|
104
|
+
Serialisation:
|
|
105
|
+
|
|
106
|
+
* ``manifest_to_dict(m) -> dict`` - produce a dict suitable for canonicalisation.
|
|
107
|
+
* ``hash_manifest(m) -> bytes`` - canonicalise + SHA-256 raw digest (32 bytes).
|
|
108
|
+
* ``hash_manifest_hex(m) -> str`` - canonicalise + SHA-256 hex digest.
|
|
109
|
+
|
|
110
|
+
Helpers:
|
|
111
|
+
|
|
112
|
+
* ``normalize_email(email) -> str`` - lowercase + strip whitespace.
|
|
113
|
+
* ``hash_email(email) -> str`` - return ``"sha256:..."`` of normalised email.
|
|
114
|
+
* ``hash_file_bytes(content) -> str`` - return ``"sha256:..."`` of raw file bytes.
|
|
115
|
+
* ``hash_json_bytes(json_bytes) -> str`` - return ``"sha256:..."`` of canonical JSON bytes.
|
|
116
|
+
|
|
117
|
+
Validation:
|
|
118
|
+
|
|
119
|
+
* ``validate_manifest_shape(m) -> None`` - shape validation (sha256 formats,
|
|
120
|
+
ISO 8601 timestamps, non-empty required fields). Raises
|
|
121
|
+
``ManifestValidationError`` on the first problem.
|
|
122
|
+
|
|
123
|
+
Catalogue conformance (does this manifest's claim match its act type's
|
|
124
|
+
required fields) is checked separately by ``actproof.catalogue.validate_manifest``
|
|
125
|
+
landing in v0.0.4.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
from __future__ import annotations
|
|
129
|
+
|
|
130
|
+
import hashlib
|
|
131
|
+
import re
|
|
132
|
+
from dataclasses import dataclass, field
|
|
133
|
+
from datetime import datetime
|
|
134
|
+
from typing import Any, Mapping, Sequence
|
|
135
|
+
|
|
136
|
+
from actproof.canonical import canonicalize, hash_canonical
|
|
137
|
+
|
|
138
|
+
__all__ = [
|
|
139
|
+
"Manifest",
|
|
140
|
+
"CatalogueBinding",
|
|
141
|
+
"Issuer",
|
|
142
|
+
"Evidence",
|
|
143
|
+
"Recipient",
|
|
144
|
+
"build_manifest",
|
|
145
|
+
"manifest_to_dict",
|
|
146
|
+
"manifest_from_dict",
|
|
147
|
+
"hash_manifest",
|
|
148
|
+
"hash_manifest_hex",
|
|
149
|
+
"normalize_email",
|
|
150
|
+
"hash_email",
|
|
151
|
+
"hash_file_bytes",
|
|
152
|
+
"hash_json_bytes",
|
|
153
|
+
"validate_manifest_shape",
|
|
154
|
+
"ManifestValidationError",
|
|
155
|
+
"RECEIPT_PROFILE_V1",
|
|
156
|
+
"BATCHING_PROFILE_SINGLE",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ─────────────────────────────────────────────────────────────────
|
|
161
|
+
# CONSTANTS
|
|
162
|
+
# ─────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
RECEIPT_PROFILE_V1: str = "actproof-jcs-v1"
|
|
165
|
+
"""The receipt profile identifier for v1 (JSON-canonical, no COSE bridge).
|
|
166
|
+
|
|
167
|
+
When the SCITT COSE_Sign1 bridge lands (post-RFC 9943 publication), v2
|
|
168
|
+
will be ``"actproof-scitt-cose-v2"``, and the receipt's ``receipt_profile``
|
|
169
|
+
field is the discriminator that lets verifiers route to the right parser.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
BATCHING_PROFILE_SINGLE: str = "single_attestation_anchor_v1"
|
|
173
|
+
"""The batching profile identifier for v1 (one attestation per anchor).
|
|
174
|
+
|
|
175
|
+
When Merkle batching lands in a future release, the new profile will be
|
|
176
|
+
``"merkle_batch_v1"`` and the receipt's ``batching_profile`` field will
|
|
177
|
+
declare which is in use.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
_SHA256_HEX_PATTERN: re.Pattern[str] = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
181
|
+
"""Pattern for SHA-256 hash strings: ``"sha256:"`` + 64 lowercase hex chars."""
|
|
182
|
+
|
|
183
|
+
_GIT_COMMIT_PATTERN: re.Pattern[str] = re.compile(r"^[0-9a-f]{40}$")
|
|
184
|
+
"""Pattern for git commit SHAs: 40 lowercase hex chars."""
|
|
185
|
+
|
|
186
|
+
_ISO_8601_PATTERN: re.Pattern[str] = re.compile(
|
|
187
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$"
|
|
188
|
+
)
|
|
189
|
+
"""Pattern for ISO 8601 UTC timestamps: ``YYYY-MM-DDTHH:MM:SS[.fff]Z``."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ─────────────────────────────────────────────────────────────────
|
|
193
|
+
# EXCEPTIONS
|
|
194
|
+
# ─────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
class ManifestValidationError(ValueError):
|
|
197
|
+
"""Raised when a manifest's shape is structurally invalid.
|
|
198
|
+
|
|
199
|
+
Subclass of ``ValueError`` so callers can catch ``ValueError`` if they
|
|
200
|
+
prefer unified handling, or ``ManifestValidationError`` specifically
|
|
201
|
+
when they need to distinguish manifest shape errors from other value
|
|
202
|
+
errors.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ─────────────────────────────────────────────────────────────────
|
|
207
|
+
# DATA CLASSES
|
|
208
|
+
# ─────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class CatalogueBinding:
|
|
212
|
+
"""Pins which catalogue entry, version, and source revision this manifest implements.
|
|
213
|
+
|
|
214
|
+
Together the five fields make the receipt independently verifiable:
|
|
215
|
+
anyone can fetch the catalogue source at the pinned commit, locate the
|
|
216
|
+
entry by ``act_type_id``, recompute the entry hash, and confirm that
|
|
217
|
+
what was used matches what the receipt claims.
|
|
218
|
+
|
|
219
|
+
Attributes:
|
|
220
|
+
act_type_id: The catalogue entry's act_type_id, e.g.
|
|
221
|
+
``"op:eu.nis2.art20.management_body_approval.v1"``.
|
|
222
|
+
entry_version: The integer version of the entry.
|
|
223
|
+
source_uri: URI of the catalogue source, typically a GitHub repo URL.
|
|
224
|
+
git_commit: 40-character git SHA-1 commit of the catalogue at issue time.
|
|
225
|
+
entry_hash: SHA-256 of the entry JSON file bytes, as ``"sha256:..."``.
|
|
226
|
+
schema_hash: SHA-256 of the catalogue schema JSON bytes, as ``"sha256:..."``.
|
|
227
|
+
"""
|
|
228
|
+
act_type_id: str
|
|
229
|
+
entry_version: int
|
|
230
|
+
source_uri: str
|
|
231
|
+
git_commit: str
|
|
232
|
+
entry_hash: str
|
|
233
|
+
schema_hash: str
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class Issuer:
|
|
238
|
+
"""The party making the commitment.
|
|
239
|
+
|
|
240
|
+
Public fields only. Internal identifiers (Auth0 user ID, internal user
|
|
241
|
+
record ID, etc.) belong in the issuer-side receipt artifact, not in
|
|
242
|
+
the public manifest.
|
|
243
|
+
|
|
244
|
+
Attributes:
|
|
245
|
+
org_name: Legal or trading name of the issuing organisation.
|
|
246
|
+
authority_label: Role under which the issuer acts, e.g.
|
|
247
|
+
``"Management Body"`` for NIS2 Article 20, ``"Operator"`` for
|
|
248
|
+
EUDR, ``"Maintainer"`` for a software release.
|
|
249
|
+
"""
|
|
250
|
+
org_name: str
|
|
251
|
+
authority_label: str
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class Evidence:
|
|
256
|
+
"""A single evidence file bound to its catalogue-required label.
|
|
257
|
+
|
|
258
|
+
Each ``Evidence`` carries its label inline so that a verifier can read
|
|
259
|
+
the manifest and immediately answer: "which file satisfies which required
|
|
260
|
+
evidence label?" The label MUST be one of the entry's
|
|
261
|
+
``required_evidence_labels`` (this is checked by
|
|
262
|
+
``actproof.catalogue.validate_manifest``, not here).
|
|
263
|
+
|
|
264
|
+
Attributes:
|
|
265
|
+
label: The catalogue-required evidence label this file satisfies.
|
|
266
|
+
filename_normalized: NFC-normalised filename with no path components,
|
|
267
|
+
no leading dot, no shell metacharacters.
|
|
268
|
+
byte_size: File size in bytes. Must be positive.
|
|
269
|
+
mime_type: IANA media type, e.g. ``"application/pdf"``.
|
|
270
|
+
sha256: SHA-256 of the file bytes, as ``"sha256:..."``.
|
|
271
|
+
"""
|
|
272
|
+
label: str
|
|
273
|
+
filename_normalized: str
|
|
274
|
+
byte_size: int
|
|
275
|
+
mime_type: str
|
|
276
|
+
sha256: str
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class Recipient:
|
|
281
|
+
"""A designated recipient (witness) of the manifest.
|
|
282
|
+
|
|
283
|
+
The public manifest contains only ``email_hash``. Plaintext email
|
|
284
|
+
lives in the issuer-side receipt artifact, NOT here. A verifier
|
|
285
|
+
proves a particular email was designated by computing the hash and
|
|
286
|
+
comparing; a public observer cannot enumerate recipients without
|
|
287
|
+
already knowing the plaintext addresses.
|
|
288
|
+
|
|
289
|
+
Attributes:
|
|
290
|
+
role: Role from the catalogue entry's ``recommended_witness_roles``,
|
|
291
|
+
e.g. ``"external_auditor"``, ``"competent_authority_supervisor"``.
|
|
292
|
+
org_name: Legal or trading name of the recipient organisation.
|
|
293
|
+
email_hash: ``"sha256:..."`` of the normalised (lowercase, trimmed)
|
|
294
|
+
recipient email address.
|
|
295
|
+
"""
|
|
296
|
+
role: str
|
|
297
|
+
org_name: str
|
|
298
|
+
email_hash: str
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class Manifest:
|
|
303
|
+
"""The canonical envelope. Once committed, every field is immutable.
|
|
304
|
+
|
|
305
|
+
Attributes:
|
|
306
|
+
receipt_profile: ``"actproof-jcs-v1"`` for this release.
|
|
307
|
+
issued_at: ISO 8601 UTC timestamp with ``Z`` suffix.
|
|
308
|
+
catalogue: Catalogue binding (which entry, which version, which
|
|
309
|
+
source revision).
|
|
310
|
+
issuer: The party making the commitment.
|
|
311
|
+
title: Human-readable title for the commitment, e.g.
|
|
312
|
+
``"NIS2 Article 20 management body approval, May 2026"``.
|
|
313
|
+
claim: Catalogue-typed claim fields, a mapping whose keys match
|
|
314
|
+
the entry's ``required_claim_fields`` plus any
|
|
315
|
+
``optional_claim_fields``. Field values are JSON-serialisable
|
|
316
|
+
primitives (str, int, bool, None) or nested dicts/lists.
|
|
317
|
+
evidence: Tuple of ``Evidence`` records, one per uploaded file.
|
|
318
|
+
recipients: Tuple of ``Recipient`` records, one per designated witness.
|
|
319
|
+
batching_profile: ``"single_attestation_anchor_v1"`` for this release.
|
|
320
|
+
"""
|
|
321
|
+
receipt_profile: str
|
|
322
|
+
issued_at: str
|
|
323
|
+
catalogue: CatalogueBinding
|
|
324
|
+
issuer: Issuer
|
|
325
|
+
title: str
|
|
326
|
+
claim: Mapping[str, Any]
|
|
327
|
+
evidence: tuple[Evidence, ...]
|
|
328
|
+
recipients: tuple[Recipient, ...]
|
|
329
|
+
batching_profile: str
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ─────────────────────────────────────────────────────────────────
|
|
333
|
+
# CONSTRUCTION
|
|
334
|
+
# ─────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
def build_manifest(
|
|
337
|
+
*,
|
|
338
|
+
act_type_id: str,
|
|
339
|
+
catalogue_entry_version: int,
|
|
340
|
+
catalogue_source_uri: str,
|
|
341
|
+
catalogue_git_commit: str,
|
|
342
|
+
catalogue_entry_hash: str,
|
|
343
|
+
catalogue_schema_hash: str,
|
|
344
|
+
issuer_org_name: str,
|
|
345
|
+
issuer_authority_label: str,
|
|
346
|
+
title: str,
|
|
347
|
+
claim: Mapping[str, Any],
|
|
348
|
+
evidence: Sequence[Evidence],
|
|
349
|
+
recipients: Sequence[Recipient],
|
|
350
|
+
issued_at: str,
|
|
351
|
+
receipt_profile: str = RECEIPT_PROFILE_V1,
|
|
352
|
+
batching_profile: str = BATCHING_PROFILE_SINGLE,
|
|
353
|
+
) -> Manifest:
|
|
354
|
+
"""Construct a Manifest from raw fields.
|
|
355
|
+
|
|
356
|
+
Use this rather than calling the ``Manifest`` constructor directly.
|
|
357
|
+
Builds nested dataclasses, coerces evidence and recipients to tuples
|
|
358
|
+
for immutability, and produces a fully populated ``Manifest`` ready
|
|
359
|
+
to canonicalise.
|
|
360
|
+
|
|
361
|
+
All arguments are keyword-only to prevent positional misuse.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
act_type_id: Catalogue entry act_type_id.
|
|
365
|
+
catalogue_entry_version: Integer entry version.
|
|
366
|
+
catalogue_source_uri: URI of the catalogue source.
|
|
367
|
+
catalogue_git_commit: 40-char git SHA-1 of the catalogue source.
|
|
368
|
+
catalogue_entry_hash: ``"sha256:..."`` of the entry JSON bytes.
|
|
369
|
+
catalogue_schema_hash: ``"sha256:..."`` of the catalogue schema JSON.
|
|
370
|
+
issuer_org_name: Issuing organisation name.
|
|
371
|
+
issuer_authority_label: Role under which issuer acts.
|
|
372
|
+
title: Human-readable manifest title.
|
|
373
|
+
claim: Catalogue-typed claim fields.
|
|
374
|
+
evidence: Sequence of ``Evidence`` records.
|
|
375
|
+
recipients: Sequence of ``Recipient`` records.
|
|
376
|
+
issued_at: ISO 8601 UTC timestamp with ``Z`` suffix.
|
|
377
|
+
receipt_profile: Receipt profile discriminator. Defaults to v1.
|
|
378
|
+
batching_profile: Batching profile discriminator. Defaults to single.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
A fully populated, immutable ``Manifest`` instance.
|
|
382
|
+
"""
|
|
383
|
+
return Manifest(
|
|
384
|
+
receipt_profile=receipt_profile,
|
|
385
|
+
issued_at=issued_at,
|
|
386
|
+
catalogue=CatalogueBinding(
|
|
387
|
+
act_type_id=act_type_id,
|
|
388
|
+
entry_version=catalogue_entry_version,
|
|
389
|
+
source_uri=catalogue_source_uri,
|
|
390
|
+
git_commit=catalogue_git_commit,
|
|
391
|
+
entry_hash=catalogue_entry_hash,
|
|
392
|
+
schema_hash=catalogue_schema_hash,
|
|
393
|
+
),
|
|
394
|
+
issuer=Issuer(
|
|
395
|
+
org_name=issuer_org_name,
|
|
396
|
+
authority_label=issuer_authority_label,
|
|
397
|
+
),
|
|
398
|
+
title=title,
|
|
399
|
+
claim=dict(claim), # defensive copy
|
|
400
|
+
evidence=tuple(evidence),
|
|
401
|
+
recipients=tuple(recipients),
|
|
402
|
+
batching_profile=batching_profile,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ─────────────────────────────────────────────────────────────────
|
|
407
|
+
# SERIALISATION
|
|
408
|
+
# ─────────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
def manifest_to_dict(m: Manifest) -> dict[str, Any]:
|
|
411
|
+
"""Convert a ``Manifest`` to a plain dict suitable for canonicalisation.
|
|
412
|
+
|
|
413
|
+
The output is what gets passed to ``canonicalize()`` to produce the
|
|
414
|
+
canonical bytes that are hashed and anchored.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
m: The Manifest to serialise.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
A nested dict with all manifest fields.
|
|
421
|
+
"""
|
|
422
|
+
return {
|
|
423
|
+
"receipt_profile": m.receipt_profile,
|
|
424
|
+
"issued_at": m.issued_at,
|
|
425
|
+
"catalogue": {
|
|
426
|
+
"act_type_id": m.catalogue.act_type_id,
|
|
427
|
+
"entry_version": m.catalogue.entry_version,
|
|
428
|
+
"source_uri": m.catalogue.source_uri,
|
|
429
|
+
"git_commit": m.catalogue.git_commit,
|
|
430
|
+
"entry_hash": m.catalogue.entry_hash,
|
|
431
|
+
"schema_hash": m.catalogue.schema_hash,
|
|
432
|
+
},
|
|
433
|
+
"issuer": {
|
|
434
|
+
"org_name": m.issuer.org_name,
|
|
435
|
+
"authority_label": m.issuer.authority_label,
|
|
436
|
+
},
|
|
437
|
+
"title": m.title,
|
|
438
|
+
"claim": dict(m.claim),
|
|
439
|
+
"evidence": [
|
|
440
|
+
{
|
|
441
|
+
"label": e.label,
|
|
442
|
+
"filename_normalized": e.filename_normalized,
|
|
443
|
+
"byte_size": e.byte_size,
|
|
444
|
+
"mime_type": e.mime_type,
|
|
445
|
+
"sha256": e.sha256,
|
|
446
|
+
}
|
|
447
|
+
for e in m.evidence
|
|
448
|
+
],
|
|
449
|
+
"recipients": [
|
|
450
|
+
{
|
|
451
|
+
"role": r.role,
|
|
452
|
+
"org_name": r.org_name,
|
|
453
|
+
"email_hash": r.email_hash,
|
|
454
|
+
}
|
|
455
|
+
for r in m.recipients
|
|
456
|
+
],
|
|
457
|
+
"batching_profile": m.batching_profile,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def manifest_from_dict(d: Mapping[str, Any]) -> Manifest:
|
|
462
|
+
"""Parse a dict back into a ``Manifest``.
|
|
463
|
+
|
|
464
|
+
Used primarily by the verifier, which receives a receipt with the
|
|
465
|
+
manifest as a nested dict and needs to reconstruct the typed object
|
|
466
|
+
for inspection.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
d: A dict matching the canonical manifest shape.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
A reconstructed ``Manifest`` instance.
|
|
473
|
+
|
|
474
|
+
Raises:
|
|
475
|
+
ManifestValidationError: If a required field is missing or has
|
|
476
|
+
the wrong type.
|
|
477
|
+
"""
|
|
478
|
+
try:
|
|
479
|
+
catalogue_dict = d["catalogue"]
|
|
480
|
+
issuer_dict = d["issuer"]
|
|
481
|
+
|
|
482
|
+
return Manifest(
|
|
483
|
+
receipt_profile=d["receipt_profile"],
|
|
484
|
+
issued_at=d["issued_at"],
|
|
485
|
+
catalogue=CatalogueBinding(
|
|
486
|
+
act_type_id=catalogue_dict["act_type_id"],
|
|
487
|
+
entry_version=int(catalogue_dict["entry_version"]),
|
|
488
|
+
source_uri=catalogue_dict["source_uri"],
|
|
489
|
+
git_commit=catalogue_dict["git_commit"],
|
|
490
|
+
entry_hash=catalogue_dict["entry_hash"],
|
|
491
|
+
schema_hash=catalogue_dict["schema_hash"],
|
|
492
|
+
),
|
|
493
|
+
issuer=Issuer(
|
|
494
|
+
org_name=issuer_dict["org_name"],
|
|
495
|
+
authority_label=issuer_dict["authority_label"],
|
|
496
|
+
),
|
|
497
|
+
title=d["title"],
|
|
498
|
+
claim=dict(d["claim"]),
|
|
499
|
+
evidence=tuple(
|
|
500
|
+
Evidence(
|
|
501
|
+
label=e["label"],
|
|
502
|
+
filename_normalized=e["filename_normalized"],
|
|
503
|
+
byte_size=int(e["byte_size"]),
|
|
504
|
+
mime_type=e["mime_type"],
|
|
505
|
+
sha256=e["sha256"],
|
|
506
|
+
)
|
|
507
|
+
for e in d.get("evidence", [])
|
|
508
|
+
),
|
|
509
|
+
recipients=tuple(
|
|
510
|
+
Recipient(
|
|
511
|
+
role=r["role"],
|
|
512
|
+
org_name=r["org_name"],
|
|
513
|
+
email_hash=r["email_hash"],
|
|
514
|
+
)
|
|
515
|
+
for r in d.get("recipients", [])
|
|
516
|
+
),
|
|
517
|
+
batching_profile=d["batching_profile"],
|
|
518
|
+
)
|
|
519
|
+
except (KeyError, TypeError) as exc:
|
|
520
|
+
raise ManifestValidationError(
|
|
521
|
+
f"Cannot parse manifest dict: {exc}"
|
|
522
|
+
) from exc
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ─────────────────────────────────────────────────────────────────
|
|
526
|
+
# HASHING
|
|
527
|
+
# ─────────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
def hash_manifest(m: Manifest) -> bytes:
|
|
530
|
+
"""Canonicalise a manifest and return the SHA-256 raw digest (32 bytes).
|
|
531
|
+
|
|
532
|
+
This is the hash that gets anchored to the public ledger inside the
|
|
533
|
+
ARC-2 disclosed-mode note.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
m: The manifest to hash.
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
The 32-byte SHA-256 digest of the canonical manifest bytes.
|
|
540
|
+
"""
|
|
541
|
+
return hash_canonical(manifest_to_dict(m))
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def hash_manifest_hex(m: Manifest) -> str:
|
|
545
|
+
"""Canonicalise a manifest and return the SHA-256 hex digest.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
m: The manifest to hash.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
64-character lowercase hex string.
|
|
552
|
+
"""
|
|
553
|
+
return hash_manifest(m).hex()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ─────────────────────────────────────────────────────────────────
|
|
557
|
+
# HELPERS
|
|
558
|
+
# ─────────────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
def normalize_email(email: str) -> str:
|
|
561
|
+
"""Normalise an email address for hashing.
|
|
562
|
+
|
|
563
|
+
Lowercase, strip leading/trailing whitespace. Does NOT validate the
|
|
564
|
+
address format, does NOT canonicalise plus-addressing, does NOT do
|
|
565
|
+
any provider-specific normalisation (like Gmail's dot insensitivity).
|
|
566
|
+
The goal is reproducibility, not deliverability validation.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
email: The plaintext email address.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Lowercased, stripped email string.
|
|
573
|
+
"""
|
|
574
|
+
return email.strip().lower()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def hash_email(email: str) -> str:
|
|
578
|
+
"""Return the ``"sha256:..."`` hash of a normalised email.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
email: The plaintext email address.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
``"sha256:"`` followed by 64 lowercase hex characters.
|
|
585
|
+
"""
|
|
586
|
+
normalized = normalize_email(email)
|
|
587
|
+
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
588
|
+
return f"sha256:{digest}"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def hash_file_bytes(content: bytes) -> str:
|
|
592
|
+
"""Return the ``"sha256:..."`` hash of raw file bytes.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
content: The file's raw byte content.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
``"sha256:"`` followed by 64 lowercase hex characters.
|
|
599
|
+
"""
|
|
600
|
+
digest = hashlib.sha256(content).hexdigest()
|
|
601
|
+
return f"sha256:{digest}"
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def hash_json_bytes(json_bytes: bytes) -> str:
|
|
605
|
+
"""Return the ``"sha256:..."`` hash of (canonical) JSON bytes.
|
|
606
|
+
|
|
607
|
+
Useful for computing ``catalogue_entry_hash`` and ``catalogue_schema_hash``
|
|
608
|
+
from the on-disk JSON files in the actproof-events repository.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
json_bytes: The JSON file's raw bytes (typically the contents of
|
|
612
|
+
a catalogue entry .json file).
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
``"sha256:"`` followed by 64 lowercase hex characters.
|
|
616
|
+
"""
|
|
617
|
+
digest = hashlib.sha256(json_bytes).hexdigest()
|
|
618
|
+
return f"sha256:{digest}"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ─────────────────────────────────────────────────────────────────
|
|
622
|
+
# VALIDATION
|
|
623
|
+
# ─────────────────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
def validate_manifest_shape(m: Manifest) -> None:
|
|
626
|
+
"""Validate that a manifest is structurally well-formed.
|
|
627
|
+
|
|
628
|
+
Checks formats of sha256 strings, git commit SHAs, ISO 8601 timestamps,
|
|
629
|
+
byte sizes, and non-emptiness of required strings. Does NOT check
|
|
630
|
+
whether the claim fields satisfy a particular catalogue entry's
|
|
631
|
+
requirements (that is ``actproof.catalogue.validate_manifest``,
|
|
632
|
+
landing in v0.0.4).
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
m: The manifest to validate.
|
|
636
|
+
|
|
637
|
+
Raises:
|
|
638
|
+
ManifestValidationError: On the first shape problem found.
|
|
639
|
+
"""
|
|
640
|
+
# Receipt profile and batching profile.
|
|
641
|
+
if not m.receipt_profile:
|
|
642
|
+
raise ManifestValidationError("receipt_profile is empty")
|
|
643
|
+
if not m.batching_profile:
|
|
644
|
+
raise ManifestValidationError("batching_profile is empty")
|
|
645
|
+
|
|
646
|
+
# Issued-at timestamp.
|
|
647
|
+
if not _ISO_8601_PATTERN.match(m.issued_at):
|
|
648
|
+
raise ManifestValidationError(
|
|
649
|
+
f"issued_at {m.issued_at!r} is not ISO 8601 UTC "
|
|
650
|
+
f"(expected YYYY-MM-DDTHH:MM:SS[.fff]Z)"
|
|
651
|
+
)
|
|
652
|
+
# Also verify it actually parses; the regex catches most shapes but
|
|
653
|
+
# leaves room for impossible dates like 2026-13-32.
|
|
654
|
+
try:
|
|
655
|
+
# Python 3.11+ accepts the Z suffix directly; for older versions
|
|
656
|
+
# we replace it explicitly to avoid surprises.
|
|
657
|
+
datetime.fromisoformat(m.issued_at.replace("Z", "+00:00"))
|
|
658
|
+
except ValueError as exc:
|
|
659
|
+
raise ManifestValidationError(
|
|
660
|
+
f"issued_at {m.issued_at!r} is not a valid datetime: {exc}"
|
|
661
|
+
) from exc
|
|
662
|
+
|
|
663
|
+
# Catalogue binding.
|
|
664
|
+
if not m.catalogue.act_type_id:
|
|
665
|
+
raise ManifestValidationError("catalogue.act_type_id is empty")
|
|
666
|
+
if m.catalogue.entry_version < 1:
|
|
667
|
+
raise ManifestValidationError(
|
|
668
|
+
f"catalogue.entry_version {m.catalogue.entry_version} must be >= 1"
|
|
669
|
+
)
|
|
670
|
+
if not m.catalogue.source_uri:
|
|
671
|
+
raise ManifestValidationError("catalogue.source_uri is empty")
|
|
672
|
+
if not _GIT_COMMIT_PATTERN.match(m.catalogue.git_commit):
|
|
673
|
+
raise ManifestValidationError(
|
|
674
|
+
f"catalogue.git_commit {m.catalogue.git_commit!r} is not "
|
|
675
|
+
f"40 lowercase hex characters"
|
|
676
|
+
)
|
|
677
|
+
if not _SHA256_HEX_PATTERN.match(m.catalogue.entry_hash):
|
|
678
|
+
raise ManifestValidationError(
|
|
679
|
+
f"catalogue.entry_hash {m.catalogue.entry_hash!r} is not in "
|
|
680
|
+
f"'sha256:<64 lowercase hex>' format"
|
|
681
|
+
)
|
|
682
|
+
if not _SHA256_HEX_PATTERN.match(m.catalogue.schema_hash):
|
|
683
|
+
raise ManifestValidationError(
|
|
684
|
+
f"catalogue.schema_hash {m.catalogue.schema_hash!r} is not in "
|
|
685
|
+
f"'sha256:<64 lowercase hex>' format"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Issuer.
|
|
689
|
+
if not m.issuer.org_name:
|
|
690
|
+
raise ManifestValidationError("issuer.org_name is empty")
|
|
691
|
+
if not m.issuer.authority_label:
|
|
692
|
+
raise ManifestValidationError("issuer.authority_label is empty")
|
|
693
|
+
|
|
694
|
+
# Title.
|
|
695
|
+
if not m.title:
|
|
696
|
+
raise ManifestValidationError("title is empty")
|
|
697
|
+
|
|
698
|
+
# Evidence.
|
|
699
|
+
for i, e in enumerate(m.evidence):
|
|
700
|
+
if not e.label:
|
|
701
|
+
raise ManifestValidationError(f"evidence[{i}].label is empty")
|
|
702
|
+
if not e.filename_normalized:
|
|
703
|
+
raise ManifestValidationError(
|
|
704
|
+
f"evidence[{i}].filename_normalized is empty"
|
|
705
|
+
)
|
|
706
|
+
if e.byte_size <= 0:
|
|
707
|
+
raise ManifestValidationError(
|
|
708
|
+
f"evidence[{i}].byte_size {e.byte_size} must be > 0"
|
|
709
|
+
)
|
|
710
|
+
if not e.mime_type:
|
|
711
|
+
raise ManifestValidationError(f"evidence[{i}].mime_type is empty")
|
|
712
|
+
if not _SHA256_HEX_PATTERN.match(e.sha256):
|
|
713
|
+
raise ManifestValidationError(
|
|
714
|
+
f"evidence[{i}].sha256 {e.sha256!r} is not in "
|
|
715
|
+
f"'sha256:<64 lowercase hex>' format"
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Recipients.
|
|
719
|
+
for i, r in enumerate(m.recipients):
|
|
720
|
+
if not r.role:
|
|
721
|
+
raise ManifestValidationError(f"recipients[{i}].role is empty")
|
|
722
|
+
if not r.org_name:
|
|
723
|
+
raise ManifestValidationError(f"recipients[{i}].org_name is empty")
|
|
724
|
+
if not _SHA256_HEX_PATTERN.match(r.email_hash):
|
|
725
|
+
raise ManifestValidationError(
|
|
726
|
+
f"recipients[{i}].email_hash {r.email_hash!r} is not in "
|
|
727
|
+
f"'sha256:<64 lowercase hex>' format"
|
|
728
|
+
)
|