x403 0.1.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.
x403-0.1.0/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ .venv
2
+ dist
3
+ __pycache__
4
+ *.egg-info
x403-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: x403
3
+ Version: 0.1.0
4
+ Summary: x403: a signed risk decision before money moves. Objects, canonical JSON, ES256 signing and verifying, the HTTP binding, a client and FastAPI middleware.
5
+ Project-URL: Homepage, https://kredit.sh/x403
6
+ Project-URL: Repository, https://github.com/kreditsh/x403
7
+ Project-URL: Spec, https://github.com/kreditsh/x403/blob/main/spec/x403.md
8
+ Author-email: kredit <hello@kredit.sh>
9
+ License-Expression: Apache-2.0
10
+ Keywords: agentic payments,ap2,kredit,mpp,risk,x401,x402,x403
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security :: Cryptography
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: cryptography>=42
22
+ Provides-Extra: client
23
+ Requires-Dist: httpx>=0.27; extra == 'client'
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.2; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: twine>=5; extra == 'dev'
29
+ Provides-Extra: fastapi
30
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
31
+ Requires-Dist: httpx>=0.27; extra == 'fastapi'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # x403
35
+
36
+ The x403 protocol for Python: the objects, RFC 8785 canonical JSON, ES256 signing and verifying, the HTTP binding, a sync and an async client for any authority, and a FastAPI gate. One dependency, `cryptography`. Python 3.10+.
37
+
38
+ ```
39
+ pip install x403 # objects, canonical JSON, signing, verifying
40
+ pip install "x403[client]" # plus the HTTP client (httpx)
41
+ pip install "x403[fastapi]" # plus the FastAPI gate
42
+ ```
43
+
44
+ ## Ask before you execute
45
+
46
+ ```python
47
+ from x403 import X403
48
+
49
+ x403 = X403(authority="https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
50
+
51
+ decision = x403.decide(
52
+ direction="inbound",
53
+ policy_id="shop-checkout-v1",
54
+ payment={"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
55
+ actor={"agent": {"status": "asserted", "id": "did:key:z6Mk..."}},
56
+ evidence=[{"type": "kredit.passport", "ref": passport_hash, "inline": passport}],
57
+ budget_ms=150,
58
+ )
59
+
60
+ if decision["decision"] == "allow":
61
+ ... # within decision["constraints"]
62
+ else:
63
+ ... # decision["http_status"], decision["reasons"], decision.get("escalation")
64
+ ```
65
+
66
+ ## Verify offline
67
+
68
+ ```python
69
+ result = x403.verify(decision) # keys fetched once from authority.json
70
+ assert result, result.reason
71
+ ```
72
+
73
+ Or without a client, against keys you already hold:
74
+
75
+ ```python
76
+ from x403 import verify_decision
77
+ ok = verify_decision(decision, jwks)
78
+ ```
79
+
80
+ ## FastAPI
81
+
82
+ ```python
83
+ from fastapi import Depends, FastAPI
84
+ from x403 import AsyncX403
85
+ from x403.middleware.fastapi import X403Gate, install, post_receipt
86
+
87
+ app = FastAPI()
88
+ install(app)
89
+ authority = AsyncX403("https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
90
+
91
+ gate = X403Gate(
92
+ authority,
93
+ payment=lambda request: {"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
94
+ policy_id="shop-checkout-v1",
95
+ budget_ms=150,
96
+ )
97
+
98
+ @app.post("/checkout")
99
+ async def checkout(decision: dict = Depends(gate)):
100
+ auth = await charge(decision["constraints"])
101
+ await post_receipt(authority, decision, executed=True, execution={"rail": "card", "ref": auth.id},
102
+ executor={"id": "did:web:shop.example", "kid": kid}, private_jwk=private_jwk)
103
+ return {"ok": True}
104
+ ```
105
+
106
+ On allow the route receives the decision. On anything else the gate answers with the decision's `http_status`, the decision as the body and `X403-Decision` on the response. If the authority cannot be reached it fails closed with an executor-signed `authority_timeout` decision.
107
+
108
+ ## Sign your side
109
+
110
+ ```python
111
+ from x403 import generate_key, sign_receipt, decision_hash
112
+
113
+ pair = generate_key() # publish pair.public_jwk at /.well-known/x403/jwks.json
114
+ receipt = sign_receipt({"decision_ref": d["id"], "decision_hash": decision_hash(d), "executed": True,
115
+ "executor": {"id": "did:web:shop.example", "kid": pair.kid}}, pair.private_jwk)
116
+ ```
117
+
118
+ ## Status codes
119
+
120
+ `status_for(decision)` implements section 6.3 of the spec. The decision already carries `http_status`; use the function to pin the table in your own tests.
121
+
122
+ Apache-2.0. Spec and registry: https://github.com/kreditsh/x403
x403-0.1.0/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # x403
2
+
3
+ The x403 protocol for Python: the objects, RFC 8785 canonical JSON, ES256 signing and verifying, the HTTP binding, a sync and an async client for any authority, and a FastAPI gate. One dependency, `cryptography`. Python 3.10+.
4
+
5
+ ```
6
+ pip install x403 # objects, canonical JSON, signing, verifying
7
+ pip install "x403[client]" # plus the HTTP client (httpx)
8
+ pip install "x403[fastapi]" # plus the FastAPI gate
9
+ ```
10
+
11
+ ## Ask before you execute
12
+
13
+ ```python
14
+ from x403 import X403
15
+
16
+ x403 = X403(authority="https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
17
+
18
+ decision = x403.decide(
19
+ direction="inbound",
20
+ policy_id="shop-checkout-v1",
21
+ payment={"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
22
+ actor={"agent": {"status": "asserted", "id": "did:key:z6Mk..."}},
23
+ evidence=[{"type": "kredit.passport", "ref": passport_hash, "inline": passport}],
24
+ budget_ms=150,
25
+ )
26
+
27
+ if decision["decision"] == "allow":
28
+ ... # within decision["constraints"]
29
+ else:
30
+ ... # decision["http_status"], decision["reasons"], decision.get("escalation")
31
+ ```
32
+
33
+ ## Verify offline
34
+
35
+ ```python
36
+ result = x403.verify(decision) # keys fetched once from authority.json
37
+ assert result, result.reason
38
+ ```
39
+
40
+ Or without a client, against keys you already hold:
41
+
42
+ ```python
43
+ from x403 import verify_decision
44
+ ok = verify_decision(decision, jwks)
45
+ ```
46
+
47
+ ## FastAPI
48
+
49
+ ```python
50
+ from fastapi import Depends, FastAPI
51
+ from x403 import AsyncX403
52
+ from x403.middleware.fastapi import X403Gate, install, post_receipt
53
+
54
+ app = FastAPI()
55
+ install(app)
56
+ authority = AsyncX403("https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
57
+
58
+ gate = X403Gate(
59
+ authority,
60
+ payment=lambda request: {"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
61
+ policy_id="shop-checkout-v1",
62
+ budget_ms=150,
63
+ )
64
+
65
+ @app.post("/checkout")
66
+ async def checkout(decision: dict = Depends(gate)):
67
+ auth = await charge(decision["constraints"])
68
+ await post_receipt(authority, decision, executed=True, execution={"rail": "card", "ref": auth.id},
69
+ executor={"id": "did:web:shop.example", "kid": kid}, private_jwk=private_jwk)
70
+ return {"ok": True}
71
+ ```
72
+
73
+ On allow the route receives the decision. On anything else the gate answers with the decision's `http_status`, the decision as the body and `X403-Decision` on the response. If the authority cannot be reached it fails closed with an executor-signed `authority_timeout` decision.
74
+
75
+ ## Sign your side
76
+
77
+ ```python
78
+ from x403 import generate_key, sign_receipt, decision_hash
79
+
80
+ pair = generate_key() # publish pair.public_jwk at /.well-known/x403/jwks.json
81
+ receipt = sign_receipt({"decision_ref": d["id"], "decision_hash": decision_hash(d), "executed": True,
82
+ "executor": {"id": "did:web:shop.example", "kid": pair.kid}}, pair.private_jwk)
83
+ ```
84
+
85
+ ## Status codes
86
+
87
+ `status_for(decision)` implements section 6.3 of the spec. The decision already carries `http_status`; use the function to pin the table in your own tests.
88
+
89
+ Apache-2.0. Spec and registry: https://github.com/kreditsh/x403
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "x403"
7
+ version = "0.1.0"
8
+ description = "x403: a signed risk decision before money moves. Objects, canonical JSON, ES256 signing and verifying, the HTTP binding, a client and FastAPI middleware."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "kredit", email = "hello@kredit.sh" }]
13
+ keywords = ["x403", "x402", "x401", "agentic payments", "risk", "ap2", "mpp", "kredit"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Security :: Cryptography",
24
+ ]
25
+ dependencies = ["cryptography>=42"]
26
+
27
+ [project.optional-dependencies]
28
+ client = ["httpx>=0.27"]
29
+ fastapi = ["fastapi>=0.110", "httpx>=0.27"]
30
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "build>=1.2", "twine>=5"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://kredit.sh/x403"
34
+ Repository = "https://github.com/kreditsh/x403"
35
+ Spec = "https://github.com/kreditsh/x403/blob/main/spec/x403.md"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/x403"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["src/x403", "README.md", "tests"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ asyncio_mode = "auto"
@@ -0,0 +1,46 @@
1
+ """x403: a signed risk decision before money moves."""
2
+
3
+ from .client import AsyncX403, X403, X403Error, build_query
4
+ from .headers import (
5
+ AUTH_SCHEME,
6
+ HEADER_DECISION,
7
+ HEADER_PRESENTATION,
8
+ HEADER_REQUIRED,
9
+ HEADER_RISK_DECISION,
10
+ authorization_risk,
11
+ decode,
12
+ encode,
13
+ parse_authorization_risk,
14
+ parse_www_authenticate,
15
+ www_authenticate,
16
+ )
17
+ from .jcs import canonicalize, hash_object, hash_string
18
+ from .jws import (
19
+ KeyPair,
20
+ VerifyResult,
21
+ decision_hash,
22
+ generate_key,
23
+ sign,
24
+ sign_outcome,
25
+ sign_query,
26
+ sign_receipt,
27
+ thumbprint,
28
+ verify,
29
+ verify_decision,
30
+ verify_receipt,
31
+ )
32
+ from .registry import CODES, GROUPS, NORMATIVE_STATUSES, REGISTRY, check_of, effect_of, entry_of, escalation_of, group_of, status_for
33
+ from .types import VERSION
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "AsyncX403", "X403", "X403Error", "build_query",
39
+ "AUTH_SCHEME", "HEADER_DECISION", "HEADER_PRESENTATION", "HEADER_REQUIRED", "HEADER_RISK_DECISION",
40
+ "authorization_risk", "decode", "encode", "parse_authorization_risk", "parse_www_authenticate", "www_authenticate",
41
+ "canonicalize", "hash_object", "hash_string",
42
+ "KeyPair", "VerifyResult", "decision_hash", "generate_key", "sign", "sign_outcome", "sign_query", "sign_receipt",
43
+ "thumbprint", "verify", "verify_decision", "verify_receipt",
44
+ "CODES", "GROUPS", "NORMATIVE_STATUSES", "REGISTRY", "check_of", "effect_of", "entry_of", "escalation_of", "group_of", "status_for",
45
+ "VERSION", "__version__",
46
+ ]
@@ -0,0 +1,166 @@
1
+ """The authority API client: the six calls of spec section 7, sync and async, and
2
+ offline verification against the authority's published keys."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from .headers import decode, encode
9
+ from .jws import VerifyResult, verify_decision
10
+ from .types import VERSION, Authority, Decision, JWKS, Outcome, Receipt, RiskQuery
11
+
12
+ try:
13
+ import httpx
14
+ except ImportError: # pragma: no cover - the client extra is optional
15
+ httpx = None # type: ignore[assignment]
16
+
17
+
18
+ class X403Error(Exception):
19
+ def __init__(self, status: int, detail: Any):
20
+ self.status = status
21
+ self.detail = detail
22
+ super().__init__(f"{status}: {detail if isinstance(detail, str) else detail!r}")
23
+
24
+
25
+ def build_query(**fields: Any) -> RiskQuery:
26
+ """Fill scheme and version so a caller passes only what varies."""
27
+ query: dict[str, Any] = {"scheme": "x403", "version": VERSION}
28
+ query.update({k: v for k, v in fields.items() if v is not None})
29
+ return query # type: ignore[return-value]
30
+
31
+
32
+ def _need_httpx() -> None:
33
+ if httpx is None:
34
+ raise RuntimeError("install x403[client] for the HTTP client")
35
+
36
+
37
+ def _unpack(res: Any) -> Any:
38
+ try:
39
+ payload = res.json() if res.content else {}
40
+ except ValueError:
41
+ payload = res.text
42
+ if res.status_code >= 400:
43
+ detail = payload.get("detail") if isinstance(payload, dict) and "detail" in payload else payload
44
+ raise X403Error(res.status_code, detail)
45
+ return payload
46
+
47
+
48
+ class _Base:
49
+ encode = staticmethod(encode)
50
+ decode = staticmethod(decode)
51
+
52
+ def __init__(self, authority: str, api_key: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0):
53
+ self.authority = authority.rstrip("/")
54
+ self.api_key = api_key
55
+ self.timeout = timeout
56
+ self.extra = dict(headers or {})
57
+ self._jwks: JWKS | None = None
58
+ self._authority: Authority | None = None
59
+
60
+ def _headers(self, body: bool) -> dict[str, str]:
61
+ out = {"accept": "application/json", **self.extra}
62
+ if body:
63
+ out["content-type"] = "application/json"
64
+ if self.api_key:
65
+ out["authorization"] = f"Bearer {self.api_key}"
66
+ return out
67
+
68
+ def _jwks_path(self) -> str:
69
+ url = (self._authority or {}).get("jwks_url") or f"{self.authority}/.well-known/x403/jwks.json"
70
+ return url[len(self.authority) :] if url.startswith(self.authority) else url
71
+
72
+ def reset(self) -> None:
73
+ """Forget cached keys, e.g. after a verify fails on an unknown kid."""
74
+ self._jwks = None
75
+ self._authority = None
76
+
77
+
78
+ class X403(_Base):
79
+ def __init__(self, authority: str, api_key: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0, client: Any = None):
80
+ super().__init__(authority, api_key, headers, timeout)
81
+ _need_httpx()
82
+ self._client = client or httpx.Client(timeout=timeout)
83
+
84
+ def _call(self, method: str, path: str, body: Any = None) -> Any:
85
+ url = path if path.startswith("http") else f"{self.authority}{path}"
86
+ res = self._client.request(method, url, json=body, headers=self._headers(body is not None))
87
+ return _unpack(res)
88
+
89
+ def decide(self, **fields: Any) -> Decision:
90
+ return self._call("POST", "/x403/decide", build_query(**fields))
91
+
92
+ def preflight(self, **fields: Any) -> Decision:
93
+ """The same question with mode preflight: decide now, pay later citing the id."""
94
+ return self._call("POST", "/x403/decide", build_query(**{**fields, "mode": "preflight"}))
95
+
96
+ def receipt(self, receipt: Receipt) -> Receipt:
97
+ return self._call("POST", "/x403/receipts", receipt)
98
+
99
+ def outcome(self, outcome: Outcome) -> Outcome:
100
+ return self._call("POST", "/x403/outcomes", outcome)
101
+
102
+ def get(self, decision_id: str) -> Decision:
103
+ return self._call("GET", f"/x403/decisions/{decision_id}")
104
+
105
+ def authority_document(self) -> Authority:
106
+ if self._authority is None:
107
+ self._authority = self._call("GET", "/.well-known/x403/authority.json")
108
+ return self._authority # type: ignore[return-value]
109
+
110
+ def jwks(self) -> JWKS:
111
+ """The authority's keys, fetched once from the address its document names."""
112
+ if self._jwks is None:
113
+ try:
114
+ self.authority_document()
115
+ except X403Error:
116
+ pass # without a document the conventional path still answers
117
+ self._jwks = self._call("GET", self._jwks_path())
118
+ return self._jwks # type: ignore[return-value]
119
+
120
+ def verify(self, decision: Decision) -> VerifyResult:
121
+ """Offline verification of a decision against the cached keys."""
122
+ return verify_decision(decision, self.jwks())
123
+
124
+
125
+ class AsyncX403(_Base):
126
+ def __init__(self, authority: str, api_key: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0, client: Any = None):
127
+ super().__init__(authority, api_key, headers, timeout)
128
+ _need_httpx()
129
+ self._client = client or httpx.AsyncClient(timeout=timeout)
130
+
131
+ async def _call(self, method: str, path: str, body: Any = None) -> Any:
132
+ url = path if path.startswith("http") else f"{self.authority}{path}"
133
+ res = await self._client.request(method, url, json=body, headers=self._headers(body is not None))
134
+ return _unpack(res)
135
+
136
+ async def decide(self, **fields: Any) -> Decision:
137
+ return await self._call("POST", "/x403/decide", build_query(**fields))
138
+
139
+ async def preflight(self, **fields: Any) -> Decision:
140
+ return await self._call("POST", "/x403/decide", build_query(**{**fields, "mode": "preflight"}))
141
+
142
+ async def receipt(self, receipt: Receipt) -> Receipt:
143
+ return await self._call("POST", "/x403/receipts", receipt)
144
+
145
+ async def outcome(self, outcome: Outcome) -> Outcome:
146
+ return await self._call("POST", "/x403/outcomes", outcome)
147
+
148
+ async def get(self, decision_id: str) -> Decision:
149
+ return await self._call("GET", f"/x403/decisions/{decision_id}")
150
+
151
+ async def authority_document(self) -> Authority:
152
+ if self._authority is None:
153
+ self._authority = await self._call("GET", "/.well-known/x403/authority.json")
154
+ return self._authority # type: ignore[return-value]
155
+
156
+ async def jwks(self) -> JWKS:
157
+ if self._jwks is None:
158
+ try:
159
+ await self.authority_document()
160
+ except X403Error:
161
+ pass
162
+ self._jwks = await self._call("GET", self._jwks_path())
163
+ return self._jwks # type: ignore[return-value]
164
+
165
+ async def verify(self, decision: Decision) -> VerifyResult:
166
+ return verify_decision(decision, await self.jwks())
@@ -0,0 +1,70 @@
1
+ """The HTTP binding: X403-* headers carry base64url JSON without padding, and the
2
+ auth-scheme binding carries the same objects under WWW-Authenticate: Risk."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from typing import Any
9
+
10
+ from .jws import base64url, from_base64url
11
+
12
+ HEADER_REQUIRED = "X403-Required"
13
+ HEADER_PRESENTATION = "X403-Presentation"
14
+ HEADER_DECISION = "X403-Decision"
15
+ HEADER_RISK_DECISION = "Risk-Decision"
16
+ AUTH_SCHEME = "Risk"
17
+
18
+
19
+ def encode(value: Any) -> str:
20
+ return base64url(json.dumps(value, separators=(",", ":"), ensure_ascii=False))
21
+
22
+
23
+ def decode(text: str) -> Any:
24
+ return json.loads(from_base64url(text).decode("utf-8"))
25
+
26
+
27
+ def _quote(value: str) -> str:
28
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
29
+
30
+
31
+ def www_authenticate(policy: str, realm: str | None = None, request: str | None = None, expires: str | None = None) -> str:
32
+ """`WWW-Authenticate: Risk policy="...", realm="...", request="...", expires="..."`"""
33
+ parts = [f"policy={_quote(policy)}"]
34
+ if realm:
35
+ parts.append(f"realm={_quote(realm)}")
36
+ if request:
37
+ parts.append(f"request={_quote(request)}")
38
+ if expires:
39
+ parts.append(f"expires={_quote(expires)}")
40
+ return f"{AUTH_SCHEME} " + ", ".join(parts)
41
+
42
+
43
+ _PARAM = re.compile(r'(\w+)=(?:"((?:[^"\\]|\\.)*)"|([^,\s]+))')
44
+
45
+
46
+ def parse_www_authenticate(header: str) -> dict[str, str] | None:
47
+ m = re.match(r"^\s*Risk\s+(.*)$", header, re.S)
48
+ if not m:
49
+ return None
50
+ out: dict[str, str] = {}
51
+ for name, quoted, bare in _PARAM.findall(m.group(1)):
52
+ out[name] = re.sub(r"\\(.)", r"\1", quoted if quoted else bare)
53
+ return out if "policy" in out else None
54
+
55
+
56
+ def authorization_risk(presentation: dict[str, Any]) -> str:
57
+ """`Authorization: Risk <base64url presentation>`"""
58
+ return f"{AUTH_SCHEME} {encode(presentation)}"
59
+
60
+
61
+ def parse_authorization_risk(header: str | None) -> dict[str, Any] | None:
62
+ if not header:
63
+ return None
64
+ m = re.match(r"^\s*Risk\s+([A-Za-z0-9_-]+)\s*$", header)
65
+ if not m:
66
+ return None
67
+ try:
68
+ return decode(m.group(1))
69
+ except (ValueError, UnicodeDecodeError):
70
+ return None
@@ -0,0 +1,103 @@
1
+ """RFC 8785 (JSON Canonicalization Scheme). Every hash and every signature in x403 is
2
+ over this form, so two implementations that agree here agree everywhere. Numbers print
3
+ the way ES6 Number.prototype.toString prints them, which is the form the RFC fixes."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import math
10
+ from typing import Any
11
+
12
+
13
+ def _es6_number(x: float) -> str:
14
+ if math.isnan(x) or math.isinf(x):
15
+ raise ValueError("JCS: non-finite number")
16
+ if x == 0:
17
+ return "0"
18
+ sign = "-" if x < 0 else ""
19
+ x = abs(x)
20
+ # repr gives the shortest digits that round-trip, which is the digit string ES6 uses;
21
+ # only the placement of the point and the exponent form differ, handled below.
22
+ text = repr(x)
23
+ if "e" in text:
24
+ mantissa, exponent = text.split("e")
25
+ e = int(exponent)
26
+ else:
27
+ mantissa, e = text, 0
28
+ if "." in mantissa:
29
+ whole, frac = mantissa.split(".")
30
+ else:
31
+ whole, frac = mantissa, ""
32
+ digits = whole + frac
33
+ point = e - len(frac) # value = int(digits) * 10 ** point
34
+ digits = digits.lstrip("0")
35
+ while digits.endswith("0"):
36
+ digits = digits[:-1]
37
+ point += 1
38
+ k = len(digits)
39
+ n = point + k # value = 0.digits * 10 ** n
40
+ if k <= n <= 21:
41
+ return sign + digits + "0" * (n - k)
42
+ if 0 < n <= 21:
43
+ return sign + digits[:n] + "." + digits[n:]
44
+ if -6 < n <= 0:
45
+ return sign + "0." + "0" * (-n) + digits
46
+ exp = n - 1
47
+ exp_text = ("+" if exp >= 0 else "-") + str(abs(exp))
48
+ if k == 1:
49
+ return sign + digits + "e" + exp_text
50
+ return sign + digits[0] + "." + digits[1:] + "e" + exp_text
51
+
52
+
53
+ def _key(name: str) -> bytes:
54
+ # The RFC sorts by UTF-16 code units; comparing UTF-16BE bytes is the same order.
55
+ return name.encode("utf-16-be")
56
+
57
+
58
+ def _serialize(value: Any) -> str:
59
+ if value is None:
60
+ return "null"
61
+ if value is True:
62
+ return "true"
63
+ if value is False:
64
+ return "false"
65
+ if isinstance(value, int):
66
+ if abs(value) < 2**53:
67
+ return str(value)
68
+ return _es6_number(float(value))
69
+ if isinstance(value, float):
70
+ return _es6_number(value)
71
+ if isinstance(value, str):
72
+ # json.dumps escapes only what the RFC escapes, with lowercase hex.
73
+ return json.dumps(value, ensure_ascii=False)
74
+ if isinstance(value, (list, tuple)):
75
+ return "[" + ",".join(_serialize(v) for v in value) + "]"
76
+ if isinstance(value, dict):
77
+ items = sorted(((str(k), v) for k, v in value.items()), key=lambda kv: _key(kv[0]))
78
+ return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + _serialize(v) for k, v in items) + "}"
79
+ raise TypeError(f"JCS: cannot serialize {type(value).__name__}")
80
+
81
+
82
+ def canonicalize(value: Any) -> str:
83
+ """The canonical JSON text of a value."""
84
+ return _serialize(value)
85
+
86
+
87
+ def canonical_bytes(value: Any) -> bytes:
88
+ return canonicalize(value).encode("utf-8")
89
+
90
+
91
+ def sha256_hex(data: bytes | str) -> str:
92
+ raw = data.encode("utf-8") if isinstance(data, str) else data
93
+ return hashlib.sha256(raw).hexdigest()
94
+
95
+
96
+ def hash_object(value: Any) -> str:
97
+ """`sha256:<hex>` of the canonical form of an object."""
98
+ return "sha256:" + sha256_hex(canonical_bytes(value))
99
+
100
+
101
+ def hash_string(text: str) -> str:
102
+ """`sha256:<hex>` of a string as given: a JWT, an SD-JWT, any opaque artifact."""
103
+ return "sha256:" + sha256_hex(text)