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.
- pyevp/__init__.py +47 -0
- pyevp/__main__.py +28 -0
- pyevp/_email.py +65 -0
- pyevp/_httpsig.py +284 -0
- pyevp/_jose.py +145 -0
- pyevp/_sf.py +406 -0
- pyevp/adapters/__init__.py +4 -0
- pyevp/adapters/_doh.py +92 -0
- pyevp/adapters/_fetch.py +86 -0
- pyevp/adapters/_http.py +38 -0
- pyevp/adapters/dnspython.py +79 -0
- pyevp/adapters/doh.py +130 -0
- pyevp/adapters/httpx.py +147 -0
- pyevp/adapters/urllib.py +170 -0
- pyevp/cache.py +83 -0
- pyevp/cli/__init__.py +348 -0
- pyevp/contrib/__init__.py +4 -0
- pyevp/contrib/django/__init__.py +306 -0
- pyevp/contrib/django/apps.py +17 -0
- pyevp/contrib/django/issuer.py +314 -0
- pyevp/contrib/django/migrations/0001_initial.py +17 -0
- pyevp/contrib/django/migrations/__init__.py +0 -0
- pyevp/contrib/django/models.py +14 -0
- pyevp/contrib/django/templatetags/__init__.py +0 -0
- pyevp/contrib/django/templatetags/pyevp.py +32 -0
- pyevp/core.py +321 -0
- pyevp/diagnostics.py +182 -0
- pyevp/discovery.py +144 -0
- pyevp/errors.py +80 -0
- pyevp/issuer/__init__.py +39 -0
- pyevp/issuer/core.py +413 -0
- pyevp/issuer/errors.py +99 -0
- pyevp/issuer/fedcm.py +44 -0
- pyevp/issuer/keys.py +140 -0
- pyevp/issuer/profile.py +96 -0
- pyevp/nonce.py +25 -0
- pyevp/observability.py +89 -0
- pyevp/ports.py +54 -0
- pyevp/profile.py +153 -0
- pyevp/py.typed +0 -0
- pyevp/replay.py +69 -0
- pyevp/testing.py +343 -0
- pyevp/token.py +135 -0
- pyevp/types.py +37 -0
- pyevp/verifier.py +486 -0
- pyevp-0.1.0.dist-info/METADATA +171 -0
- pyevp-0.1.0.dist-info/RECORD +50 -0
- pyevp-0.1.0.dist-info/WHEEL +4 -0
- pyevp-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|