PyEVP 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.
Files changed (50) hide show
  1. pyevp/__init__.py +47 -0
  2. pyevp/__main__.py +28 -0
  3. pyevp/_email.py +65 -0
  4. pyevp/_httpsig.py +284 -0
  5. pyevp/_jose.py +145 -0
  6. pyevp/_sf.py +406 -0
  7. pyevp/adapters/__init__.py +4 -0
  8. pyevp/adapters/_doh.py +92 -0
  9. pyevp/adapters/_fetch.py +86 -0
  10. pyevp/adapters/_http.py +38 -0
  11. pyevp/adapters/dnspython.py +79 -0
  12. pyevp/adapters/doh.py +130 -0
  13. pyevp/adapters/httpx.py +147 -0
  14. pyevp/adapters/urllib.py +170 -0
  15. pyevp/cache.py +83 -0
  16. pyevp/cli/__init__.py +348 -0
  17. pyevp/contrib/__init__.py +4 -0
  18. pyevp/contrib/django/__init__.py +306 -0
  19. pyevp/contrib/django/apps.py +17 -0
  20. pyevp/contrib/django/issuer.py +314 -0
  21. pyevp/contrib/django/migrations/0001_initial.py +17 -0
  22. pyevp/contrib/django/migrations/__init__.py +0 -0
  23. pyevp/contrib/django/models.py +14 -0
  24. pyevp/contrib/django/templatetags/__init__.py +0 -0
  25. pyevp/contrib/django/templatetags/pyevp.py +32 -0
  26. pyevp/core.py +321 -0
  27. pyevp/diagnostics.py +182 -0
  28. pyevp/discovery.py +144 -0
  29. pyevp/errors.py +80 -0
  30. pyevp/issuer/__init__.py +39 -0
  31. pyevp/issuer/core.py +413 -0
  32. pyevp/issuer/errors.py +99 -0
  33. pyevp/issuer/fedcm.py +44 -0
  34. pyevp/issuer/keys.py +140 -0
  35. pyevp/issuer/profile.py +96 -0
  36. pyevp/nonce.py +25 -0
  37. pyevp/observability.py +89 -0
  38. pyevp/ports.py +54 -0
  39. pyevp/profile.py +153 -0
  40. pyevp/py.typed +0 -0
  41. pyevp/replay.py +69 -0
  42. pyevp/testing.py +343 -0
  43. pyevp/token.py +135 -0
  44. pyevp/types.py +37 -0
  45. pyevp/verifier.py +486 -0
  46. pyevp-0.1.0.dist-info/METADATA +171 -0
  47. pyevp-0.1.0.dist-info/RECORD +50 -0
  48. pyevp-0.1.0.dist-info/WHEEL +4 -0
  49. pyevp-0.1.0.dist-info/entry_points.txt +3 -0
  50. pyevp-0.1.0.dist-info/licenses/LICENSE +21 -0
