class Registration(ResourceBody): """Registration Resource Body. :ivar acme.jose.jwk.JWK key: Public key. :ivar tuple contact: Contact information following ACME spec, `tuple` of `unicode`. :ivar unicode agreement: """ # on new-reg key server ignores 'key' and populates it based on # JWS.signature.combined.jwk key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) contact = jose.Field('contact', omitempty=True, default=()) agreement = jose.Field('agreement', omitempty=True) status = jose.Field('status', omitempty=True) phone_prefix = 'tel:' email_prefix = 'mailto:' @classmethod def from_data(cls, phone=None, email=None, **kwargs): """Create registration resource from contact details.""" details = list(kwargs.pop('contact', ())) if phone is not None: details.append(cls.phone_prefix + phone) if email is not None: details.append(cls.email_prefix + email) kwargs['contact'] = tuple(details) return cls(**kwargs) def _filter_contact(self, prefix): return tuple(detail[len(prefix):] for detail in self.contact if detail.startswith(prefix)) @property def phones(self): """All phones found in the ``contact`` field.""" return self._filter_contact(self.phone_prefix) @property def emails(self): """All emails found in the ``contact`` field.""" return self._filter_contact(self.email_prefix)
class CertificateRequest(jose.JSONObjectWithFields): """ACME new-cert request. :ivar acme.jose.util.ComparableX509 csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509` """ resource_type = 'new-cert' resource = fields.Resource(resource_type) csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
class ChallengeBody(ResourceBody): """Challenge Resource Body. .. todo:: Confusingly, this has a similar name to `.challenges.Challenge`, as well as `.achallenges.AnnotatedChallenge`. Please use names such as ``challb`` to distinguish instances of this class from ``achall``. :ivar acme.challenges.Challenge: Wrapped challenge. Conveniently, all challenge fields are proxied, i.e. you can call ``challb.x`` to get ``challb.chall.x`` contents. :ivar acme.messages.Status status: :ivar datetime.datetime validated: :ivar messages.Error error: """ __slots__ = ('chall', ) uri = jose.Field('uri') status = jose.Field('status', decoder=Status.from_json, omitempty=True, default=STATUS_PENDING) validated = fields.RFC3339Field('validated', omitempty=True) error = jose.Field('error', decoder=Error.from_json, omitempty=True, default=None) def to_partial_json(self): jobj = super(ChallengeBody, self).to_partial_json() jobj.update(self.chall.to_partial_json()) return jobj @classmethod def fields_from_json(cls, jobj): jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj) jobj_fields['chall'] = challenges.Challenge.from_json(jobj) return jobj_fields def __getattr__(self, name): return getattr(self.chall, name)
class Revocation(jose.JSONObjectWithFields): """Revocation message. :ivar .ComparableX509 certificate: `OpenSSL.crypto.X509` wrapped in `.ComparableX509` """ resource_type = 'revoke-cert' resource = fields.Resource(resource_type) certificate = jose.Field( 'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
class Signature(jose.Signature): """ACME-specific Signature. Uses ACME-specific Header for customer fields.""" __slots__ = jose.Signature._orig_slots # pylint: disable=no-member # TODO: decoder/encoder should accept cls? Otherwise, subclassing # JSONObjectWithFields is tricky... header_cls = Header header = jose.Field('header', omitempty=True, default=header_cls(), decoder=header_cls.from_json)
class ProofOfPossessionResponse(ChallengeResponse): """ACME "proofOfPossession" challenge response. :ivar bytes nonce: Random data, **not** base64-encoded. :ivar acme.other.Signature signature: Sugnature of this message. """ typ = "proofOfPossession" NONCE_SIZE = ProofOfPossession.NONCE_SIZE nonce = jose.Field( "nonce", encoder=jose.encode_b64jose, decoder=functools.partial( jose.decode_b64jose, size=NONCE_SIZE)) signature = jose.Field("signature", decoder=other.Signature.from_json) def verify(self): """Verify the challenge.""" # self.signature is not Field | pylint: disable=no-member return self.signature.verify(self.nonce)
class SimpleHTTPResponse(ChallengeResponse): """ACME "simpleHttp" challenge response.""" typ = "simpleHttp" path = jose.Field("path") tls = jose.Field("tls", default=True, omitempty=True) URI_ROOT_PATH = ".well-known/acme-challenge" """URI root path for the server provisioned resource.""" _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}" MAX_PATH_LEN = 25 """Maximum allowed `path` length.""" @property def good_path(self): """Is `path` good? .. todo:: acme-spec: "The value MUST be comprised entirely of characters from the URL-safe alphabet for Base64 encoding [RFC4648]", base64.b64decode ignores those characters """ return len(self.path) <= 25 @property def scheme(self): """URL scheme for the provisioned resource.""" return "https" if self.tls else "http" def uri(self, domain): """Create an URI to the provisioned resource. Forms an URI to the HTTPS server provisioned resource (containing :attr:`~SimpleHTTP.token`). :param str domain: Domain name being verified. """ return self._URI_TEMPLATE.format(scheme=self.scheme, domain=domain, path=self.path)
class Meta(jose.JSONObjectWithFields): """Account metadata :ivar datetime.datetime creation_dt: Creation date and time (UTC). :ivar str creation_host: FQDN of host, where account has been created. .. note:: ``creation_dt`` and ``creation_host`` are useful in cross-machine migration scenarios. """ creation_dt = acme_fields.RFC3339Field("creation_dt") creation_host = jose.Field("creation_host")
class Header(jose.Header): """ACME JOSE Header. .. todo:: Implement ``acmePath``. """ nonce = jose.Field('nonce', omitempty=True, encoder=jose.encode_b64jose) @nonce.decoder def nonce(value): # pylint: disable=missing-docstring,no-self-argument try: return jose.decode_b64jose(value) except jose.DeserializationError as error: # TODO: custom error raise jose.DeserializationError("Invalid nonce: {0}".format(error))
class SimpleHTTPResponse(ChallengeResponse): """ACME "simpleHttp" challenge response.""" typ = "simpleHttp" path = jose.Field("path") URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}" """URI template for HTTPS server provisioned resource.""" def uri(self, domain): """Create an URI to the provisioned resource. Forms an URI to the HTTPS server provisioned resource (containing :attr:`~SimpleHTTP.token`) by populating the :attr:`URI_TEMPLATE`. :param str domain: Domain name being verified. """ return self.URI_TEMPLATE.format(domain=domain, path=self.path)
class KeyAuthorizationChallengeResponse(ChallengeResponse): """Response to Challenges based on Key Authorization. :param unicode key_authorization: """ key_authorization = jose.Field("keyAuthorization") thumbprint_hash_function = hashes.SHA256 def verify(self, chall, account_public_key): """Verify the key authorization. :param KeyAuthorization chall: Challenge that corresponds to this response. :param JWK account_public_key: :return: ``True`` iff verification of the key authorization was successful. :rtype: bool """ parts = self.key_authorization.split('.') # pylint: disable=no-member if len(parts) != 2: logger.debug("Key authorization (%r) is not well formed", self.key_authorization) return False if parts[0] != chall.encode("token"): logger.debug( "Mismatching token in key authorization: " "%r instead of %r", parts[0], chall.encode("token")) return False thumbprint = jose.b64encode( account_public_key.thumbprint( hash_function=self.thumbprint_hash_function)).decode() if parts[1] != thumbprint: logger.debug( "Mismatching thumbprint in key authorization: " "%r instead of %r", parts[0], thumbprint) return False return True
class DNSResponse(ChallengeResponse): """ACME "dns" challenge response. :param JWS validation: """ typ = "dns" validation = jose.Field("validation", decoder=jose.JWS.from_json) def check_validation(self, chall, account_public_key): """Check validation. :param challenges.DNS chall: :param JWK account_public_key: :rtype: bool """ return chall.check_validation(self.validation, account_public_key)
class Revocation(jose.JSONObjectWithFields): """Revocation message. :ivar .ComparableX509 certificate: `M2Crypto.X509.X509` wrapped in `.ComparableX509` """ certificate = jose.Field('certificate', decoder=jose.decode_cert, encoder=jose.encode_cert) # TODO: acme-spec#138, this allows only one ACME server instance per domain PATH = '/acme/revoke-cert' """Path to revocation URL, see `url`""" @classmethod def url(cls, base): """Get revocation URL. :param str base: New Registration Resource or server (root) URL. """ return urlparse.urljoin(base, cls.PATH)
class SimpleHTTP(DVChallenge): """ACME "simpleHttp" challenge. :ivar unicode token: """ typ = "simpleHttp" TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec """Minimum size of the :attr:`token` in bytes.""" URI_ROOT_PATH = ".well-known/acme-challenge" """URI root path for the server provisioned resource.""" # TODO: acme-spec doesn't specify token as base64-encoded value token = jose.Field("token", encoder=jose.encode_b64jose, decoder=functools.partial(jose.decode_b64jose, size=TOKEN_SIZE, minimum=True)) @property def good_token(self): # XXX: @token.decoder """Is `token` good? .. todo:: acme-spec wants "It MUST NOT contain any non-ASCII characters", but it should also warrant that it doesn't contain ".." or "/"... """ # TODO: check that path combined with uri does not go above # URI_ROOT_PATH! return b'..' not in self.token and b'/' not in self.token @property def path(self): """Path (starting with '/') for provisioned resource.""" return '/' + self.URI_ROOT_PATH + '/' + self.encode('token')
class DVSNIResponse(ChallengeResponse): """ACME "dvsni" challenge response. :param str s: Random data, **not** base64-encoded. """ typ = "dvsni" DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX """Domain name suffix.""" S_SIZE = 32 """Required size of the :attr:`s` in bytes.""" s = jose.Field( "s", encoder=jose.b64encode, # pylint: disable=invalid-name decoder=functools.partial(jose.decode_b64jose, size=S_SIZE)) def __init__(self, s=None, *args, **kwargs): s = Crypto.Random.get_random_bytes(self.S_SIZE) if s is None else s super(DVSNIResponse, self).__init__(s=s, *args, **kwargs) def z(self, chall): # pylint: disable=invalid-name """Compute the parameter ``z``. :param challenge: Corresponding challenge. :type challenge: :class:`DVSNI` """ z = hashlib.new("sha256") # pylint: disable=invalid-name z.update(chall.r) z.update(self.s) return z.hexdigest() def z_domain(self, chall): """Domain name for certificate subjectAltName.""" return self.z(chall) + self.DOMAIN_SUFFIX
class DNS(DVChallenge): """ACME "dns" challenge. :ivar unicode token: """ typ = "dns" LABEL = "_acme-challenge" """Label clients prepend to the domain name being validated.""" TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec """Minimum size of the :attr:`token` in bytes.""" token = jose.Field("token", encoder=jose.encode_b64jose, decoder=functools.partial(jose.decode_b64jose, size=TOKEN_SIZE, minimum=True)) def gen_validation(self, account_key, alg=jose.RS256, **kwargs): """Generate validation. :param .JWK account_key: Private account key. :param .JWA alg: :returns: This challenge wrapped in `.JWS` :rtype: .JWS """ return jose.JWS.sign( payload=self.json_dumps(sort_keys=True).encode('utf-8'), key=account_key, alg=alg, **kwargs) def check_validation(self, validation, account_public_key): """Check validation. :param JWS validation: :type account_public_key: `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` wrapped in `.ComparableKey` :rtype: bool """ if not validation.verify(key=account_public_key): return False try: return self == self.json_loads(validation.payload.decode('utf-8')) except jose.DeserializationError as error: logger.debug("Checking validation for DNS failed: %s", error) return False def gen_response(self, account_key, **kwargs): """Generate response. :param .JWK account_key: Private account key. :param .JWA alg: :rtype: DNSResponse """ return DNSResponse( validation=self.gen_validation(self, account_key, **kwargs)) def validation_domain_name(self, name): """Domain name for TXT validation record. :param unicode name: Domain name being validated. """ return "{0}.{1}".format(self.LABEL, name)
class Meta(jose.JSONObjectWithFields): """Directory Meta.""" terms_of_service = jose.Field('terms-of-service', omitempty=True) website = jose.Field('website', omitempty=True) caa_identities = jose.Field('caa-identities', omitempty=True)
class Signature(jose.JSONObjectWithFields): """ACME signature. :ivar .JWASignature alg: Signature algorithm. :ivar bytes sig: Signature. :ivar bytes nonce: Nonce. :ivar .JWK jwk: JWK. """ NONCE_SIZE = 16 """Minimum size of nonce in bytes.""" alg = jose.Field('alg', decoder=jose.JWASignature.from_json) sig = jose.Field('sig', encoder=jose.encode_b64jose, decoder=jose.decode_b64jose) nonce = jose.Field('nonce', encoder=jose.encode_b64jose, decoder=functools.partial(jose.decode_b64jose, size=NONCE_SIZE, minimum=True)) jwk = jose.Field('jwk', decoder=jose.JWK.from_json) @classmethod def from_msg(cls, msg, key, nonce=None, nonce_size=None, alg=jose.RS256): """Create signature with nonce prepended to the message. :param bytes msg: Message to be signed. :param key: Key used for signing. :type key: `cryptography.hazmat.primitives.assymetric.rsa.RSAPrivateKey` (optionally wrapped in `.ComparableRSAKey`). :param bytes nonce: Nonce to be used. If None, nonce of ``nonce_size`` will be randomly generated. :param int nonce_size: Size of the automatically generated nonce. Defaults to :const:`NONCE_SIZE`. :param .JWASignature alg: """ nonce_size = cls.NONCE_SIZE if nonce_size is None else nonce_size nonce = os.urandom(nonce_size) if nonce is None else nonce msg_with_nonce = nonce + msg sig = alg.sign(key, nonce + msg) logger.debug('%r signed as %r', msg_with_nonce, sig) return cls(alg=alg, sig=sig, nonce=nonce, jwk=alg.kty(key=key.public_key())) def verify(self, msg): """Verify the signature. :param bytes msg: Message that was used in signing. """ # self.alg is not Field, but JWA | pylint: disable=no-member return self.alg.verify(self.jwk.key, self.nonce + msg, self.sig)
class StatusRequest(Message): """ACME "statusRequest" message.""" typ = "statusRequest" schema = util.load_schema(typ) token = jose.Field("token")
class Signature(jose.JSONObjectWithFields): """ACME signature. :ivar str alg: Signature algorithm. :ivar str sig: Signature. :ivar str nonce: Nonce. :ivar jwk: JWK. :type jwk: :class:`JWK` """ NONCE_SIZE = 16 """Minimum size of nonce in bytes.""" alg = jose.Field('alg', decoder=jose.JWASignature.from_json) sig = jose.Field('sig', encoder=jose.b64encode, decoder=jose.decode_b64jose) nonce = jose.Field('nonce', encoder=jose.b64encode, decoder=functools.partial(jose.decode_b64jose, size=NONCE_SIZE, minimum=True)) jwk = jose.Field('jwk', decoder=jose.JWK.from_json) @classmethod def from_msg(cls, msg, key, nonce=None, nonce_size=None, alg=jose.RS256): """Create signature with nonce prepended to the message. .. todo:: Protect against crypto unicode errors... is this sufficient? Do I need to escape? :param str msg: Message to be signed. :param key: Key used for signing. :type key: :class:`Crypto.PublicKey.RSA` :param str nonce: Nonce to be used. If None, nonce of ``nonce_size`` will be randomly generated. :param int nonce_size: Size of the automatically generated nonce. Defaults to :const:`NONCE_SIZE`. """ nonce_size = cls.NONCE_SIZE if nonce_size is None else nonce_size if nonce is None: nonce = Crypto.Random.get_random_bytes(nonce_size) msg_with_nonce = nonce + msg sig = alg.sign(key, nonce + msg) logging.debug('%s signed as %s', msg_with_nonce, sig) return cls(alg=alg, sig=sig, nonce=nonce, jwk=alg.kty(key=key.publickey())) def verify(self, msg): """Verify the signature. :param str msg: Message that was used in signing. """ # self.alg is not Field, but JWA | pylint: disable=no-member return self.alg.verify(self.jwk.key, self.nonce + msg, self.sig)
class Registration(ResourceBody): """Registration Resource Body. :ivar acme.jose.jwk.JWK key: Public key. :ivar tuple contact: Contact information following ACME spec, `tuple` of `unicode`. :ivar unicode agreement: :ivar unicode authorizations: URI where `messages.Registration.Authorizations` can be found. :ivar unicode certificates: URI where `messages.Registration.Certificates` can be found. """ # on new-reg key server ignores 'key' and populates it based on # JWS.signature.combined.jwk key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) contact = jose.Field('contact', omitempty=True, default=()) agreement = jose.Field('agreement', omitempty=True) authorizations = jose.Field('authorizations', omitempty=True) certificates = jose.Field('certificates', omitempty=True) class Authorizations(jose.JSONObjectWithFields): """Authorizations granted to Account in the process of registration. :ivar tuple authorizations: URIs to Authorization Resources. """ authorizations = jose.Field('authorizations') class Certificates(jose.JSONObjectWithFields): """Certificates granted to Account in the process of registration. :ivar tuple certificates: URIs to Certificate Resources. """ certificates = jose.Field('certificates') phone_prefix = 'tel:' email_prefix = 'mailto:' @classmethod def from_data(cls, phone=None, email=None, **kwargs): """Create registration resource from contact details.""" details = list(kwargs.pop('contact', ())) if phone is not None: details.append(cls.phone_prefix + phone) if email is not None: details.append(cls.email_prefix + email) kwargs['contact'] = tuple(details) return cls(**kwargs) def _filter_contact(self, prefix): return tuple(detail[len(prefix):] for detail in self.contact if detail.startswith(prefix)) @property def phones(self): """All phones found in the ``contact`` field.""" return self._filter_contact(self.phone_prefix) @property def emails(self): """All emails found in the ``contact`` field.""" return self._filter_contact(self.email_prefix)
class SimpleHTTPResponse(ChallengeResponse): """ACME "simpleHttp" challenge response. :ivar bool tls: """ typ = "simpleHttp" tls = jose.Field("tls", default=True, omitempty=True) URI_ROOT_PATH = ".well-known/acme-challenge" """URI root path for the server provisioned resource.""" _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{token}" CONTENT_TYPE = "application/jose+json" @property def scheme(self): """URL scheme for the provisioned resource.""" return "https" if self.tls else "http" @property def port(self): """Port that the ACME client should be listening for validation.""" return 443 if self.tls else 80 def uri(self, domain, chall): """Create an URI to the provisioned resource. Forms an URI to the HTTPS server provisioned resource (containing :attr:`~SimpleHTTP.token`). :param unicode domain: Domain name being verified. :param challenges.SimpleHTTP chall: """ return self._URI_TEMPLATE.format( scheme=self.scheme, domain=domain, token=chall.encode("token")) def gen_resource(self, chall): """Generate provisioned resource. :param challenges.SimpleHTTP chall: :rtype: SimpleHTTPProvisionedResource """ return SimpleHTTPProvisionedResource(token=chall.token, tls=self.tls) def gen_validation(self, chall, account_key, alg=jose.RS256, **kwargs): """Generate validation. :param challenges.SimpleHTTP chall: :param .JWK account_key: Private account key. :param .JWA alg: :returns: `.SimpleHTTPProvisionedResource` signed in `.JWS` :rtype: .JWS """ return jose.JWS.sign( payload=self.gen_resource(chall).json_dumps( sort_keys=True).encode('utf-8'), key=account_key, alg=alg, **kwargs) def check_validation(self, validation, chall, account_public_key): """Check validation. :param .JWS validation: :param challenges.SimpleHTTP chall: :type account_public_key: `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` wrapped in `.ComparableKey` :rtype: bool """ if not validation.verify(key=account_public_key): return False try: resource = SimpleHTTPProvisionedResource.json_loads( validation.payload.decode('utf-8')) except jose.DeserializationError as error: logger.debug(error) return False return resource.token == chall.token and resource.tls == self.tls def simple_verify(self, chall, domain, account_public_key, port=None): """Simple verify. According to the ACME specification, "the ACME server MUST ignore the certificate provided by the HTTPS server", so ``requests.get`` is called with ``verify=False``. :param challenges.SimpleHTTP chall: Corresponding challenge. :param unicode domain: Domain name being verified. :param account_public_key: Public key for the key pair being authorized. If ``None`` key verification is not performed! :type account_public_key: `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` wrapped in `.ComparableKey` :param int port: Port used in the validation. :returns: ``True`` iff validation is successful, ``False`` otherwise. :rtype: bool """ # TODO: ACME specification defines URI template that doesn't # allow to use a custom port... Make sure port is not in the # request URI, if it's standard. if port is not None and port != self.port: logger.warn( "Using non-standard port for SimpleHTTP verification: %s", port) domain += ":{0}".format(port) uri = self.uri(domain, chall) logger.debug("Verifying %s at %s...", chall.typ, uri) try: http_response = requests.get(uri, verify=False) except requests.exceptions.RequestException as error: logger.error("Unable to reach %s: %s", uri, error) return False logger.debug("Received %s: %s. Headers: %s", http_response, http_response.text, http_response.headers) if self.CONTENT_TYPE != http_response.headers.get( "Content-Type", self.CONTENT_TYPE): return False try: validation = jose.JWS.json_loads(http_response.text) except jose.DeserializationError as error: logger.debug(error) return False return self.check_validation(validation, chall, account_public_key)
class DVSNIResponse(ChallengeResponse): """ACME "dvsni" challenge response. :param bytes s: Random data, **not** base64-encoded. """ typ = "dvsni" DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX """Domain name suffix.""" S_SIZE = 32 """Required size of the :attr:`s` in bytes.""" s = jose.Field( "s", encoder=jose.encode_b64jose, # pylint: disable=invalid-name decoder=functools.partial(jose.decode_b64jose, size=S_SIZE)) def __init__(self, s=None, *args, **kwargs): s = os.urandom(self.S_SIZE) if s is None else s super(DVSNIResponse, self).__init__(s=s, *args, **kwargs) def z(self, chall): # pylint: disable=invalid-name """Compute the parameter ``z``. :param challenge: Corresponding challenge. :type challenge: :class:`DVSNI` :rtype: bytes """ z = hashlib.new("sha256") # pylint: disable=invalid-name z.update(chall.r) z.update(self.s) return z.hexdigest().encode() def z_domain(self, chall): """Domain name for certificate subjectAltName. :rtype bytes: """ return self.z(chall) + self.DOMAIN_SUFFIX def gen_cert(self, chall, domain, key): """Generate DVSNI certificate. :param .DVSNI chall: Corresponding challenge. :param unicode domain: :param OpenSSL.crypto.PKey """ return crypto_util.gen_ss_cert(key, [ domain, chall.nonce_domain.decode(), self.z_domain(chall).decode() ]) def simple_verify(self, chall, domain, public_key, **kwargs): """Simple verify. Probes DVSNI certificate and checks it using `verify_cert`; hence all arguments documented in `verify_cert`. """ try: cert = chall.probe_cert(domain=domain, **kwargs) except errors.Error as error: logger.debug(error, exc_info=True) return False return self.verify_cert(chall, domain, public_key, cert) def verify_cert(self, chall, domain, public_key, cert): """Verify DVSNI certificate. :param .challenges.DVSNI chall: Corresponding challenge. :param str domain: Domain name being validated. :param public_key: Public key for the key pair being authorized. If ``None`` key verification is not performed! :type public_key: `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` wrapped in `.ComparableKey :param OpenSSL.crypto.X509 cert: :returns: ``True`` iff client's control of the domain has been verified, ``False`` otherwise. :rtype: bool """ # TODO: check "It is a valid self-signed certificate" and # return False if not # pylint: disable=protected-access sans = crypto_util._pyopenssl_cert_or_req_san(cert) logging.debug('Certificate %s. SANs: %s', cert.digest('sha1'), sans) cert = x509.load_der_x509_certificate( OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert), default_backend()) if public_key is None: logging.warn('No key verification is performed') elif public_key != jose.ComparableKey(cert.public_key()): return False return domain in sans and self.z_domain(chall).decode() in sans
class AuthorizationRequest(Message): """ACME "authorizationRequest" message. :ivar str nonce: Random data from the corresponding :attr:`Challenge.nonce`, **not** base64-encoded. :ivar list responses: List of completed challenges ( :class:`acme.challenges.ChallengeResponse`). :ivar signature: Signature (:class:`acme.other.Signature`). """ typ = "authorizationRequest" schema = util.load_schema(typ) session_id = jose.Field("sessionID") nonce = jose.Field("nonce", encoder=jose.b64encode, decoder=jose.decode_b64jose) responses = jose.Field("responses") signature = jose.Field("signature", decoder=other.Signature.from_json) contact = jose.Field("contact", omitempty=True, default=()) @responses.decoder def responses(value): # pylint: disable=missing-docstring,no-self-argument return tuple( challenges.ChallengeResponse.from_json(chall) for chall in value) @classmethod def create(cls, name, key, sig_nonce=None, **kwargs): """Create signed "authorizationRequest". :param str name: Hostname :param key: Key used for signing. :type key: :class:`Crypto.PublicKey.RSA` :param str sig_nonce: Nonce used for signature. Useful for testing. :kwargs: Any other arguments accepted by the class constructor. :returns: Signed "authorizationRequest" ACME message. :rtype: :class:`AuthorizationRequest` """ # pylint: disable=too-many-arguments signature = other.Signature.from_msg(name + kwargs["nonce"], key, sig_nonce) return cls(signature=signature, contact=kwargs.pop("contact", ()), **kwargs) def verify(self, name): """Verify signature. .. warning:: Caller must check that the public key encoded in the :attr:`signature`'s :class:`acme.jose.JWK` object is the correct key for a given context. :param str name: Hostname :returns: True iff ``signature`` can be verified, False otherwise. :rtype: bool """ # self.signature is not Field | pylint: disable=no-member return self.signature.verify(name + self.nonce)
class RecoveryTokenResponse(ChallengeResponse): """ACME "recoveryToken" challenge response.""" typ = "recoveryToken" token = jose.Field("token", omitempty=True)
class SimpleHTTPResponse(ChallengeResponse): """ACME "simpleHttp" challenge response.""" typ = "simpleHttp" path = jose.Field("path") tls = jose.Field("tls", default=True, omitempty=True) URI_ROOT_PATH = ".well-known/acme-challenge" """URI root path for the server provisioned resource.""" _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}" MAX_PATH_LEN = 25 """Maximum allowed `path` length.""" CONTENT_TYPE = "text/plain" @property def good_path(self): """Is `path` good? .. todo:: acme-spec: "The value MUST be comprised entirely of characters from the URL-safe alphabet for Base64 encoding [RFC4648]", base64.b64decode ignores those characters """ # TODO: check that path combined with uri does not go above # URI_ROOT_PATH! return len(self.path) <= 25 @property def scheme(self): """URL scheme for the provisioned resource.""" return "https" if self.tls else "http" @property def port(self): """Port that the ACME client should be listening for validation.""" return 443 if self.tls else 80 def uri(self, domain): """Create an URI to the provisioned resource. Forms an URI to the HTTPS server provisioned resource (containing :attr:`~SimpleHTTP.token`). :param str domain: Domain name being verified. """ return self._URI_TEMPLATE.format(scheme=self.scheme, domain=domain, path=self.path) def simple_verify(self, chall, domain, port=None): """Simple verify. According to the ACME specification, "the ACME server MUST ignore the certificate provided by the HTTPS server", so ``requests.get`` is called with ``verify=False``. :param .SimpleHTTP chall: Corresponding challenge. :param str domain: Domain name being verified. :param int port: Port used in the validation. :returns: ``True`` iff validation is successful, ``False`` otherwise. :rtype: bool """ # TODO: ACME specification defines URI template that doesn't # allow to use a custom port... Make sure port is not in the # request URI, if it's standard. if port is not None and port != self.port: logger.warn( "Using non-standard port for SimpleHTTP verification: %s", port) domain += ":{0}".format(port) uri = self.uri(domain) logger.debug("Verifying %s at %s...", chall.typ, uri) try: http_response = requests.get(uri, verify=False) except requests.exceptions.RequestException as error: logger.error("Unable to reach %s: %s", uri, error) return False logger.debug("Received %s. Headers: %s", http_response, http_response.headers) good_token = http_response.text == chall.token if not good_token: logger.error("Unable to verify %s! Expected: %r, returned: %r.", uri, chall.token, http_response.text) # TODO: spec contradicts itself, c.f. # https://github.com/letsencrypt/acme-spec/pull/156/files#r33136438 good_ct = self.CONTENT_TYPE == http_response.headers.get( "Content-Type", self.CONTENT_TYPE) return self.good_path and good_ct and good_token
class SimpleHTTP(DVChallenge): """ACME "simpleHttp" challenge.""" typ = "simpleHttp" token = jose.Field("token")
class DNS(DVChallenge): """ACME "dns" challenge.""" typ = "dns" token = jose.Field("token")
class DVSNIResponse(ChallengeResponse): """ACME "dvsni" challenge response. :param bytes s: Random data, **not** base64-encoded. """ typ = "dvsni" DOMAIN_SUFFIX = b".acme.invalid" """Domain name suffix.""" PORT = DVSNI.PORT """Port to perform DVSNI challenge.""" validation = jose.Field("validation", decoder=jose.JWS.from_json) @property def z(self): # pylint: disable=invalid-name """The ``z`` parameter. :rtype: bytes """ # Instance of 'Field' has no 'signature' member # pylint: disable=no-member return hashlib.sha256(self.validation.signature.encode( "signature").encode("utf-8")).hexdigest().encode() @property def z_domain(self): """Domain name for certificate subjectAltName. :rtype: bytes """ z = self.z # pylint: disable=invalid-name return z[:32] + b'.' + z[32:] + self.DOMAIN_SUFFIX @property def chall(self): """Get challenge encoded in the `validation` payload. :rtype: challenges.DVSNI """ # pylint: disable=no-member return DVSNI.json_loads(self.validation.payload.decode('utf-8')) def gen_cert(self, key=None, bits=2048): """Generate DVSNI certificate. :param OpenSSL.crypto.PKey key: Optional private key used in certificate generation. If not provided (``None``), then fresh key will be generated. :param int bits: Number of bits for newly generated key. :rtype: `tuple` of `OpenSSL.crypto.X509` and `OpenSSL.crypto.PKey` """ if key is None: key = OpenSSL.crypto.PKey() key.generate_key(OpenSSL.crypto.TYPE_RSA, bits) return crypto_util.gen_ss_cert(key, [ # z_domain is too big to fit into CN, hence first dummy domain 'dummy', self.z_domain.decode()], force_san=True), key def probe_cert(self, domain, **kwargs): """Probe DVSNI challenge certificate. :param unicode domain: """ if "host" not in kwargs: host = socket.gethostbyname(domain) logging.debug('%s resolved to %s', domain, host) kwargs["host"] = host kwargs.setdefault("port", self.PORT) kwargs["name"] = self.z_domain # TODO: try different methods? # pylint: disable=protected-access return crypto_util.probe_sni(**kwargs) def verify_cert(self, cert): """Verify DVSNI challenge certificate.""" # pylint: disable=protected-access sans = crypto_util._pyopenssl_cert_or_req_san(cert) logging.debug('Certificate %s. SANs: %s', cert.digest('sha1'), sans) return self.z_domain.decode() in sans def simple_verify(self, chall, domain, account_public_key, cert=None, **kwargs): """Simple verify. Verify ``validation`` using ``account_public_key``, optionally probe DVSNI certificate and check using `verify_cert`. :param .challenges.DVSNI chall: Corresponding challenge. :param str domain: Domain name being validated. :type account_public_key: `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` or `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` wrapped in `.ComparableKey` :param OpenSSL.crypto.X509 cert: Optional certificate. If not provided (``None``) certificate will be retrieved using `probe_cert`. :returns: ``True`` iff client's control of the domain has been verified, ``False`` otherwise. :rtype: bool """ # pylint: disable=no-member if not self.validation.verify(key=account_public_key): return False # TODO: it's not checked that payload has exectly 2 fields! try: decoded_chall = self.chall except jose.DeserializationError as error: logger.debug(error, exc_info=True) return False if decoded_chall.token != chall.token: logger.debug("Wrong token: expected %r, found %r", chall.token, decoded_chall.token) return False if cert is None: try: cert = self.probe_cert(domain=domain, **kwargs) except errors.Error as error: logger.debug(error, exc_info=True) return False return self.verify_cert(cert)
class ChallengeRequest(Message): """ACME "challengeRequest" message.""" typ = "challengeRequest" schema = util.load_schema(typ) identifier = jose.Field("identifier")