allowly-receipt-format 1.0.0__tar.gz

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.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: allowly-receipt-format
3
+ Version: 1.0.0
4
+ Summary: Reference Python verifier for the Allowly Receipt Format
5
+ Author-email: Allowly <support@allowly.ai>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://allowly.ai
8
+ Project-URL: Repository, https://github.com/Allowly-AI/allowly-receipt-format
9
+ Project-URL: Specification, https://github.com/Allowly-AI/allowly-receipt-format/tree/main/spec
10
+ Project-URL: Issues, https://github.com/Allowly-AI/allowly-receipt-format/issues
11
+ Keywords: allowly,receipts,audit,ed25519,verification
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: cryptography>=42.0
23
+
24
+ # Python Reference Verifier
25
+
26
+ Packaged Python verifier for the Allowly Receipt Format v1.0.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install allowly-receipt-format
32
+ ```
33
+
34
+ Only dependency: `cryptography` for Ed25519 signature verification.
35
+
36
+ ## CLI
37
+
38
+ Verify a single receipt:
39
+
40
+ ```bash
41
+ allowly-receipt-verify path/to/receipt.json path/to/keys.json
42
+ ```
43
+
44
+ Verify a whole export or audit-package chain in one go (`.jsonl` or `.jsonl.gz`):
45
+
46
+ ```bash
47
+ # Each line is either a bare receipt (audit-package chain.jsonl) or a
48
+ # {"receipt_id", ..., "receipt": {...}} export wrapper — both are handled.
49
+ allowly-receipt-verify --export chain.jsonl keys.json
50
+ ```
51
+
52
+ Verify only one authorization's chain and check its structure (exactly one
53
+ `authorization.create`, at most one `authorization.revoke`, well-formed
54
+ timestamps), printing the timeline:
55
+
56
+ ```bash
57
+ allowly-receipt-verify --export export.jsonl.gz keys.json --authorization-id auth_01HXZ2...
58
+ ```
59
+
60
+ For local development without installing from PyPI:
61
+
62
+ ```bash
63
+ pip install -e .
64
+ python verifier.py path/to/receipt.json path/to/keys.json
65
+ ```
66
+
67
+ Exit codes:
68
+ - `0` — all receipts valid (and, with `--authorization-id`, the chain is well-formed)
69
+ - `1` — any receipt invalid, no receipts matched, or a chain anomaly (reason on stderr)
70
+
71
+ ## Library
72
+
73
+ ```python
74
+ from allowly_receipt_format import verify_receipt, VerificationError, load_keys_from_json
75
+ import json
76
+
77
+ with open("receipt.json") as f:
78
+ receipt = json.load(f)
79
+ with open("keys.json") as f:
80
+ keys = load_keys_from_json(json.load(f))
81
+
82
+ try:
83
+ verify_receipt(receipt, keys)
84
+ print("valid")
85
+ except VerificationError as e:
86
+ print(f"invalid: {e}")
87
+ ```
88
+
89
+ Pass `expected_workspace_id` to bind the receipt to a workspace — a `key_id`
90
+ alone does not (spec §7, "Workspace binding"). The `allowly-receipt-verify` CLI
91
+ enforces this automatically using the key document's `workspace_id`:
92
+
93
+ ```python
94
+ verify_receipt(receipt, keys, expected_workspace_id="ws_01HXA1B2C3D4E5F6G7H8J9K0L1")
95
+ ```
96
+
97
+ The package exposes typed verifier exceptions:
98
+
99
+ - `SchemaError`
100
+ - `UnknownKeyError`
101
+ - `KeyOutsideActiveWindowError`
102
+ - `SignatureMismatchError`
103
+
104
+ All inherit from `VerificationError`.
105
+
106
+ ## Test vectors
107
+
108
+ Run against the shared test vectors:
109
+
110
+ ```bash
111
+ pip install -e .
112
+ python test_vectors.py ../../test-vectors.json
113
+ python test_exception_types.py ../../test-vectors.json
114
+ ```
115
+
116
+ All `should_verify` vectors must pass; all `should_reject` vectors must be rejected with the expected reason.
117
+
118
+ ## License
119
+
120
+ Apache 2.0.
@@ -0,0 +1,97 @@
1
+ # Python Reference Verifier
2
+
3
+ Packaged Python verifier for the Allowly Receipt Format v1.0.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install allowly-receipt-format
9
+ ```
10
+
11
+ Only dependency: `cryptography` for Ed25519 signature verification.
12
+
13
+ ## CLI
14
+
15
+ Verify a single receipt:
16
+
17
+ ```bash
18
+ allowly-receipt-verify path/to/receipt.json path/to/keys.json
19
+ ```
20
+
21
+ Verify a whole export or audit-package chain in one go (`.jsonl` or `.jsonl.gz`):
22
+
23
+ ```bash
24
+ # Each line is either a bare receipt (audit-package chain.jsonl) or a
25
+ # {"receipt_id", ..., "receipt": {...}} export wrapper — both are handled.
26
+ allowly-receipt-verify --export chain.jsonl keys.json
27
+ ```
28
+
29
+ Verify only one authorization's chain and check its structure (exactly one
30
+ `authorization.create`, at most one `authorization.revoke`, well-formed
31
+ timestamps), printing the timeline:
32
+
33
+ ```bash
34
+ allowly-receipt-verify --export export.jsonl.gz keys.json --authorization-id auth_01HXZ2...
35
+ ```
36
+
37
+ For local development without installing from PyPI:
38
+
39
+ ```bash
40
+ pip install -e .
41
+ python verifier.py path/to/receipt.json path/to/keys.json
42
+ ```
43
+
44
+ Exit codes:
45
+ - `0` — all receipts valid (and, with `--authorization-id`, the chain is well-formed)
46
+ - `1` — any receipt invalid, no receipts matched, or a chain anomaly (reason on stderr)
47
+
48
+ ## Library
49
+
50
+ ```python
51
+ from allowly_receipt_format import verify_receipt, VerificationError, load_keys_from_json
52
+ import json
53
+
54
+ with open("receipt.json") as f:
55
+ receipt = json.load(f)
56
+ with open("keys.json") as f:
57
+ keys = load_keys_from_json(json.load(f))
58
+
59
+ try:
60
+ verify_receipt(receipt, keys)
61
+ print("valid")
62
+ except VerificationError as e:
63
+ print(f"invalid: {e}")
64
+ ```
65
+
66
+ Pass `expected_workspace_id` to bind the receipt to a workspace — a `key_id`
67
+ alone does not (spec §7, "Workspace binding"). The `allowly-receipt-verify` CLI
68
+ enforces this automatically using the key document's `workspace_id`:
69
+
70
+ ```python
71
+ verify_receipt(receipt, keys, expected_workspace_id="ws_01HXA1B2C3D4E5F6G7H8J9K0L1")
72
+ ```
73
+
74
+ The package exposes typed verifier exceptions:
75
+
76
+ - `SchemaError`
77
+ - `UnknownKeyError`
78
+ - `KeyOutsideActiveWindowError`
79
+ - `SignatureMismatchError`
80
+
81
+ All inherit from `VerificationError`.
82
+
83
+ ## Test vectors
84
+
85
+ Run against the shared test vectors:
86
+
87
+ ```bash
88
+ pip install -e .
89
+ python test_vectors.py ../../test-vectors.json
90
+ python test_exception_types.py ../../test-vectors.json
91
+ ```
92
+
93
+ All `should_verify` vectors must pass; all `should_reject` vectors must be rejected with the expected reason.
94
+
95
+ ## License
96
+
97
+ Apache 2.0.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "allowly-receipt-format"
7
+ version = "1.0.0"
8
+ description = "Reference Python verifier for the Allowly Receipt Format"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ { name = "Allowly", email = "support@allowly.ai" },
14
+ ]
15
+ keywords = ["allowly", "receipts", "audit", "ed25519", "verification"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security :: Cryptography",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "cryptography>=42.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://allowly.ai"
32
+ Repository = "https://github.com/Allowly-AI/allowly-receipt-format"
33
+ Specification = "https://github.com/Allowly-AI/allowly-receipt-format/tree/main/spec"
34
+ Issues = "https://github.com/Allowly-AI/allowly-receipt-format/issues"
35
+
36
+ [project.scripts]
37
+ allowly-receipt-verify = "allowly_receipt_format.verifier:main"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.setuptools.package-data]
43
+ allowly_receipt_format = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ from .verifier import (
2
+ KeyOutsideActiveWindowError,
3
+ PublicKey,
4
+ SchemaError,
5
+ SignatureMismatchError,
6
+ UnknownKeyError,
7
+ VerificationError,
8
+ canonicalize,
9
+ load_keys_from_json,
10
+ main,
11
+ verify_receipt,
12
+ )
13
+
14
+ __all__ = [
15
+ "KeyOutsideActiveWindowError",
16
+ "PublicKey",
17
+ "SchemaError",
18
+ "SignatureMismatchError",
19
+ "UnknownKeyError",
20
+ "VerificationError",
21
+ "canonicalize",
22
+ "load_keys_from_json",
23
+ "main",
24
+ "verify_receipt",
25
+ ]
@@ -0,0 +1,655 @@
1
+ """
2
+ Allowly Receipt Verifier (Python reference implementation).
3
+
4
+ Verifies Allowly receipts per receipt-format.md v1.0.
5
+
6
+ Usage (library):
7
+ from allowly_receipt_format import verify_receipt, VerificationError, load_keys_from_json
8
+
9
+ try:
10
+ verify_receipt(receipt_dict, public_keys)
11
+ print("valid")
12
+ except VerificationError as e:
13
+ print(f"invalid: {e}")
14
+
15
+ Usage (CLI):
16
+ python verifier.py path/to/receipt.json path/to/keys.json
17
+
18
+ Spec: https://github.com/allowly/receipt-format
19
+ License: Apache 2.0
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ import re
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timedelta, timezone
29
+ from typing import Any
30
+
31
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
32
+ from cryptography.exceptions import InvalidSignature
33
+
34
+
35
+ SPEC_VERSION = "1.0"
36
+ ACTION_DECISIONS = {"allow", "deny", "confirm", "escalate"}
37
+ EVENT_DECISIONS = {
38
+ "authorization.create": {"authorization_granted"},
39
+ "authorization.revoke": {"authorization_revoked"},
40
+ "escalation.resolve": {"escalation_approved", "escalation_rejected"},
41
+ }
42
+ AUTHORIZATION_LIFECYCLE_EVENTS = {"authorization.create", "authorization.revoke"}
43
+ EVENT_ONLY_DECISIONS = {decision for decisions in EVENT_DECISIONS.values() for decision in decisions}
44
+ REQUIRED_FIELDS = {
45
+ "version", "receipt_id", "workspace_id", "issued_at", "decision", "reason",
46
+ "user_id", "agent_id", "resource", "context",
47
+ "authorization_id", "engine_version", "signature",
48
+ }
49
+ OPTIONAL_FIELDS = {"policy_eval"}
50
+ # Exactly one of these must be present:
51
+ DISCRIMINATOR_FIELDS = {"action", "event"}
52
+ ALL_TOP_LEVEL_FIELDS = REQUIRED_FIELDS | DISCRIMINATOR_FIELDS | OPTIONAL_FIELDS
53
+ MAX_FUTURE_SKEW = timedelta(minutes=5)
54
+ # I-JSON / RFC 8785 safe-integer bound. Integers outside ±(2^53-1) cannot be
55
+ # represented exactly by IEEE-754 double consumers (e.g. JavaScript verifiers),
56
+ # so v1 receipts MUST NOT carry them (spec §4.2 rule 6).
57
+ MAX_SAFE_INTEGER = 2**53 - 1
58
+ _B64URL_RE = re.compile(r"^[A-Za-z0-9_-]*$")
59
+ _RFC3339_RE = re.compile(
60
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$"
61
+ )
62
+ __all__ = [
63
+ "KeyOutsideActiveWindowError",
64
+ "PublicKey",
65
+ "SchemaError",
66
+ "SignatureMismatchError",
67
+ "UnknownKeyError",
68
+ "VerificationError",
69
+ "canonicalize",
70
+ "load_keys_from_json",
71
+ "main",
72
+ "verify_receipt",
73
+ ]
74
+
75
+
76
+ class VerificationError(Exception):
77
+ """Raised when a receipt fails any verification step."""
78
+
79
+
80
+ class SchemaError(VerificationError):
81
+ """Raised when receipt shape, field pairing, or timestamp validation fails."""
82
+
83
+
84
+ class UnknownKeyError(VerificationError):
85
+ """Raised when the receipt references a key id absent from the public key set."""
86
+
87
+
88
+ class KeyOutsideActiveWindowError(VerificationError):
89
+ """Raised when the referenced key does not cover receipt.issued_at."""
90
+
91
+
92
+ class SignatureMismatchError(VerificationError):
93
+ """Raised when the Ed25519 signature does not match the canonical payload."""
94
+
95
+
96
+ @dataclass
97
+ class PublicKey:
98
+ key_id: str
99
+ alg: str # "Ed25519"
100
+ public_key_bytes: bytes # 32 raw bytes
101
+ active_from: datetime
102
+ active_until: datetime | None # None = still active
103
+
104
+
105
+ def _b64url_decode(s: str) -> bytes:
106
+ """Decode base64url without padding.
107
+
108
+ The input MUST use the URL-safe alphabet (`A-Z a-z 0-9 - _`) with no
109
+ padding. ``base64.urlsafe_b64decode`` silently ignores out-of-alphabet
110
+ bytes, so we reject them explicitly to enforce spec §5.1.
111
+ """
112
+ if not _B64URL_RE.match(s):
113
+ raise ValueError(f"not unpadded base64url: {s!r}")
114
+ padding = "=" * (-len(s) % 4)
115
+ return base64.urlsafe_b64decode(s + padding)
116
+
117
+
118
+ def _parse_rfc3339(s: str) -> datetime:
119
+ """Parse an RFC 3339 timestamp. Requires Z suffix or explicit offset.
120
+
121
+ A timezone-less or date-only string is rejected: without an offset the
122
+ instant is ambiguous and verifiers would disagree on the key-window check.
123
+ """
124
+ if not isinstance(s, str) or not _RFC3339_RE.match(s):
125
+ raise SchemaError(f"not an RFC 3339 timestamp with timezone: {s!r}")
126
+ iso = s[:-1] + "+00:00" if s.endswith("Z") else s
127
+ dt = datetime.fromisoformat(iso)
128
+ if dt.tzinfo is None:
129
+ raise SchemaError(f"timestamp missing timezone: {s}")
130
+ return dt
131
+
132
+
133
+ def canonicalize(payload: dict[str, Any]) -> bytes:
134
+ """
135
+ Produce canonical JSON bytes per receipt-format.md §4.
136
+
137
+ Rules:
138
+ - UTF-8, no BOM
139
+ - No whitespace between tokens
140
+ - Object keys sorted lexicographically (UTF-16 code unit order)
141
+ - Array order preserved
142
+ - Integers only; no floats
143
+ - Non-ASCII passed through as UTF-8
144
+
145
+ NOTE: this is a hand-rolled serializer, not ``json.dumps(sort_keys=True,
146
+ ...)``. The stdlib helper diverges from the spec in two ways that silently
147
+ break cross-language signature verification:
148
+ 1. It sorts keys by Unicode code point, but §4.2 rule 3 mandates UTF-16
149
+ code-unit order — these differ for non-BMP keys (e.g. an emoji key
150
+ sorts *before* U+FF61 under UTF-16, *after* under code point).
151
+ 2. It emits short escapes like ``\\n`` for control characters, but §4.2
152
+ rule 5 mandates the lowercase ``\\uXXXX`` form.
153
+ """
154
+ _assert_no_floats(payload)
155
+ return _encode_value(payload).encode("utf-8")
156
+
157
+
158
+ def _utf16_sort_key(key: str) -> bytes:
159
+ """Sort key for UTF-16 code-unit lexicographic order (spec §4.2 rule 3)."""
160
+ return key.encode("utf-16-be")
161
+
162
+
163
+ def _encode_value(v: Any) -> str:
164
+ if v is None:
165
+ return "null"
166
+ if isinstance(v, bool): # before int: bool is an int subclass.
167
+ return "true" if v else "false"
168
+ if isinstance(v, int):
169
+ return str(v)
170
+ if isinstance(v, str):
171
+ return _encode_string(v)
172
+ if isinstance(v, dict):
173
+ items = sorted(v.items(), key=lambda kv: _utf16_sort_key(kv[0]))
174
+ return "{" + ",".join(
175
+ _encode_string(k) + ":" + _encode_value(val) for k, val in items
176
+ ) + "}"
177
+ if isinstance(v, list):
178
+ return "[" + ",".join(_encode_value(item) for item in v) + "]"
179
+ raise SchemaError(f"unsupported type in payload: {type(v).__name__}")
180
+
181
+
182
+ def _encode_string(s: str) -> str:
183
+ """Serialize a JSON string per §4.2 rule 5.
184
+
185
+ Escapes only `"`, `\\`, and control chars U+0000-U+001F (as lowercase
186
+ `\\uXXXX`). Non-ASCII is passed through as its UTF-8 byte sequence.
187
+ """
188
+ out = ['"']
189
+ for ch in s:
190
+ if ch == '"':
191
+ out.append('\\"')
192
+ elif ch == "\\":
193
+ out.append("\\\\")
194
+ elif ord(ch) < 0x20:
195
+ out.append(f"\\u{ord(ch):04x}")
196
+ else:
197
+ out.append(ch)
198
+ out.append('"')
199
+ return "".join(out)
200
+
201
+
202
+ def _assert_no_floats(obj: Any) -> None:
203
+ """v1 receipts MUST NOT contain non-integer numbers, nor integers outside
204
+ the I-JSON safe range (spec §4.2 rule 6)."""
205
+ if isinstance(obj, bool):
206
+ return # bool is a subclass of int in Python; allow.
207
+ if isinstance(obj, float):
208
+ raise SchemaError("v1 receipts must not contain non-integer numbers")
209
+ if isinstance(obj, int):
210
+ if abs(obj) > MAX_SAFE_INTEGER:
211
+ raise SchemaError(
212
+ f"integer {obj} exceeds the safe range ±(2^53-1); v1 receipts "
213
+ f"must not carry integers that lose precision in IEEE-754 doubles"
214
+ )
215
+ return
216
+ if isinstance(obj, dict):
217
+ for v in obj.values():
218
+ _assert_no_floats(v)
219
+ elif isinstance(obj, list):
220
+ for v in obj:
221
+ _assert_no_floats(v)
222
+
223
+
224
+ def verify_receipt(
225
+ receipt: dict[str, Any],
226
+ public_keys: list[PublicKey],
227
+ *,
228
+ now: datetime | None = None,
229
+ expected_workspace_id: str | None = None,
230
+ ) -> None:
231
+ """
232
+ Verify a receipt. Raises VerificationError on any failure.
233
+
234
+ Args:
235
+ receipt: the full receipt dict (payload + signature).
236
+ public_keys: list of known public keys for the workspace.
237
+ now: override current time (for testing). Defaults to datetime.now(UTC).
238
+ expected_workspace_id: if given, the receipt's ``workspace_id`` MUST
239
+ equal it. Key ids alone do not bind a receipt to a workspace, so
240
+ pass the workspace the keys were published for to prevent a receipt
241
+ from verifying against another workspace's key document.
242
+ """
243
+ now = now or datetime.now(timezone.utc)
244
+
245
+ # Step 1: version check
246
+ if receipt.get("version") != SPEC_VERSION:
247
+ raise SchemaError(
248
+ f"unsupported version: {receipt.get('version')!r} (want {SPEC_VERSION!r})"
249
+ )
250
+
251
+ if (
252
+ expected_workspace_id is not None
253
+ and receipt.get("workspace_id") != expected_workspace_id
254
+ ):
255
+ raise SchemaError(
256
+ f"workspace_id mismatch: receipt has {receipt.get('workspace_id')!r}, "
257
+ f"expected {expected_workspace_id!r}"
258
+ )
259
+
260
+ # Step 2: schema check (includes signature.value shape — rejects placeholders)
261
+ _check_schema(receipt)
262
+
263
+ # Step 3: receipt kind and pairing
264
+ has_action = "action" in receipt
265
+ has_event = "event" in receipt
266
+ decision = receipt["decision"]
267
+ authorization_id = receipt["authorization_id"]
268
+ resource = receipt["resource"]
269
+
270
+ if has_action and has_event:
271
+ raise SchemaError(
272
+ "receipt has both 'action' and 'event'; exactly one must be present"
273
+ )
274
+ if not has_action and not has_event:
275
+ raise SchemaError(
276
+ "receipt has neither 'action' nor 'event'; exactly one must be present"
277
+ )
278
+
279
+ if has_event:
280
+ # Event receipt
281
+ event = receipt["event"]
282
+ if not isinstance(event, str):
283
+ raise SchemaError("event must be a string")
284
+ if event not in EVENT_DECISIONS:
285
+ raise SchemaError(
286
+ f"event must be one of {sorted(EVENT_DECISIONS)}, got {event!r}"
287
+ )
288
+ expected_decisions = EVENT_DECISIONS[event]
289
+ if decision not in expected_decisions:
290
+ raise SchemaError(
291
+ f"event receipt with event={event!r} must have "
292
+ f"decision in {sorted(expected_decisions)}, got {decision!r}"
293
+ )
294
+ if authorization_id is None:
295
+ raise SchemaError(
296
+ f"event receipt with event={event!r} must have non-null authorization_id"
297
+ )
298
+ if event in AUTHORIZATION_LIFECYCLE_EVENTS and resource is not None:
299
+ raise SchemaError(
300
+ f"authorization lifecycle receipt with event={event!r} must have null resource"
301
+ )
302
+ if "policy_eval" in receipt:
303
+ raise SchemaError("policy_eval must be absent on event receipts")
304
+ else:
305
+ # Action receipt (has_action is True)
306
+ action = receipt["action"]
307
+ if not isinstance(action, str):
308
+ raise SchemaError("action must be a string")
309
+ # Reject reserved event-only decisions on action receipts.
310
+ if decision in EVENT_ONLY_DECISIONS:
311
+ raise SchemaError(
312
+ f"decision={decision!r} requires an event receipt (event field), "
313
+ f"got an action receipt with action={action!r}"
314
+ )
315
+ if decision not in ACTION_DECISIONS:
316
+ raise SchemaError(
317
+ f"action receipt must have decision in {sorted(ACTION_DECISIONS)}, "
318
+ f"got {decision!r}"
319
+ )
320
+
321
+ # Step 4: algorithm check
322
+ sig = receipt["signature"]
323
+ if sig.get("alg") != "Ed25519":
324
+ raise SchemaError(f"unsupported signature alg: {sig.get('alg')!r}")
325
+
326
+ # Step 5: timestamp sanity
327
+ issued_at = _parse_rfc3339(receipt["issued_at"])
328
+ if issued_at > now + MAX_FUTURE_SKEW:
329
+ raise SchemaError(
330
+ f"receipt issued in the future: {issued_at.isoformat()} > {now.isoformat()}"
331
+ )
332
+
333
+ # Step 6: canonicalize
334
+ payload = {k: v for k, v in receipt.items() if k != "signature"}
335
+ canonical = canonicalize(payload)
336
+
337
+ # Step 7: signature verification
338
+ key = _find_key(public_keys, sig["key_id"], issued_at)
339
+ sig_bytes = _b64url_decode(sig["value"]) # length already validated in schema check
340
+
341
+ try:
342
+ Ed25519PublicKey.from_public_bytes(key.public_key_bytes).verify(
343
+ sig_bytes, canonical
344
+ )
345
+ except InvalidSignature:
346
+ raise SignatureMismatchError("signature verification failed") from None
347
+
348
+ # Step 8: accept (implicit — no exception raised)
349
+
350
+
351
+ def _check_schema(receipt: dict[str, Any]) -> None:
352
+ extra = set(receipt.keys()) - ALL_TOP_LEVEL_FIELDS
353
+ if extra:
354
+ raise SchemaError(f"unknown top-level fields: {sorted(extra)}")
355
+ missing = REQUIRED_FIELDS - set(receipt.keys())
356
+ if missing:
357
+ raise SchemaError(f"missing top-level fields: {sorted(missing)}")
358
+
359
+ # String fields (always present)
360
+ for field in ("version", "receipt_id", "workspace_id", "issued_at",
361
+ "decision", "reason", "user_id", "agent_id",
362
+ "engine_version"):
363
+ if not isinstance(receipt[field], str):
364
+ raise SchemaError(f"{field} must be a string")
365
+
366
+ # Nullable string fields
367
+ for field in ("resource", "authorization_id"):
368
+ if not (isinstance(receipt[field], str) or receipt[field] is None):
369
+ raise SchemaError(f"{field} must be string or null")
370
+
371
+ # Object fields
372
+ if not isinstance(receipt["context"], dict):
373
+ raise SchemaError("context must be an object")
374
+ if not isinstance(receipt["signature"], dict):
375
+ raise SchemaError("signature must be an object")
376
+
377
+ # Signature sub-fields
378
+ sig = receipt["signature"]
379
+ for field in ("alg", "key_id", "value"):
380
+ if field not in sig:
381
+ raise SchemaError(f"signature.{field} is required")
382
+ if not isinstance(sig[field], str):
383
+ raise SchemaError(f"signature.{field} must be a string")
384
+
385
+ # signature.value must decode from base64url to exactly 64 bytes.
386
+ # This rejects placeholder strings ("pending", empty, anything malformed)
387
+ # before the verification path even starts.
388
+ try:
389
+ sig_bytes = _b64url_decode(sig["value"])
390
+ except Exception:
391
+ raise SchemaError(
392
+ f"signature.value is not valid base64url: {sig['value']!r}"
393
+ )
394
+ if len(sig_bytes) != 64:
395
+ raise SchemaError(
396
+ f"signature.value must decode to 64 bytes (Ed25519), got {len(sig_bytes)}"
397
+ )
398
+
399
+ if "policy_eval" in receipt:
400
+ _check_policy_eval(receipt["policy_eval"])
401
+
402
+
403
+ def _is_policy_scalar(value: Any) -> bool:
404
+ return value is None or isinstance(value, (str, bool)) or (
405
+ isinstance(value, int) and not isinstance(value, bool)
406
+ )
407
+
408
+
409
+ def _is_policy_condition_value(value: Any) -> bool:
410
+ if _is_policy_scalar(value):
411
+ return True
412
+ return isinstance(value, list) and all(_is_policy_scalar(item) for item in value)
413
+
414
+
415
+ def _check_policy_eval(value: Any) -> None:
416
+ if not isinstance(value, dict):
417
+ raise SchemaError("policy_eval must be an object")
418
+ expected = {"matched_condition", "field_value"}
419
+ extra = set(value.keys()) - expected
420
+ missing = expected - set(value.keys())
421
+ if extra:
422
+ raise SchemaError(f"policy_eval has unknown fields: {sorted(extra)}")
423
+ if missing:
424
+ raise SchemaError(f"policy_eval missing fields: {sorted(missing)}")
425
+
426
+ matched = value["matched_condition"]
427
+ if matched is not None:
428
+ if not isinstance(matched, dict):
429
+ raise SchemaError("policy_eval.matched_condition must be an object or null")
430
+ condition_fields = {"field", "op", "value"}
431
+ extra = set(matched.keys()) - condition_fields
432
+ missing = condition_fields - set(matched.keys())
433
+ if extra:
434
+ raise SchemaError(
435
+ f"policy_eval.matched_condition has unknown fields: {sorted(extra)}"
436
+ )
437
+ if missing:
438
+ raise SchemaError(
439
+ f"policy_eval.matched_condition missing fields: {sorted(missing)}"
440
+ )
441
+ if not isinstance(matched["field"], str):
442
+ raise SchemaError("policy_eval.matched_condition.field must be a string")
443
+ if not isinstance(matched["op"], str):
444
+ raise SchemaError("policy_eval.matched_condition.op must be a string")
445
+ if not _is_policy_condition_value(matched["value"]):
446
+ raise SchemaError(
447
+ "policy_eval.matched_condition.value must be string, integer, boolean, null, or an array of those"
448
+ )
449
+
450
+ if not _is_policy_scalar(value["field_value"]):
451
+ raise SchemaError("policy_eval.field_value must be string, integer, boolean, or null")
452
+
453
+
454
+ def _find_key(
455
+ keys: list[PublicKey], key_id: str, issued_at: datetime
456
+ ) -> PublicKey:
457
+ for k in keys:
458
+ if k.key_id != key_id:
459
+ continue
460
+ if issued_at < k.active_from:
461
+ raise KeyOutsideActiveWindowError(f"key {key_id!r} not yet active at issued_at")
462
+ if k.active_until is not None and issued_at >= k.active_until:
463
+ raise KeyOutsideActiveWindowError(f"key {key_id!r} retired before issued_at")
464
+ return k
465
+ raise UnknownKeyError(f"no public key found for key_id={key_id!r}")
466
+
467
+
468
+ def load_keys_from_json(doc: dict[str, Any]) -> list[PublicKey]:
469
+ """Parse the /v1/workspaces/{id}/keys response shape into PublicKey list."""
470
+ out = []
471
+ for k in doc["keys"]:
472
+ out.append(PublicKey(
473
+ key_id=k["key_id"],
474
+ alg=k["alg"],
475
+ public_key_bytes=_b64url_decode(k["public_key"]),
476
+ active_from=_parse_rfc3339(k["active_from"]),
477
+ active_until=_parse_rfc3339(k["active_until"]) if k.get("active_until") else None,
478
+ ))
479
+ return out
480
+
481
+
482
+ def _open_maybe_gzip(path: str):
483
+ """Open a .jsonl or .jsonl.gz export as a text stream."""
484
+ import gzip
485
+
486
+ if path.endswith(".gz"):
487
+ return gzip.open(path, "rt", encoding="utf-8")
488
+ return open(path, encoding="utf-8")
489
+
490
+
491
+ def _extract_receipt(obj: Any) -> dict[str, Any]:
492
+ """A line may be a bare receipt (audit-package chain.jsonl) or a receipt-export
493
+ wrapper ``{"receipt_id", "workspace_id", "created_at", "receipt": {...}}``."""
494
+ if isinstance(obj, dict) and isinstance(obj.get("receipt"), dict):
495
+ return obj["receipt"]
496
+ return obj
497
+
498
+
499
+ def _verify_export(
500
+ path: str,
501
+ keys: list[PublicKey],
502
+ expected_workspace_id: str | None,
503
+ authorization_id: str | None,
504
+ ) -> int:
505
+ import sys
506
+
507
+ ok = failed = skipped = total = 0
508
+ chain: list[dict[str, Any]] = []
509
+
510
+ with _open_maybe_gzip(path) as f:
511
+ for lineno, raw in enumerate(f, 1):
512
+ line = raw.strip()
513
+ if not line:
514
+ continue
515
+ try:
516
+ receipt = _extract_receipt(json.loads(line))
517
+ except json.JSONDecodeError as e:
518
+ total += 1
519
+ failed += 1
520
+ print(f"INVALID line {lineno}: not JSON ({e})", file=sys.stderr)
521
+ continue
522
+ if authorization_id is not None and receipt.get("authorization_id") != authorization_id:
523
+ skipped += 1
524
+ continue
525
+ total += 1
526
+ rid = receipt.get("receipt_id", f"line {lineno}")
527
+ try:
528
+ verify_receipt(receipt, keys, expected_workspace_id=expected_workspace_id)
529
+ label = receipt.get("action") or receipt.get("event") or "?"
530
+ print(f"OK {rid} {label} {receipt.get('decision')}")
531
+ ok += 1
532
+ chain.append(receipt)
533
+ except VerificationError as e:
534
+ print(f"INVALID {rid} {e}", file=sys.stderr)
535
+ failed += 1
536
+
537
+ summary = f"\n{ok} ok, {failed} invalid"
538
+ if authorization_id is not None:
539
+ summary += f", {skipped} skipped (other authorizations)"
540
+ summary += f" ({total} checked)"
541
+ print(summary)
542
+
543
+ if total == 0:
544
+ print("No matching receipts found.", file=sys.stderr)
545
+ return 1
546
+
547
+ chain_rc = 0
548
+ if authorization_id is not None:
549
+ chain_rc = _check_chain_invariants(chain, authorization_id)
550
+
551
+ return 0 if failed == 0 and chain_rc == 0 else 1
552
+
553
+
554
+ def _check_chain_invariants(chain: list[dict[str, Any]], authorization_id: str) -> int:
555
+ """Report the chain timeline and flag structural anomalies (spec §3.5)."""
556
+ import sys
557
+
558
+ creates = [r for r in chain if r.get("event") == "authorization.create"]
559
+ revokes = [r for r in chain if r.get("event") == "authorization.revoke"]
560
+ problems: list[str] = []
561
+
562
+ if len(creates) == 0:
563
+ problems.append("no authorization.create receipt in this chain")
564
+ elif len(creates) > 1:
565
+ problems.append(f"{len(creates)} authorization.create receipts (expected exactly 1)")
566
+ if len(revokes) > 1:
567
+ problems.append(f"{len(revokes)} authorization.revoke receipts (expected at most 1)")
568
+
569
+ print("\nChain timeline:")
570
+ for r in chain:
571
+ kind = r.get("event") or f"action:{r.get('action')}"
572
+ issued = r.get("issued_at", "?")
573
+ try:
574
+ _parse_rfc3339(r["issued_at"])
575
+ except Exception:
576
+ problems.append(f"{r.get('receipt_id')} has a non-RFC-3339 issued_at: {issued!r}")
577
+ print(f" {issued} {kind} {r.get('decision')}")
578
+
579
+ if problems:
580
+ for problem in problems:
581
+ print(f"CHAIN WARNING: {problem}", file=sys.stderr)
582
+ return 1
583
+
584
+ print(f"Chain OK: 1 create, {len(revokes)} revoke, {len(chain)} signed receipt(s).")
585
+ return 0
586
+
587
+
588
+ def main(argv: list[str] | None = None) -> int:
589
+ import argparse
590
+ import sys
591
+
592
+ p = argparse.ArgumentParser(description="Verify Allowly receipts.")
593
+ p.add_argument(
594
+ "paths",
595
+ nargs="*",
596
+ help="single mode: <receipt.json> <keys.json>; with --export: <keys.json>",
597
+ )
598
+ p.add_argument(
599
+ "--export",
600
+ metavar="FILE",
601
+ help="verify a JSONL or .jsonl.gz export in bulk (audit-package chain.jsonl or a receipt export)",
602
+ )
603
+ p.add_argument(
604
+ "--authorization-id",
605
+ metavar="ID",
606
+ help="with --export: verify only this authorization's chain and check chain invariants",
607
+ )
608
+ args = p.parse_args(argv)
609
+
610
+ # The keys document is published per workspace; bind receipts to it so a
611
+ # receipt cannot verify against another workspace's keys that share a key_id.
612
+ if args.export:
613
+ if len(args.paths) != 1:
614
+ p.error("with --export, provide exactly one positional argument: the keys JSON file")
615
+ with open(args.paths[0]) as f:
616
+ keys_doc = json.load(f)
617
+ keys = load_keys_from_json(keys_doc)
618
+ return _verify_export(
619
+ args.export,
620
+ keys,
621
+ keys_doc.get("workspace_id"),
622
+ args.authorization_id,
623
+ )
624
+
625
+ if args.authorization_id:
626
+ p.error("--authorization-id requires --export")
627
+ if len(args.paths) != 2:
628
+ p.error("provide <receipt.json> <keys.json>, or use --export <file> <keys.json>")
629
+
630
+ receipt_path, keys_path = args.paths
631
+ with open(receipt_path) as f:
632
+ receipt = json.load(f)
633
+ with open(keys_path) as f:
634
+ keys_doc = json.load(f)
635
+
636
+ keys = load_keys_from_json(keys_doc)
637
+ expected_workspace_id = keys_doc.get("workspace_id")
638
+
639
+ try:
640
+ verify_receipt(receipt, keys, expected_workspace_id=expected_workspace_id)
641
+ print(f"OK receipt_id={receipt['receipt_id']} decision={receipt['decision']}")
642
+ return 0
643
+ except VerificationError as e:
644
+ print(f"INVALID {e}", file=sys.stderr)
645
+ return 1
646
+
647
+
648
+ # ---------------------------------------------------------------------------
649
+ # CLI
650
+ # ---------------------------------------------------------------------------
651
+
652
+ if __name__ == "__main__":
653
+ import sys
654
+
655
+ sys.exit(main())
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: allowly-receipt-format
3
+ Version: 1.0.0
4
+ Summary: Reference Python verifier for the Allowly Receipt Format
5
+ Author-email: Allowly <support@allowly.ai>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://allowly.ai
8
+ Project-URL: Repository, https://github.com/Allowly-AI/allowly-receipt-format
9
+ Project-URL: Specification, https://github.com/Allowly-AI/allowly-receipt-format/tree/main/spec
10
+ Project-URL: Issues, https://github.com/Allowly-AI/allowly-receipt-format/issues
11
+ Keywords: allowly,receipts,audit,ed25519,verification
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: cryptography>=42.0
23
+
24
+ # Python Reference Verifier
25
+
26
+ Packaged Python verifier for the Allowly Receipt Format v1.0.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install allowly-receipt-format
32
+ ```
33
+
34
+ Only dependency: `cryptography` for Ed25519 signature verification.
35
+
36
+ ## CLI
37
+
38
+ Verify a single receipt:
39
+
40
+ ```bash
41
+ allowly-receipt-verify path/to/receipt.json path/to/keys.json
42
+ ```
43
+
44
+ Verify a whole export or audit-package chain in one go (`.jsonl` or `.jsonl.gz`):
45
+
46
+ ```bash
47
+ # Each line is either a bare receipt (audit-package chain.jsonl) or a
48
+ # {"receipt_id", ..., "receipt": {...}} export wrapper — both are handled.
49
+ allowly-receipt-verify --export chain.jsonl keys.json
50
+ ```
51
+
52
+ Verify only one authorization's chain and check its structure (exactly one
53
+ `authorization.create`, at most one `authorization.revoke`, well-formed
54
+ timestamps), printing the timeline:
55
+
56
+ ```bash
57
+ allowly-receipt-verify --export export.jsonl.gz keys.json --authorization-id auth_01HXZ2...
58
+ ```
59
+
60
+ For local development without installing from PyPI:
61
+
62
+ ```bash
63
+ pip install -e .
64
+ python verifier.py path/to/receipt.json path/to/keys.json
65
+ ```
66
+
67
+ Exit codes:
68
+ - `0` — all receipts valid (and, with `--authorization-id`, the chain is well-formed)
69
+ - `1` — any receipt invalid, no receipts matched, or a chain anomaly (reason on stderr)
70
+
71
+ ## Library
72
+
73
+ ```python
74
+ from allowly_receipt_format import verify_receipt, VerificationError, load_keys_from_json
75
+ import json
76
+
77
+ with open("receipt.json") as f:
78
+ receipt = json.load(f)
79
+ with open("keys.json") as f:
80
+ keys = load_keys_from_json(json.load(f))
81
+
82
+ try:
83
+ verify_receipt(receipt, keys)
84
+ print("valid")
85
+ except VerificationError as e:
86
+ print(f"invalid: {e}")
87
+ ```
88
+
89
+ Pass `expected_workspace_id` to bind the receipt to a workspace — a `key_id`
90
+ alone does not (spec §7, "Workspace binding"). The `allowly-receipt-verify` CLI
91
+ enforces this automatically using the key document's `workspace_id`:
92
+
93
+ ```python
94
+ verify_receipt(receipt, keys, expected_workspace_id="ws_01HXA1B2C3D4E5F6G7H8J9K0L1")
95
+ ```
96
+
97
+ The package exposes typed verifier exceptions:
98
+
99
+ - `SchemaError`
100
+ - `UnknownKeyError`
101
+ - `KeyOutsideActiveWindowError`
102
+ - `SignatureMismatchError`
103
+
104
+ All inherit from `VerificationError`.
105
+
106
+ ## Test vectors
107
+
108
+ Run against the shared test vectors:
109
+
110
+ ```bash
111
+ pip install -e .
112
+ python test_vectors.py ../../test-vectors.json
113
+ python test_exception_types.py ../../test-vectors.json
114
+ ```
115
+
116
+ All `should_verify` vectors must pass; all `should_reject` vectors must be rejected with the expected reason.
117
+
118
+ ## License
119
+
120
+ Apache 2.0.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/allowly_receipt_format/__init__.py
4
+ src/allowly_receipt_format/py.typed
5
+ src/allowly_receipt_format/verifier.py
6
+ src/allowly_receipt_format.egg-info/PKG-INFO
7
+ src/allowly_receipt_format.egg-info/SOURCES.txt
8
+ src/allowly_receipt_format.egg-info/dependency_links.txt
9
+ src/allowly_receipt_format.egg-info/entry_points.txt
10
+ src/allowly_receipt_format.egg-info/requires.txt
11
+ src/allowly_receipt_format.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ allowly-receipt-verify = allowly_receipt_format.verifier:main