Пример #1
0
    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}"
Пример #2
0
    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
Пример #3
0
    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
Пример #4
0
    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)
Пример #5
0
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))
Пример #6
0
 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)
Пример #7
0
    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)
Пример #8
0
    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()
Пример #9
0
 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}"
Пример #10
0
def encode_csr(csr):
    # Encode CSR as JOSE Base-64 DER.
    return josepy.encode_b64jose(
        csr.public_bytes(encoding=serialization.Encoding.DER))
Пример #11
0
def encode_cert(cert):
    return josepy.encode_b64jose(
        OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert))
Пример #12
0
 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)
Пример #13
0
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()
Пример #14
0
    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)