def setUp(self) -> None: super().setUp() self.hostname = "challenge.example.com" self.account = AcmeAccount.objects.create( ca=self.cas["root"], contact="mailto:[email protected]", terms_of_service_agreed=True, status=AcmeAccount.STATUS_VALID, pem=self.ACME_PEM_1, thumbprint=self.ACME_THUMBPRINT_1, ) self.order = AcmeOrder.objects.create(account=self.account) self.auth = AcmeAuthorization.objects.create( order=self.order, type=AcmeAuthorization.TYPE_DNS, value=self.hostname) self.chall = AcmeChallenge.objects.create( auth=self.auth, type=self.type, status=AcmeChallenge.STATUS_PROCESSING) encoded = jose.encode_b64jose(self.chall.token.encode("utf-8")) thumbprint = self.account.thumbprint self.expected = f"{encoded}.{thumbprint}" self.url = f"http://{self.auth.value}/.well-known/acme-challenge/{encoded}"
def get_nonce(self) -> str: """Get a random Nonce and add it to the cache.""" data = secrets.token_bytes(self.nonce_length) nonce = jose.encode_b64jose(data) cache_key = f"acme-nonce-{self.kwargs['serial']}-{nonce}" cache.set(cache_key, 0) return nonce
def get_nonce(self): """Get a random Nonce and add it to the cache.""" data = secrets.token_bytes(self.nonce_length) nonce = jose.encode_b64jose(data) cache_key = 'acme-nonce-%s-%s' % (self.kwargs['serial'], nonce) cache.set(cache_key, 0) return nonce
def get(self, request: HttpRequest, serial: Optional[str] = None) -> HttpResponse: # pylint: disable=missing-function-docstring; standard Django view function if not ca_settings.CA_ENABLE_ACME: raise Http404("Page not found.") if serial is None: try: # NOTE: default() already calls usable() ca = CertificateAuthority.objects.acme().default() except ImproperlyConfigured: return AcmeResponseNotFound( message="No (usable) default CA configured.") else: try: # NOTE: Serial is already sanitized by URL converter ca = CertificateAuthority.objects.acme().usable().get( serial=serial) except CertificateAuthority.DoesNotExist: return AcmeResponseNotFound(message="%s: CA not found." % serial) # Get some random data into the directory view, as explained in the Let's Encrypt directory: # https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417 rnd = jose.encode_b64jose(secrets.token_bytes(16)) directory: Dict[str, Union[str, DirectoryMetaAlias]] = { rnd: "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", "keyChange": "http://localhost:8000/django_ca/acme/todo/key-change", "newAccount": self._url(request, "acme-new-account", ca), "newNonce": self._url(request, "acme-new-nonce", ca), "newOrder": self._url(request, "acme-new-order", ca), "revokeCert": "http://localhost:8000/django_ca/acme/todo/revoke-cert", } # Construct a "meta" object if and add it if any fields are defined. Note that the meta object is # optional (RFC 8555, section 7.1.1: "The object MAY additionally contain a "meta" field."). meta: DirectoryMetaAlias = {} if ca.website: meta["website"] = ca.website if ca.terms_of_service: meta["termsOfService"] = ca.terms_of_service if ca.caa_identity: meta["caaIdentities"] = [ca.caa_identity] # array of string if meta: directory["meta"] = meta return JsonResponse(directory)
class HeaderTest(unittest.TestCase): """Tests for acme.jws.Header.""" good_nonce = jose.encode_b64jose(b'foo') wrong_nonce = u'F' # Following just makes sure wrong_nonce is wrong try: jose.b64decode(wrong_nonce) except (ValueError, TypeError): assert True else: assert False # pragma: no cover def test_nonce_decoder(self): from acme.jws import Header nonce_field = Header._fields['nonce'] self.assertRaises(jose.DeserializationError, nonce_field.decode, self.wrong_nonce) self.assertEqual(b'foo', nonce_field.decode(self.good_nonce))
def test_from_json_invalid_token_length(self): from acme.challenges import TLSALPN01 self.jmsg['token'] = jose.encode_b64jose(b'abcd') self.assertRaises( jose.DeserializationError, TLSALPN01.from_json, self.jmsg)
def acme_request( # pylint: disable=unused-argument self, message: messages.Registration, slug: Optional[str] ) -> AcmeResponseAccount: """Process ACME request.""" pem = ( self.jwk.key.public_bytes( encoding=Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) .decode("utf-8") .strip() ) thumbprint = jose.encode_b64jose(self.jwk.thumbprint()) # RFC 8555, section 7.3: # # If this field is present with the value "true", then the server MUST NOT create a new account if # one does not already exist. This allows a client to look up an account URL based on an account # key (see Section 7.3.1). if message.only_return_existing: try: account = AcmeAccount.objects.get(thumbprint=thumbprint, pem=pem) return AcmeResponseAccount(self.request, account) except AcmeAccount.DoesNotExist as ex: # RFC 8555, section 7.3: # # ... account does not exist, then the server MUST return an error response with status code # 400 (Bad Request) and type "urn:ietf:params:acme:error:accountDoesNotExist". raise AcmeMalformed(typ="accountDoesNotExist", message="Account does not exist.") from ex # RFC 8555, section 7.3.1 # # If the server receives a newAccount request signed with a key for which it already has an account # registered with the provided account key, then it MUST return a response with status code 200 (OK) # and provide the URL of that account in the Location header field. try: # NOTE: Filter for thumbprint too b/c index for the field should speed up lookups. account = AcmeAccount.objects.get(thumbprint=thumbprint, pem=pem) return AcmeResponseAccount(self.request, account) except AcmeAccount.DoesNotExist: pass if self.ca.acme_requires_contact and not message.emails: # NOTE: RFC 8555 does not specify an error code in this case raise AcmeUnauthorized(message="Must provide at least one contact address.") # Make sure that contact addresses are valid self.validate_contacts(message) account = AcmeAccount( ca=self.ca, contact="\n".join(message.contact), status=AcmeAccount.STATUS_VALID, terms_of_service_agreed=message.terms_of_service_agreed, thumbprint=thumbprint, pem=pem, ) account.set_kid(self.request) # Call full_clean() so that model validation can do its magic try: account.full_clean() account.save() except ValidationError as ex: # Add a pretty list of validation errors to the detail field in the response subproblems = ", ".join( sorted([f"{k}: {v1.rstrip('.')}" for k, v in ex.message_dict.items() for v1 in v]) ) raise AcmeMalformed(message=f"Invalid account: {subproblems}.") from ex # self.prepared['thumbprint'] = account.thumbprint # self.prepared['pem'] = account.pem # self.prepared['account_pk'] = account.pem # RFC 8555, section 7.3 # # The server returns this account object in a 201 (Created) response, with the account URL in a # Location header field. # # AcmeResponseAccountCreated adds the Location field currently. return AcmeResponseAccountCreated(self.request, account)
def post(self, request: HttpRequest, serial: str, slug: Optional[str] = None) -> AcmeResponse: # pylint: disable=missing-function-docstring; standard Django view function # pylint: disable=attribute-defined-outside-init # pylint: disable=too-many-return-statements,too-many-branches; b/c of the many checks # TODO: RFC 8555, 6.2 has a nice list of things to check here that we don't yet fully cover if request.content_type != "application/jose+json": # RFC 8555, 6.2: # "Because client requests in ACME carry JWS objects in the Flattened JSON Serialization, they # must have the Content-Type header field set to "application/jose+json". If a request does not # meet this requirement, then the server MUST return a response with status code 415 (Unsupported # Media Type). return AcmeResponseUnsupportedMediaType() # self.prepared['body'] = json.loads(request.body.decode('utf-8')) try: self.jws = acme.jws.JWS.json_loads(request.body) except (jose.DeserializationError, TypeError): return AcmeResponseMalformed(message="Could not parse JWS token.") combined = self.jws.signature.combined if combined.jwk and combined.kid: # 'The "jwk" and "kid" fields are mutually exclusive. Servers MUST reject requests that contain # both.' return AcmeResponseMalformed(message="jwk and kid are mutually exclusive.") # Get certificate authority for this request try: self.ca = CertificateAuthority.objects.acme().usable().get(serial=serial) except CertificateAuthority.DoesNotExist: return AcmeResponseNotFound(message="The requested CA cannot be found.") if combined.jwk: if not self.requires_key: return AcmeResponseMalformed(message="Request requires a JWK key ID.") self.jwk = combined.jwk # set JWK from request elif combined.kid: if self.requires_key: return AcmeResponseMalformed(message="Request requires a full JWK key.") # combined.kid is a full URL pointing to the account. try: account = AcmeAccount.objects.viewable().get(ca=self.ca, kid=combined.kid) except AcmeAccount.DoesNotExist: return AcmeResponseUnauthorized(message="Account not found.") if account.usable is False: return AcmeResponseUnauthorized(message="Account not usable.") # self.prepared['thumbprint'] = account.thumbprint # self.prepared['pem'] = account.pem # self.prepared['account_pk'] = account.pk self.jwk = jose.JWK.load(account.pem.encode("utf-8")) # load JWK from database self.account = account else: # ... 'Either "jwk" (JSON Web Key) or "kid" (Key ID)' return AcmeResponseMalformed(message="JWS contained neither key nor key ID.") if len(self.jws.signatures) != 1: # pragma: no cover # RFC 8555, 6.2: "The JWS MUST NOT have multiple signatures" return AcmeResponseMalformed(message="Multiple JWS signatures encountered.") # "The JWS Protected Header MUST include the following fields:... if not combined.alg: # pragma: no cover # ... "alg" return AcmeResponseMalformed(message="No algorithm specified.") # Verify JWS signature try: if not self.jws.verify(self.jwk): return AcmeResponseMalformed(message="JWS signature invalid.") except Exception: # pylint: disable=broad-except return AcmeResponseMalformed(message="JWS signature invalid.") # self.prepared['nonce'] = jose.encode_b64jose(combined.nonce) if not self.validate_nonce(jose.encode_b64jose(combined.nonce)): # ... "nonce" return AcmeResponseBadNonce() if combined.url != request.build_absolute_uri(): # ... "url" # RFC 8555 is not really clear on the required response code, but merely says "If the two do not # match, then the server MUST reject the request as unauthorized." return AcmeResponseUnauthorized(message="URL does not match.") try: return self.process_acme_request(slug=slug) except AcmeException as e: return e.get_response()
def setUp(self) -> None: super().setUp() encoded = jose.encode_b64jose(self.chall.token.encode("utf-8")) thumbprint = self.account.thumbprint self.expected = f"{encoded}.{thumbprint}" self.url = f"http://{self.auth.value}/.well-known/acme-challenge/{encoded}"
def encode_csr(csr): # Encode CSR as JOSE Base-64 DER. return josepy.encode_b64jose( csr.public_bytes(encoding=serialization.Encoding.DER))
def encode_cert(cert): return josepy.encode_b64jose( OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert))
def acme_validate_challenge(challenge_pk): """Validate an ACME challenge.""" if not ca_settings.CA_ENABLE_ACME: log.error('ACME is not enabled.') return if jose is None: # pragma: no cover log.error('josepy is not installed, cannot do challenge validation.') return try: challenge = AcmeChallenge.objects.url().get(pk=challenge_pk) except AcmeChallenge.DoesNotExist: log.error('Challenge with id=%s not found', challenge_pk) return # Whoever is invoking this task is responsible for setting the status to "processing" first. if challenge.status != AcmeChallenge.STATUS_PROCESSING: log.error('%s: %s: Invalid state (must be %s)', challenge, challenge.status, AcmeChallenge.STATUS_PROCESSING) return # If the auth cannot be used for validation, neither can this challenge. We check auth.usable instead of # challenge.usable b/c a challenge in the "processing" state is not "usable" (= it is already being used). if challenge.auth.usable is False: log.error('%s: Authentication is not usable', challenge) return # General data for challenge validation token = challenge.token value = challenge.auth.value encoded = jose.encode_b64jose(token.encode('utf-8')) thumbprint = challenge.auth.order.account.thumbprint expected = f'{encoded}.{thumbprint}' if challenge.type == AcmeChallenge.TYPE_HTTP_01: if requests is None: # pragma: no cover log.error( 'requests is not installed, cannot do http-01 challenge validation.' ) return url = f'http://{value}/.well-known/acme-challenge/{encoded}' # Validate HTTP challenge (only thing supported so far) try: response = requests.get(url, timeout=1) if response.status_code == HTTPStatus.OK: received = response.text else: received = False except Exception as ex: # pylint: disable=broad-except log.exception(ex) received = False else: log.error("%s: Only HTTP-01 challenges supported so far", challenge) received = False # Transition state of the challenge depending on if the challenge is valid or not. RFC8555, Section 7.1.6: # # "If validation is successful, the challenge moves to the "valid" state; if there is an error, the # challenge moves to the "invalid" state." # # We also transition the matching authorization object: # # "If one of the challenges listed in the authorization transitions to the "valid" state, then the # authorization also changes to the "valid" state. If the client attempts to fulfill a challenge and # fails, or if there is an error while the authorization is still pending, then the authorization # transitions to the "invalid" state. # # We also transition the matching order object (section 7.4): # # "* ready: The server agrees that the requirements have been fulfilled, and is awaiting finalization. # Submit a finalization request." if received == expected: challenge.status = AcmeChallenge.STATUS_VALID challenge.validated = timezone.now() challenge.auth.status = AcmeAuthorization.STATUS_VALID # Set the order status to READY if all challenges are valid auths = AcmeAuthorization.objects.filter(order=challenge.auth.order) auths = auths.exclude(status=AcmeAuthorization.STATUS_VALID) if not auths.exclude(pk=challenge.auth.pk).exists(): log.info('Order is now valid') challenge.auth.order.status = AcmeOrder.STATUS_READY else: challenge.status = AcmeChallenge.STATUS_INVALID # RFC 8555, section 7.1.6: # # If the client attempts to fulfill a challenge and fails, or if there is an error while the # authorization is still pending, then the authorization transitions to the "invalid" state. challenge.auth.status = AcmeAuthorization.STATUS_INVALID # RFC 8555, section 7.1.6: # # If an error occurs at any of these stages, the order moves to the "invalid" state. challenge.auth.order.status = AcmeOrder.STATUS_INVALID log.info('Challenge %s is %s', challenge.pk, challenge.status) challenge.save() challenge.auth.save() challenge.auth.order.save()
def acme_request(self, message): # pylint: disable=arguments-differ; more concrete here pem = self.jwk['key'].public_bytes( encoding=Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo).decode( 'utf-8').strip() thumbprint = jose.encode_b64jose(self.jwk.thumbprint()) # RFC 8555, section 7.3: # # If this field is present with the value "true", then the server MUST NOT create a new account if # one does not already exist. This allows a client to look up an account URL based on an account # key (see Section 7.3.1). if message.only_return_existing: try: account = AcmeAccount.objects.get(thumbprint=thumbprint, pem=pem) return AcmeResponseAccount(self.request, account) except AcmeAccount.DoesNotExist: # RFC 8555, section 7.3: # # ... account does not exist, then the server MUST return an error response with status code # 400 (Bad Request) and type "urn:ietf:params:acme:error:accountDoesNotExist". return AcmeResponseMalformed(typ='accountDoesNotExist', message='Account does not exist.') # RFC 8555, section 7.3.1 # # If the server receives a newAccount request signed with a key for which it already has an account # registered with the provided account key, then it MUST return a response with status code 200 (OK) # and provide the URL of that account in the Location header field. try: # NOTE: Filter for thumbprint too b/c index for the field should speed up lookups. account = AcmeAccount.objects.get(thumbprint=thumbprint, pem=pem) return AcmeResponseAccount(self.request, account) except AcmeAccount.DoesNotExist: pass if self.ca.acme_requires_contact and not message.emails: # NOTE: RFC 8555 does not specify an error code in this case return AcmeResponseUnauthorized( message='Must provide at least one contact address.') # Make sure that contact addresses are valid self.validate_contacts(message) account = AcmeAccount( ca=self.ca, contact='\n'.join(message.contact), status=AcmeAccount.STATUS_VALID, terms_of_service_agreed=message.terms_of_service_agreed, thumbprint=thumbprint, pem=pem) account.set_kid(self.request) # Call full_clean() so that model validation can do its magic try: account.full_clean() account.save() except ValidationError: return AcmeResponseMalformed(message="Account cannot be stored.") #self.prepared['thumbprint'] = account.thumbprint #self.prepared['pem'] = account.pem #self.prepared['account_pk'] = account.pem # RFC 8555, section 7.3 # # The server returns this account object in a 201 (Created) response, with the account URL in a # Location header field. # # AcmeResponseAccountCreated adds the Location field currently. return AcmeResponseAccountCreated(self.request, account)