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.
Files changed (37) hide show
  1. {codespar-0.10.2 → codespar-0.11.0}/CHANGELOG.md +17 -0
  2. {codespar-0.10.2 → codespar-0.11.0}/PKG-INFO +4 -1
  3. {codespar-0.10.2 → codespar-0.11.0}/pyproject.toml +8 -1
  4. codespar-0.11.0/src/codespar/mandate.py +320 -0
  5. codespar-0.11.0/tests/_fixtures/canonical.v3.fixture.json +28 -0
  6. codespar-0.11.0/tests/test_mandate.py +217 -0
  7. {codespar-0.10.2 → codespar-0.11.0}/.gitignore +0 -0
  8. {codespar-0.10.2 → codespar-0.11.0}/README.md +0 -0
  9. {codespar-0.10.2 → codespar-0.11.0}/examples/README.md +0 -0
  10. {codespar-0.10.2 → codespar-0.11.0}/examples/async_basic.py +0 -0
  11. {codespar-0.10.2 → codespar-0.11.0}/examples/connect_link.py +0 -0
  12. {codespar-0.10.2 → codespar-0.11.0}/examples/ecommerce_checkout.py +0 -0
  13. {codespar-0.10.2 → codespar-0.11.0}/examples/mocks_round_trip.py +0 -0
  14. {codespar-0.10.2 → codespar-0.11.0}/examples/pix_payment.py +0 -0
  15. {codespar-0.10.2 → codespar-0.11.0}/examples/proxy_execute.py +0 -0
  16. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/__init__.py +0 -0
  17. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_async_client.py +0 -0
  18. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_async_session.py +0 -0
  19. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_http.py +0 -0
  20. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_presets.py +0 -0
  21. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/_sync_client.py +0 -0
  22. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/errors.py +0 -0
  23. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/py.typed +0 -0
  24. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/tool_result_codes.py +0 -0
  25. {codespar-0.10.2 → codespar-0.11.0}/src/codespar/types.py +0 -0
  26. {codespar-0.10.2 → codespar-0.11.0}/tests/__init__.py +0 -0
  27. {codespar-0.10.2 → codespar-0.11.0}/tests/_fixtures/mocks_canonical.json +0 -0
  28. {codespar-0.10.2 → codespar-0.11.0}/tests/_fixtures/shop_canonical.json +0 -0
  29. {codespar-0.10.2 → codespar-0.11.0}/tests/test_base_url_resolution.py +0 -0
  30. {codespar-0.10.2 → codespar-0.11.0}/tests/test_config.py +0 -0
  31. {codespar-0.10.2 → codespar-0.11.0}/tests/test_error_code_precedence.py +0 -0
  32. {codespar-0.10.2 → codespar-0.11.0}/tests/test_forward_mocks.py +0 -0
  33. {codespar-0.10.2 → codespar-0.11.0}/tests/test_mocks_types.py +0 -0
  34. {codespar-0.10.2 → codespar-0.11.0}/tests/test_session.py +0 -0
  35. {codespar-0.10.2 → codespar-0.11.0}/tests/test_shop.py +0 -0
  36. {codespar-0.10.2 → codespar-0.11.0}/tests/test_streaming.py +0 -0
  37. {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.10.2
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.10.2"
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