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 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