ratify-protocol 1.0.0a5__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.
- ratify_protocol/__init__.py +209 -0
- ratify_protocol/canonical.py +156 -0
- ratify_protocol/constraints.py +205 -0
- ratify_protocol/crypto.py +592 -0
- ratify_protocol/scope.py +269 -0
- ratify_protocol/types.py +408 -0
- ratify_protocol/verify.py +497 -0
- ratify_protocol-1.0.0a5.dist-info/METADATA +228 -0
- ratify_protocol-1.0.0a5.dist-info/RECORD +11 -0
- ratify_protocol-1.0.0a5.dist-info/WHEEL +5 -0
- ratify_protocol-1.0.0a5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Ratify Protocol v1 — Python reference SDK.
|
|
2
|
+
|
|
3
|
+
A cryptographic trust protocol for human-agent and agent-agent interactions
|
|
4
|
+
as agents start to transact. Every signature is hybrid Ed25519 + ML-DSA-65
|
|
5
|
+
(FIPS 204): quantum-safe by design.
|
|
6
|
+
|
|
7
|
+
Quickstart:
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from ratify_protocol import (
|
|
11
|
+
generate_human_root, generate_agent,
|
|
12
|
+
DelegationCert, ProofBundle, VerifyOptions,
|
|
13
|
+
PROTOCOL_VERSION, SCOPE_MEETING_ATTEND,
|
|
14
|
+
issue_delegation, sign_challenge, generate_challenge,
|
|
15
|
+
derive_id, verify_bundle,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
root, root_priv = generate_human_root()
|
|
19
|
+
agent, agent_priv = generate_agent("Alice's Assistant", "voice_agent")
|
|
20
|
+
|
|
21
|
+
cert = DelegationCert(
|
|
22
|
+
cert_id="cert-1", version=PROTOCOL_VERSION,
|
|
23
|
+
issuer_id=root.id, issuer_pub_key=root.public_key,
|
|
24
|
+
subject_id=agent.id, subject_pub_key=agent.public_key,
|
|
25
|
+
scope=[SCOPE_MEETING_ATTEND],
|
|
26
|
+
issued_at=0, expires_at=2000000000,
|
|
27
|
+
signature=None, # filled by issue_delegation
|
|
28
|
+
)
|
|
29
|
+
issue_delegation(cert, root_priv)
|
|
30
|
+
# ... then agent builds a ProofBundle, verifier runs verify_bundle(bundle)
|
|
31
|
+
|
|
32
|
+
See docs/EXPLAINED.md and docs/AGENT_TO_AGENT.md for full semantics.
|
|
33
|
+
"""
|
|
34
|
+
from .canonical import (
|
|
35
|
+
base64_standard_decode,
|
|
36
|
+
base64_standard_encode,
|
|
37
|
+
canonical_json,
|
|
38
|
+
hex_decode,
|
|
39
|
+
hex_encode,
|
|
40
|
+
)
|
|
41
|
+
from .crypto import (
|
|
42
|
+
chain_hash,
|
|
43
|
+
challenge_sign_bytes,
|
|
44
|
+
delegation_sign_bytes,
|
|
45
|
+
derive_id,
|
|
46
|
+
generate_agent,
|
|
47
|
+
generate_challenge,
|
|
48
|
+
generate_human_root,
|
|
49
|
+
generate_hybrid_keypair,
|
|
50
|
+
hybrid_keypair_from_seeds,
|
|
51
|
+
issue_delegation,
|
|
52
|
+
issue_key_rotation_statement,
|
|
53
|
+
issue_revocation_list,
|
|
54
|
+
issue_revocation_push,
|
|
55
|
+
issue_session_token,
|
|
56
|
+
issue_witness_entry,
|
|
57
|
+
key_rotation_sign_bytes,
|
|
58
|
+
revocation_push_sign_bytes,
|
|
59
|
+
revocation_sign_bytes,
|
|
60
|
+
session_token_sign_bytes,
|
|
61
|
+
sign_both,
|
|
62
|
+
sign_challenge,
|
|
63
|
+
sign_transaction_receipt_party,
|
|
64
|
+
transaction_receipt_sign_bytes,
|
|
65
|
+
verify_both,
|
|
66
|
+
verify_challenge_signature,
|
|
67
|
+
verify_delegation_signature,
|
|
68
|
+
verify_delegation_signature_e,
|
|
69
|
+
verify_key_rotation_statement,
|
|
70
|
+
verify_key_rotation_statement_e,
|
|
71
|
+
verify_revocation_list,
|
|
72
|
+
verify_revocation_push,
|
|
73
|
+
verify_session_token,
|
|
74
|
+
verify_session_token_e,
|
|
75
|
+
verify_witness_entry,
|
|
76
|
+
witness_entry_sign_bytes,
|
|
77
|
+
)
|
|
78
|
+
from .scope import (
|
|
79
|
+
CUSTOM_SCOPE_PREFIX,
|
|
80
|
+
SCOPE_COMMS_CALENDAR_READ,
|
|
81
|
+
SCOPE_COMMS_CALENDAR_WRITE,
|
|
82
|
+
SCOPE_COMMS_EMAIL_DELETE,
|
|
83
|
+
SCOPE_COMMS_EMAIL_READ,
|
|
84
|
+
SCOPE_COMMS_EMAIL_SEND,
|
|
85
|
+
SCOPE_COMMS_MESSAGE_DELETE,
|
|
86
|
+
SCOPE_COMMS_MESSAGE_READ,
|
|
87
|
+
SCOPE_COMMS_MESSAGE_SEND,
|
|
88
|
+
SCOPE_CONTRACT_READ,
|
|
89
|
+
SCOPE_CONTRACT_SIGN,
|
|
90
|
+
SCOPE_DATA_DELETE,
|
|
91
|
+
SCOPE_DATA_EXPORT,
|
|
92
|
+
SCOPE_DATA_READ,
|
|
93
|
+
SCOPE_DATA_SHARE,
|
|
94
|
+
SCOPE_DATA_WRITE,
|
|
95
|
+
SCOPE_EXECUTE_CODE,
|
|
96
|
+
SCOPE_EXECUTE_TOOL,
|
|
97
|
+
SCOPE_FILES_READ,
|
|
98
|
+
SCOPE_FILES_WRITE,
|
|
99
|
+
SCOPE_GENERATE_CONTENT,
|
|
100
|
+
SCOPE_GENERATE_DEEPFAKE,
|
|
101
|
+
SCOPE_IDENTITY_DELEGATE,
|
|
102
|
+
SCOPE_IDENTITY_PROVE,
|
|
103
|
+
SCOPE_MEETING_ATTEND,
|
|
104
|
+
SCOPE_MEETING_CHAT,
|
|
105
|
+
SCOPE_MEETING_RECORD,
|
|
106
|
+
SCOPE_MEETING_SHARE_SCREEN,
|
|
107
|
+
SCOPE_MEETING_SPEAK,
|
|
108
|
+
SCOPE_MEETING_VIDEO,
|
|
109
|
+
SCOPE_PAYMENTS_AUTHORIZE,
|
|
110
|
+
SCOPE_PAYMENTS_RECEIVE,
|
|
111
|
+
SCOPE_PAYMENTS_SEND,
|
|
112
|
+
SCOPE_TRANSACT_PURCHASE,
|
|
113
|
+
SCOPE_TRANSACT_SELL,
|
|
114
|
+
expand_scopes,
|
|
115
|
+
has_scope,
|
|
116
|
+
intersect_scopes,
|
|
117
|
+
is_sensitive,
|
|
118
|
+
validate_scopes,
|
|
119
|
+
)
|
|
120
|
+
from .types import (
|
|
121
|
+
CHALLENGE_WINDOW_SECONDS,
|
|
122
|
+
ED25519_PUBLIC_KEY_SIZE,
|
|
123
|
+
ED25519_SIGNATURE_SIZE,
|
|
124
|
+
MAX_DELEGATION_CHAIN_DEPTH,
|
|
125
|
+
MLDSA65_PUBLIC_KEY_SIZE,
|
|
126
|
+
MLDSA65_SIGNATURE_SIZE,
|
|
127
|
+
PROTOCOL_VERSION,
|
|
128
|
+
AgentIdentity,
|
|
129
|
+
Anchor,
|
|
130
|
+
Constraint,
|
|
131
|
+
DelegationCert,
|
|
132
|
+
HumanRoot,
|
|
133
|
+
HybridPrivateKey,
|
|
134
|
+
HybridPublicKey,
|
|
135
|
+
HybridSignature,
|
|
136
|
+
IdentityStatus,
|
|
137
|
+
KeyRotationReason,
|
|
138
|
+
KeyRotationStatement,
|
|
139
|
+
ProofBundle,
|
|
140
|
+
ReceiptParty,
|
|
141
|
+
ReceiptPartySignature,
|
|
142
|
+
RevocationList,
|
|
143
|
+
RevocationPush,
|
|
144
|
+
SessionToken,
|
|
145
|
+
StreamContext,
|
|
146
|
+
TransactionReceipt,
|
|
147
|
+
TransactionReceiptResult,
|
|
148
|
+
VerifierContext,
|
|
149
|
+
VerifyOptions,
|
|
150
|
+
VerifyResult,
|
|
151
|
+
WitnessEntry,
|
|
152
|
+
)
|
|
153
|
+
from .verify import verify_bundle, verify_streamed_turn, verify_transaction_receipt
|
|
154
|
+
|
|
155
|
+
__version__ = "1.0.0a5"
|
|
156
|
+
|
|
157
|
+
__all__ = [
|
|
158
|
+
# types
|
|
159
|
+
"PROTOCOL_VERSION", "MAX_DELEGATION_CHAIN_DEPTH", "CHALLENGE_WINDOW_SECONDS",
|
|
160
|
+
"ED25519_PUBLIC_KEY_SIZE", "ED25519_SIGNATURE_SIZE",
|
|
161
|
+
"MLDSA65_PUBLIC_KEY_SIZE", "MLDSA65_SIGNATURE_SIZE",
|
|
162
|
+
"HybridPublicKey", "HybridSignature", "HybridPrivateKey",
|
|
163
|
+
"Anchor", "HumanRoot", "AgentIdentity",
|
|
164
|
+
"DelegationCert", "ProofBundle", "KeyRotationStatement", "KeyRotationReason",
|
|
165
|
+
"VerifyResult", "VerifyOptions", "StreamContext", "SessionToken",
|
|
166
|
+
"RevocationList", "RevocationPush", "WitnessEntry", "IdentityStatus",
|
|
167
|
+
"TransactionReceipt", "ReceiptParty", "ReceiptPartySignature",
|
|
168
|
+
"TransactionReceiptResult",
|
|
169
|
+
# crypto
|
|
170
|
+
"derive_id", "generate_hybrid_keypair", "hybrid_keypair_from_seeds",
|
|
171
|
+
"generate_human_root", "generate_agent",
|
|
172
|
+
"delegation_sign_bytes", "challenge_sign_bytes", "revocation_sign_bytes",
|
|
173
|
+
"key_rotation_sign_bytes", "revocation_push_sign_bytes", "witness_entry_sign_bytes",
|
|
174
|
+
"session_token_sign_bytes", "chain_hash",
|
|
175
|
+
"transaction_receipt_sign_bytes", "sign_transaction_receipt_party",
|
|
176
|
+
"sign_both", "verify_both",
|
|
177
|
+
"issue_delegation", "verify_delegation_signature", "verify_delegation_signature_e",
|
|
178
|
+
"issue_key_rotation_statement", "verify_key_rotation_statement",
|
|
179
|
+
"verify_key_rotation_statement_e",
|
|
180
|
+
"issue_session_token", "verify_session_token", "verify_session_token_e",
|
|
181
|
+
"sign_challenge", "verify_challenge_signature",
|
|
182
|
+
"issue_revocation_list", "verify_revocation_list",
|
|
183
|
+
"issue_revocation_push", "verify_revocation_push",
|
|
184
|
+
"issue_witness_entry", "verify_witness_entry",
|
|
185
|
+
"generate_challenge",
|
|
186
|
+
# scope
|
|
187
|
+
"expand_scopes", "intersect_scopes", "has_scope", "is_sensitive", "validate_scopes",
|
|
188
|
+
# verify
|
|
189
|
+
"verify_bundle", "verify_streamed_turn", "verify_transaction_receipt",
|
|
190
|
+
# canonical / utils
|
|
191
|
+
"canonical_json", "base64_standard_encode", "base64_standard_decode",
|
|
192
|
+
"hex_encode", "hex_decode",
|
|
193
|
+
# all scope constants
|
|
194
|
+
"SCOPE_MEETING_ATTEND", "SCOPE_MEETING_SPEAK", "SCOPE_MEETING_VIDEO",
|
|
195
|
+
"SCOPE_MEETING_CHAT", "SCOPE_MEETING_SHARE_SCREEN", "SCOPE_MEETING_RECORD",
|
|
196
|
+
"SCOPE_COMMS_MESSAGE_READ", "SCOPE_COMMS_MESSAGE_SEND", "SCOPE_COMMS_MESSAGE_DELETE",
|
|
197
|
+
"SCOPE_COMMS_EMAIL_READ", "SCOPE_COMMS_EMAIL_SEND", "SCOPE_COMMS_EMAIL_DELETE",
|
|
198
|
+
"SCOPE_COMMS_CALENDAR_READ", "SCOPE_COMMS_CALENDAR_WRITE",
|
|
199
|
+
"SCOPE_FILES_READ", "SCOPE_FILES_WRITE",
|
|
200
|
+
"SCOPE_IDENTITY_PROVE", "SCOPE_IDENTITY_DELEGATE",
|
|
201
|
+
"SCOPE_TRANSACT_PURCHASE", "SCOPE_TRANSACT_SELL",
|
|
202
|
+
"SCOPE_PAYMENTS_SEND", "SCOPE_PAYMENTS_RECEIVE", "SCOPE_PAYMENTS_AUTHORIZE",
|
|
203
|
+
"SCOPE_CONTRACT_READ", "SCOPE_CONTRACT_SIGN",
|
|
204
|
+
"SCOPE_DATA_READ", "SCOPE_DATA_WRITE", "SCOPE_DATA_DELETE",
|
|
205
|
+
"SCOPE_DATA_EXPORT", "SCOPE_DATA_SHARE",
|
|
206
|
+
"SCOPE_EXECUTE_TOOL", "SCOPE_EXECUTE_CODE",
|
|
207
|
+
"SCOPE_GENERATE_CONTENT", "SCOPE_GENERATE_DEEPFAKE",
|
|
208
|
+
"CUSTOM_SCOPE_PREFIX",
|
|
209
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Canonical JSON serialization per Ratify Protocol SPEC §6.
|
|
2
|
+
|
|
3
|
+
Every implementation MUST produce byte-identical output for the same input
|
|
4
|
+
or signatures will not verify across languages.
|
|
5
|
+
|
|
6
|
+
Rules:
|
|
7
|
+
- Object members in lex order (byte order on UTF-8), RECURSIVELY.
|
|
8
|
+
- No whitespace between tokens. No trailing newline.
|
|
9
|
+
- UTF-8 encoding.
|
|
10
|
+
- Integers as shortest decimal (no leading zeros, no exponent).
|
|
11
|
+
- bytes-type values encoded as base64-standard strings with padding.
|
|
12
|
+
- '<', '>', '&' pass through unmodified (NO HTML escaping).
|
|
13
|
+
- U+2028 / U+2029 escaped as \\u2028 / \\u2029 (matches Go behavior).
|
|
14
|
+
- Minimum string escaping per RFC 8259.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
from dataclasses import is_dataclass, fields
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_ESCAPE_MAP = {
|
|
24
|
+
ord('"'): b'\\"',
|
|
25
|
+
ord('\\'): b'\\\\',
|
|
26
|
+
0x08: b'\\b',
|
|
27
|
+
0x0c: b'\\f',
|
|
28
|
+
0x0a: b'\\n',
|
|
29
|
+
0x0d: b'\\r',
|
|
30
|
+
0x09: b'\\t',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def canonical_json(value: Any) -> bytes:
|
|
35
|
+
"""Canonical JSON-encode a value. Returns UTF-8 bytes."""
|
|
36
|
+
text = _encode(value)
|
|
37
|
+
return text.encode("utf-8")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _encode(v: Any) -> str:
|
|
41
|
+
if v is None:
|
|
42
|
+
return "null"
|
|
43
|
+
if v is True:
|
|
44
|
+
return "true"
|
|
45
|
+
if v is False:
|
|
46
|
+
return "false"
|
|
47
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
48
|
+
return str(v)
|
|
49
|
+
if isinstance(v, float):
|
|
50
|
+
# Shouldn't appear in v1 signable objects; fall back to repr-like
|
|
51
|
+
if v.is_integer():
|
|
52
|
+
return str(int(v))
|
|
53
|
+
return repr(v)
|
|
54
|
+
if isinstance(v, str):
|
|
55
|
+
return _encode_string(v)
|
|
56
|
+
if isinstance(v, (bytes, bytearray)):
|
|
57
|
+
# Project convention: base64-standard encoding.
|
|
58
|
+
return _encode_string(base64.b64encode(bytes(v)).decode("ascii"))
|
|
59
|
+
if isinstance(v, (list, tuple)):
|
|
60
|
+
return "[" + ",".join(_encode(x) for x in v) + "]"
|
|
61
|
+
if isinstance(v, dict):
|
|
62
|
+
return _encode_object(v)
|
|
63
|
+
if is_dataclass(v):
|
|
64
|
+
return _encode_dataclass(v)
|
|
65
|
+
raise TypeError(f"canonical_json: unsupported type {type(v).__name__}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _encode_object(obj: dict) -> str:
|
|
69
|
+
keys = sorted(obj.keys())
|
|
70
|
+
parts = []
|
|
71
|
+
for k in keys:
|
|
72
|
+
val = obj[k]
|
|
73
|
+
if val is None:
|
|
74
|
+
# Skip None values — matches Go's omitempty for optional fields
|
|
75
|
+
continue
|
|
76
|
+
parts.append(_encode_string(k) + ":" + _encode(val))
|
|
77
|
+
return "{" + ",".join(parts) + "}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _encode_dataclass(obj: Any) -> str:
|
|
81
|
+
"""Serialize a dataclass as a JSON object.
|
|
82
|
+
|
|
83
|
+
Uses the dataclass field *definition* order as the input set, then sorts
|
|
84
|
+
by JSON key (field name) for canonical output. Omits fields whose value
|
|
85
|
+
is None or, for list[str] scope-like fields, empty — to match Go's
|
|
86
|
+
omitempty behavior on optional fields.
|
|
87
|
+
"""
|
|
88
|
+
entries: dict[str, Any] = {}
|
|
89
|
+
for f in fields(obj):
|
|
90
|
+
val = getattr(obj, f.name)
|
|
91
|
+
if val is None:
|
|
92
|
+
continue
|
|
93
|
+
# Convert nested bytes to the right shape; strings / lists pass through.
|
|
94
|
+
entries[f.name] = val
|
|
95
|
+
return _encode_object(entries)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _encode_string(s: str) -> str:
|
|
99
|
+
"""Encode a string per Ratify canonical rules.
|
|
100
|
+
|
|
101
|
+
No HTML escaping. U+2028/U+2029 escape as \\u2028/\\u2029. Control chars
|
|
102
|
+
below U+0020 escape as \\u00XX. Standard RFC 8259 escapes for ", \\, etc.
|
|
103
|
+
Everything else passes through.
|
|
104
|
+
"""
|
|
105
|
+
out = ['"']
|
|
106
|
+
for ch in s:
|
|
107
|
+
cp = ord(ch)
|
|
108
|
+
if cp == 0x22: # "
|
|
109
|
+
out.append('\\"')
|
|
110
|
+
elif cp == 0x5c: # \
|
|
111
|
+
out.append('\\\\')
|
|
112
|
+
elif cp == 0x08:
|
|
113
|
+
out.append('\\b')
|
|
114
|
+
elif cp == 0x09:
|
|
115
|
+
out.append('\\t')
|
|
116
|
+
elif cp == 0x0a:
|
|
117
|
+
out.append('\\n')
|
|
118
|
+
elif cp == 0x0c:
|
|
119
|
+
out.append('\\f')
|
|
120
|
+
elif cp == 0x0d:
|
|
121
|
+
out.append('\\r')
|
|
122
|
+
elif cp < 0x20:
|
|
123
|
+
out.append(f'\\u{cp:04x}')
|
|
124
|
+
elif cp == 0x2028:
|
|
125
|
+
out.append('\\u2028')
|
|
126
|
+
elif cp == 0x2029:
|
|
127
|
+
out.append('\\u2029')
|
|
128
|
+
else:
|
|
129
|
+
# Everything else passes through as UTF-8 (no HTML escape).
|
|
130
|
+
out.append(ch)
|
|
131
|
+
out.append('"')
|
|
132
|
+
return "".join(out)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def base64_standard_encode(data: bytes) -> str:
|
|
136
|
+
"""Base64-standard encoding with padding (A-Za-z0-9+/=)."""
|
|
137
|
+
return base64.b64encode(data).decode("ascii")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def base64_standard_decode(s: str) -> bytes:
|
|
141
|
+
"""Decode base64-standard (padded or unpadded)."""
|
|
142
|
+
# Add padding if needed
|
|
143
|
+
pad = (-len(s)) % 4
|
|
144
|
+
if pad:
|
|
145
|
+
s = s + "=" * pad
|
|
146
|
+
return base64.b64decode(s)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def hex_encode(data: bytes) -> str:
|
|
150
|
+
"""Lowercase hex."""
|
|
151
|
+
return data.hex()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def hex_decode(s: str) -> bytes:
|
|
155
|
+
"""Lower- or upper-case hex."""
|
|
156
|
+
return bytes.fromhex(s)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Constraint evaluation — mirrors Go's constraints.go exactly.
|
|
2
|
+
|
|
3
|
+
Every semantic must produce the same verdict for the same inputs, or
|
|
4
|
+
cross-language conformance fails.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
11
|
+
|
|
12
|
+
from .types import Constraint, DelegationCert, VerifierContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def evaluate_constraints(
|
|
16
|
+
cert: DelegationCert,
|
|
17
|
+
ctx: VerifierContext | None,
|
|
18
|
+
now_sec: int,
|
|
19
|
+
) -> str | None:
|
|
20
|
+
"""Run every Constraint on cert against the caller-supplied
|
|
21
|
+
VerifierContext. Return None iff all pass; error message otherwise.
|
|
22
|
+
|
|
23
|
+
Fail-closed: unknown Type or missing required context field causes
|
|
24
|
+
rejection.
|
|
25
|
+
"""
|
|
26
|
+
if ctx is None:
|
|
27
|
+
ctx = VerifierContext()
|
|
28
|
+
for i, c in enumerate(cert.constraints or []):
|
|
29
|
+
err = _evaluate_constraint(c, cert.cert_id, ctx, now_sec)
|
|
30
|
+
if err is not None:
|
|
31
|
+
return f"constraint[{i}] ({c.type}): {err}"
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _evaluate_constraint(
|
|
36
|
+
c: Constraint,
|
|
37
|
+
cert_id: str,
|
|
38
|
+
ctx: VerifierContext,
|
|
39
|
+
now_sec: int,
|
|
40
|
+
) -> str | None:
|
|
41
|
+
t = c.type
|
|
42
|
+
if t == "geo_circle":
|
|
43
|
+
if ctx.current_lat is None or ctx.current_lon is None:
|
|
44
|
+
return "constraint_unverifiable: no current location in context"
|
|
45
|
+
d = _haversine_meters(ctx.current_lat, ctx.current_lon, c.lat, c.lon)
|
|
46
|
+
if d > c.radius_m:
|
|
47
|
+
return f"outside allowed radius: {d:.1f}m > {c.radius_m:.1f}m"
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
if t == "geo_polygon":
|
|
51
|
+
if ctx.current_lat is None or ctx.current_lon is None:
|
|
52
|
+
return "constraint_unverifiable: no current location in context"
|
|
53
|
+
if not c.points or len(c.points) < 3:
|
|
54
|
+
return "polygon has fewer than 3 points"
|
|
55
|
+
# v1 polygon is defined over equirectangular projection — correct
|
|
56
|
+
# for small regions, incorrect for anti-meridian-crossing shapes.
|
|
57
|
+
# Fail closed rather than silently return wrong answers. SPEC §5.7.2
|
|
58
|
+
# documents the v1 small-region limitation; geodesic semantics are v2.
|
|
59
|
+
if _polygon_spans_antimeridian(c.points):
|
|
60
|
+
return "geo_polygon spans >180° longitude — v1 semantics are undefined for anti-meridian-crossing polygons (see SPEC §5.7.2)"
|
|
61
|
+
if not _point_in_polygon(ctx.current_lat, ctx.current_lon, c.points):
|
|
62
|
+
return "outside allowed polygon"
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
if t == "geo_bbox":
|
|
66
|
+
if ctx.current_lat is None or ctx.current_lon is None:
|
|
67
|
+
return "constraint_unverifiable: no current location in context"
|
|
68
|
+
# Format mirrors Go's %.6f — cross-SDK error_reason parity requires it.
|
|
69
|
+
if ctx.current_lat < c.min_lat or ctx.current_lat > c.max_lat:
|
|
70
|
+
return f"latitude {ctx.current_lat:.6f} outside [{c.min_lat:.6f}, {c.max_lat:.6f}]"
|
|
71
|
+
# Anti-meridian-aware longitude check (SPEC §5.7.2).
|
|
72
|
+
# min_lon <= max_lon → ordinary bbox, lon must be in [min_lon, max_lon]
|
|
73
|
+
# min_lon > max_lon → bbox wraps the 180° meridian (e.g.,
|
|
74
|
+
# min_lon=170, max_lon=-170 = "from 170°E through 180 to -170°W")
|
|
75
|
+
# — inside iff lon >= min_lon OR lon <= max_lon.
|
|
76
|
+
if c.min_lon <= c.max_lon:
|
|
77
|
+
if ctx.current_lon < c.min_lon or ctx.current_lon > c.max_lon:
|
|
78
|
+
return f"longitude {ctx.current_lon:.6f} outside [{c.min_lon:.6f}, {c.max_lon:.6f}]"
|
|
79
|
+
else:
|
|
80
|
+
if ctx.current_lon < c.min_lon and ctx.current_lon > c.max_lon:
|
|
81
|
+
return f"longitude {ctx.current_lon:.6f} outside wrapped [{c.min_lon:.6f}, {c.max_lon:.6f}]"
|
|
82
|
+
has_alt = c.min_alt_m != 0.0 or c.max_alt_m != 0.0
|
|
83
|
+
if has_alt:
|
|
84
|
+
if ctx.current_alt_m is None:
|
|
85
|
+
return "constraint_unverifiable: no altitude in context but bbox has altitude bounds"
|
|
86
|
+
if ctx.current_alt_m < c.min_alt_m or ctx.current_alt_m > c.max_alt_m:
|
|
87
|
+
return f"altitude {ctx.current_alt_m:.1f}m outside [{c.min_alt_m:.1f}m, {c.max_alt_m:.1f}m]"
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
if t == "time_window":
|
|
91
|
+
if not c.tz or not c.start or not c.end:
|
|
92
|
+
return "malformed time_window: tz/start/end required"
|
|
93
|
+
start = _parse_hhmm(c.start)
|
|
94
|
+
end = _parse_hhmm(c.end)
|
|
95
|
+
if start is None:
|
|
96
|
+
return f"bad start time: {c.start}"
|
|
97
|
+
if end is None:
|
|
98
|
+
return f"bad end time: {c.end}"
|
|
99
|
+
try:
|
|
100
|
+
zone = ZoneInfo(c.tz)
|
|
101
|
+
except ZoneInfoNotFoundError:
|
|
102
|
+
return f'unknown timezone "{c.tz}"'
|
|
103
|
+
local = datetime.fromtimestamp(now_sec, tz=timezone.utc).astimezone(zone)
|
|
104
|
+
cur = local.hour * 60 + local.minute
|
|
105
|
+
if start <= end:
|
|
106
|
+
if cur < start or cur > end:
|
|
107
|
+
return f"current {local.hour:02d}:{local.minute:02d} outside [{c.start}, {c.end}] {c.tz}"
|
|
108
|
+
else:
|
|
109
|
+
# Wrapping window (e.g. 22:00 to 06:00).
|
|
110
|
+
if cur < start and cur > end:
|
|
111
|
+
return f"current {local.hour:02d}:{local.minute:02d} outside wrapped [{c.start}, {c.end}] {c.tz}"
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
if t == "max_speed_mps":
|
|
115
|
+
if ctx.current_speed_mps is None:
|
|
116
|
+
return "constraint_unverifiable: no current speed in context"
|
|
117
|
+
if ctx.current_speed_mps > c.max_mps:
|
|
118
|
+
return f"speed {ctx.current_speed_mps:.2f}mps exceeds max {c.max_mps:.2f}mps"
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
if t == "max_amount":
|
|
122
|
+
if ctx.requested_amount is None or ctx.requested_currency is None:
|
|
123
|
+
return "constraint_unverifiable: no requested amount in context"
|
|
124
|
+
if ctx.requested_currency != c.currency:
|
|
125
|
+
return f'currency mismatch: requested "{ctx.requested_currency}", constraint "{c.currency}"'
|
|
126
|
+
if ctx.requested_amount > c.max_amount:
|
|
127
|
+
return f"amount {ctx.requested_amount:.2f} {ctx.requested_currency} exceeds max {c.max_amount:.2f} {c.currency}"
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if t == "max_rate":
|
|
131
|
+
if ctx.invocations_in_window is None:
|
|
132
|
+
return "constraint_unverifiable: no rate counter in context"
|
|
133
|
+
if c.count <= 0 or c.window_s <= 0:
|
|
134
|
+
return "malformed max_rate: count and window_s must be positive"
|
|
135
|
+
got = ctx.invocations_in_window(cert_id, c.window_s)
|
|
136
|
+
if got >= c.count:
|
|
137
|
+
return f"rate limit exceeded: {got} invocations in last {c.window_s}s (max {c.count})"
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
# Sentinel prefix lets verify.py route to identity_status=constraint_unknown.
|
|
141
|
+
# Matches Go reference unknownConstraintError shape.
|
|
142
|
+
return f'constraint_unknown: unknown constraint type "{t}"'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---- helpers ----
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _haversine_meters(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
149
|
+
"""Great-circle distance on a sphere (WGS-84 mean radius)."""
|
|
150
|
+
earth_radius_m = 6371000.0
|
|
151
|
+
rad = math.pi / 180
|
|
152
|
+
d_lat = (lat2 - lat1) * rad
|
|
153
|
+
d_lon = (lon2 - lon1) * rad
|
|
154
|
+
a = (
|
|
155
|
+
math.sin(d_lat / 2) ** 2
|
|
156
|
+
+ math.cos(lat1 * rad) * math.cos(lat2 * rad) * math.sin(d_lon / 2) ** 2
|
|
157
|
+
)
|
|
158
|
+
return 2 * earth_radius_m * math.asin(min(1.0, math.sqrt(a)))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _polygon_spans_antimeridian(points: list[list[float]]) -> bool:
|
|
162
|
+
"""Return True if the polygon's longitudes span more than 180°.
|
|
163
|
+
|
|
164
|
+
The only way to span more than half the globe in longitude is to
|
|
165
|
+
cross the anti-meridian, and equirectangular ray-casting can't handle
|
|
166
|
+
that — so we refuse these polygons up-front (fail-closed) rather than
|
|
167
|
+
silently compute wrong inclusion. Mirrors Go's polygonSpansAntimeridian.
|
|
168
|
+
"""
|
|
169
|
+
if len(points) < 3:
|
|
170
|
+
return False
|
|
171
|
+
min_lon = points[0][1]
|
|
172
|
+
max_lon = points[0][1]
|
|
173
|
+
for p in points:
|
|
174
|
+
if p[1] < min_lon:
|
|
175
|
+
min_lon = p[1]
|
|
176
|
+
if p[1] > max_lon:
|
|
177
|
+
max_lon = p[1]
|
|
178
|
+
return (max_lon - min_lon) > 180
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _point_in_polygon(lat: float, lon: float, poly: list[list[float]]) -> bool:
|
|
182
|
+
"""Ray casting in equirectangular projection. Fine for small polygons."""
|
|
183
|
+
inside = False
|
|
184
|
+
n = len(poly)
|
|
185
|
+
j = n - 1
|
|
186
|
+
for i in range(n):
|
|
187
|
+
yi, xi = poly[i][0], poly[i][1] # lat, lon
|
|
188
|
+
yj, xj = poly[j][0], poly[j][1]
|
|
189
|
+
if ((yi > lat) != (yj > lat)) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
|
|
190
|
+
inside = not inside
|
|
191
|
+
j = i
|
|
192
|
+
return inside
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_hhmm(s: str) -> int | None:
|
|
196
|
+
if len(s) != 5 or s[2] != ":":
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
h = int(s[:2])
|
|
200
|
+
m = int(s[3:])
|
|
201
|
+
except ValueError:
|
|
202
|
+
return None
|
|
203
|
+
if h < 0 or h > 23 or m < 0 or m > 59:
|
|
204
|
+
return None
|
|
205
|
+
return h * 60 + m
|