x403 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- x403/__init__.py +46 -0
- x403/client.py +166 -0
- x403/headers.py +70 -0
- x403/jcs.py +103 -0
- x403/jws.py +211 -0
- x403/middleware/__init__.py +0 -0
- x403/middleware/fastapi.py +189 -0
- x403/reason-codes.json +136 -0
- x403/registry.py +70 -0
- x403/types.py +306 -0
- x403-0.1.0.dist-info/METADATA +122 -0
- x403-0.1.0.dist-info/RECORD +13 -0
- x403-0.1.0.dist-info/WHEEL +4 -0
x403/__init__.py
ADDED
|
@@ -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
|
+
]
|
x403/client.py
ADDED
|
@@ -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())
|
x403/headers.py
ADDED
|
@@ -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
|
x403/jcs.py
ADDED
|
@@ -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)
|
x403/jws.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Compact JWS with ES256 over the RFC 8785 form. Signatures are raw r||s as JWS wants;
|
|
2
|
+
`cryptography` hands back DER, so the two are converted at the edge and nowhere else."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cryptography.exceptions import InvalidSignature
|
|
13
|
+
from cryptography.hazmat.primitives import hashes
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
|
|
16
|
+
|
|
17
|
+
from .jcs import canonical_bytes, canonicalize, sha256_hex
|
|
18
|
+
from .types import JWK, JWKS, Decision, Outcome, Receipt, RiskQuery
|
|
19
|
+
|
|
20
|
+
CURVE = ec.SECP256R1()
|
|
21
|
+
COORD = 32
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def base64url(data: bytes | str) -> str:
|
|
25
|
+
raw = data.encode("utf-8") if isinstance(data, str) else data
|
|
26
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def from_base64url(text: str) -> bytes:
|
|
30
|
+
text = text.strip()
|
|
31
|
+
return base64.urlsafe_b64decode(text + "=" * ((4 - len(text) % 4) % 4))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _int(text: str) -> int:
|
|
35
|
+
return int.from_bytes(from_base64url(text), "big")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _b64int(n: int) -> str:
|
|
39
|
+
return base64url(n.to_bytes(COORD, "big"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def thumbprint(jwk: JWK) -> str:
|
|
43
|
+
"""RFC 7638: over exactly these members, in this order."""
|
|
44
|
+
if jwk.get("kty") != "EC":
|
|
45
|
+
raise TypeError("thumbprint: only EC keys")
|
|
46
|
+
text = canonicalize({"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"], "y": jwk["y"]})
|
|
47
|
+
return base64url(bytes.fromhex(sha256_hex(text)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class KeyPair:
|
|
52
|
+
private_jwk: JWK
|
|
53
|
+
public_jwk: JWK
|
|
54
|
+
kid: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generate_key() -> KeyPair:
|
|
58
|
+
"""A fresh P-256 pair as JWKs, with the RFC 7638 thumbprint as kid."""
|
|
59
|
+
private = ec.generate_private_key(CURVE)
|
|
60
|
+
return key_pair_from_private(private)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def key_pair_from_private(private: ec.EllipticCurvePrivateKey) -> KeyPair:
|
|
64
|
+
numbers = private.private_numbers()
|
|
65
|
+
pub = numbers.public_numbers
|
|
66
|
+
public_jwk: JWK = {"kty": "EC", "crv": "P-256", "x": _b64int(pub.x), "y": _b64int(pub.y)}
|
|
67
|
+
kid = thumbprint(public_jwk)
|
|
68
|
+
public_jwk.update({"kid": kid, "alg": "ES256", "use": "sig"})
|
|
69
|
+
private_jwk: JWK = {**public_jwk, "d": _b64int(numbers.private_value)}
|
|
70
|
+
private_jwk.pop("use", None)
|
|
71
|
+
return KeyPair(private_jwk=private_jwk, public_jwk=public_jwk, kid=kid)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def private_key(jwk: JWK) -> ec.EllipticCurvePrivateKey:
|
|
75
|
+
if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256" or "d" not in jwk:
|
|
76
|
+
raise TypeError("a P-256 private JWK is needed")
|
|
77
|
+
return ec.derive_private_key(_int(jwk["d"]), CURVE)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def public_key(jwk: JWK) -> ec.EllipticCurvePublicKey:
|
|
81
|
+
if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
|
|
82
|
+
raise TypeError("a P-256 public JWK is needed")
|
|
83
|
+
return ec.EllipticCurvePublicNumbers(_int(jwk["x"]), _int(jwk["y"]), CURVE).public_key()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def sign(payload: Any, private_jwk: JWK, kid: str | None = None, typ: str | None = None) -> str:
|
|
87
|
+
"""Sign the canonical form of `payload` as a compact JWS."""
|
|
88
|
+
kid = kid or private_jwk.get("kid") or thumbprint(private_jwk)
|
|
89
|
+
header: dict[str, Any] = {"alg": "ES256", "kid": kid}
|
|
90
|
+
if typ:
|
|
91
|
+
header["typ"] = typ
|
|
92
|
+
signing_input = base64url(canonicalize(header)) + "." + base64url(canonical_bytes(payload))
|
|
93
|
+
der = private_key(private_jwk).sign(signing_input.encode("ascii"), ec.ECDSA(hashes.SHA256()))
|
|
94
|
+
r, s = decode_dss_signature(der)
|
|
95
|
+
return signing_input + "." + base64url(r.to_bytes(COORD, "big") + s.to_bytes(COORD, "big"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Verified:
|
|
100
|
+
header: dict[str, Any]
|
|
101
|
+
payload: Any
|
|
102
|
+
payload_text: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _pick_key(jwks: JWKS | JWK, kid: str | None) -> JWK:
|
|
106
|
+
if "keys" not in jwks:
|
|
107
|
+
return jwks # type: ignore[return-value]
|
|
108
|
+
keys = [k for k in jwks["keys"] if k.get("kty") == "EC" and k.get("crv") == "P-256"] # type: ignore[index]
|
|
109
|
+
match = next((k for k in keys if kid and k.get("kid") == kid), None)
|
|
110
|
+
if match is None and len(keys) == 1:
|
|
111
|
+
match = keys[0]
|
|
112
|
+
if match is None:
|
|
113
|
+
raise ValueError(f"no key with kid {kid}" if kid else "no single P-256 key to verify with")
|
|
114
|
+
return match
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def verify(jws: str, jwks: JWKS | JWK) -> Verified:
|
|
118
|
+
"""Verify a compact JWS against a key set and return its header and payload. Raises when it does not verify."""
|
|
119
|
+
parts = jws.split(".")
|
|
120
|
+
if len(parts) != 3:
|
|
121
|
+
raise ValueError("not a compact JWS")
|
|
122
|
+
h, p, s = parts
|
|
123
|
+
header = json.loads(from_base64url(h))
|
|
124
|
+
if header.get("alg") != "ES256":
|
|
125
|
+
raise ValueError(f"unsupported alg {header.get('alg')}")
|
|
126
|
+
raw = from_base64url(s)
|
|
127
|
+
if len(raw) != 2 * COORD:
|
|
128
|
+
raise ValueError("signature is not raw r||s")
|
|
129
|
+
der = encode_dss_signature(int.from_bytes(raw[:COORD], "big"), int.from_bytes(raw[COORD:], "big"))
|
|
130
|
+
key = public_key(_pick_key(jwks, header.get("kid")))
|
|
131
|
+
try:
|
|
132
|
+
key.verify(der, f"{h}.{p}".encode("ascii"), ec.ECDSA(hashes.SHA256()))
|
|
133
|
+
except InvalidSignature as e:
|
|
134
|
+
raise ValueError("signature does not verify") from e
|
|
135
|
+
payload_text = from_base64url(p).decode("utf-8")
|
|
136
|
+
return Verified(header=header, payload=json.loads(payload_text), payload_text=payload_text)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class VerifyResult:
|
|
141
|
+
ok: bool
|
|
142
|
+
reason: str | None = None
|
|
143
|
+
kid: str | None = None
|
|
144
|
+
|
|
145
|
+
def __bool__(self) -> bool:
|
|
146
|
+
return self.ok
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_time(text: str) -> datetime:
|
|
150
|
+
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
151
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def verify_decision(decision: Decision, jwks: JWKS | JWK, now: datetime | None = None) -> VerifyResult:
|
|
155
|
+
"""A decision verifies when decision_sig is a valid ES256 signature by one of the
|
|
156
|
+
authority's keys over the object without decision_sig, and it has not expired."""
|
|
157
|
+
sig = decision.get("decision_sig")
|
|
158
|
+
if not sig:
|
|
159
|
+
return VerifyResult(False, "unsigned")
|
|
160
|
+
body = {k: v for k, v in decision.items() if k != "decision_sig"}
|
|
161
|
+
try:
|
|
162
|
+
verified = verify(sig, jwks)
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
return VerifyResult(False, str(e))
|
|
165
|
+
kid = verified.header.get("kid")
|
|
166
|
+
# The signed payload must be this object, byte for byte in canonical form: a decision
|
|
167
|
+
# whose fields were edited after signing carries a signature over something else.
|
|
168
|
+
if verified.payload_text != canonicalize(body):
|
|
169
|
+
return VerifyResult(False, "object differs from signed payload", kid)
|
|
170
|
+
expires = decision.get("expires_at")
|
|
171
|
+
if expires and _parse_time(expires) <= (now or datetime.now(timezone.utc)):
|
|
172
|
+
return VerifyResult(False, "decision_expired", kid)
|
|
173
|
+
return VerifyResult(True, None, kid)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def verify_receipt(receipt: Receipt, jwks: JWKS | JWK) -> VerifyResult:
|
|
177
|
+
sig = receipt.get("executor_sig")
|
|
178
|
+
if not sig:
|
|
179
|
+
return VerifyResult(False, "unsigned")
|
|
180
|
+
body = {k: v for k, v in receipt.items() if k != "executor_sig"}
|
|
181
|
+
try:
|
|
182
|
+
verified = verify(sig, jwks)
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
return VerifyResult(False, str(e))
|
|
185
|
+
if verified.payload_text != canonicalize(body):
|
|
186
|
+
return VerifyResult(False, "object differs from signed payload", verified.header.get("kid"))
|
|
187
|
+
return VerifyResult(True, None, verified.header.get("kid"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def sign_receipt(receipt: Receipt, private_jwk: JWK, kid: str | None = None) -> Receipt:
|
|
191
|
+
"""Countersign a receipt: executor_sig is over the receipt without executor_sig."""
|
|
192
|
+
body = {k: v for k, v in receipt.items() if k != "executor_sig"}
|
|
193
|
+
kid = kid or (receipt.get("executor") or {}).get("kid")
|
|
194
|
+
return {**body, "executor_sig": sign(body, private_jwk, kid=kid, typ="x403-receipt+jws")} # type: ignore[return-value]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def sign_outcome(outcome: Outcome, private_jwk: JWK, kid: str | None = None) -> Outcome:
|
|
198
|
+
body = {k: v for k, v in outcome.items() if k != "sig"}
|
|
199
|
+
kid = kid or (outcome.get("reporter") or {}).get("kid")
|
|
200
|
+
return {**body, "sig": sign(body, private_jwk, kid=kid, typ="x403-outcome+jws")} # type: ignore[return-value]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def sign_query(query: RiskQuery, private_jwk: JWK, kid: str | None = None) -> RiskQuery:
|
|
204
|
+
body = {k: v for k, v in query.items() if k != "requester_sig"}
|
|
205
|
+
kid = kid or (query.get("requester") or {}).get("kid")
|
|
206
|
+
return {**body, "requester_sig": sign(body, private_jwk, kid=kid, typ="x403-query+jws")} # type: ignore[return-value]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def decision_hash(decision: Decision) -> str:
|
|
210
|
+
"""The hash a receipt must carry: the decision as signed, decision_sig included."""
|
|
211
|
+
return "sha256:" + sha256_hex(canonical_bytes(decision))
|
|
File without changes
|