pyevp/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """Relying-party verification for the Email Verification Protocol (EVP)."""
2
+
3
+ from pyevp._email import emails_match
4
+ from pyevp.cache import AsyncCache, Cache, CacheEntry, InMemoryCache, NullCache
5
+ from pyevp.errors import DiscoveryError, ErrorCode, EVPError, PolicyError, TokenError
6
+ from pyevp.nonce import generate_nonce, nonces_equal
7
+ from pyevp.observability import LoggingObserver, Observer, VerificationEvent
8
+ from pyevp.ports import AsyncJsonFetcher, AsyncTxtResolver, Clock, JsonFetcher, TxtResolver
9
+ from pyevp.profile import DEFAULT_PROFILE, EmailComparison, IssuerFormat, Profile
10
+ from pyevp.replay import AsyncReplayGuard, InMemoryReplayGuard, ReplayGuard
11
+ from pyevp.types import IssuerMetadata, VerifiedEmail
12
+ from pyevp.verifier import AsyncVerifier, Verifier
13
+
14
+ __all__ = [
15
+ "DEFAULT_PROFILE",
16
+ "AsyncCache",
17
+ "AsyncJsonFetcher",
18
+ "AsyncReplayGuard",
19
+ "AsyncTxtResolver",
20
+ "AsyncVerifier",
21
+ "Cache",
22
+ "CacheEntry",
23
+ "Clock",
24
+ "DiscoveryError",
25
+ "EVPError",
26
+ "EmailComparison",
27
+ "ErrorCode",
28
+ "InMemoryCache",
29
+ "InMemoryReplayGuard",
30
+ "IssuerFormat",
31
+ "IssuerMetadata",
32
+ "JsonFetcher",
33
+ "LoggingObserver",
34
+ "NullCache",
35
+ "Observer",
36
+ "PolicyError",
37
+ "Profile",
38
+ "ReplayGuard",
39
+ "TokenError",
40
+ "TxtResolver",
41
+ "VerificationEvent",
42
+ "VerifiedEmail",
43
+ "Verifier",
44
+ "emails_match",
45
+ "generate_nonce",
46
+ "nonces_equal",
47
+ ]
pyevp/__main__.py ADDED
@@ -0,0 +1,28 @@
1
+ """``pyevp`` command / ``python -m pyevp``. Requires ``pip install "pyevp[cli]"``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ MISSING_EXTRA = 3
8
+
9
+
10
+ def main() -> None:
11
+ try:
12
+ from pyevp.cli import app # noqa: PLC0415
13
+ except ImportError as exc:
14
+ if exc.name not in {"typer", "click", "rich"}:
15
+ raise
16
+ print(
17
+ 'The pyevp command needs the "cli" extra:\n'
18
+ ' pip install "pyevp[cli]"\n'
19
+ "or run it without installing:\n"
20
+ ' uvx --from "pyevp[cli]" pyevp --help',
21
+ file=sys.stderr,
22
+ )
23
+ raise SystemExit(MISSING_EXTRA) from None
24
+ app()
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
pyevp/_email.py ADDED
@@ -0,0 +1,65 @@
1
+ """Email address normalisation and comparison shared by verification and account lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ import idna
8
+
9
+ __all__ = ["EmailComparison", "email_domain", "emails_match"]
10
+
11
+
12
+ class EmailComparison(StrEnum):
13
+ """How a submitted address is compared with the asserted one."""
14
+
15
+ EXACT = "exact"
16
+ """Byte for byte (IETF draft)."""
17
+ CASE_INSENSITIVE = "case_insensitive"
18
+ """Local part Unicode case-folded, domain compared in IDNA2008 A-label form.
19
+
20
+ Follows the W3C Email Verification API, without letting case folding merge two
21
+ DNS names: ``a@faß.example`` does not match ``a@fass.example``.
22
+ """
23
+
24
+
25
+ def _split(email: str) -> tuple[str, str]:
26
+ local, sep, domain = email.rpartition("@")
27
+ if not sep or not local or not domain:
28
+ raise ValueError(f"not an email address: {email!r}")
29
+ return local, domain
30
+
31
+
32
+ def email_domain(email: str) -> str:
33
+ """Return the DNS (A-label) form of the domain part of ``email``.
34
+
35
+ Internationalised domains are mapped with UTS #46 / IDNA2008, as browsers do.
36
+ Python's ``"idna"`` codec implements IDNA2003, which maps some names onto other
37
+ domains (``faß.example`` → ``fass.example``). Raises :class:`UnicodeError` for
38
+ invalid internationalised domains.
39
+ """
40
+ domain = _split(email)[1].rstrip(".")
41
+ if domain.isascii():
42
+ return domain.lower()
43
+ return idna.encode(domain, uts46=True).decode("ascii")
44
+
45
+
46
+ def emails_match(asserted: str, submitted: str, comparison: EmailComparison) -> bool:
47
+ """Whether two addresses name the same mailbox under ``comparison``.
48
+
49
+ ``EXACT`` compares the strings byte for byte. ``CASE_INSENSITIVE`` case-folds the
50
+ local part and compares domains in their IDNA2008 A-label form, so that case
51
+ folding never merges two DNS names (``faß.example`` is not ``fass.example``).
52
+ Invalid addresses never match.
53
+ """
54
+ if comparison is EmailComparison.EXACT:
55
+ return asserted == submitted
56
+ try:
57
+ (a_local, a_domain), (s_local, s_domain) = _split(asserted), _split(submitted)
58
+ same_domain = email_domain(asserted) == email_domain(submitted)
59
+ except (ValueError, UnicodeError):
60
+ return False
61
+ return (
62
+ same_domain
63
+ and a_domain.endswith(".") == s_domain.endswith(".")
64
+ and a_local.casefold() == s_local.casefold()
65
+ )
pyevp/_httpsig.py ADDED
@@ -0,0 +1,284 @@
1
+ """HTTP Message Signatures (RFC 9421) with an ``hwk`` Signature-Key, as browsers send them.
2
+
3
+ Only what EVP issuance needs is implemented: one signature per request, carrying
4
+ its public key in ``Signature-Key: <label>=hwk;…`` (draft-hardt-httpbis-signature-key),
5
+ over a body bound by ``Content-Digest: sha-256=…`` (RFC 9530). Derived components
6
+ are computed from the issuance endpoint the issuer is configured with, never from
7
+ the ``Host`` header, so a signature made for another endpoint does not verify.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ from collections.abc import Iterable, Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime, timedelta
17
+ from typing import Any, TypeAlias, cast
18
+ from urllib.parse import urlsplit
19
+
20
+ from pyevp import _jose, _sf
21
+
22
+ __all__ = [
23
+ "Headers",
24
+ "SignatureError",
25
+ "SignedRequest",
26
+ "content_digest",
27
+ "sign_request",
28
+ "verify_request",
29
+ ]
30
+
31
+ # TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
32
+ Headers: TypeAlias = Mapping[str, str] | Iterable[tuple[str, str]]
33
+ """Request headers: a mapping, or ``(name, value)`` pairs that may repeat a name."""
34
+
35
+ REQUIRED_COMPONENTS = ("@method", "@authority", "@path", "content-digest", "signature-key")
36
+ _DERIVED = frozenset({"@method", "@authority", "@path", "@scheme", "@target-uri"})
37
+ # Curve -> the fully specified algorithm a key without ``alg`` is taken to use.
38
+ _IMPLIED_ALG = {("OKP", "Ed25519"): "Ed25519", ("EC", "P-256"): "ES256"}
39
+ _HWK_MEMBERS = {"OKP": ("kty", "crv", "x"), "EC": ("kty", "crv", "x", "y")}
40
+
41
+
42
+ class SignatureError(Exception):
43
+ """Verification failed. ``code`` is a Signature-Error code (signature-key draft)."""
44
+
45
+ def __init__(self, code: str, message: str) -> None:
46
+ super().__init__(message)
47
+ self.code = code
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class SignedRequest:
52
+ label: str
53
+ alg: str
54
+ public_jwk: dict[str, str]
55
+ """The signer's key as a JWK, always with ``alg``."""
56
+ created: datetime
57
+ signature: bytes
58
+ base: bytes
59
+ """The signature base that ``signature`` was verified over."""
60
+
61
+
62
+ def header_pairs(headers: Headers) -> list[tuple[str, str]]:
63
+ """``headers`` as a list, so that an iterator can be read more than once."""
64
+ # A Mapping is also an Iterable of its keys, so narrowing the union confuses type checkers.
65
+ pairs = cast("Mapping[str, str]", headers).items() if isinstance(headers, Mapping) else headers
66
+ return list(pairs)
67
+
68
+
69
+ def _field_lines(headers: Headers) -> dict[str, list[str]]:
70
+ lines: dict[str, list[str]] = {}
71
+ for name, value in header_pairs(headers):
72
+ lines.setdefault(name.lower(), []).append(value.strip(" \t"))
73
+ return lines
74
+
75
+
76
+ def content_digest(body: bytes) -> str:
77
+ return _sf.serialize_dictionary({"sha-256": _sf.Item(hashlib.sha256(body).digest())})
78
+
79
+
80
+ def _check_digest(lines: Sequence[str] | None, body: bytes) -> None:
81
+ if not lines:
82
+ raise SignatureError("invalid_input", "missing Content-Digest")
83
+ try:
84
+ digests = _sf.parse_dictionary(lines)
85
+ except _sf.SFError as exc:
86
+ raise SignatureError("invalid_request", f"malformed Content-Digest: {exc}") from None
87
+ member = digests.get("sha-256")
88
+ if not isinstance(member, _sf.Item) or not isinstance(member.value, bytes):
89
+ raise SignatureError("invalid_request", "Content-Digest has no sha-256 digest")
90
+ if not hmac.compare_digest(member.value, hashlib.sha256(body).digest()):
91
+ raise SignatureError("invalid_signature", "Content-Digest does not match the body")
92
+
93
+
94
+ def _derived(name: str, method: str, endpoint: str) -> str:
95
+ url = urlsplit(endpoint)
96
+ host = (url.hostname or "").lower()
97
+ authority = host if url.port in (None, 443) else f"{host}:{url.port}"
98
+ return {
99
+ "@method": method,
100
+ "@authority": authority,
101
+ "@path": url.path or "/",
102
+ "@scheme": url.scheme.lower(),
103
+ "@target-uri": endpoint,
104
+ }[name]
105
+
106
+
107
+ def _signature_base(
108
+ components: _sf.InnerList,
109
+ *,
110
+ method: str,
111
+ endpoint: str,
112
+ lines: Mapping[str, list[str]],
113
+ required: Sequence[str] = REQUIRED_COMPONENTS,
114
+ ) -> bytes:
115
+ out: list[str] = []
116
+ seen: set[str] = set()
117
+ for component in components.items:
118
+ name = component.value
119
+ if not isinstance(name, str) or isinstance(name, _sf.Token) or component.params:
120
+ raise SignatureError("invalid_input", f"unsupported component {component!r}")
121
+ if name in seen:
122
+ raise SignatureError("invalid_input", f"component {name!r} is covered twice")
123
+ seen.add(name)
124
+ if name in _DERIVED:
125
+ value = _derived(name, method, endpoint)
126
+ elif name.startswith("@") or name != name.lower():
127
+ raise SignatureError("invalid_input", f"unsupported component {name!r}")
128
+ elif name not in lines:
129
+ raise SignatureError("invalid_input", f"covered field {name!r} is missing")
130
+ else:
131
+ value = ", ".join(lines[name])
132
+ out.append(f'"{name}": {value}')
133
+ if missing := [c for c in required if c not in seen]:
134
+ raise SignatureError("invalid_input", f"components not covered: {', '.join(missing)}")
135
+ out.append(f'"@signature-params": {_sf.serialize_inner_list(components)}')
136
+ try:
137
+ return "\n".join(out).encode("ascii")
138
+ except UnicodeEncodeError:
139
+ raise SignatureError("invalid_input", "covered field is not ASCII") from None
140
+
141
+
142
+ def _hwk_jwk(member: _sf.Item | _sf.InnerList, *, require_alg: bool) -> dict[str, str]:
143
+ if not isinstance(member, _sf.Item) or not isinstance(member.value, _sf.Token):
144
+ raise SignatureError("unsupported_scheme", "Signature-Key member is not a scheme token")
145
+ if member.value != "hwk":
146
+ raise SignatureError("unsupported_scheme", "only the hwk Signature-Key scheme is supported")
147
+ params = member.params
148
+ if "kid" in params:
149
+ raise SignatureError("invalid_key", "hwk keys must not carry kid")
150
+ kty = params.get("kty")
151
+ if not isinstance(kty, str) or kty not in _HWK_MEMBERS:
152
+ raise SignatureError("invalid_key", "unsupported or missing kty")
153
+ jwk: dict[str, str] = {}
154
+ for name in _HWK_MEMBERS[kty]:
155
+ value = params.get(name)
156
+ if not isinstance(value, str) or isinstance(value, _sf.Token):
157
+ raise SignatureError("invalid_key", f"hwk {name} must be a string")
158
+ jwk[name] = value
159
+ alg = params.get("alg")
160
+ if alg is None and not require_alg:
161
+ alg = _IMPLIED_ALG.get((kty, jwk["crv"]))
162
+ if not isinstance(alg, str) or isinstance(alg, _sf.Token):
163
+ raise SignatureError("invalid_key", "hwk alg is missing or not a string")
164
+ jwk["alg"] = alg
165
+ return jwk
166
+
167
+
168
+ def _single_label(lines: Mapping[str, list[str]]) -> tuple[str, Any, Any, Any]:
169
+ parsed = {}
170
+ for name in ("signature-input", "signature", "signature-key"):
171
+ if not lines.get(name):
172
+ raise SignatureError("invalid_signature", f"missing {name} header")
173
+ try:
174
+ parsed[name] = _sf.parse_dictionary(lines[name])
175
+ except _sf.SFError as exc:
176
+ raise SignatureError("invalid_signature", f"malformed {name}: {exc}") from None
177
+ keys = parsed["signature-key"]
178
+ if len(keys) != 1:
179
+ raise SignatureError("invalid_signature", "expected exactly one Signature-Key")
180
+ (label,) = keys
181
+ if label not in parsed["signature-input"] or label not in parsed["signature"]:
182
+ raise SignatureError("invalid_signature", f"no signature labelled {label!r}")
183
+ return label, parsed["signature-input"][label], parsed["signature"][label], keys[label]
184
+
185
+
186
+ def verify_request(
187
+ *,
188
+ method: str,
189
+ endpoint: str,
190
+ headers: Headers,
191
+ body: bytes,
192
+ now: datetime,
193
+ max_age: timedelta,
194
+ algorithms: frozenset[str],
195
+ require_key_alg: bool,
196
+ ) -> SignedRequest:
197
+ """Verify the signature on a request to ``endpoint`` (the configured issuance URL).
198
+
199
+ ``algorithms`` are the fully specified algorithms accepted for the signer's key.
200
+ """
201
+ lines = _field_lines(headers)
202
+ label, components, signature, key_member = _single_label(lines)
203
+ if not isinstance(components, _sf.InnerList):
204
+ raise SignatureError("invalid_signature", "Signature-Input member is not an inner list")
205
+ if not isinstance(signature, _sf.Item) or not isinstance(signature.value, bytes):
206
+ raise SignatureError("invalid_signature", "Signature member is not a byte sequence")
207
+
208
+ jwk = _hwk_jwk(key_member, require_alg=require_key_alg)
209
+ alg = jwk["alg"]
210
+ if alg not in algorithms:
211
+ raise SignatureError("unsupported_algorithm", f"signature algorithm {alg!r} not accepted")
212
+ params = components.params
213
+ if "alg" in params and params["alg"] != alg:
214
+ raise SignatureError("invalid_signature", "Signature-Input alg contradicts the key")
215
+
216
+ created = params.get("created")
217
+ if not isinstance(created, int) or isinstance(created, bool):
218
+ raise SignatureError("invalid_signature", "Signature-Input has no integer created")
219
+ try:
220
+ created_at = datetime.fromtimestamp(created, UTC)
221
+ except (OverflowError, OSError, ValueError):
222
+ raise SignatureError(
223
+ "invalid_signature", "Signature-Input created is out of range"
224
+ ) from None
225
+ if created_at > now + max_age:
226
+ raise SignatureError("clock_skew", "signature created in the future")
227
+ if created_at < now - max_age:
228
+ raise SignatureError("invalid_signature", "signature is too old")
229
+ expires = params.get("expires")
230
+ if expires is not None and (
231
+ not isinstance(expires, int) or isinstance(expires, bool) or expires < now.timestamp()
232
+ ):
233
+ raise SignatureError("invalid_signature", "signature has expired")
234
+
235
+ base = _signature_base(components, method=method, endpoint=endpoint, lines=lines)
236
+ if not _jose.verify_raw(base, signature.value, jwk, alg):
237
+ if _jose.import_public(jwk) is None:
238
+ raise SignatureError("invalid_key", "the hwk public key is not valid")
239
+ raise SignatureError("invalid_signature", "HTTP Message Signature verification failed")
240
+ # Only now that the body is known to be what was signed is the digest worth checking.
241
+ _check_digest(lines.get("content-digest"), body)
242
+ return SignedRequest(label, alg, jwk, created_at, signature.value, base)
243
+
244
+
245
+ def sign_request(
246
+ *,
247
+ method: str,
248
+ endpoint: str,
249
+ body: bytes,
250
+ private_key: Any,
251
+ public_jwk: Mapping[str, Any],
252
+ alg: str,
253
+ created: datetime,
254
+ label: str = "sig",
255
+ include_alg: bool = True,
256
+ signature_key: str | None = None,
257
+ digest: str | None = None,
258
+ ) -> dict[str, str]:
259
+ """Return the headers a browser adds to sign an issuance request (for tests).
260
+
261
+ ``signature_key`` and ``digest`` replace the generated ``Signature-Key`` and
262
+ ``Content-Digest`` values verbatim.
263
+ """
264
+ if signature_key is None:
265
+ members: dict[str, Any] = {k: public_jwk[k] for k in _HWK_MEMBERS[public_jwk["kty"]]}
266
+ if include_alg:
267
+ members["alg"] = alg
268
+ signature_key = _sf.serialize_dictionary({label: _sf.Item(_sf.Token("hwk"), members)})
269
+ headers = {
270
+ "Content-Type": "application/json",
271
+ "Sec-Fetch-Dest": "email-verification",
272
+ "Content-Digest": content_digest(body) if digest is None else digest,
273
+ "Signature-Key": signature_key,
274
+ }
275
+ components = _sf.InnerList(
276
+ tuple(_sf.Item(c) for c in REQUIRED_COMPONENTS), {"created": int(created.timestamp())}
277
+ )
278
+ base = _signature_base(
279
+ components, method=method, endpoint=endpoint, lines=_field_lines(headers)
280
+ )
281
+ signature = _jose.sign_raw(base, private_key, alg)
282
+ headers["Signature-Input"] = _sf.serialize_dictionary({label: components})
283
+ headers["Signature"] = _sf.serialize_dictionary({label: _sf.Item(signature)})
284
+ return headers
pyevp/_jose.py ADDED
@@ -0,0 +1,145 @@
1
+ """Thin wrappers over joserfc. Nothing outside this module imports joserfc for verifying."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import warnings
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ from joserfc import jwk, jws
12
+ from joserfc.errors import JoseError, SecurityWarning
13
+ from joserfc.jws import JWSRegistry
14
+
15
+ from pyevp.types import JSONObject
16
+
17
+ # Never acceptable regardless of profile.
18
+ FORBIDDEN_ALGORITHMS = frozenset({"none", "HS256", "HS384", "HS512"})
19
+
20
+ _EC_CURVES = {"ES256": "P-256", "ES384": "P-384", "ES512": "P-521", "ES256K": "secp256k1"}
21
+ _OKP_CURVES = {"Ed25519": {"Ed25519"}, "Ed448": {"Ed448"}, "EdDSA": {"Ed25519", "Ed448"}}
22
+ _EDDSA_FAMILY = frozenset({"EdDSA", "Ed25519", "Ed448"})
23
+
24
+
25
+ def b64url_decode(segment: str) -> bytes:
26
+ return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
27
+
28
+
29
+ def b64url_encode(data: bytes) -> str:
30
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
31
+
32
+
33
+ def decode_json_segment(segment: str) -> dict[str, Any]:
34
+ """Decode a base64url JSON object. Raises ``ValueError`` on any problem."""
35
+ try:
36
+ value = json.loads(b64url_decode(segment))
37
+ except (ValueError, UnicodeDecodeError, RecursionError) as exc:
38
+ raise ValueError("segment is not base64url-encoded JSON") from exc
39
+ if not isinstance(value, dict):
40
+ raise ValueError("segment is not a JSON object")
41
+ try:
42
+ # JSON escapes can produce lone surrogates, which no string operation downstream expects.
43
+ json.dumps(value, ensure_ascii=False).encode()
44
+ except UnicodeEncodeError as exc:
45
+ raise ValueError("segment contains invalid Unicode") from exc
46
+ return value
47
+
48
+
49
+ def algorithms_compatible(a: str, b: str) -> bool:
50
+ """Whether two ``alg`` values may name the same algorithm.
51
+
52
+ The polymorphic ``EdDSA`` (RFC 8037) covers the fully specified ``Ed25519`` and
53
+ ``Ed448`` (RFC 9864), but those two are different algorithms.
54
+ """
55
+ return a == b or ("EdDSA" in (a, b) and a in _EDDSA_FAMILY and b in _EDDSA_FAMILY)
56
+
57
+
58
+ def key_supports(alg: str, key: Mapping[str, Any]) -> bool:
59
+ """Whether a public JWK can verify signatures made with ``alg``."""
60
+ if alg in FORBIDDEN_ALGORITHMS or not has_valid_members(key):
61
+ return False
62
+ if key.get("use") not in (None, "sig"):
63
+ return False
64
+ if "key_ops" in key and "verify" not in key["key_ops"]:
65
+ return False
66
+ key_alg = key.get("alg")
67
+ if isinstance(key_alg, str) and not algorithms_compatible(alg, key_alg):
68
+ return False
69
+ kty, crv = key.get("kty"), key.get("crv")
70
+ if alg in _OKP_CURVES:
71
+ # The key's own alg narrows the curve too: "EdDSA" against an Ed448-only key.
72
+ curves = _OKP_CURVES[alg] & _OKP_CURVES.get(key_alg, _OKP_CURVES[alg])
73
+ return kty == "OKP" and crv in curves
74
+ if alg in _EC_CURVES:
75
+ return kty == "EC" and crv == _EC_CURVES[alg]
76
+ if alg[:2] in ("RS", "PS"):
77
+ return kty == "RSA"
78
+ return False
79
+
80
+
81
+ _STRING_MEMBERS = ("alg", "crv", "kid", "use")
82
+
83
+
84
+ def has_valid_members(key: Mapping[str, Any]) -> bool:
85
+ """Whether the JWK members this library reads have the types RFC 7517 gives them."""
86
+ if not isinstance(key.get("kty"), str):
87
+ return False
88
+ if any(m in key and not isinstance(key[m], str) for m in _STRING_MEMBERS):
89
+ return False
90
+ if "key_ops" not in key:
91
+ return True
92
+ ops = key["key_ops"]
93
+ return isinstance(ops, list) and all(isinstance(o, str) for o in ops)
94
+
95
+
96
+ def is_public_jwk(key: Mapping[str, Any]) -> bool:
97
+ return has_valid_members(key) and not any(p in key for p in ("d", "p", "q", "k"))
98
+
99
+
100
+ def import_public(key: Mapping[str, Any]) -> Any | None:
101
+ """Import a public JWK, or return ``None`` if its key material is invalid."""
102
+ # joserfc enforces the JWK "alg" literally; alias handling is done by key_supports,
103
+ # which also checks "key_ops".
104
+ material = {k: v for k, v in key.items() if k not in ("alg", "key_ops")}
105
+ try:
106
+ return jwk.import_key(material)
107
+ except (JoseError, ValueError, TypeError):
108
+ return None
109
+
110
+
111
+ def verify_compact(compact: str, key: JSONObject, alg: str) -> bool:
112
+ """Verify a compact JWS signature with a single public JWK."""
113
+ if not key_supports(alg, key) or (imported := import_public(key)) is None:
114
+ return False
115
+ try:
116
+ with warnings.catch_warnings():
117
+ warnings.simplefilter("ignore", SecurityWarning)
118
+ jws.deserialize_compact(compact, imported, algorithms=[alg])
119
+ except (JoseError, ValueError, TypeError):
120
+ return False
121
+ return True
122
+
123
+
124
+ def sign_compact(header: JSONObject, claims: JSONObject, private_key: Any) -> str:
125
+ payload = json.dumps(dict(claims), separators=(",", ":")).encode()
126
+ with warnings.catch_warnings():
127
+ warnings.simplefilter("ignore", SecurityWarning)
128
+ return jws.serialize_compact(dict(header), payload, private_key, algorithms=[header["alg"]])
129
+
130
+
131
+ def verify_raw(message: bytes, signature: bytes, key: JSONObject, alg: str) -> bool:
132
+ """Verify a bare signature (JWS encoding: raw ``r || s`` for ECDSA) with a public JWK."""
133
+ if not key_supports(alg, key) or (imported := import_public(key)) is None:
134
+ return False
135
+ try:
136
+ return bool(JWSRegistry.algorithms[alg].verify(message, signature, imported))
137
+ except (JoseError, ValueError, TypeError):
138
+ return False
139
+
140
+
141
+ def sign_raw(message: bytes, private_key: Any, alg: str) -> bytes:
142
+ """Sign ``message`` with a joserfc private key, in JWS signature encoding."""
143
+ if alg in FORBIDDEN_ALGORITHMS:
144
+ raise ValueError(f"refusing to sign with {alg!r}")
145
+ return JWSRegistry.algorithms[alg].sign(message, private_key)