aot-ev1 0.1.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.
- aot_ev1/__init__.py +4 -0
- aot_ev1/anchor.py +16 -0
- aot_ev1/bundle.py +196 -0
- aot_ev1/canonical.py +119 -0
- aot_ev1/cli.py +206 -0
- aot_ev1/ed25519_strict.py +49 -0
- aot_ev1/errors.py +112 -0
- aot_ev1/jcs_strict.py +134 -0
- aot_ev1/schema/aot-ev1.schema.json +92 -0
- aot_ev1/schema_lite.py +177 -0
- aot_ev1/sign.py +297 -0
- aot_ev1/verify.py +430 -0
- aot_ev1-0.1.0.dist-info/METADATA +62 -0
- aot_ev1-0.1.0.dist-info/RECORD +18 -0
- aot_ev1-0.1.0.dist-info/WHEEL +5 -0
- aot_ev1-0.1.0.dist-info/entry_points.txt +2 -0
- aot_ev1-0.1.0.dist-info/licenses/LICENSE +202 -0
- aot_ev1-0.1.0.dist-info/top_level.txt +1 -0
aot_ev1/__init__.py
ADDED
aot_ev1/anchor.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Optional anchoring hook — optional, not part of core.
|
|
2
|
+
|
|
3
|
+
AOT-EV1 deliberately ships without any anchoring (no BTC timestamps, no
|
|
4
|
+
network calls). A future extra may implement :func:`anchor_bundle`; until
|
|
5
|
+
then it is an explicit stub so callers fail loudly instead of assuming that
|
|
6
|
+
an evidence bundle has been anchored.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, NoReturn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def anchor_bundle(*args: Any, **kwargs: Any) -> NoReturn:
|
|
15
|
+
"""Anchor a bundle (optional, not part of core) — not implemented."""
|
|
16
|
+
raise NotImplementedError("optional, not part of core")
|
aot_ev1/bundle.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Deterministic ``.aotev.zip`` bundles (SPEC.md §6, §7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable, Mapping
|
|
10
|
+
|
|
11
|
+
from .canonical import canonical_bytes, check_canonical_payload, evidence_hash_hex
|
|
12
|
+
from .errors import AOTEV1Error
|
|
13
|
+
from .jcs_strict import MAX_SAFE_INT
|
|
14
|
+
from .schema_lite import validate_payload
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MANIFEST_NAME",
|
|
18
|
+
"PAYLOAD_NAME",
|
|
19
|
+
"MAX_ENTRIES",
|
|
20
|
+
"MAX_UNCOMPRESSED",
|
|
21
|
+
"ZIP_DATE_TIME",
|
|
22
|
+
"ZIP_EXTERNAL_ATTR",
|
|
23
|
+
"signature_file",
|
|
24
|
+
"bundle_hash",
|
|
25
|
+
"build_manifest",
|
|
26
|
+
"bundle_bytes",
|
|
27
|
+
"write_bundle",
|
|
28
|
+
"read_signed_dir",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
MANIFEST_NAME = "manifest.json"
|
|
32
|
+
PAYLOAD_NAME = "payload.json"
|
|
33
|
+
MAX_ENTRIES = 32
|
|
34
|
+
MAX_UNCOMPRESSED = 8 * 1024 * 1024
|
|
35
|
+
ZIP_DATE_TIME = (1980, 1, 1, 0, 0, 0)
|
|
36
|
+
ZIP_EXTERNAL_ATTR = 0o100644 << 16
|
|
37
|
+
|
|
38
|
+
_SIGNATURE_SUFFIX = {"ed25519": "ed25519", "eip712": "eip712"}
|
|
39
|
+
_SIGNATURE_SIZE = {"ed25519": 64, "eip712": 65}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def signature_file(role: str, scheme: str) -> str:
|
|
43
|
+
try:
|
|
44
|
+
suffix = _SIGNATURE_SUFFIX[scheme]
|
|
45
|
+
except KeyError:
|
|
46
|
+
raise AOTEV1Error(f"unsupported signature scheme: {scheme!r}") from None
|
|
47
|
+
if role not in ("issuer", "auditor"):
|
|
48
|
+
raise AOTEV1Error(f"unsupported signature role: {role!r}")
|
|
49
|
+
return f"signatures/{role}.{suffix}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bundle_hash(entries: Iterable[tuple[str, bytes]]) -> bytes:
|
|
53
|
+
"""``sha256`` over ``name + b"\\x00" + sha256(content)`` sorted by name."""
|
|
54
|
+
parts = []
|
|
55
|
+
for name, content in sorted(entries, key=lambda item: item[0]):
|
|
56
|
+
parts.append(name.encode("ascii") + b"\x00" + hashlib.sha256(content).digest())
|
|
57
|
+
return hashlib.sha256(b"".join(parts)).digest()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _signature_entries(
|
|
61
|
+
payload: dict, signatures: Mapping[str, bytes]
|
|
62
|
+
) -> list[tuple[str, bytes]]:
|
|
63
|
+
for role in signatures:
|
|
64
|
+
if role not in ("issuer", "auditor"):
|
|
65
|
+
raise AOTEV1Error(f"unsupported signature role: {role!r}")
|
|
66
|
+
if "issuer" not in signatures:
|
|
67
|
+
raise AOTEV1Error("missing issuer signature")
|
|
68
|
+
if payload["auditor"] is None and "auditor" in signatures:
|
|
69
|
+
raise AOTEV1Error("auditor signature given but payload.auditor is null")
|
|
70
|
+
if payload["auditor"] is not None and "auditor" not in signatures:
|
|
71
|
+
raise AOTEV1Error("missing auditor signature")
|
|
72
|
+
|
|
73
|
+
entries: list[tuple[str, bytes]] = []
|
|
74
|
+
for role in ("issuer", "auditor"):
|
|
75
|
+
if role not in signatures:
|
|
76
|
+
continue
|
|
77
|
+
identity = payload[role]
|
|
78
|
+
scheme = identity["scheme"]
|
|
79
|
+
signature = bytes(signatures[role])
|
|
80
|
+
expected = _SIGNATURE_SIZE[scheme]
|
|
81
|
+
if len(signature) != expected:
|
|
82
|
+
raise AOTEV1Error(
|
|
83
|
+
f"{role} signature must be {expected} bytes for {scheme}, got {len(signature)}"
|
|
84
|
+
)
|
|
85
|
+
entries.append((signature_file(role, scheme), signature))
|
|
86
|
+
return entries
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_manifest(
|
|
90
|
+
payload: dict,
|
|
91
|
+
data_entries: list[tuple[str, bytes]],
|
|
92
|
+
created_at: int,
|
|
93
|
+
) -> dict:
|
|
94
|
+
signatures = []
|
|
95
|
+
for role in ("issuer", "auditor"):
|
|
96
|
+
identity = payload[role]
|
|
97
|
+
if identity is None:
|
|
98
|
+
continue
|
|
99
|
+
signatures.append(
|
|
100
|
+
{
|
|
101
|
+
"role": role,
|
|
102
|
+
"scheme": identity["scheme"],
|
|
103
|
+
"key_id": identity["key_id"],
|
|
104
|
+
"file": signature_file(role, identity["scheme"]),
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
payload_bytes = dict(data_entries)[PAYLOAD_NAME]
|
|
108
|
+
return {
|
|
109
|
+
"v": "1",
|
|
110
|
+
"created_at": created_at,
|
|
111
|
+
"evidence_hash": evidence_hash_hex(payload),
|
|
112
|
+
"payload_sha256": hashlib.sha256(payload_bytes).hexdigest(),
|
|
113
|
+
"entries": [
|
|
114
|
+
{"name": name, "sha256": hashlib.sha256(content).hexdigest(), "size": len(content)}
|
|
115
|
+
for name, content in sorted(data_entries, key=lambda item: item[0])
|
|
116
|
+
],
|
|
117
|
+
"signatures": signatures,
|
|
118
|
+
"bundle_hash": bundle_hash(data_entries).hex(),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _zip_info(name: str) -> zipfile.ZipInfo:
|
|
123
|
+
info = zipfile.ZipInfo(name, date_time=ZIP_DATE_TIME)
|
|
124
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
125
|
+
info.create_system = 0
|
|
126
|
+
info.external_attr = ZIP_EXTERNAL_ATTR
|
|
127
|
+
info.flag_bits = 0
|
|
128
|
+
info.extra = b""
|
|
129
|
+
info.comment = b""
|
|
130
|
+
return info
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _write_zip(records: list[tuple[str, bytes]]) -> bytes:
|
|
134
|
+
buffer = io.BytesIO()
|
|
135
|
+
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED, allowZip64=False) as archive:
|
|
136
|
+
for name, content in records:
|
|
137
|
+
info = _zip_info(name)
|
|
138
|
+
info.file_size = len(content)
|
|
139
|
+
archive.writestr(info, content)
|
|
140
|
+
return buffer.getvalue()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def bundle_bytes(
|
|
144
|
+
payload: dict,
|
|
145
|
+
signatures: Mapping[str, bytes],
|
|
146
|
+
created_at: int | None = None,
|
|
147
|
+
) -> bytes:
|
|
148
|
+
"""Build deterministic zip bytes for ``payload`` and its signatures."""
|
|
149
|
+
validate_payload(payload)
|
|
150
|
+
payload_bytes = canonical_bytes(payload)
|
|
151
|
+
if created_at is None:
|
|
152
|
+
created_at = payload["valid_from"]
|
|
153
|
+
if not isinstance(created_at, int) or isinstance(created_at, bool):
|
|
154
|
+
raise AOTEV1Error("created_at must be an integer")
|
|
155
|
+
if created_at < 0 or created_at > MAX_SAFE_INT:
|
|
156
|
+
raise AOTEV1Error("created_at outside the safe integer range")
|
|
157
|
+
|
|
158
|
+
data_entries = [(PAYLOAD_NAME, payload_bytes)]
|
|
159
|
+
data_entries.extend(_signature_entries(payload, signatures))
|
|
160
|
+
if len(data_entries) + 1 > MAX_ENTRIES:
|
|
161
|
+
raise AOTEV1Error(f"bundle exceeds {MAX_ENTRIES} entries")
|
|
162
|
+
if sum(len(content) for _, content in data_entries) > MAX_UNCOMPRESSED:
|
|
163
|
+
raise AOTEV1Error(f"bundle exceeds {MAX_UNCOMPRESSED} uncompressed bytes")
|
|
164
|
+
|
|
165
|
+
manifest = build_manifest(payload, data_entries, created_at)
|
|
166
|
+
records = [(MANIFEST_NAME, canonical_bytes(manifest))]
|
|
167
|
+
records.extend(sorted(data_entries, key=lambda item: item[0]))
|
|
168
|
+
return _write_zip(records)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def write_bundle(
|
|
172
|
+
path: str | Path,
|
|
173
|
+
payload: dict,
|
|
174
|
+
signatures: Mapping[str, bytes],
|
|
175
|
+
created_at: int | None = None,
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Write ``bundle_bytes`` to ``path`` and return the sha256 hex digest."""
|
|
178
|
+
data = bundle_bytes(payload, signatures, created_at)
|
|
179
|
+
target = Path(path)
|
|
180
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
target.write_bytes(data)
|
|
182
|
+
return hashlib.sha256(data).hexdigest()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_signed_dir(directory: str | Path) -> tuple[dict, dict[str, bytes]]:
|
|
186
|
+
"""Read ``payload.json`` + ``signatures/`` produced by ``aotev sign``."""
|
|
187
|
+
root = Path(directory)
|
|
188
|
+
payload = check_canonical_payload((root / PAYLOAD_NAME).read_bytes())
|
|
189
|
+
signatures: dict[str, bytes] = {}
|
|
190
|
+
for role in ("issuer", "auditor"):
|
|
191
|
+
identity = payload[role]
|
|
192
|
+
if identity is None:
|
|
193
|
+
continue
|
|
194
|
+
path = root / signature_file(role, identity["scheme"])
|
|
195
|
+
signatures[role] = path.read_bytes()
|
|
196
|
+
return payload, signatures
|
aot_ev1/canonical.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Payload bytes, canonical form and ``evidence_hash`` (SPEC.md §1, §3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, NoReturn
|
|
8
|
+
|
|
9
|
+
from .errors import CanonicalError
|
|
10
|
+
from .jcs_strict import MAX_SAFE_INT, canonical_bytes, canonical_text
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"EVIDENCE_DOMAIN",
|
|
14
|
+
"canonical_bytes",
|
|
15
|
+
"canonical_text",
|
|
16
|
+
"evidence_hash",
|
|
17
|
+
"evidence_hash_hex",
|
|
18
|
+
"parse_payload",
|
|
19
|
+
"parse_json",
|
|
20
|
+
"check_canonical_payload",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
EVIDENCE_DOMAIN = b"AOT-EV1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reject_float(token: str) -> NoReturn:
|
|
27
|
+
raise CanonicalError(f"floats are not allowed: {token}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reject_constant(token: str) -> NoReturn:
|
|
31
|
+
raise CanonicalError(f"non-finite numbers are not allowed: {token}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_int(token: str) -> int:
|
|
35
|
+
if token == "-0":
|
|
36
|
+
raise CanonicalError("negative zero is not allowed")
|
|
37
|
+
value = int(token)
|
|
38
|
+
if value < -MAX_SAFE_INT or value > MAX_SAFE_INT:
|
|
39
|
+
raise CanonicalError(f"integer outside safe range: {token}")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
44
|
+
result: dict[str, Any] = {}
|
|
45
|
+
for key, value in pairs:
|
|
46
|
+
if key in result:
|
|
47
|
+
raise CanonicalError(f"duplicate object key: {key!r}")
|
|
48
|
+
result[key] = value
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_node(value: Any) -> None:
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
for ch in value:
|
|
55
|
+
if 0xD800 <= ord(ch) <= 0xDFFF:
|
|
56
|
+
raise CanonicalError("lone surrogate in string")
|
|
57
|
+
elif isinstance(value, list):
|
|
58
|
+
for item in value:
|
|
59
|
+
_check_node(item)
|
|
60
|
+
elif isinstance(value, dict):
|
|
61
|
+
for key, item in value.items():
|
|
62
|
+
if not isinstance(key, str):
|
|
63
|
+
raise CanonicalError(f"object key is not a string: {type(key).__name__}")
|
|
64
|
+
for ch in key:
|
|
65
|
+
if 0xD800 <= ord(ch) <= 0xDFFF:
|
|
66
|
+
raise CanonicalError("lone surrogate in object key")
|
|
67
|
+
_check_node(item)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_json(data: bytes | str) -> Any:
|
|
71
|
+
"""Parse strict JSON: no floats, no ``-0``, no duplicate keys."""
|
|
72
|
+
if isinstance(data, bytes):
|
|
73
|
+
try:
|
|
74
|
+
text = data.decode("utf-8")
|
|
75
|
+
except UnicodeDecodeError as exc:
|
|
76
|
+
raise CanonicalError(f"payload is not valid UTF-8: {exc}") from exc
|
|
77
|
+
else:
|
|
78
|
+
text = data
|
|
79
|
+
if text.startswith("\ufeff"):
|
|
80
|
+
raise CanonicalError("byte-order mark is not allowed")
|
|
81
|
+
try:
|
|
82
|
+
value = json.loads(
|
|
83
|
+
text,
|
|
84
|
+
object_pairs_hook=_pairs_hook,
|
|
85
|
+
parse_float=_reject_float,
|
|
86
|
+
parse_constant=_reject_constant,
|
|
87
|
+
parse_int=_parse_int,
|
|
88
|
+
)
|
|
89
|
+
except CanonicalError:
|
|
90
|
+
raise
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise CanonicalError(f"invalid JSON: {exc}") from exc
|
|
93
|
+
_check_node(value)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_payload(data: bytes | str) -> dict[str, Any]:
|
|
98
|
+
"""Parse ``payload.json`` bytes and require a top-level object."""
|
|
99
|
+
value = parse_json(data)
|
|
100
|
+
if not isinstance(value, dict):
|
|
101
|
+
raise CanonicalError("payload must be a JSON object")
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def evidence_hash(payload: Any) -> bytes:
|
|
106
|
+
"""``sha256(b"AOT-EV1" + canonical_bytes)`` (SPEC.md §1)."""
|
|
107
|
+
return hashlib.sha256(EVIDENCE_DOMAIN + canonical_bytes(payload)).digest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def evidence_hash_hex(payload: Any) -> str:
|
|
111
|
+
return evidence_hash(payload).hex()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_canonical_payload(raw: bytes) -> dict[str, Any]:
|
|
115
|
+
"""Step 6 helper: parse ``raw`` and require it to be canonical bytes."""
|
|
116
|
+
payload = parse_payload(raw)
|
|
117
|
+
if canonical_bytes(payload) != raw:
|
|
118
|
+
raise CanonicalError("payload.json is not stored in canonical bytes")
|
|
119
|
+
return payload
|
aot_ev1/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Command line interface for AOT-EV1 (see SPEC.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .canonical import canonical_bytes, evidence_hash_hex, parse_payload
|
|
12
|
+
from .errors import AOTEV1Error, exit_code_for
|
|
13
|
+
from .bundle import read_signed_dir, write_bundle
|
|
14
|
+
from .verify import verify_bundle
|
|
15
|
+
from .schema_lite import validate_payload
|
|
16
|
+
from .sign import eip712_types, sign_evidence
|
|
17
|
+
|
|
18
|
+
__all__ = ["main"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_input(path: str | None) -> bytes:
|
|
22
|
+
if path is None or path == "-":
|
|
23
|
+
return sys.stdin.buffer.read()
|
|
24
|
+
with open(path, "rb") as handle:
|
|
25
|
+
return handle.read()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cmd_canonical(args: argparse.Namespace) -> int:
|
|
29
|
+
raw = _read_input(args.path)
|
|
30
|
+
payload = parse_payload(raw)
|
|
31
|
+
data = canonical_bytes(payload)
|
|
32
|
+
sys.stdout.write(f"canonical_bytes: {data.hex()}\n")
|
|
33
|
+
sys.stdout.write(f"canonical_json: {data.decode('ascii')}\n")
|
|
34
|
+
sys.stdout.write(f"evidence_hash: {evidence_hash_hex(payload)}\n")
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cmd_sign(args: argparse.Namespace) -> int:
|
|
39
|
+
payload = parse_payload(_read_input(args.payload))
|
|
40
|
+
validate_payload(payload)
|
|
41
|
+
out = Path(args.out)
|
|
42
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
if payload["issuer"]["scheme"] == "ed25519":
|
|
44
|
+
issuer_file = "signatures/issuer.ed25519"
|
|
45
|
+
else:
|
|
46
|
+
issuer_file = "signatures/issuer.eip712"
|
|
47
|
+
(out / "signatures").mkdir(exist_ok=True)
|
|
48
|
+
(out / "payload.json").write_bytes(canonical_bytes(payload))
|
|
49
|
+
(out / issuer_file).write_bytes(sign_evidence(payload, "issuer", args.issuer_key, args.chain_id))
|
|
50
|
+
sys.stdout.write(f"issuer: {issuer_file} ({payload['issuer']['key_id']})\n")
|
|
51
|
+
if payload["auditor"] is not None:
|
|
52
|
+
if not args.auditor_key:
|
|
53
|
+
raise AOTEV1Error("payload has an auditor but --auditor-key was not given")
|
|
54
|
+
auditor_file = (
|
|
55
|
+
"signatures/auditor.ed25519"
|
|
56
|
+
if payload["auditor"]["scheme"] == "ed25519"
|
|
57
|
+
else "signatures/auditor.eip712"
|
|
58
|
+
)
|
|
59
|
+
(out / auditor_file).write_bytes(
|
|
60
|
+
sign_evidence(payload, "auditor", args.auditor_key, args.chain_id)
|
|
61
|
+
)
|
|
62
|
+
sys.stdout.write(f"auditor: {auditor_file} ({payload['auditor']['key_id']})\n")
|
|
63
|
+
elif args.auditor_key:
|
|
64
|
+
raise AOTEV1Error("--auditor-key given but payload.auditor is null")
|
|
65
|
+
sys.stdout.write(f"payload: payload.json\nevidence_hash: {evidence_hash_hex(payload)}\n")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _print_types() -> None:
|
|
70
|
+
from .sign import PAYLOAD_FIELDS
|
|
71
|
+
|
|
72
|
+
sys.stdout.write("payload fields:\n")
|
|
73
|
+
for name, kind in PAYLOAD_FIELDS:
|
|
74
|
+
sys.stdout.write(f" {name}: {kind}\n")
|
|
75
|
+
sys.stdout.write("eip712 types:\n")
|
|
76
|
+
for type_name, fields in eip712_types().items():
|
|
77
|
+
rendered = ", ".join(f"{item['name']} {item['type']}" for item in fields)
|
|
78
|
+
sys.stdout.write(f" {type_name}({rendered})\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
82
|
+
if args.types:
|
|
83
|
+
_print_types()
|
|
84
|
+
if not args.path:
|
|
85
|
+
return 0
|
|
86
|
+
if not args.path:
|
|
87
|
+
raise AOTEV1Error("inspect needs a bundle path or --types")
|
|
88
|
+
import json
|
|
89
|
+
import zipfile
|
|
90
|
+
|
|
91
|
+
from .bundle import MANIFEST_NAME, PAYLOAD_NAME
|
|
92
|
+
from .canonical import parse_json
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
with zipfile.ZipFile(args.path) as archive:
|
|
96
|
+
manifest = parse_json(archive.read(MANIFEST_NAME))
|
|
97
|
+
payload_bytes = archive.read(PAYLOAD_NAME)
|
|
98
|
+
payload = parse_json(payload_bytes)
|
|
99
|
+
except (zipfile.BadZipFile, KeyError, OSError) as exc:
|
|
100
|
+
raise AOTEV1Error(f"cannot inspect bundle: {exc}") from exc
|
|
101
|
+
sys.stdout.write(f"evidence_hash: {manifest['evidence_hash']}\n")
|
|
102
|
+
sys.stdout.write(f"created_at: {manifest['created_at']}\n")
|
|
103
|
+
sys.stdout.write(f"bundle_hash: {manifest['bundle_hash']}\n")
|
|
104
|
+
sys.stdout.write("entries:\n")
|
|
105
|
+
for entry in manifest["entries"]:
|
|
106
|
+
sys.stdout.write(f" {entry['name']} {entry['size']} {entry['sha256']}\n")
|
|
107
|
+
sys.stdout.write("signatures:\n")
|
|
108
|
+
for signature in manifest["signatures"]:
|
|
109
|
+
sys.stdout.write(
|
|
110
|
+
f" {signature['role']} {signature['scheme']} {signature['key_id']} {signature['file']}\n"
|
|
111
|
+
)
|
|
112
|
+
sys.stdout.write("payload:\n")
|
|
113
|
+
sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=True, sort_keys=True) + "\n")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_bundle(args: argparse.Namespace) -> int:
|
|
118
|
+
payload, signatures = read_signed_dir(args.directory)
|
|
119
|
+
out = Path(args.out) if args.out else Path(f"{evidence_hash_hex(payload)}.aotev.zip")
|
|
120
|
+
digest = write_bundle(out, payload, signatures, args.created_at)
|
|
121
|
+
sys.stdout.write(f"bundle: {out}\n")
|
|
122
|
+
sys.stdout.write(f"evidence_hash: {evidence_hash_hex(payload)}\n")
|
|
123
|
+
sys.stdout.write(f"bundle_sha256: {digest}\n")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
128
|
+
result = verify_bundle(
|
|
129
|
+
args.bundle,
|
|
130
|
+
now=args.now,
|
|
131
|
+
chain_path=args.chain,
|
|
132
|
+
chain_id=args.chain_id,
|
|
133
|
+
)
|
|
134
|
+
for step in result.steps:
|
|
135
|
+
state = "ok" if step.ok else (step.error or "failed")
|
|
136
|
+
sys.stdout.write(f"step {step.number:2d}: {state:<14} {step.name}\n")
|
|
137
|
+
sys.stdout.write(f"status: {result.status}\n")
|
|
138
|
+
if result.bundle_sha256:
|
|
139
|
+
sys.stdout.write(f"bundle_sha256: {result.bundle_sha256}\n")
|
|
140
|
+
if result.evidence_hash:
|
|
141
|
+
sys.stdout.write(f"evidence_hash: {result.evidence_hash}\n")
|
|
142
|
+
sys.stdout.write(f"chain: {result.chain}\n")
|
|
143
|
+
if result.error is not None:
|
|
144
|
+
sys.stdout.write(f"error: {result.error}\n")
|
|
145
|
+
return result.exit_code
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
149
|
+
parser = argparse.ArgumentParser(
|
|
150
|
+
prog="aotev",
|
|
151
|
+
description="AOT-EV1 — deterministic evidence layer for ERC-8325.",
|
|
152
|
+
epilog="Exit codes: 0 valid, 2 broken (steps 1-10), 3 expired/revoked, 4 chain error.",
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument("--version", action="version", version=f"aot-ev1 {__version__}")
|
|
155
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
156
|
+
|
|
157
|
+
canonical = sub.add_parser("canonical", help="print canonical payload bytes and evidence_hash")
|
|
158
|
+
canonical.add_argument("path", nargs="?", help="payload JSON file (default: stdin)")
|
|
159
|
+
canonical.set_defaults(func=_cmd_canonical)
|
|
160
|
+
|
|
161
|
+
sign = sub.add_parser("sign", help="sign a payload and write payload.json + signatures/")
|
|
162
|
+
sign.add_argument("--payload", required=True, help="payload JSON file")
|
|
163
|
+
sign.add_argument("--issuer-key", required=True, help="Ed25519 seed/secret hex or 0x EVM private key")
|
|
164
|
+
sign.add_argument("--auditor-key", help="auditor key, required when payload.auditor is set")
|
|
165
|
+
sign.add_argument("--chain-id", type=int, default=1, help="EIP-712 chainId (default: 1)")
|
|
166
|
+
sign.add_argument("--out", required=True, help="output directory")
|
|
167
|
+
sign.set_defaults(func=_cmd_sign)
|
|
168
|
+
|
|
169
|
+
inspect = sub.add_parser("inspect", help="inspect a payload bundle or print type information")
|
|
170
|
+
inspect.add_argument("path", nargs="?", help="bundle file")
|
|
171
|
+
inspect.add_argument("--types", action="store_true", help="print payload and EIP-712 type tables")
|
|
172
|
+
inspect.set_defaults(func=_cmd_inspect)
|
|
173
|
+
|
|
174
|
+
bundle = sub.add_parser("bundle", help="pack a signed payload directory into a deterministic zip")
|
|
175
|
+
bundle.add_argument("directory", help="directory with payload.json and signatures/")
|
|
176
|
+
bundle.add_argument("-o", "--out", help="output zip path (default: <evidence_hash>.aotev.zip)")
|
|
177
|
+
bundle.add_argument("--created-at", type=int, help="manifest created_at (default: payload.valid_from)")
|
|
178
|
+
bundle.set_defaults(func=_cmd_bundle)
|
|
179
|
+
|
|
180
|
+
verify = sub.add_parser("verify", help="verify a bundle offline (12 checks)")
|
|
181
|
+
verify.add_argument("bundle", help="bundle file")
|
|
182
|
+
verify.add_argument("--chain", help="previous bundle for chain continuity (optional)")
|
|
183
|
+
verify.add_argument("--now", type=int, help="verification time in unix seconds (default: system clock)")
|
|
184
|
+
verify.add_argument("--chain-id", type=int, default=1, help="EIP-712 chainId (default: 1)")
|
|
185
|
+
verify.set_defaults(func=_cmd_verify)
|
|
186
|
+
return parser
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
190
|
+
parser = build_parser()
|
|
191
|
+
args = parser.parse_args(argv)
|
|
192
|
+
try:
|
|
193
|
+
return int(args.func(args))
|
|
194
|
+
except AOTEV1Error as exc:
|
|
195
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
196
|
+
return exit_code_for(exc)
|
|
197
|
+
except ImportError as exc:
|
|
198
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
199
|
+
return 2
|
|
200
|
+
except OSError as exc:
|
|
201
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
202
|
+
return 2
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__": # pragma: no cover
|
|
206
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Strict Ed25519 verification profile (D2, 2026-11-02).
|
|
2
|
+
|
|
3
|
+
Rejects non-canonical/small-order public keys, S >= L, and non-canonical R.
|
|
4
|
+
Used for both agent (sig_A) and notary (sig_N) signatures.
|
|
5
|
+
"""
|
|
6
|
+
import nacl.bindings
|
|
7
|
+
from nacl.exceptions import BadSignatureError
|
|
8
|
+
from nacl.signing import VerifyKey
|
|
9
|
+
|
|
10
|
+
L = 2**252 + 27742317777372353535851937790883648493
|
|
11
|
+
|
|
12
|
+
# Canonical encodings of the eight small-order points (identity, order 2, etc.).
|
|
13
|
+
SMALL_ORDER_POINTS = {
|
|
14
|
+
bytes.fromhex("00" * 32),
|
|
15
|
+
bytes.fromhex("01" + "00" * 31),
|
|
16
|
+
bytes.fromhex("ec" + "ff" * 31),
|
|
17
|
+
bytes.fromhex("ed" + "ff" * 31),
|
|
18
|
+
bytes.fromhex("26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05"),
|
|
19
|
+
bytes.fromhex("c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a"),
|
|
20
|
+
bytes.fromhex("c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa"),
|
|
21
|
+
bytes.fromhex("26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _canonical_point(enc: bytes) -> bool:
|
|
26
|
+
if len(enc) != 32:
|
|
27
|
+
return False
|
|
28
|
+
try:
|
|
29
|
+
return bool(nacl.bindings.crypto_core_ed25519_is_valid_point(enc))
|
|
30
|
+
except Exception:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verify_strict(pubkey: bytes, message: bytes, signature: bytes) -> bool:
|
|
35
|
+
if len(pubkey) != 32 or len(signature) != 64:
|
|
36
|
+
return False
|
|
37
|
+
pubkey = bytes(pubkey)
|
|
38
|
+
if pubkey in SMALL_ORDER_POINTS or not _canonical_point(pubkey):
|
|
39
|
+
return False
|
|
40
|
+
s = int.from_bytes(signature[32:], "little")
|
|
41
|
+
if s >= L:
|
|
42
|
+
return False
|
|
43
|
+
if not _canonical_point(signature[:32]):
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
VerifyKey(pubkey).verify(bytes(message), bytes(signature))
|
|
47
|
+
return True
|
|
48
|
+
except (BadSignatureError, ValueError, TypeError):
|
|
49
|
+
return False
|