From ed94f4bbcb63cbaae2f37bba8765c10fc1e0b91a Mon Sep 17 00:00:00 2001 From: Dmitry Volodin Date: Thu, 23 Nov 2023 14:39:23 +0100 Subject: [PATCH] AcmeClient.get_challeges replaced with AcmeClient.get_authorization_status --- CHANGELOG.md | 11 ++++++ src/gufo/acme/clients/base.py | 72 +++++++++++++++++++---------------- src/gufo/acme/types.py | 14 +++++++ tests/clients/test_base.py | 42 ++++++++++++++++++++ 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc216c1..34aa826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 To see unreleased changes, please see the [CHANGELOG on the master branch](https://github.com/gufolabs/gufo_acme/blob/master/CHANGELOG.md) guide. +## [Unreleased] + +## Added + +* `AcmeAuthorizationStatus` structure + +## Changed + +* `AcmeClient.get_challenges` replaced with `AcmeClient.get_authorization_status`. +* Respond to challenges only if authorization status is `pending`. + ## 0.3.0 - 2023-11-23 ## Added diff --git a/src/gufo/acme/clients/base.py b/src/gufo/acme/clients/base.py index 1282274..8a8552a 100644 --- a/src/gufo/acme/clients/base.py +++ b/src/gufo/acme/clients/base.py @@ -61,6 +61,7 @@ from ..log import logger from ..types import ( AcmeAuthorization, + AcmeAuthorizationStatus, AcmeChallenge, AcmeDirectory, AcmeOrder, @@ -505,11 +506,11 @@ async def new_order( finalize=data["finalize"], ) - async def get_challenges( + async def get_authorization_status( self: "AcmeClient", auth: AcmeAuthorization - ) -> List[AcmeChallenge]: + ) -> AcmeAuthorizationStatus: """ - Get a challenge for an authoriations. + Get an authorization status. Performs RFC-8555 pp. 7.5 sequence. @@ -525,7 +526,7 @@ async def get_challenges( "sub.example.com" ]) for auth in order.authorizations: - challenges = await client.get_challenges(auth) + auth_status = await client.get_authorization_status(auth) ``` Args: @@ -538,14 +539,17 @@ async def get_challenges( Raises: AcmeError: In case of the errors. """ - logger.warning("Getting challenges for %s", auth.domain) + logger.warning("Getting authorization status for %s", auth.domain) self._check_bound() resp = await self._post(auth.url, None) data = resp.json() - return [ - AcmeChallenge(type=d["type"], url=d["url"], token=d["token"]) - for d in data["challenges"] - ] + return AcmeAuthorizationStatus( + status=data["status"], + challenges=[ + AcmeChallenge(type=d["type"], url=d["url"], token=d["token"]) + for d in data["challenges"] + ], + ) async def respond_challenge( self: "AcmeClient", challenge: AcmeChallenge @@ -559,7 +563,7 @@ async def respond_challenge( Args: challenge: ACME challenge as returned by - `get_challenges` function. + `get_authorization_status` function. """ logger.warning("Responding challenge %s", challenge.type) self._check_bound() @@ -574,21 +578,18 @@ async def wait_for_authorization( Args: auth: ACME Authorization """ + logger.warning("Polling authorization for %s", auth.domain) while True: - logger.warning("Polling authorization for %s", auth.domain) - self._check_bound() - resp = await self._post(auth.url, None) - data = resp.json() - status = data.get("status") or "pending" + status = await self.get_authorization_status(auth) logger.warning( - "Authorization status for %s is %s", auth.domain, status + "Authorization status for %s is %s", auth.domain, status.status ) - if status == "valid": + if status.status == "valid": return - if status == "pending": + if status.status == "pending": await self._random_delay(3.0) else: - msg = f"Status is {status}" + msg = f"Status is {status.status}" raise AcmeAuthorizationError(msg) @staticmethod @@ -722,20 +723,25 @@ async def fulfill_http_01( # Process authorizations for auth in order.authorizations: logger.warning("Processing authorization for %s", auth.domain) - # Get challenges - challenges = await self.get_challenges(auth) - fulfilled_challenge = None - for ch in challenges: - if await self.fulfill_challenge(domain, ch): - await self.respond_challenge(ch) - fulfilled_challenge = ch - break - else: - raise AcmeFulfillmentFailed - # Wait for authorization became valid - await self._wait_for(self.wait_for_authorization(auth), 60.0) - # Clear challenge - await self.clear_challenge(domain, fulfilled_challenge) + # Get authorization status. + auth_status = await self.get_authorization_status(auth) + if auth_status.status == "pending": + # Get challenges + fulfilled_challenge = None + for ch in auth_status.challenges: + if await self.fulfill_challenge(domain, ch): + await self.respond_challenge(ch) + fulfilled_challenge = ch + break + else: + raise AcmeFulfillmentFailed + # Wait for authorization became valid + await self._wait_for(self.wait_for_authorization(auth), 60.0) + # Clear challenge + await self.clear_challenge(domain, fulfilled_challenge) + elif auth_status.status != "valid": + msg = f"Status is {auth_status.status}" + raise AcmeAuthorizationError(msg) # Finalize order and get certificate return await self._wait_for( self.finalize_and_wait(order, csr=csr), 60.0 diff --git a/src/gufo/acme/types.py b/src/gufo/acme/types.py index cc9c7f2..de2c257 100644 --- a/src/gufo/acme/types.py +++ b/src/gufo/acme/types.py @@ -55,6 +55,20 @@ class AcmeChallenge(object): token: str +@dataclass +class AcmeAuthorizationStatus(object): + """ + Authorization status response. + + Attributes: + status: Current status. + challenges: List of ACME challenge. + """ + + status: str + challenges: List[AcmeChallenge] + + @dataclass class AcmeDirectory(object): """ diff --git a/tests/clients/test_base.py b/tests/clients/test_base.py index 63c948b..d099221 100644 --- a/tests/clients/test_base.py +++ b/tests/clients/test_base.py @@ -583,3 +583,45 @@ def test_valid_order_status() -> None: resp = httpx.Response(200, json={"status": "valid"}) s = AcmeClient._get_order_status(resp) assert s == "valid" + + +@pytest.mark.parametrize( + ("input", "expected"), + [ + ( + "0twaM-fK_6yfQ_rxH4eZdqj1O6blhB2", + b"\xd2\xdc\x1a3\xe7\xca\xff\xac\x9fC\xfa\xf1\x1f\x87\x99v\xa8\xf5;\xa6\xe5\x84\x1d", + ), + ( + "0twaM+fK_6yfQ/rxH4eZdqj1O6blhB2", + b"\xd2\xdc\x1a3\xe7\xca\xff\xac\x9fC\xfa\xf1\x1f\x87\x99v\xa8\xf5;\xa6\xe5\x84\x1d", + ), + ], +) +def test_decode_auto_base64(input: str, expected: bytes) -> None: + r = AcmeClient.decode_auto_base64(input) + assert r == expected + + +TEST_EAB_KID = "53271e20d46fc7462c1b615ef239e853" +TEST_EAB_HMAC = ( + "WA2XYh7UvKnG0twaM-fK_6yfQ_rxH4eZdqj1O6blhB2RIwyh3KjFDIrPyUZk" + "ao5EyyPaUaYmk1Hl24LwgmqdEA" +) + + +def test_get_eab() -> None: + client = AcmeClient(LE_STAGE_DIRECTORY, key=KEY) + eab = client._get_eab( + ExternalAccountBinding( + kid=TEST_EAB_KID, + hmac_key=AcmeClient.decode_auto_base64(TEST_EAB_HMAC), + ), + "http://127.0.0.1/new_account", + ) + print(eab) + assert eab == { + "protected": "eyJhbGciOiAiSFMyNTYiLCAia2lkIjogIjUzMjcxZTIwZDQ2ZmM3NDYyYzFiNjE1ZWYyMzllODUzIiwgInVybCI6ICJodHRwOi8vMTI3LjAuMC4xL25ld19hY2NvdW50In0", + "signature": "WdpEOa1e-Q9zyib7xzAFq3MrbUj82Gwt9RcLqqsr__Y", + "payload": "eyJuIjogImd2dmpvSlBkMUw0c3ExYlQwcTJDOTROM1dWN1c3bHJvQV9NekYtU0dNVllGYXNJMmx2cXcza0FrRlJ4RzM2NkpmSHIzQjFSLXhsQ3pFUEhOaXhiTDZiMGNjdlBGWlpzdW5nbng1bV91R0wyRk1paXN1MTg2ZE1uZnNrNllzc3ZlYm94aVFYRWhHTXhJOVQ2R2pFNmw2ZWMxUEdZNXVCNzB2UDJ3a0dQeGt2UkxEMnRHYWVfLTdrQ2dSenZGMnhPYUdaalQtanhIY1lwV3V0Tk4tcVF6RG9IbmhMdTBMSXdXbFhCYXpBczZ6YmtQdlBXOVBOWkFVZW5jV3h4UTVoSnRMa1ZTdmdTWXd6STFjeGxyQzhsQ2pnNnJJUjlMQThzNVBMemVlX25Fb3RsamxVMGxqWHozZXlEOVc0Zmw0ckM0NnY4LXVmazVFejl1dFFRMnNWaklNUSIsICJlIjogIkFRQUIiLCAia3R5IjogIlJTQSJ9", + }