codespar 0.10.2__tar.gz → 0.11.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.
- {codespar-0.10.2 → codespar-0.11.0}/CHANGELOG.md +17 -0
- {codespar-0.10.2 → codespar-0.11.0}/PKG-INFO +4 -1
- {codespar-0.10.2 → codespar-0.11.0}/pyproject.toml +8 -1
- codespar-0.11.0/src/codespar/mandate.py +320 -0
- codespar-0.11.0/tests/_fixtures/canonical.v3.fixture.json +28 -0
- codespar-0.11.0/tests/test_mandate.py +217 -0
- {codespar-0.10.2 → codespar-0.11.0}/.gitignore +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/README.md +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/examples/README.md +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/examples/async_basic.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/examples/connect_link.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/examples/ecommerce_checkout.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/examples/mocks_round_trip.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/examples/pix_payment.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/examples/proxy_execute.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/__init__.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_async_client.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_async_session.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_http.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_presets.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_sync_client.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/errors.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/py.typed +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/tool_result_codes.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/src/codespar/types.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/__init__.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/_fixtures/mocks_canonical.json +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/_fixtures/shop_canonical.json +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_base_url_resolution.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_config.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_error_code_precedence.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_forward_mocks.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_mocks_types.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_session.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_shop.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_streaming.py +0 -0
- {codespar-0.10.2 → codespar-0.11.0}/tests/test_tool_result_codes.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# codespar (Python SDK) — CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
Offline V3 mandate verification lands as the `codespar.mandate`
|
|
6
|
+
submodule, in parity with `@codespar/sdk/mandate` and the CLI. See
|
|
7
|
+
[codespar/codespar-core#114](https://github.com/codespar/codespar-core/pull/114).
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `codespar.mandate`: `decode_mandate_token` and
|
|
12
|
+
`reconstruct_signing_string` are pure stdlib; `verify_ed25519` and
|
|
13
|
+
`verify_mandate_token` verify the V3 dual Ed25519 signatures and
|
|
14
|
+
lazily import `cryptography`, raising a clear install hint when it is
|
|
15
|
+
absent. New optional extra: `pip install codespar[verify]`. The base
|
|
16
|
+
package keeps its single runtime dependency (httpx).
|
|
17
|
+
- The canonical signing string is byte-locked against the platform's
|
|
18
|
+
frozen fixture, shared with the TS SDK and CLI tests.
|
|
19
|
+
|
|
3
20
|
## 0.10.0
|
|
4
21
|
|
|
5
22
|
The hosted test-mode SDK surface lands across the `codespar` Python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codespar
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America.
|
|
5
5
|
Project-URL: Homepage, https://codespar.dev
|
|
6
6
|
Project-URL: Documentation, https://docs.codespar.dev
|
|
@@ -22,11 +22,14 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Requires-Dist: httpx>=0.27.0
|
|
24
24
|
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
25
26
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
26
27
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
28
|
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
28
29
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
30
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Provides-Extra: verify
|
|
32
|
+
Requires-Dist: cryptography>=42; extra == 'verify'
|
|
30
33
|
Description-Content-Type: text/markdown
|
|
31
34
|
|
|
32
35
|
# codespar — Python SDK
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codespar"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
description = "Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -30,10 +30,17 @@ dependencies = [
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
|
+
# Offline V3 mandate verification (codespar.mandate) needs Ed25519, which is
|
|
34
|
+
# not in the stdlib. Opt in with `pip install codespar[verify]`; the base
|
|
35
|
+
# package keeps its single runtime dependency (httpx).
|
|
36
|
+
verify = [
|
|
37
|
+
"cryptography>=42",
|
|
38
|
+
]
|
|
33
39
|
dev = [
|
|
34
40
|
"pytest>=8.0",
|
|
35
41
|
"pytest-asyncio>=0.23",
|
|
36
42
|
"pytest-httpx>=0.30",
|
|
43
|
+
"cryptography>=42",
|
|
37
44
|
"ruff>=0.6",
|
|
38
45
|
"mypy>=1.11",
|
|
39
46
|
]
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline V3 mandate verification for the CodeSpar Python SDK.
|
|
3
|
+
|
|
4
|
+
A third party holding only a presentation token and a raw Ed25519 public key
|
|
5
|
+
(from the agent's ``did:web`` document) can reconstruct the exact signing string
|
|
6
|
+
and verify the agent + issuer signatures — no CodeSpar API call. This mirrors
|
|
7
|
+
``@codespar/sdk/mandate`` (TS) and ``codespar mandate verify`` (CLI); all three
|
|
8
|
+
byte-lock the canonical string against the same fixture, so they cannot drift.
|
|
9
|
+
|
|
10
|
+
Import it explicitly (``from codespar.mandate import verify_mandate_token``) — it
|
|
11
|
+
is not re-exported from the top-level ``codespar`` namespace, keeping the crypto
|
|
12
|
+
dependency out of the base import path. Decoding and signing-string
|
|
13
|
+
reconstruction are pure stdlib (zero dependencies). Ed25519 *verification* needs
|
|
14
|
+
the optional ``cryptography`` extra::
|
|
15
|
+
|
|
16
|
+
pip install 'codespar[verify]'
|
|
17
|
+
|
|
18
|
+
``verify_ed25519`` / ``verify_mandate_token`` (when given a key) raise a clear
|
|
19
|
+
``RuntimeError`` if that extra is not installed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DecodedMandateToken",
|
|
31
|
+
"MandateDecodeError",
|
|
32
|
+
"MandateVerification",
|
|
33
|
+
"SignatureCheck",
|
|
34
|
+
"agent_did_from_kid",
|
|
35
|
+
"decode_mandate_token",
|
|
36
|
+
"reconstruct_signing_string",
|
|
37
|
+
"verify_ed25519",
|
|
38
|
+
"verify_mandate_token",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MandateDecodeError(ValueError):
|
|
43
|
+
"""Raised when a token is not a well-formed mandate presentation token.
|
|
44
|
+
|
|
45
|
+
The message is the stable machine code: ``invalid_payload`` or
|
|
46
|
+
``mandate_format_unsupported``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class DecodedMandateToken:
|
|
52
|
+
"""A decoded token: the signed fields plus the signature envelope."""
|
|
53
|
+
|
|
54
|
+
mandate: dict[str, Any]
|
|
55
|
+
# Org-HMAC hex digest. Present on every version; NOT offline-verifiable
|
|
56
|
+
# (needs the org secret) — carried through for completeness.
|
|
57
|
+
signature: str
|
|
58
|
+
agent_sig: str | None = None
|
|
59
|
+
issuer_sig: str | None = None
|
|
60
|
+
kid: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SignatureCheck:
|
|
65
|
+
"""Per-signature outcome. ``skipped`` = present but no key supplied."""
|
|
66
|
+
|
|
67
|
+
present: bool
|
|
68
|
+
# "verified" | "failed" | "skipped" | "absent"
|
|
69
|
+
status: str
|
|
70
|
+
kid: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class MandateVerification:
|
|
75
|
+
"""Result of :func:`verify_mandate_token`."""
|
|
76
|
+
|
|
77
|
+
verified: bool
|
|
78
|
+
mandate: dict[str, Any]
|
|
79
|
+
agent: SignatureCheck
|
|
80
|
+
issuer: SignatureCheck
|
|
81
|
+
agent_did: str | None = None
|
|
82
|
+
kid: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _b64url_decode(s: str) -> bytes:
|
|
86
|
+
# Node emits base64url without padding; restore it before decoding.
|
|
87
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _s(value: Any) -> str:
|
|
91
|
+
# Absent optional fields render as the empty string so the separator count
|
|
92
|
+
# stays invariant — never the literal "None".
|
|
93
|
+
return "" if value is None else str(value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_valid_fields(r: dict[str, Any]) -> bool:
|
|
97
|
+
version = r.get("format_version")
|
|
98
|
+
if not isinstance(version, int) or isinstance(version, bool):
|
|
99
|
+
return False
|
|
100
|
+
if not isinstance(r.get("id"), str):
|
|
101
|
+
return False
|
|
102
|
+
if not isinstance(r.get("agent_id"), str):
|
|
103
|
+
return False
|
|
104
|
+
if r.get("type") not in ("payment", "subscription", "delegation"):
|
|
105
|
+
return False
|
|
106
|
+
if not isinstance(r.get("amount"), str):
|
|
107
|
+
return False
|
|
108
|
+
if not isinstance(r.get("currency"), str):
|
|
109
|
+
return False
|
|
110
|
+
if not isinstance(r.get("purposes"), list):
|
|
111
|
+
return False
|
|
112
|
+
expires_at = r.get("expires_at")
|
|
113
|
+
if not isinstance(expires_at, int) or isinstance(expires_at, bool):
|
|
114
|
+
return False
|
|
115
|
+
secret_version = r.get("secret_version")
|
|
116
|
+
if not isinstance(secret_version, int) or isinstance(secret_version, bool):
|
|
117
|
+
return False
|
|
118
|
+
# V3 binds the KYC'd principal and the signing key into the signed string.
|
|
119
|
+
if version == 3:
|
|
120
|
+
if not isinstance(r.get("principal_kyc_ref"), str):
|
|
121
|
+
return False
|
|
122
|
+
if not isinstance(r.get("agent_kid"), str):
|
|
123
|
+
return False
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def decode_mandate_token(token: str) -> DecodedMandateToken:
|
|
128
|
+
"""Decode a base64url JSON presentation token.
|
|
129
|
+
|
|
130
|
+
Splits the envelope (``signature`` / ``agent_sig`` / ``issuer_sig`` / ``kid``)
|
|
131
|
+
from the signed fields so ``mandate`` is exactly the field set the signatures
|
|
132
|
+
cover. Does not verify anything. Raises :class:`MandateDecodeError` on a
|
|
133
|
+
malformed or unsupported token.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
raw = json.loads(_b64url_decode(token).decode("utf-8"))
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
raise MandateDecodeError("invalid_payload") from exc
|
|
139
|
+
|
|
140
|
+
if not isinstance(raw, dict):
|
|
141
|
+
raise MandateDecodeError("invalid_payload")
|
|
142
|
+
|
|
143
|
+
version = raw.get("format_version")
|
|
144
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 2:
|
|
145
|
+
raise MandateDecodeError("mandate_format_unsupported")
|
|
146
|
+
|
|
147
|
+
if not _is_valid_fields(raw):
|
|
148
|
+
raise MandateDecodeError("invalid_payload")
|
|
149
|
+
|
|
150
|
+
signature = raw.get("signature")
|
|
151
|
+
if not isinstance(signature, str):
|
|
152
|
+
raise MandateDecodeError("invalid_payload")
|
|
153
|
+
|
|
154
|
+
envelope = {"signature", "agent_sig", "issuer_sig", "kid"}
|
|
155
|
+
mandate: dict[str, Any] = {k: v for k, v in raw.items() if k not in envelope}
|
|
156
|
+
|
|
157
|
+
def _str_or_none(key: str) -> str | None:
|
|
158
|
+
value = raw.get(key)
|
|
159
|
+
return value if isinstance(value, str) else None
|
|
160
|
+
|
|
161
|
+
return DecodedMandateToken(
|
|
162
|
+
mandate=mandate,
|
|
163
|
+
signature=signature,
|
|
164
|
+
agent_sig=_str_or_none("agent_sig"),
|
|
165
|
+
issuer_sig=_str_or_none("issuer_sig"),
|
|
166
|
+
kid=_str_or_none("kid"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def reconstruct_signing_string(fields: dict[str, Any]) -> str:
|
|
171
|
+
r"""Reconstruct the canonical signing string the Ed25519 signatures cover.
|
|
172
|
+
|
|
173
|
+
Field order (V3 = 14 fields, 13 ``:`` separators): V2's 12 fields then the
|
|
174
|
+
two V3-only fields (``principal_kyc_ref``, ``agent_kid``). Absent optionals
|
|
175
|
+
render empty; ``purposes`` is comma-joined after a lexicographic sort with
|
|
176
|
+
escaping (``\`` -> ``\\`` first, then ``,`` -> ``\,``). Colons inside
|
|
177
|
+
``agent_kid`` (from ``did:web``) are emitted verbatim. The V3 tail is
|
|
178
|
+
appended only for ``format_version >= 3``.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def esc(member: str) -> str:
|
|
182
|
+
return member.replace("\\", "\\\\").replace(",", "\\,")
|
|
183
|
+
|
|
184
|
+
purposes = ",".join(esc(p) for p in sorted(fields.get("purposes") or []))
|
|
185
|
+
version = int(fields.get("format_version") or 0)
|
|
186
|
+
parts = [
|
|
187
|
+
str(fields.get("format_version")),
|
|
188
|
+
_s(fields.get("id")),
|
|
189
|
+
_s(fields.get("agent_id")),
|
|
190
|
+
_s(fields.get("type")),
|
|
191
|
+
_s(fields.get("amount")),
|
|
192
|
+
_s(fields.get("currency")),
|
|
193
|
+
purposes,
|
|
194
|
+
str(fields.get("expires_at")),
|
|
195
|
+
_s(fields.get("max_amount")),
|
|
196
|
+
_s(fields.get("parent_id")),
|
|
197
|
+
_s(fields.get("denomination")),
|
|
198
|
+
str(fields.get("secret_version")),
|
|
199
|
+
]
|
|
200
|
+
if version >= 3:
|
|
201
|
+
parts.append(_s(fields.get("principal_kyc_ref")))
|
|
202
|
+
parts.append(_s(fields.get("agent_kid")))
|
|
203
|
+
return ":".join(parts)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def verify_ed25519(signing_string: str, signature_b64url: str, pubkey: bytes) -> bool:
|
|
207
|
+
"""Verify an Ed25519 signature (base64url) over a signing string.
|
|
208
|
+
|
|
209
|
+
Uses only the raw 32-byte public key. Returns ``False`` — never raises — on a
|
|
210
|
+
bad signature or malformed key. Requires the optional ``cryptography``
|
|
211
|
+
dependency; raises ``RuntimeError`` with install guidance if it is absent.
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
from cryptography.exceptions import InvalidSignature
|
|
215
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
216
|
+
Ed25519PublicKey,
|
|
217
|
+
)
|
|
218
|
+
except ImportError as exc:
|
|
219
|
+
raise RuntimeError(
|
|
220
|
+
"Ed25519 verification requires the optional 'cryptography' dependency. "
|
|
221
|
+
"Install it with: pip install 'codespar[verify]'"
|
|
222
|
+
) from exc
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
key = Ed25519PublicKey.from_public_bytes(pubkey)
|
|
226
|
+
except Exception:
|
|
227
|
+
# A malformed key is a verification failure, not an error.
|
|
228
|
+
return False
|
|
229
|
+
try:
|
|
230
|
+
key.verify(_b64url_decode(signature_b64url), signing_string.encode("utf-8"))
|
|
231
|
+
return True
|
|
232
|
+
except InvalidSignature:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _to_pubkey(key: str | bytes | bytearray | None) -> bytes | None:
|
|
237
|
+
if key is None:
|
|
238
|
+
return None
|
|
239
|
+
if isinstance(key, str):
|
|
240
|
+
clean = key.strip().lower()
|
|
241
|
+
if clean.startswith("0x"):
|
|
242
|
+
clean = clean[2:]
|
|
243
|
+
if len(clean) != 64 or any(c not in "0123456789abcdef" for c in clean):
|
|
244
|
+
return None
|
|
245
|
+
return bytes.fromhex(clean)
|
|
246
|
+
raw = bytes(key)
|
|
247
|
+
return raw if len(raw) == 32 else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def agent_did_from_kid(kid: str) -> str:
|
|
251
|
+
"""Strip the ``#<fragment>`` from an agent key id to recover the bare DID."""
|
|
252
|
+
idx = kid.find("#")
|
|
253
|
+
return kid if idx == -1 else kid[:idx]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _check_signature(
|
|
257
|
+
signing_string: str,
|
|
258
|
+
sig: str | None,
|
|
259
|
+
pubkey: bytes | None,
|
|
260
|
+
kid: str | None,
|
|
261
|
+
) -> SignatureCheck:
|
|
262
|
+
if sig is None:
|
|
263
|
+
return SignatureCheck(present=False, status="absent", kid=kid)
|
|
264
|
+
if pubkey is None:
|
|
265
|
+
return SignatureCheck(present=True, status="skipped", kid=kid)
|
|
266
|
+
ok = verify_ed25519(signing_string, sig, pubkey)
|
|
267
|
+
return SignatureCheck(present=True, status="verified" if ok else "failed", kid=kid)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def verify_mandate_token(
|
|
271
|
+
token: str,
|
|
272
|
+
*,
|
|
273
|
+
agent_public_key: str | bytes | bytearray | None = None,
|
|
274
|
+
issuer_public_key: str | bytes | bytearray | None = None,
|
|
275
|
+
) -> MandateVerification:
|
|
276
|
+
"""Offline-verify a V3 mandate presentation token against supplied keys.
|
|
277
|
+
|
|
278
|
+
Pure and network-free: pass the agent and/or issuer public keys (hex string
|
|
279
|
+
or raw bytes, from their ``did:web`` documents). A signature with a supplied
|
|
280
|
+
key that validates is ``verified``; a supplied key that fails is ``failed``;
|
|
281
|
+
a carried signature with no supplied key is ``skipped``; a signature the
|
|
282
|
+
token does not carry is ``absent``. ``verified`` is ``True`` iff at least one
|
|
283
|
+
signature verified and none failed.
|
|
284
|
+
|
|
285
|
+
Raises :class:`MandateDecodeError` on an undecodable token, and
|
|
286
|
+
``RuntimeError`` if a key is supplied but the ``cryptography`` extra is not
|
|
287
|
+
installed.
|
|
288
|
+
"""
|
|
289
|
+
decoded = decode_mandate_token(token)
|
|
290
|
+
mandate = decoded.mandate
|
|
291
|
+
signing_string = reconstruct_signing_string(mandate)
|
|
292
|
+
|
|
293
|
+
kid_value = decoded.kid or mandate.get("agent_kid")
|
|
294
|
+
kid = kid_value if isinstance(kid_value, str) else None
|
|
295
|
+
agent_did = agent_did_from_kid(kid) if kid is not None else None
|
|
296
|
+
|
|
297
|
+
agent = _check_signature(
|
|
298
|
+
signing_string,
|
|
299
|
+
decoded.agent_sig,
|
|
300
|
+
_to_pubkey(agent_public_key),
|
|
301
|
+
kid,
|
|
302
|
+
)
|
|
303
|
+
issuer = _check_signature(
|
|
304
|
+
signing_string,
|
|
305
|
+
decoded.issuer_sig,
|
|
306
|
+
_to_pubkey(issuer_public_key),
|
|
307
|
+
None,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
any_verified = agent.status == "verified" or issuer.status == "verified"
|
|
311
|
+
any_failed = agent.status == "failed" or issuer.status == "failed"
|
|
312
|
+
|
|
313
|
+
return MandateVerification(
|
|
314
|
+
verified=any_verified and not any_failed,
|
|
315
|
+
mandate=mandate,
|
|
316
|
+
agent=agent,
|
|
317
|
+
issuer=issuer,
|
|
318
|
+
agent_did=agent_did,
|
|
319
|
+
kid=kid,
|
|
320
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Build 2 V3 byte-frozen fixture. DO NOT CHANGE. Mirrors canonical.fixture.json for V3 (14 fields, 13 separators = V2's 12 + principal_kyc_ref + agent_kid). HMAC key is the same test key; Ed25519 seeds are sha256('codespar-build2-agent-key-fixture') and sha256('codespar-build2-issuer-key-fixture'). Ed25519 is deterministic (RFC 8032) so agent_sig/issuer_sig are frozen. agent_kid intentionally contains colons (did:web) to pin the colon-in-final-field behaviour.",
|
|
3
|
+
"key": "test-fixture-key-do-not-use-in-prod-0123456789abcdef",
|
|
4
|
+
"input": {
|
|
5
|
+
"format_version": 3,
|
|
6
|
+
"id": "mnd_v3_abc...",
|
|
7
|
+
"agent_id": "a1",
|
|
8
|
+
"type": "delegation",
|
|
9
|
+
"amount": "5000",
|
|
10
|
+
"currency": "BRL",
|
|
11
|
+
"purposes": ["refund", "utility"],
|
|
12
|
+
"expires_at": 1735689600,
|
|
13
|
+
"max_amount": null,
|
|
14
|
+
"parent_id": null,
|
|
15
|
+
"denomination": null,
|
|
16
|
+
"secret_version": 1,
|
|
17
|
+
"principal_kyc_ref": "kyc_celcoin_11144477735",
|
|
18
|
+
"agent_kid": "did:web:id.codespar.dev:org_demo:a1#1"
|
|
19
|
+
},
|
|
20
|
+
"canonical_string": "3:mnd_v3_abc...:a1:delegation:5000:BRL:refund,utility:1735689600::::1:kyc_celcoin_11144477735:did:web:id.codespar.dev:org_demo:a1#1",
|
|
21
|
+
"hmac_sha256_hex": "4e627ac1074f1c15684cb10d63b1d4481fbeda8f82450f26c04b7860210fc3e4",
|
|
22
|
+
"agent_seed_hex": "1050a34bae067327e59c1ac43f3a25190a582448219521d35d000650d714ec9a",
|
|
23
|
+
"agent_pubkey_hex": "70940e2c00698a520339c07332ff35c05cad7b39d024a4f4737310e740b2ecc3",
|
|
24
|
+
"issuer_seed_hex": "23dddf5e3a0038f1a81cda29dbbd5d1c97d434a6610a814d3d628f7b269d031f",
|
|
25
|
+
"issuer_pubkey_hex": "41826edefd6d40fd7c32dd63ebcc0476e4324cd6a424881c53f170fee9865b14",
|
|
26
|
+
"agent_sig_b64url": "F9V0rSsfT1IjeF_4_9pciIV7w2ERdLN5r-UMkCdQWjce9ouYYY2awrrjFw5cENKbTEw0l61HitYPO5ZocfQ7Bg",
|
|
27
|
+
"issuer_sig_b64url": "ClsA7hWq1fBAG-qrhYAahDPm0xMdPl5lOJ6X0KxSzruJ2kD6NCzH-baSbxcwTxTCkWd1eBpJVke3onUR6mH1Ag"
|
|
28
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline V3 mandate verification (``codespar.mandate``).
|
|
3
|
+
|
|
4
|
+
Byte-locks the canonical signing string against the shared freeze at
|
|
5
|
+
``tests/_fixtures/canonical.v3.fixture.json`` — the same JSON the enterprise
|
|
6
|
+
codec, the TS SDK, and the CLI pin — so the four implementations cannot drift.
|
|
7
|
+
|
|
8
|
+
Decode + reconstruction are pure stdlib and always run. Ed25519 verification
|
|
9
|
+
needs the optional ``cryptography`` extra; those tests skip when it is absent
|
|
10
|
+
(``pip install codespar[verify]`` / the ``dev`` extra installs it in CI).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from codespar.mandate import (
|
|
23
|
+
MandateDecodeError,
|
|
24
|
+
agent_did_from_kid,
|
|
25
|
+
decode_mandate_token,
|
|
26
|
+
reconstruct_signing_string,
|
|
27
|
+
verify_ed25519,
|
|
28
|
+
verify_mandate_token,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
FIXTURE_PATH = Path(__file__).parent / "_fixtures" / "canonical.v3.fixture.json"
|
|
32
|
+
FX: dict[str, Any] = json.loads(FIXTURE_PATH.read_text())
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import cryptography # noqa: F401
|
|
36
|
+
|
|
37
|
+
HAS_CRYPTO = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
HAS_CRYPTO = False
|
|
40
|
+
|
|
41
|
+
requires_crypto = pytest.mark.skipif(
|
|
42
|
+
not HAS_CRYPTO,
|
|
43
|
+
reason="offline verify needs the optional 'cryptography' extra (pip install codespar[verify])",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _make_token(fields: dict[str, Any], **envelope: Any) -> str:
|
|
48
|
+
raw = json.dumps({**fields, **envelope}).encode("utf-8")
|
|
49
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
TOKEN = _make_token(
|
|
53
|
+
FX["input"],
|
|
54
|
+
signature=FX["hmac_sha256_hex"],
|
|
55
|
+
agent_sig=FX["agent_sig_b64url"],
|
|
56
|
+
issuer_sig=FX["issuer_sig_b64url"],
|
|
57
|
+
kid=FX["input"]["agent_kid"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── decode ────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_decode_round_trips_envelope_and_splits_signed_fields() -> None:
|
|
65
|
+
dec = decode_mandate_token(TOKEN)
|
|
66
|
+
assert dec.signature == FX["hmac_sha256_hex"]
|
|
67
|
+
assert dec.agent_sig == FX["agent_sig_b64url"]
|
|
68
|
+
assert dec.issuer_sig == FX["issuer_sig_b64url"]
|
|
69
|
+
assert dec.kid == FX["input"]["agent_kid"]
|
|
70
|
+
for leaked in ("signature", "agent_sig", "issuer_sig", "kid"):
|
|
71
|
+
assert leaked not in dec.mandate
|
|
72
|
+
assert dec.mandate["principal_kyc_ref"] == FX["input"]["principal_kyc_ref"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_decode_rejects_garbage() -> None:
|
|
76
|
+
with pytest.raises(MandateDecodeError, match="invalid_payload"):
|
|
77
|
+
decode_mandate_token("!!!nope!!!")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_decode_rejects_unsupported_version() -> None:
|
|
81
|
+
bad = _make_token({**FX["input"], "format_version": 1}, signature="x")
|
|
82
|
+
with pytest.raises(MandateDecodeError, match="mandate_format_unsupported"):
|
|
83
|
+
decode_mandate_token(bad)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_decode_rejects_v3_missing_required_fields() -> None:
|
|
87
|
+
fields = {k: v for k, v in FX["input"].items() if k not in ("principal_kyc_ref", "agent_kid")}
|
|
88
|
+
with pytest.raises(MandateDecodeError, match="invalid_payload"):
|
|
89
|
+
decode_mandate_token(_make_token(fields, signature="x"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── reconstruct (byte-lock) ───────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_reconstruct_is_byte_identical_to_frozen_canonical_string() -> None:
|
|
96
|
+
assert reconstruct_signing_string(FX["input"]) == FX["canonical_string"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_reconstruct_sorts_and_escapes_purposes() -> None:
|
|
100
|
+
fields = {**FX["input"], "purposes": ["utility", "refund"]}
|
|
101
|
+
assert reconstruct_signing_string(fields) == FX["canonical_string"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_reconstruct_from_decoded_mandate_matches() -> None:
|
|
105
|
+
dec = decode_mandate_token(TOKEN)
|
|
106
|
+
assert reconstruct_signing_string(dec.mandate) == FX["canonical_string"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── helpers ───────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_agent_did_from_kid_strips_fragment() -> None:
|
|
113
|
+
assert agent_did_from_kid("did:web:id.codespar.dev:org_demo:a1#1") == (
|
|
114
|
+
"did:web:id.codespar.dev:org_demo:a1"
|
|
115
|
+
)
|
|
116
|
+
assert agent_did_from_kid("did:web:x") == "did:web:x"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_verify_with_no_keys_skips_and_is_not_verified() -> None:
|
|
120
|
+
res = verify_mandate_token(TOKEN)
|
|
121
|
+
assert res.agent.status == "skipped"
|
|
122
|
+
assert res.issuer.status == "skipped"
|
|
123
|
+
assert res.verified is False
|
|
124
|
+
assert res.agent_did == "did:web:id.codespar.dev:org_demo:a1"
|
|
125
|
+
assert res.kid == FX["input"]["agent_kid"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.skipif(HAS_CRYPTO, reason="only meaningful when cryptography is absent")
|
|
129
|
+
def test_verify_without_cryptography_raises_helpful_error() -> None:
|
|
130
|
+
with pytest.raises(RuntimeError, match="cryptography"):
|
|
131
|
+
verify_mandate_token(TOKEN, agent_public_key=FX["agent_pubkey_hex"])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Ed25519 verification (optional extra) ─────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@requires_crypto
|
|
138
|
+
def test_verify_ed25519_agent_and_issuer() -> None:
|
|
139
|
+
agent = bytes.fromhex(FX["agent_pubkey_hex"])
|
|
140
|
+
issuer = bytes.fromhex(FX["issuer_pubkey_hex"])
|
|
141
|
+
assert verify_ed25519(FX["canonical_string"], FX["agent_sig_b64url"], agent) is True
|
|
142
|
+
assert verify_ed25519(FX["canonical_string"], FX["issuer_sig_b64url"], issuer) is True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@requires_crypto
|
|
146
|
+
def test_verify_ed25519_tamper_and_wrong_key_fail() -> None:
|
|
147
|
+
agent = bytes.fromhex(FX["agent_pubkey_hex"])
|
|
148
|
+
issuer = bytes.fromhex(FX["issuer_pubkey_hex"])
|
|
149
|
+
forged = reconstruct_signing_string({**FX["input"], "amount": "9999"})
|
|
150
|
+
assert verify_ed25519(forged, FX["agent_sig_b64url"], agent) is False
|
|
151
|
+
assert verify_ed25519(FX["canonical_string"], FX["agent_sig_b64url"], issuer) is False
|
|
152
|
+
assert verify_ed25519(FX["canonical_string"], FX["agent_sig_b64url"], b"\x00" * 5) is False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@requires_crypto
|
|
156
|
+
def test_verify_mandate_token_both_signatures() -> None:
|
|
157
|
+
res = verify_mandate_token(
|
|
158
|
+
TOKEN,
|
|
159
|
+
agent_public_key=FX["agent_pubkey_hex"],
|
|
160
|
+
issuer_public_key=FX["issuer_pubkey_hex"],
|
|
161
|
+
)
|
|
162
|
+
assert res.verified is True
|
|
163
|
+
assert res.agent.status == "verified"
|
|
164
|
+
assert res.issuer.status == "verified"
|
|
165
|
+
assert res.agent.kid == FX["input"]["agent_kid"]
|
|
166
|
+
assert res.mandate["amount"] == "5000"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@requires_crypto
|
|
170
|
+
def test_verify_mandate_token_accepts_raw_bytes_keys() -> None:
|
|
171
|
+
res = verify_mandate_token(
|
|
172
|
+
TOKEN,
|
|
173
|
+
agent_public_key=bytes.fromhex(FX["agent_pubkey_hex"]),
|
|
174
|
+
issuer_public_key=bytes.fromhex(FX["issuer_pubkey_hex"]),
|
|
175
|
+
)
|
|
176
|
+
assert res.verified is True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@requires_crypto
|
|
180
|
+
def test_verify_mandate_token_agent_only_skips_issuer() -> None:
|
|
181
|
+
res = verify_mandate_token(TOKEN, agent_public_key=FX["agent_pubkey_hex"])
|
|
182
|
+
assert res.agent.status == "verified"
|
|
183
|
+
assert res.issuer.status == "skipped"
|
|
184
|
+
assert res.verified is True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@requires_crypto
|
|
188
|
+
def test_verify_mandate_token_wrong_key_fails() -> None:
|
|
189
|
+
res = verify_mandate_token(TOKEN, agent_public_key=FX["issuer_pubkey_hex"])
|
|
190
|
+
assert res.agent.status == "failed"
|
|
191
|
+
assert res.verified is False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@requires_crypto
|
|
195
|
+
def test_verify_mandate_token_tampered_fails() -> None:
|
|
196
|
+
tampered = _make_token(
|
|
197
|
+
{**FX["input"], "amount": "9999"},
|
|
198
|
+
signature=FX["hmac_sha256_hex"],
|
|
199
|
+
agent_sig=FX["agent_sig_b64url"],
|
|
200
|
+
issuer_sig=FX["issuer_sig_b64url"],
|
|
201
|
+
kid=FX["input"]["agent_kid"],
|
|
202
|
+
)
|
|
203
|
+
res = verify_mandate_token(
|
|
204
|
+
tampered,
|
|
205
|
+
agent_public_key=FX["agent_pubkey_hex"],
|
|
206
|
+
issuer_public_key=FX["issuer_pubkey_hex"],
|
|
207
|
+
)
|
|
208
|
+
assert res.verified is False
|
|
209
|
+
assert res.agent.status == "failed"
|
|
210
|
+
assert res.issuer.status == "failed"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@requires_crypto
|
|
214
|
+
def test_verify_mandate_token_malformed_hex_key_is_skipped() -> None:
|
|
215
|
+
res = verify_mandate_token(TOKEN, agent_public_key="xyz")
|
|
216
|
+
assert res.agent.status == "skipped"
|
|
217
|
+
assert res.verified is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|