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.
- allowly_receipt_format-1.0.0/PKG-INFO +120 -0
- allowly_receipt_format-1.0.0/README.md +97 -0
- allowly_receipt_format-1.0.0/pyproject.toml +43 -0
- allowly_receipt_format-1.0.0/setup.cfg +4 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format/__init__.py +25 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format/py.typed +1 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format/verifier.py +655 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format.egg-info/PKG-INFO +120 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format.egg-info/SOURCES.txt +11 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format.egg-info/dependency_links.txt +1 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format.egg-info/entry_points.txt +2 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format.egg-info/requires.txt +1 -0
- allowly_receipt_format-1.0.0/src/allowly_receipt_format.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=42.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
allowly_receipt_format
|