acmev2 0.0.3__tar.gz
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.
- acmev2-0.0.3/PKG-INFO +13 -0
- acmev2-0.0.3/acmev2/__init__.py +6 -0
- acmev2-0.0.3/acmev2/challenges/__init__.py +15 -0
- acmev2-0.0.3/acmev2/challenges/base.py +27 -0
- acmev2-0.0.3/acmev2/challenges/custom.py +25 -0
- acmev2-0.0.3/acmev2/challenges/http.py +58 -0
- acmev2-0.0.3/acmev2/errors.py +130 -0
- acmev2-0.0.3/acmev2/handlers/__init__.py +12 -0
- acmev2-0.0.3/acmev2/handlers/authorization.py +35 -0
- acmev2-0.0.3/acmev2/handlers/base.py +84 -0
- acmev2-0.0.3/acmev2/handlers/certificate.py +42 -0
- acmev2-0.0.3/acmev2/handlers/challenge.py +47 -0
- acmev2-0.0.3/acmev2/handlers/handler.py +138 -0
- acmev2-0.0.3/acmev2/handlers/new_account.py +142 -0
- acmev2-0.0.3/acmev2/handlers/new_nonce.py +31 -0
- acmev2-0.0.3/acmev2/handlers/order.py +249 -0
- acmev2-0.0.3/acmev2/logging.py +19 -0
- acmev2-0.0.3/acmev2/messages/__init__.py +6 -0
- acmev2-0.0.3/acmev2/messages/account.py +6 -0
- acmev2-0.0.3/acmev2/messages/authz.py +13 -0
- acmev2-0.0.3/acmev2/messages/base.py +222 -0
- acmev2-0.0.3/acmev2/messages/certificate.py +14 -0
- acmev2-0.0.3/acmev2/messages/challenge.py +10 -0
- acmev2-0.0.3/acmev2/messages/nonce.py +5 -0
- acmev2-0.0.3/acmev2/messages/order.py +30 -0
- acmev2-0.0.3/acmev2/models/__init__.py +25 -0
- acmev2-0.0.3/acmev2/models/account.py +40 -0
- acmev2-0.0.3/acmev2/models/authorization.py +34 -0
- acmev2-0.0.3/acmev2/models/base.py +87 -0
- acmev2-0.0.3/acmev2/models/certificate.py +7 -0
- acmev2-0.0.3/acmev2/models/challenge.py +56 -0
- acmev2-0.0.3/acmev2/models/identifiers.py +15 -0
- acmev2-0.0.3/acmev2/models/nonce.py +6 -0
- acmev2-0.0.3/acmev2/models/order.py +73 -0
- acmev2-0.0.3/acmev2/models/problem.py +21 -0
- acmev2-0.0.3/acmev2/services/__init__.py +11 -0
- acmev2-0.0.3/acmev2/services/services.py +345 -0
- acmev2-0.0.3/acmev2/settings.py +39 -0
- acmev2-0.0.3/acmev2/tests/__init__.py +0 -0
- acmev2-0.0.3/acmev2/tests/challenges/test_auth_state_change.py +130 -0
- acmev2-0.0.3/acmev2/tests/challenges/test_http.py +198 -0
- acmev2-0.0.3/acmev2/tests/clients/test_caddy.py +91 -0
- acmev2-0.0.3/acmev2/tests/clients/test_certbot.py +77 -0
- acmev2-0.0.3/acmev2/tests/clients/test_getssl.py +155 -0
- acmev2-0.0.3/acmev2/tests/conftest.py +528 -0
- acmev2-0.0.3/acmev2/tests/handlers/__init__.py +0 -0
- acmev2-0.0.3/acmev2/tests/handlers/test_authorization.py +73 -0
- acmev2-0.0.3/acmev2/tests/handlers/test_cert.py +105 -0
- acmev2-0.0.3/acmev2/tests/handlers/test_challenge.py +61 -0
- acmev2-0.0.3/acmev2/tests/handlers/test_finalize.py +186 -0
- acmev2-0.0.3/acmev2/tests/handlers/test_newaccount.py +164 -0
- acmev2-0.0.3/acmev2/tests/handlers/test_neworder.py +126 -0
- acmev2-0.0.3/acmev2/tests/handlers/test_nonce.py +58 -0
- acmev2-0.0.3/acmev2/tests/helpers.py +116 -0
- acmev2-0.0.3/acmev2/tests/test_base.py +13 -0
- acmev2-0.0.3/acmev2/tests/test_invalid_account.py +44 -0
- acmev2-0.0.3/acmev2/tests/test_invalid_content_type.py +28 -0
- acmev2-0.0.3/acmev2/tests/test_invalid_url.py +27 -0
- acmev2-0.0.3/acmev2/tests/test_resolve_state.py +83 -0
- acmev2-0.0.3/acmev2/tests/test_services.py +365 -0
- acmev2-0.0.3/acmev2/tests/xprocess/acme_server.py +229 -0
- acmev2-0.0.3/acmev2/tests/xprocess/http_redirector.py +36 -0
- acmev2-0.0.3/acmev2/tests/xprocess/https_server.py +124 -0
- acmev2-0.0.3/acmev2.egg-info/PKG-INFO +13 -0
- acmev2-0.0.3/acmev2.egg-info/SOURCES.txt +68 -0
- acmev2-0.0.3/acmev2.egg-info/dependency_links.txt +1 -0
- acmev2-0.0.3/acmev2.egg-info/requires.txt +6 -0
- acmev2-0.0.3/acmev2.egg-info/top_level.txt +1 -0
- acmev2-0.0.3/pyproject.toml +43 -0
- acmev2-0.0.3/setup.cfg +4 -0
acmev2-0.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: acmev2
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: ACME v2 Protocol Implementation (limited)
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: inject>=5.2.1
|
|
9
|
+
Requires-Dist: josepy>=1.14.0
|
|
10
|
+
Requires-Dist: parse>=1.20.2
|
|
11
|
+
Requires-Dist: pydantic-settings>=2.5.2
|
|
12
|
+
Requires-Dist: pydantic>=2.9.1
|
|
13
|
+
Requires-Dist: requests>=2.32.3
|
|
@@ -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
|
|
@@ -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
|