acmev2 0.0.3__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 (66) hide show
  1. acmev2/__init__.py +6 -0
  2. acmev2/challenges/__init__.py +15 -0
  3. acmev2/challenges/base.py +27 -0
  4. acmev2/challenges/custom.py +25 -0
  5. acmev2/challenges/http.py +58 -0
  6. acmev2/errors.py +130 -0
  7. acmev2/handlers/__init__.py +12 -0
  8. acmev2/handlers/authorization.py +35 -0
  9. acmev2/handlers/base.py +84 -0
  10. acmev2/handlers/certificate.py +42 -0
  11. acmev2/handlers/challenge.py +47 -0
  12. acmev2/handlers/handler.py +138 -0
  13. acmev2/handlers/new_account.py +142 -0
  14. acmev2/handlers/new_nonce.py +31 -0
  15. acmev2/handlers/order.py +249 -0
  16. acmev2/logging.py +19 -0
  17. acmev2/messages/__init__.py +6 -0
  18. acmev2/messages/account.py +6 -0
  19. acmev2/messages/authz.py +13 -0
  20. acmev2/messages/base.py +222 -0
  21. acmev2/messages/certificate.py +14 -0
  22. acmev2/messages/challenge.py +10 -0
  23. acmev2/messages/nonce.py +5 -0
  24. acmev2/messages/order.py +30 -0
  25. acmev2/models/__init__.py +25 -0
  26. acmev2/models/account.py +40 -0
  27. acmev2/models/authorization.py +34 -0
  28. acmev2/models/base.py +87 -0
  29. acmev2/models/certificate.py +7 -0
  30. acmev2/models/challenge.py +56 -0
  31. acmev2/models/identifiers.py +15 -0
  32. acmev2/models/nonce.py +6 -0
  33. acmev2/models/order.py +73 -0
  34. acmev2/models/problem.py +21 -0
  35. acmev2/services/__init__.py +11 -0
  36. acmev2/services/services.py +345 -0
  37. acmev2/settings.py +39 -0
  38. acmev2/tests/__init__.py +0 -0
  39. acmev2/tests/challenges/test_auth_state_change.py +130 -0
  40. acmev2/tests/challenges/test_http.py +198 -0
  41. acmev2/tests/clients/test_caddy.py +91 -0
  42. acmev2/tests/clients/test_certbot.py +77 -0
  43. acmev2/tests/clients/test_getssl.py +155 -0
  44. acmev2/tests/conftest.py +528 -0
  45. acmev2/tests/handlers/__init__.py +0 -0
  46. acmev2/tests/handlers/test_authorization.py +73 -0
  47. acmev2/tests/handlers/test_cert.py +105 -0
  48. acmev2/tests/handlers/test_challenge.py +61 -0
  49. acmev2/tests/handlers/test_finalize.py +186 -0
  50. acmev2/tests/handlers/test_newaccount.py +164 -0
  51. acmev2/tests/handlers/test_neworder.py +126 -0
  52. acmev2/tests/handlers/test_nonce.py +58 -0
  53. acmev2/tests/helpers.py +116 -0
  54. acmev2/tests/test_base.py +13 -0
  55. acmev2/tests/test_invalid_account.py +44 -0
  56. acmev2/tests/test_invalid_content_type.py +28 -0
  57. acmev2/tests/test_invalid_url.py +27 -0
  58. acmev2/tests/test_resolve_state.py +83 -0
  59. acmev2/tests/test_services.py +365 -0
  60. acmev2/tests/xprocess/acme_server.py +229 -0
  61. acmev2/tests/xprocess/http_redirector.py +36 -0
  62. acmev2/tests/xprocess/https_server.py +124 -0
  63. acmev2-0.0.3.dist-info/METADATA +13 -0
  64. acmev2-0.0.3.dist-info/RECORD +66 -0
  65. acmev2-0.0.3.dist-info/WHEEL +5 -0
  66. acmev2-0.0.3.dist-info/top_level.txt +1 -0
acmev2/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ import acmev2.messages
2
+ import acmev2.services
3
+ import acmev2.models
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("acmev2")
@@ -0,0 +1,15 @@
1
+ from typing import Type
2
+ from .http import Http01ChallengeValidator
3
+ from .base import ChallengeValidator
4
+ from .custom import CustomChallengeValidatorRunner, CustomChallengeValidator
5
+ from acmev2.models import ChallengeType
6
+
7
+
8
+ def validator_for(typ: ChallengeType) -> Type[ChallengeValidator]:
9
+ match typ:
10
+ case ChallengeType.http_01:
11
+ return Http01ChallengeValidator
12
+ case ChallengeType.custom:
13
+ return CustomChallengeValidatorRunner
14
+
15
+ raise Exception("No match found for challenge type {typ}")
@@ -0,0 +1,27 @@
1
+ import inject
2
+ import josepy.jwk
3
+
4
+ from acmev2.models import AuthorizationResource, HTTPChallengeResource
5
+ from acmev2.settings import ACMESettings
6
+
7
+
8
+ class ValidationError(Exception):
9
+ pass
10
+
11
+
12
+ class ChallengeValidator:
13
+
14
+ settings = inject.attr(ACMESettings)
15
+
16
+ def __init__(
17
+ self,
18
+ acct_jwk: josepy.jwk.JWK,
19
+ authz: AuthorizationResource,
20
+ chall: HTTPChallengeResource,
21
+ ):
22
+ self.chall = chall
23
+ self.authz = authz
24
+ self.acct_jwk = acct_jwk
25
+
26
+ def validate(self) -> bool:
27
+ return False
@@ -0,0 +1,25 @@
1
+ import abc
2
+ import inject
3
+ import requests
4
+ import urllib3
5
+ from urllib3.exceptions import InsecureRequestWarning
6
+
7
+ from acmev2.settings import ACMESettings
8
+ from .base import ChallengeValidator
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class CustomChallengeValidatorRunner(ChallengeValidator):
15
+
16
+ def validate(self) -> bool:
17
+ validator = inject.instance(CustomChallengeValidator)
18
+ return validator.validate(self)
19
+
20
+
21
+ class CustomChallengeValidator(abc.ABC):
22
+
23
+ @abc.abstractmethod
24
+ def validate(self, challenge_validator: ChallengeValidator) -> bool:
25
+ pass
@@ -0,0 +1,58 @@
1
+ import inject
2
+ import requests
3
+ import urllib3
4
+ from urllib3.exceptions import InsecureRequestWarning
5
+
6
+ from acmev2.settings import ACMESettings
7
+ from .base import ChallengeValidator, ValidationError
8
+ import logging
9
+ import josepy
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Http01ChallengeValidator(ChallengeValidator):
15
+
16
+ def validate(self) -> bool:
17
+ settings = inject.instance(ACMESettings)
18
+ # Only DNS identifiers supported
19
+ domain = f"http://{self.authz.identifier.value}"
20
+
21
+ endpoint = f"{domain}/.well-known/acme-challenge/{self.chall.token}"
22
+ logger.info(f"Validating endpoint <{endpoint}> for challenge <{self.chall.id}>")
23
+ expected_content = (
24
+ f"{self.chall.token}.{josepy.encode_b64jose(self.acct_jwk.thumbprint())}"
25
+ )
26
+
27
+ try:
28
+ # warnings can sometimes be treated as errors, we need to allow the challenge
29
+ # to run against certificates that are expired
30
+ urllib3.disable_warnings(InsecureRequestWarning)
31
+ challenge_headers = requests.utils.default_headers()
32
+ challenge_headers["User-Agent"] = settings.http_01_challenge_user_agent
33
+ # again, pass verify=False because this challenge should succeed on invalid certs
34
+ # if the challenge is redirected to https
35
+ challenge_response = requests.get(
36
+ endpoint, allow_redirects=True, verify=False
37
+ )
38
+
39
+ if challenge_response.status_code != 200:
40
+ raise ValidationError(
41
+ "{challenge_response.status_code} status code returned from server"
42
+ )
43
+
44
+ logger.info(
45
+ f"Challenge returned: <{challenge_response}>, expected: <{expected_content}>"
46
+ )
47
+ except requests.RequestException as exc:
48
+ logger.exception("Error validating http-01 challenge")
49
+ raise ValidationError(
50
+ f"{exc.__class__.__name__}: error requesting {exc.request.url}"
51
+ )
52
+
53
+ # remove extra text returned
54
+ actual = challenge_response.text.splitlines()[0].strip()
55
+ if actual != expected_content:
56
+ raise ValidationError("Content did not match expected token")
57
+
58
+ return actual == expected_content
acmev2/errors.py ADDED
@@ -0,0 +1,130 @@
1
+ from enum import Enum
2
+
3
+
4
+ ERROR_PREFIX = "urn:ietf:params:acme:error:"
5
+
6
+ ERROR_CODES = {
7
+ "accountDoesNotExist": "The request specified an account that does not exist",
8
+ "alreadyRevoked": "The request specified a certificate to be revoked that has"
9
+ " already been revoked",
10
+ "badCSR": "The CSR is unacceptable (e.g., due to a short key)",
11
+ "badNonce": "The client sent an unacceptable anti-replay nonce",
12
+ "badPublicKey": "The JWS was signed by a public key the server does not support",
13
+ "badRevocationReason": "The revocation reason provided is not allowed by the server",
14
+ "badSignatureAlgorithm": "The JWS was signed with an algorithm the server does not support",
15
+ "caa": "Certification Authority Authorization (CAA) records forbid the CA from issuing"
16
+ " a certificate",
17
+ "compound": 'Specific error conditions are indicated in the "subproblems" array',
18
+ "connection": ("The server could not connect to the client to verify the domain"),
19
+ "dns": "There was a problem with a DNS query during identifier validation",
20
+ "dnssec": "The server could not validate a DNSSEC signed domain",
21
+ "incorrectResponse": "Response received didn't match the challenge's requirements",
22
+ "invalidContact": "The provided contact URI was invalid",
23
+ "malformed": "The request message was malformed",
24
+ "rejectedIdentifier": "The server will not issue certificates for the identifier",
25
+ "orderNotReady": "The request attempted to finalize an order that is not ready to be finalized",
26
+ "rateLimited": "There were too many requests of a given type",
27
+ "serverInternal": "The server experienced an internal error",
28
+ "tls": "The server experienced a TLS error during domain verification",
29
+ "unauthorized": "The client lacks sufficient authorization",
30
+ "unsupportedContact": "A contact URL for an account used an unsupported protocol scheme",
31
+ "unknownHost": "The server could not resolve a domain name",
32
+ "unsupportedIdentifier": "An identifier is of an unsupported type",
33
+ "externalAccountRequired": "The server requires external account binding",
34
+ "userActionRequired": "User action required",
35
+ }
36
+
37
+
38
+ class ACMEError(Exception):
39
+ type: str = "malformed"
40
+ detail: str = None
41
+ code: int = 403
42
+ header_links: list[tuple[str, str]] = []
43
+
44
+ def __init__(self, *args, **kwargs):
45
+ super().__init__(*args)
46
+
47
+ if "type" in kwargs:
48
+ self.type = kwargs["type"]
49
+
50
+ if "detail" in kwargs:
51
+ self.detail = kwargs["detail"]
52
+
53
+ if "code" in kwargs:
54
+ self.code = kwargs["code"]
55
+
56
+ if "header_links" in kwargs:
57
+ self.header_links = kwargs["header_links"]
58
+
59
+ if self.type in ERROR_CODES:
60
+ self.type = f"{ERROR_PREFIX}{self.type}"
61
+
62
+
63
+ class ACMEUnauthorizedError(ACMEError):
64
+ type = "unauthorized"
65
+ code = 401
66
+
67
+
68
+ class ACMEResourceNotFoundError(ACMEError):
69
+ type = "resourceNotFound"
70
+ code = 404
71
+
72
+
73
+ class ACMEMalformedError(ACMEError):
74
+ type = "malformed"
75
+ detail = "Malformed request"
76
+ code = 400
77
+
78
+
79
+ class ACMESignatureVerificationError(ACMEError):
80
+ type = "malformed"
81
+ detail = "Malformed signature"
82
+ code = 400
83
+
84
+
85
+ class ACMEToSAgreementError(ACMEError):
86
+ type = "userActionRequired"
87
+ code = 403
88
+ detail = "Please read and agree to the terms of service"
89
+
90
+
91
+ class ACMEUserActionRequiredError(ACMEError):
92
+ type = "userActionRequired"
93
+ code = 403
94
+
95
+ def __init__(self, detail: str, *args, **kwargs):
96
+ self.detail = detail
97
+ super().__init__(*args, **kwargs)
98
+
99
+
100
+ class ACMEMethodNotAllowedError(ACMEError):
101
+ type = "malformed"
102
+ code = 403
103
+
104
+
105
+ class ACMEBadCSRDetail(str, Enum):
106
+ sansRequired = "SANS are required"
107
+ invalidSan = "Invalid subjectAlternateName extension value"
108
+ cnMissingFromSan = (
109
+ "The common name must also appear in the subjectAlternateName extension"
110
+ )
111
+ csrOrderMismatch = (
112
+ "The CSR must indiciate the exact same set of identifiers as the order"
113
+ )
114
+ orderNotAuthorized = "Order has not completed all authorizations"
115
+
116
+
117
+ class ACMEBadCSRError(ACMEError):
118
+ type = "badCSR"
119
+
120
+ def __init__(self, detail: ACMEBadCSRDetail):
121
+ self.detail = str(detail)
122
+ super().__init__()
123
+
124
+
125
+ class ACMEInvalidContentTypeError(ACMEError):
126
+ code = 415
127
+
128
+
129
+ class JoseJsonFormatError(ACMEError):
130
+ pass
@@ -0,0 +1,12 @@
1
+ from .authorization import AuthorizationRequestHandler
2
+ from .base import ACMERequestHandler, ACMEModelResponse
3
+ from .challenge import ChallengeRequestHandler
4
+ from .new_account import NewAccountRequestHandler
5
+ from .new_nonce import NewNonceRequestHandler
6
+ from .order import (
7
+ NewOrderRequestHandler,
8
+ OrderFinalizationRequestHandler,
9
+ OrderRequestHandler,
10
+ )
11
+ from .certificate import CertRequestHandler
12
+ from .handler import handle
@@ -0,0 +1,35 @@
1
+ import inject
2
+ from acmev2.errors import ACMEError
3
+ from acmev2.messages import AuthorizationMessage
4
+ from acmev2.models import AuthorizationStatus
5
+ from acmev2.services import IAuthorizationService
6
+ from .base import ACMERequestHandler, ACMEModelResponse
7
+
8
+
9
+ class AuthorizationRequestHandler(ACMERequestHandler):
10
+ """Returns an authorization and all the associated challenges."""
11
+
12
+ message_type = AuthorizationMessage
13
+ authorization_service = inject.attr(IAuthorizationService)
14
+
15
+ def process(self, msg: AuthorizationMessage):
16
+ authz_resource = msg.resource
17
+
18
+ if self.should_deactivate(msg):
19
+ authz_resource = self.authorization_service.update_status(
20
+ authz_resource, AuthorizationStatus.deactivated
21
+ )
22
+
23
+ return ACMEModelResponse(msg=authz_resource, code=200)
24
+
25
+ def should_deactivate(self, msg: AuthorizationMessage) -> bool:
26
+ if msg.payload.status == AuthorizationStatus.deactivated:
27
+ if msg.resource.status == AuthorizationStatus.valid:
28
+ return True
29
+
30
+ # Invalid, cannot transition to deactivated from any state but valid
31
+ raise ACMEError(
32
+ detail=f"May not transition to '{AuthorizationStatus.deactivated.value}' from '{msg.resource.status.value}'"
33
+ )
34
+
35
+ return False
@@ -0,0 +1,84 @@
1
+ from typing import Any, Generic, Mapping, Optional, Type, TypeVar
2
+ from urllib.parse import quote
3
+
4
+ import inject
5
+
6
+ from acmev2.messages.base import ACMEMessage
7
+ from acmev2.models import ACMEResource
8
+ from acmev2.services import IDirectoryService
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HttpVerb:
15
+ HEAD = "HEAD"
16
+ GET = "GET"
17
+ POST = "POST"
18
+
19
+
20
+ class ACMERequestHandler:
21
+ allowed_verbs = [HttpVerb.POST]
22
+ message_type: Type[ACMEMessage] = ACMEMessage
23
+ requires_nonce = True
24
+
25
+ def __init__(
26
+ self,
27
+ request_url: str,
28
+ verb: HttpVerb,
29
+ headers: Mapping[str, Any] = None,
30
+ msg: Optional[Mapping[str, Any]] = None,
31
+ ):
32
+ self.request_url = request_url
33
+ self.verb = verb
34
+ self.headers = headers or {}
35
+ self.msg = msg
36
+
37
+ def extract_message(self, msg: Optional[Mapping[str, Any]]):
38
+ return self.message_type.from_json(msg)
39
+
40
+ def process(msg: ACMEMessage) -> "ACMEModelResponse":
41
+ pass
42
+
43
+
44
+ T = TypeVar("T")
45
+
46
+
47
+ class ACMEResponse(Generic[T]):
48
+ content_type: str = "application/json"
49
+ directory_service = inject.attr(IDirectoryService)
50
+ msg: T | None
51
+
52
+ def __init__(
53
+ self,
54
+ headers: Mapping[str, Any] = None,
55
+ msg: Optional[T] = None,
56
+ code: int = 200,
57
+ location: str = None,
58
+ ):
59
+ self.headers = headers or {}
60
+ self.msg = msg or None
61
+ self.code = code
62
+
63
+ if location:
64
+ self.headers["Location"] = location
65
+
66
+ directory_root = self.directory_service.root_url
67
+ self.add_link_header(directory_root, "index")
68
+
69
+ def add_link_header(self, link: str, rel: str):
70
+ link_header = self.headers.get("Link", "")
71
+
72
+ header_value = f'<{quote(link, safe="/:")}>;rel="{rel}"'
73
+ if link_header:
74
+ link_header += ","
75
+
76
+ self.headers["Link"] = link_header + header_value
77
+
78
+
79
+ class ACMEModelResponse(ACMEResponse[ACMEResource]):
80
+ def serialize(self) -> str:
81
+ if self.msg:
82
+ return self.msg.model_dump_json(indent=2)
83
+ else:
84
+ return ""
@@ -0,0 +1,42 @@
1
+ import inject
2
+
3
+ from acmev2.messages import (
4
+ CertMessage,
5
+ )
6
+ from acmev2.services import (
7
+ IOrderService,
8
+ IAuthorizationService,
9
+ IDirectoryService,
10
+ )
11
+ from acmev2.models import CertResource
12
+ from .base import ACMERequestHandler, ACMEResponse
13
+
14
+
15
+ class ACMECertResponse(ACMEResponse[CertResource]):
16
+ content_type = "application/pem-certificate-chain"
17
+
18
+ def serialize(self) -> str:
19
+ # TODO: return different certs depending on the
20
+ # client Accept header.
21
+ # application/pkix-cert - DER
22
+ # application/pkcs7-mime - DER
23
+ return self.msg.pem
24
+
25
+
26
+ class CertRequestHandler(ACMERequestHandler):
27
+ """Returns a certificate chain for the requested identifier. Only supports
28
+ PEM-encoded certificates."""
29
+
30
+ message_type = CertMessage
31
+ order_service = inject.attr(IOrderService)
32
+ directory_service = inject.attr(IDirectoryService)
33
+ authorization_service = inject.attr(IAuthorizationService)
34
+
35
+ def process(
36
+ self,
37
+ msg: CertMessage,
38
+ ):
39
+ # todo: this is actually multiple certs, change the response
40
+ cert = msg.resource
41
+
42
+ return ACMECertResponse(msg=cert, code=200)
@@ -0,0 +1,47 @@
1
+ from datetime import datetime
2
+
3
+ import inject
4
+
5
+ from acmev2.errors import (
6
+ ACMEError,
7
+ )
8
+ from acmev2.messages import ChallengeMessage
9
+ from acmev2.models import AuthorizationResource, ChallengeStatus
10
+ from acmev2.services import (
11
+ ACMEEndpoint,
12
+ IAuthorizationService,
13
+ IChallengeService,
14
+ IDirectoryService,
15
+ )
16
+
17
+ from .base import ACMEModelResponse, ACMERequestHandler
18
+
19
+
20
+ class ChallengeRequestHandler(ACMERequestHandler):
21
+ """Begins validating a specific challenge. Validation can be done in-line or
22
+ the service implementer can decide to periodically validate in the background."""
23
+
24
+ message_type = ChallengeMessage
25
+ directory_service = inject.attr(IDirectoryService)
26
+ challenge_service = inject.attr(IChallengeService)
27
+ authorization_service = inject.attr(IAuthorizationService)
28
+
29
+ def process(self, msg: ChallengeMessage):
30
+ chall = msg.resource
31
+ authz = self.authorization_service.get(chall.authz_id)
32
+ self.validate_expiration(msg, authz)
33
+
34
+ if chall.status == ChallengeStatus.pending:
35
+ chall = self.challenge_service.queue_validation(chall)
36
+ chall.status = ChallengeStatus.processing
37
+
38
+ resp = ACMEModelResponse(msg=chall, code=200)
39
+ resp.add_link_header(
40
+ self.directory_service.url_for(ACMEEndpoint.authz, authz.id), "up"
41
+ )
42
+
43
+ return resp
44
+
45
+ def validate_expiration(self, msg: ChallengeMessage, authz: AuthorizationResource):
46
+ if datetime.now() > authz.expires:
47
+ raise ACMEError(detail="Authorization has expired")
@@ -0,0 +1,138 @@
1
+ import logging
2
+
3
+ import inject
4
+ import pydantic
5
+
6
+ from acmev2.errors import (
7
+ ACMEError,
8
+ ACMEInvalidContentTypeError,
9
+ ACMEMethodNotAllowedError,
10
+ ACMEUnauthorizedError,
11
+ )
12
+ from acmev2.handlers import ACMEModelResponse, ACMERequestHandler
13
+ from acmev2.logging import LazyLoggedResponse
14
+ from acmev2.handlers.base import HttpVerb
15
+ from acmev2.messages.base import (
16
+ ACMEMessage,
17
+ AuthenticatedACMEMessage,
18
+ SignedACMEMessage,
19
+ )
20
+ from acmev2.models import AccountStatus, ProblemResource
21
+ from acmev2.services import INonceService
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def format_problem(error: ACMEError):
27
+ """Formats an ACMEError as a problem document response.
28
+
29
+ Args:
30
+ error (ACMEError): The error to transform.
31
+
32
+ Returns:
33
+ ACMEModelResponse: A model response that will serialize to a problem document.
34
+ """
35
+ resp = ACMEModelResponse(
36
+ headers={"Content-Type": "application/problem+json"},
37
+ msg=ProblemResource(type=error.type, detail=error.detail),
38
+ code=error.code,
39
+ )
40
+
41
+ for link, rel in error.header_links:
42
+ resp.add_link_header(link, rel)
43
+
44
+ return resp
45
+
46
+
47
+ class LazyLoggedMessage:
48
+ def __init__(self, message: ACMEMessage):
49
+ self.message = message
50
+
51
+ def __str__(self):
52
+ import json
53
+
54
+ if isinstance(self.message, SignedACMEMessage):
55
+ return json.dumps(
56
+ {
57
+ "payload": self.message.jose_message.payload_decoded,
58
+ "protected": self.message.jose_message.protected_decoded,
59
+ },
60
+ indent=2,
61
+ )
62
+ else:
63
+ return json.dumps(self.message.body, indent=2)
64
+
65
+
66
+ def handle(req: ACMERequestHandler) -> ACMEModelResponse:
67
+ """Handles the ACME request and any errors it raises, along with
68
+ ensuring every request that needs a nonce has one returned.
69
+
70
+ Args:
71
+ req (ACMERequestHandler): The request handler to be executed.
72
+
73
+ Returns:
74
+ ACMEModelResponse: A serializable model that an ACME client can understand.
75
+ """
76
+ nonce_service = inject.instance(INonceService)
77
+ resp: ACMEModelResponse = None
78
+ acme_message: ACMEMessage | None = None
79
+ try:
80
+ if (
81
+ req.msg is not None
82
+ and req.verb == HttpVerb.POST
83
+ and req.headers.get("Content-Type") != "application/jose+json"
84
+ ):
85
+ raise ACMEInvalidContentTypeError()
86
+
87
+ if req.verb not in req.allowed_verbs:
88
+ raise ACMEMethodNotAllowedError()
89
+
90
+ acme_message = req.extract_message(req.msg)
91
+ logger.debug("client message: %s", LazyLoggedMessage(acme_message))
92
+
93
+ # Request url integrity: https://datatracker.ietf.org/doc/html/rfc8555/#section-6.4
94
+ if isinstance(acme_message, SignedACMEMessage):
95
+ if acme_message.url != req.request_url:
96
+ raise ACMEUnauthorizedError()
97
+
98
+ # Ensure account hasn't been revoked or deactivated
99
+ if isinstance(acme_message, AuthenticatedACMEMessage):
100
+ if acme_message.account.status != AccountStatus.valid:
101
+ raise ACMEError(detail="Account not valid")
102
+
103
+ # Consume the nonce right before processing the request
104
+ if req.requires_nonce and not nonce_service.consume(acme_message.nonce):
105
+ raise ACMEError(type="badNonce")
106
+
107
+ resp = req.process(acme_message)
108
+
109
+ if resp.content_type:
110
+ resp.headers["Content-Type"] = resp.content_type
111
+ except ACMEError as exc:
112
+ # These are normal expected errors raised by handlers
113
+ logging_extra_info = ""
114
+ if acme_message and isinstance(acme_message, SignedACMEMessage):
115
+ logging_extra_info += f" url<{acme_message.url}>"
116
+ if acme_message and isinstance(acme_message, AuthenticatedACMEMessage):
117
+ logging_extra_info += f" account<{acme_message.account_id}>"
118
+
119
+ logging.info(
120
+ f"ACMEError - type<{exc.type}> detail<{exc.detail}>{logging_extra_info}"
121
+ )
122
+ resp = format_problem(exc)
123
+ except pydantic.ValidationError:
124
+ # Pydantic errors usually happen when the request is malformed
125
+ logger.exception("Error validating Pydantic model")
126
+ resp = format_problem(ACMEError(type="malformed"))
127
+ except Exception:
128
+ # All other errors return a generic problem document
129
+ logging.exception("Unhandled exception caught at error boundry")
130
+ resp = format_problem(ACMEError(type="serverInternal", code=500))
131
+
132
+ if req.verb == HttpVerb.POST and "Replay-Nonce" not in resp.headers:
133
+ # Every POST needs a nonce returned. Head/Get requests should only
134
+ # generate a nonce with the newNonce handler.
135
+ resp.headers["Replay-Nonce"] = nonce_service.generate()
136
+
137
+ logger.debug("response: %s", LazyLoggedResponse(resp))
138
+ return resp