1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-26 07:41:33 +03:00

Typed jose fields (#9073)

* Add generic methods to save some casts, and fix lint

* Update current and oldest pinning

* Fix classes

* Remove some todos thanks to josepy 1.11.0

* Cleanup some useless pylint disable

* Finish complete typing

* Better TypeVar names

* Upgrade pinning and fix some typing errors

* Use protocol

* Fix types in apache

Co-authored-by: Brad Warren <bmw@users.noreply.github.com>
This commit is contained in:
Adrien Ferrand
2022-01-25 00:16:19 +01:00
committed by GitHub
parent 7198f43008
commit dac0b2c187
32 changed files with 398 additions and 325 deletions

View File

@@ -12,6 +12,8 @@ from typing import Mapping
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TypeVar
from typing import Union
from cryptography.hazmat.primitives import hashes
import josepy as jose
@@ -27,6 +29,8 @@ from acme.mixins import TypeMixin
logger = logging.getLogger(__name__)
GenericChallenge = TypeVar('GenericChallenge', bound='Challenge')
class Challenge(jose.TypedJSONObjectWithFields):
# _fields_to_partial_json
@@ -34,9 +38,10 @@ class Challenge(jose.TypedJSONObjectWithFields):
TYPES: Dict[str, Type['Challenge']] = {}
@classmethod
def from_json(cls, jobj: Mapping[str, Any]) -> 'Challenge':
def from_json(cls: Type[GenericChallenge],
jobj: Mapping[str, Any]) -> Union[GenericChallenge, 'UnrecognizedChallenge']:
try:
return super().from_json(jobj)
return cast(GenericChallenge, super().from_json(jobj))
except jose.UnrecognizedTypeError as error:
logger.debug(error)
return UnrecognizedChallenge.from_json(jobj)
@@ -47,7 +52,7 @@ class ChallengeResponse(ResourceMixin, TypeMixin, jose.TypedJSONObjectWithFields
"""ACME challenge response."""
TYPES: Dict[str, Type['ChallengeResponse']] = {}
resource_type = 'challenge'
resource = fields.Resource(resource_type)
resource: str = fields.resource(resource_type)
class UnrecognizedChallenge(Challenge):
@@ -62,6 +67,7 @@ class UnrecognizedChallenge(Challenge):
:ivar jobj: Original JSON decoded object.
"""
jobj: Dict[str, Any]
def __init__(self, jobj: Mapping[str, Any]) -> None:
super().__init__()
@@ -85,7 +91,7 @@ class _TokenChallenge(Challenge):
"""Minimum size of the :attr:`token` in bytes."""
# TODO: acme-spec doesn't specify token as base64-encoded value
token: bytes = jose.Field(
token: bytes = jose.field(
"token", encoder=jose.encode_b64jose, decoder=functools.partial(
jose.decode_b64jose, size=TOKEN_SIZE, minimum=True))
@@ -108,10 +114,10 @@ class _TokenChallenge(Challenge):
class KeyAuthorizationChallengeResponse(ChallengeResponse):
"""Response to Challenges based on Key Authorization.
:param unicode key_authorization:
:param str key_authorization:
"""
key_authorization = jose.Field("keyAuthorization")
key_authorization: str = jose.field("keyAuthorization")
thumbprint_hash_function = hashes.SHA256
def verify(self, chall: 'KeyAuthorizationChallenge', account_public_key: jose.JWK) -> bool:
@@ -126,7 +132,7 @@ class KeyAuthorizationChallengeResponse(ChallengeResponse):
:rtype: bool
"""
parts = self.key_authorization.split('.')
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)
@@ -152,6 +158,9 @@ class KeyAuthorizationChallengeResponse(ChallengeResponse):
return jobj
# TODO: Make this method a generic of K (bound=KeyAuthorizationChallenge), response_cls of type
# Type[K] and use it in response/response_and_validation return types once Python 3.6 support is
# dropped (do not support generic ABC classes, see https://github.com/python/typing/issues/449).
class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta):
"""Challenge based on Key Authorization.
@@ -168,7 +177,7 @@ class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta):
"""Generate Key Authorization.
:param JWK account_key:
:rtype unicode:
:rtype str:
"""
return self.encode("token") + "." + jose.b64encode(
@@ -229,7 +238,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse):
around `KeyAuthorizationChallengeResponse.verify`.
:param challenges.DNS01 chall: Corresponding challenge.
:param unicode domain: Domain name being verified.
:param str domain: Domain name being verified.
:param JWK account_public_key: Public key for the key pair
being authorized.
@@ -257,7 +266,7 @@ class DNS01(KeyAuthorizationChallenge):
"""Generate validation.
:param JWK account_key:
:rtype: unicode
:rtype: str
"""
return jose.b64encode(hashlib.sha256(self.key_authorization(
@@ -266,7 +275,8 @@ class DNS01(KeyAuthorizationChallenge):
def validation_domain_name(self, name: str) -> str:
"""Domain name for TXT validation record.
:param unicode name: Domain name being validated.
:param str name: Domain name being validated.
:rtype: str
"""
return "{0}.{1}".format(self.LABEL, name)
@@ -293,7 +303,7 @@ class HTTP01Response(KeyAuthorizationChallengeResponse):
"""Simple verify.
:param challenges.SimpleHTTP chall: Corresponding challenge.
:param unicode domain: Domain name being verified.
:param str domain: Domain name being verified.
:param JWK account_public_key: Public key for the key pair
being authorized.
:param int port: Port used in the validation.
@@ -357,7 +367,7 @@ class HTTP01(KeyAuthorizationChallenge):
def path(self) -> str:
"""Path (starting with '/') for provisioned resource.
:rtype: string
:rtype: str
"""
return '/' + self.URI_ROOT_PATH + '/' + self.encode('token')
@@ -368,8 +378,8 @@ class HTTP01(KeyAuthorizationChallenge):
Forms an URI to the HTTPS server provisioned resource
(containing :attr:`~SimpleHTTP.token`).
:param unicode domain: Domain name being verified.
:rtype: string
:param str domain: Domain name being verified.
:rtype: str
"""
return "http://" + domain + self.path
@@ -378,7 +388,7 @@ class HTTP01(KeyAuthorizationChallenge):
"""Generate validation.
:param JWK account_key:
:rtype: unicode
:rtype: str
"""
return self.key_authorization(account_key)
@@ -409,7 +419,7 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
) -> Tuple[crypto.X509, crypto.PKey]:
"""Generate tls-alpn-01 certificate.
:param unicode domain: Domain verified by the challenge.
:param str domain: Domain verified by the challenge.
:param OpenSSL.crypto.PKey key: Optional private key used in
certificate generation. If not provided (``None``), then
fresh key will be generated.
@@ -433,8 +443,8 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
port: Optional[int] = None) -> crypto.X509:
"""Probe tls-alpn-01 challenge certificate.
:param unicode domain: domain being validated, required.
:param string host: IP address used to probe the certificate.
:param str domain: domain being validated, required.
:param str host: IP address used to probe the certificate.
:param int port: Port used to probe the certificate.
"""
@@ -450,7 +460,7 @@ class TLSALPN01Response(KeyAuthorizationChallengeResponse):
def verify_cert(self, domain: str, cert: crypto.X509) -> bool:
"""Verify tls-alpn-01 challenge certificate.
:param unicode domain: Domain name being validated.
:param str domain: Domain name being validated.
:param OpensSSL.crypto.X509 cert: Challenge certificate.
:returns: Whether the certificate was successfully verified.
@@ -523,7 +533,7 @@ class TLSALPN01(KeyAuthorizationChallenge):
"""Generate validation.
:param JWK account_key:
:param unicode domain: Domain verified by the challenge.
:param str domain: Domain verified by the challenge.
:param OpenSSL.crypto.PKey cert_key: Optional private key used
in certificate generation. If not provided (``None``), then
fresh key will be generated.
@@ -531,9 +541,10 @@ class TLSALPN01(KeyAuthorizationChallenge):
:rtype: `tuple` of `OpenSSL.crypto.X509` and `OpenSSL.crypto.PKey`
"""
return self.response(account_key).gen_cert(
# TODO: Remove cast when response() is generic.
return cast(TLSALPN01Response, self.response(account_key)).gen_cert(
key=kwargs.get('cert_key'),
domain=kwargs.get('domain'))
domain=cast(str, kwargs.get('domain')))
@staticmethod
def is_supported() -> bool:
@@ -599,13 +610,12 @@ class DNS(_TokenChallenge):
:rtype: DNSResponse
"""
return DNSResponse(validation=self.gen_validation(
account_key, **kwargs))
return DNSResponse(validation=self.gen_validation(account_key, **kwargs))
def validation_domain_name(self, name: str) -> str:
"""Domain name for TXT validation record.
:param unicode name: Domain name being validated.
:param str name: Domain name being validated.
"""
return "{0}.{1}".format(self.LABEL, name)
@@ -620,7 +630,7 @@ class DNSResponse(ChallengeResponse):
"""
typ = "dns"
validation = jose.Field("validation", decoder=jose.JWS.from_json)
validation: jose.JWS = jose.field("validation", decoder=jose.JWS.from_json)
def check_validation(self, chall: 'DNS', account_public_key: jose.JWK) -> bool:
"""Check validation.
@@ -631,4 +641,4 @@ class DNSResponse(ChallengeResponse):
:rtype: bool
"""
return chall.check_validation(cast(jose.JWS, self.validation), account_public_key)
return chall.check_validation(self.validation, account_public_key)

View File

@@ -19,6 +19,7 @@ from typing import cast
from typing import Dict
from typing import Iterable
from typing import List
from typing import Mapping
from typing import Optional
from typing import Set
from typing import Text
@@ -33,6 +34,7 @@ from requests.adapters import HTTPAdapter
from requests.utils import parse_header_links
from requests_toolbelt.adapters.source import SourceAddressAdapter
from acme import challenges
from acme import crypto_util
from acme import errors
from acme import jws
@@ -156,12 +158,12 @@ class ClientBase:
authzr = messages.AuthorizationResource(
body=messages.Authorization.from_json(response.json()),
uri=response.headers.get('Location', uri))
if identifier is not None and authzr.body.identifier != identifier:
if identifier is not None and authzr.body.identifier != identifier: # pylint: disable=no-member
raise errors.UnexpectedUpdate(authzr)
return authzr
def answer_challenge(self, challb: messages.ChallengeBody, response: requests.Response
) -> messages.ChallengeResource:
def answer_challenge(self, challb: messages.ChallengeBody,
response: challenges.ChallengeResponse) -> messages.ChallengeResource:
"""Answer challenge.
:param challb: Challenge Resource body.
@@ -176,15 +178,15 @@ class ClientBase:
:raises .UnexpectedUpdate:
"""
response = self._post(challb.uri, response)
resp = self._post(challb.uri, response)
try:
authzr_uri = response.links['up']['url']
authzr_uri = resp.links['up']['url']
except KeyError:
raise errors.ClientError('"up" Link header missing')
challr = messages.ChallengeResource(
authzr_uri=authzr_uri,
body=messages.ChallengeBody.from_json(response.json()))
# TODO: check that challr.uri == response.headers['Location']?
body=messages.ChallengeBody.from_json(resp.json()))
# TODO: check that challr.uri == resp.headers['Location']?
if challr.uri != challb.uri:
raise errors.UnexpectedUpdate(challr.uri)
return challr
@@ -492,7 +494,7 @@ class Client(ClientBase):
updated[authzr] = updated_authzr
attempts[authzr] += 1
if updated_authzr.body.status not in (
if updated_authzr.body.status not in ( # pylint: disable=no-member
messages.STATUS_VALID, messages.STATUS_INVALID):
if attempts[authzr] < max_attempts:
# push back to the priority queue, with updated retry_after
@@ -599,7 +601,7 @@ class Client(ClientBase):
:raises .ClientError: If revocation is unsuccessful.
"""
self._revoke(cert, rsn, self.directory[cast(str, messages.Revocation)])
self._revoke(cert, rsn, self.directory[messages.Revocation])
class ClientV2(ClientBase):
@@ -756,7 +758,7 @@ class ClientV2(ClientBase):
for url in orderr.body.authorizations:
while datetime.datetime.now() < deadline:
authzr = self._authzr_from_response(self._post_as_get(url), uri=url)
if authzr.body.status != messages.STATUS_PENDING:
if authzr.body.status != messages.STATUS_PENDING: # pylint: disable=no-member
responses.append(authzr)
break
time.sleep(1)
@@ -897,11 +899,11 @@ class BackwardsCompatibleClientV2:
check_tos_cb(tos)
if self.acme_version == 1:
client_v1 = cast(Client, self.client)
regr = client_v1.register(regr)
if regr.terms_of_service is not None:
_assess_tos(regr.terms_of_service)
return client_v1.agree_to_tos(regr)
return regr
regr_res = client_v1.register(regr)
if regr_res.terms_of_service is not None:
_assess_tos(regr_res.terms_of_service)
return client_v1.agree_to_tos(regr_res)
return regr_res
else:
client_v2 = cast(ClientV2, self.client)
if "terms_of_service" in client_v2.directory.meta:
@@ -970,7 +972,8 @@ class BackwardsCompatibleClientV2:
'certificate, please rerun the command for a new one.')
cert = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, certr.body.wrapped).decode()
OpenSSL.crypto.FILETYPE_PEM,
cast(OpenSSL.crypto.X509, cast(jose.ComparableX509, certr.body).wrapped)).decode()
chain_str = crypto_util.dump_pyopenssl_chain(chain).decode()
return orderr.update(fullchain_pem=(cert + chain_str))
@@ -1056,7 +1059,7 @@ class ClientNetwork:
pass
def _wrap_in_jws(self, obj: jose.JSONDeSerializable, nonce: str, url: str,
acme_version: int) -> jose.JWS:
acme_version: int) -> str:
"""Wrap `JSONDeSerializable` object in JWS.
.. todo:: Implement ``acmePath``.
@@ -1064,7 +1067,7 @@ class ClientNetwork:
:param josepy.JSONDeSerializable obj:
:param str url: The URL to which this object will be POSTed
:param str nonce:
:rtype: `josepy.JWS`
:rtype: str
"""
if isinstance(obj, VersionedLEACMEMixin):
@@ -1082,7 +1085,7 @@ class ClientNetwork:
if self.account is not None:
kwargs["kid"] = self.account["uri"]
kwargs["key"] = self.key
return jws.JWS.sign(jobj, **kwargs).json_dumps(indent=2)
return jws.JWS.sign(jobj, **cast(Mapping[str, Any], kwargs)).json_dumps(indent=2)
@classmethod
def _check_response(cls, response: requests.Response,

View File

@@ -278,7 +278,7 @@ def _pyopenssl_cert_or_req_san(cert_or_req: Union[crypto.X509, crypto.X509Req])
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
:returns: A list of Subject Alternative Names that is DNS.
:rtype: `list` of `unicode`
:rtype: `list` of `str`
"""
# This function finds SANs with dns name
@@ -300,7 +300,7 @@ def _pyopenssl_cert_or_req_san_ip(cert_or_req: Union[crypto.X509, crypto.X509Req
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
:returns: A list of Subject Alternative Names that are IP Addresses.
:rtype: `list` of `unicode`. note that this returns as string, not IPaddress object
:rtype: `list` of `str`. note that this returns as string, not IPaddress object
"""
@@ -320,7 +320,7 @@ def _pyopenssl_extract_san_list_raw(cert_or_req: Union[crypto.X509, crypto.X509R
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
:returns: raw san strings, parsed byte as utf-8
:rtype: `list` of `unicode`
:rtype: `list` of `str`
"""
# This function finds SANs by dumping the certificate/CSR to text and
@@ -352,7 +352,7 @@ def gen_ss_cert(key: crypto.PKey, domains: Optional[List[str]] = None,
) -> crypto.X509:
"""Generate new self-signed certificate.
:type domains: `list` of `unicode`
:type domains: `list` of `str`
:param OpenSSL.crypto.PKey key:
:param bool force_san:
:param extensions: List of additional extensions to include in the cert.
@@ -410,7 +410,8 @@ def gen_ss_cert(key: crypto.PKey, domains: Optional[List[str]] = None,
return cert
def dump_pyopenssl_chain(chain: List[crypto.X509], filetype: int = crypto.FILETYPE_PEM) -> bytes:
def dump_pyopenssl_chain(chain: Union[List[jose.ComparableX509], List[crypto.X509]],
filetype: int = crypto.FILETYPE_PEM) -> bytes:
"""Dump certificate chain into a bundle.
:param list chain: List of `OpenSSL.crypto.X509` (or wrapped in
@@ -425,6 +426,8 @@ def dump_pyopenssl_chain(chain: List[crypto.X509], filetype: int = crypto.FILETY
def _dump_cert(cert: Union[jose.ComparableX509, crypto.X509]) -> bytes:
if isinstance(cert, jose.ComparableX509):
if isinstance(cert.wrapped, crypto.X509Req):
raise errors.Error("Unexpected CSR provided.") # pragma: no cover
cert = cert.wrapped
return crypto.dump_certificate(filetype, cert)

View File

@@ -56,8 +56,8 @@ class Resource(jose.Field):
def __init__(self, resource_type: str, *args: Any, **kwargs: Any) -> None:
self.resource_type = resource_type
super().__init__(
'resource', default=resource_type, *args, **kwargs)
kwargs['default'] = resource_type
super().__init__('resource', *args, **kwargs)
def decode(self, value: Any) -> Any:
if value != self.resource_type:
@@ -65,3 +65,18 @@ class Resource(jose.Field):
'Wrong resource type: {0} instead of {1}'.format(
value, self.resource_type))
return value
def fixed(json_name: str, value: Any) -> Any:
"""Generates a type-friendly Fixed field."""
return Fixed(json_name, value)
def rfc3339(json_name: str, omitempty: bool = False) -> Any:
"""Generates a type-friendly RFC3339 field."""
return RFC3339Field(json_name, omitempty=omitempty)
def resource(resource_type: str) -> Any:
"""Generates a type-friendly Resource field."""
return Resource(resource_type)

View File

@@ -12,14 +12,14 @@ import josepy as jose
class Header(jose.Header):
"""ACME-specific JOSE Header. Implements nonce, kid, and url.
"""
nonce = jose.Field('nonce', omitempty=True, encoder=jose.encode_b64jose)
kid = jose.Field('kid', omitempty=True)
url = jose.Field('url', omitempty=True)
nonce: Optional[bytes] = jose.field('nonce', omitempty=True, encoder=jose.encode_b64jose)
kid: Optional[str] = jose.field('kid', omitempty=True)
url: Optional[str] = jose.field('url', omitempty=True)
# Mypy does not understand the josepy magic happening here, and falsely claims
# that nonce is redefined. Let's ignore the type check here.
@nonce.decoder # type: ignore
def nonce(value: str) -> bytes: # pylint: disable=no-self-argument,missing-function-docstring
@nonce.decoder # type: ignore[no-redef,union-attr]
def nonce(value: str) -> bytes: # type: ignore[misc] # pylint: disable=no-self-argument,missing-function-docstring
try:
return jose.decode_b64jose(value)
except jose.DeserializationError as error:
@@ -29,12 +29,12 @@ class Header(jose.Header):
class Signature(jose.Signature):
"""ACME-specific Signature. Uses ACME-specific Header for customer fields."""
__slots__ = jose.Signature._orig_slots # pylint: disable=no-member
__slots__ = jose.Signature._orig_slots # type: ignore[attr-defined] # pylint: disable=protected-access,no-member
# TODO: decoder/encoder should accept cls? Otherwise, subclassing
# JSONObjectWithFields is tricky...
header_cls = Header
header = jose.Field(
header: Header = jose.field(
'header', omitempty=True, default=header_cls(),
decoder=header_cls.from_json)
@@ -44,10 +44,10 @@ class Signature(jose.Signature):
class JWS(jose.JWS):
"""ACME-specific JWS. Includes none, url, and kid in protected header."""
signature_cls = Signature
__slots__ = jose.JWS._orig_slots
__slots__ = jose.JWS._orig_slots # type: ignore[attr-defined] # pylint: disable=protected-access
@classmethod
# pylint: disable=arguments-differ
# type: ignore[override] # pylint: disable=arguments-differ
def sign(cls, payload: bytes, key: jose.JWK, alg: jose.JWASignature, nonce: Optional[bytes],
url: Optional[str] = None, kid: Optional[str] = None) -> jose.JWS:
# Per ACME spec, jwk and kid are mutually exclusive, so only include a

View File

@@ -1,4 +1,5 @@
"""ACME protocol messages."""
import datetime
from collections.abc import Hashable
import json
from typing import Any
@@ -7,9 +8,12 @@ from typing import Iterator
from typing import List
from typing import Mapping
from typing import MutableMapping
from typing import Optional
from typing import Tuple
from typing import Type
from typing import Optional
from typing import TYPE_CHECKING
from typing import TypeVar
from typing import Union
import josepy as jose
@@ -20,6 +24,11 @@ from acme import jws
from acme import util
from acme.mixins import ResourceMixin
if TYPE_CHECKING:
from typing_extensions import Protocol # pragma: no cover
else:
Protocol = object
OLD_ERROR_PREFIX = "urn:acme:error:"
ERROR_PREFIX = "urn:ietf:params:acme:error:"
@@ -75,20 +84,20 @@ class Error(jose.JSONObjectWithFields, errors.Error):
https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
:ivar unicode typ:
:ivar unicode title:
:ivar unicode detail:
:ivar str typ:
:ivar str title:
:ivar str detail:
"""
typ = jose.Field('type', omitempty=True, default='about:blank')
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail', omitempty=True)
typ: str = jose.field('type', omitempty=True, default='about:blank')
title: str = jose.field('title', omitempty=True)
detail: str = jose.field('detail', omitempty=True)
@classmethod
def with_code(cls, code: str, **kwargs: Any) -> 'Error':
"""Create an Error instance with an ACME Error code.
:unicode code: An ACME error code, like 'dnssec'.
:str code: An ACME error code, like 'dnssec'.
:kwargs: kwargs to pass to Error.
"""
@@ -98,14 +107,14 @@ class Error(jose.JSONObjectWithFields, errors.Error):
typ = ERROR_PREFIX + code
# Mypy will not understand that the Error constructor accepts a named argument
# "typ" because of josepy magic. Let's ignore the type check here.
return cls(typ=typ, **kwargs) # type: ignore
return cls(typ=typ, **kwargs)
@property
def description(self) -> Optional[str]:
"""Hardcoded error description based on its type.
:returns: Description if standard ACME error or ``None``.
:rtype: unicode
:rtype: str
"""
return ERROR_TYPE_DESCRIPTIONS.get(self.typ)
@@ -117,7 +126,7 @@ class Error(jose.JSONObjectWithFields, errors.Error):
Basically self.typ without the ERROR_PREFIX.
:returns: error code if standard ACME code or ``None``.
:rtype: unicode
:rtype: str
"""
code = str(self.typ).rsplit(':', maxsplit=1)[-1]
@@ -164,7 +173,7 @@ class _Constant(jose.JSONDeSerializable, Hashable):
class Status(_Constant):
"""ACME "status" field."""
POSSIBLE_NAMES: Dict[str, 'Status'] = {}
POSSIBLE_NAMES: Dict[str, _Constant] = {}
STATUS_UNKNOWN = Status('unknown')
@@ -179,7 +188,7 @@ STATUS_DEACTIVATED = Status('deactivated')
class IdentifierType(_Constant):
"""ACME identifier type."""
POSSIBLE_NAMES: Dict[str, 'IdentifierType'] = {}
POSSIBLE_NAMES: Dict[str, _Constant] = {}
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
@@ -190,25 +199,35 @@ class Identifier(jose.JSONObjectWithFields):
"""ACME identifier.
:ivar IdentifierType typ:
:ivar unicode value:
:ivar str value:
"""
typ = jose.Field('type', decoder=IdentifierType.from_json)
value = jose.Field('value')
typ: IdentifierType = jose.field('type', decoder=IdentifierType.from_json)
value: str = jose.field('value')
class HasResourceType(Protocol):
"""
Represents a class with a resource_type class parameter of type string.
"""
resource_type: str = NotImplemented
GenericHasResourceType = TypeVar("GenericHasResourceType", bound=HasResourceType)
class Directory(jose.JSONDeSerializable):
"""Directory."""
_REGISTERED_TYPES: Dict[str, Type['Directory']] = {}
_REGISTERED_TYPES: Dict[str, Type[HasResourceType]] = {}
class Meta(jose.JSONObjectWithFields):
"""Directory Meta."""
_terms_of_service = jose.Field('terms-of-service', omitempty=True)
_terms_of_service_v2 = jose.Field('termsOfService', omitempty=True)
website = jose.Field('website', omitempty=True)
caa_identities = jose.Field('caaIdentities', omitempty=True)
external_account_required = jose.Field('externalAccountRequired', omitempty=True)
_terms_of_service: str = jose.field('terms-of-service', omitempty=True)
_terms_of_service_v2: str = jose.field('termsOfService', omitempty=True)
website: str = jose.field('website', omitempty=True)
caa_identities: List[str] = jose.field('caaIdentities', omitempty=True)
external_account_required: bool = jose.field('externalAccountRequired', omitempty=True)
def __init__(self, **kwargs: Any) -> None:
kwargs = {self._internal_name(k): v for k, v in kwargs.items()}
@@ -229,11 +248,14 @@ class Directory(jose.JSONDeSerializable):
return '_' + name if name == 'terms_of_service' else name
@classmethod
def _canon_key(cls, key: str) -> str:
return getattr(key, 'resource_type', key)
def _canon_key(cls, key: Union[str, HasResourceType, Type[HasResourceType]]) -> str:
if isinstance(key, str):
return key
return key.resource_type
@classmethod
def register(cls, resource_body_cls: Type['Directory']) -> Type['Directory']:
def register(cls,
resource_body_cls: Type[GenericHasResourceType]) -> Type[GenericHasResourceType]:
"""Register resource."""
resource_type = resource_body_cls.resource_type
assert resource_type not in cls._REGISTERED_TYPES
@@ -252,7 +274,7 @@ class Directory(jose.JSONDeSerializable):
except KeyError as error:
raise AttributeError(str(error))
def __getitem__(self, name: str) -> Any:
def __getitem__(self, name: Union[str, HasResourceType, Type[HasResourceType]]) -> Any:
try:
return self._jobj[self._canon_key(name)]
except KeyError:
@@ -273,16 +295,16 @@ class Resource(jose.JSONObjectWithFields):
:ivar acme.messages.ResourceBody body: Resource body.
"""
body = jose.Field('body')
body: "ResourceBody" = jose.field('body')
class ResourceWithURI(Resource):
"""ACME Resource with URI.
:ivar unicode ~.uri: Location of the resource.
:ivar str uri: Location of the resource.
"""
uri = jose.Field('uri') # no ChallengeResource.uri
uri: str = jose.field('uri') # no ChallengeResource.uri
class ResourceBody(jose.JSONObjectWithFields):
@@ -308,35 +330,40 @@ class ExternalAccountBinding:
return eab.to_partial_json()
GenericRegistration = TypeVar('GenericRegistration', bound='Registration')
class Registration(ResourceBody):
"""Registration Resource Body.
:ivar josepy.jwk.JWK key: Public key.
:ivar jose.JWK key: Public key.
:ivar tuple contact: Contact information following ACME spec,
`tuple` of `unicode`.
:ivar unicode agreement:
`tuple` of `str`.
:ivar str 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)
key: jose.JWK = jose.field('key', omitempty=True, decoder=jose.JWK.from_json)
# Contact field implements special behavior to allow messages that clear existing
# contacts while not expecting the `contact` field when loading from json.
# This is implemented in the constructor and *_json methods.
contact = jose.Field('contact', omitempty=True, default=())
agreement = jose.Field('agreement', omitempty=True)
status = jose.Field('status', omitempty=True)
terms_of_service_agreed = jose.Field('termsOfServiceAgreed', omitempty=True)
only_return_existing = jose.Field('onlyReturnExisting', omitempty=True)
external_account_binding = jose.Field('externalAccountBinding', omitempty=True)
contact: Tuple[str, ...] = jose.field('contact', omitempty=True, default=())
agreement: str = jose.field('agreement', omitempty=True)
status: Status = jose.field('status', omitempty=True)
terms_of_service_agreed: bool = jose.field('termsOfServiceAgreed', omitempty=True)
only_return_existing: bool = jose.field('onlyReturnExisting', omitempty=True)
external_account_binding: Dict[str, Any] = jose.field('externalAccountBinding',
omitempty=True)
phone_prefix = 'tel:'
email_prefix = 'mailto:'
@classmethod
def from_data(cls, phone: Optional[str] = None, email: Optional[str] = None,
def from_data(cls: Type[GenericRegistration], phone: Optional[str] = None,
email: Optional[str] = None,
external_account_binding: Optional[Dict[str, Any]] = None,
**kwargs: Any) -> 'Registration':
**kwargs: Any) -> GenericRegistration:
"""
Create registration resource from contact details.
@@ -419,26 +446,26 @@ class Registration(ResourceBody):
class NewRegistration(ResourceMixin, Registration):
"""New registration."""
resource_type = 'new-reg'
resource = fields.Resource(resource_type)
resource: str = fields.resource(resource_type)
class UpdateRegistration(ResourceMixin, Registration):
"""Update registration."""
resource_type = 'reg'
resource = fields.Resource(resource_type)
resource: str = fields.resource(resource_type)
class RegistrationResource(ResourceWithURI):
"""Registration Resource.
:ivar acme.messages.Registration body:
:ivar unicode new_authzr_uri: Deprecated. Do not use.
:ivar unicode terms_of_service: URL for the CA TOS.
:ivar str new_authzr_uri: Deprecated. Do not use.
:ivar str terms_of_service: URL for the CA TOS.
"""
body = jose.Field('body', decoder=Registration.from_json)
new_authzr_uri = jose.Field('new_authzr_uri', omitempty=True)
terms_of_service = jose.Field('terms_of_service', omitempty=True)
body: Registration = jose.field('body', decoder=Registration.from_json)
new_authzr_uri: str = jose.field('new_authzr_uri', omitempty=True)
terms_of_service: str = jose.field('terms_of_service', omitempty=True)
class ChallengeBody(ResourceBody):
@@ -463,12 +490,12 @@ class ChallengeBody(ResourceBody):
# challenge object supports either one, but should be accessed through the
# name "uri". In Client.answer_challenge, whichever one is set will be
# used.
_uri = jose.Field('uri', omitempty=True, default=None)
_url = jose.Field('url', omitempty=True, default=None)
status = jose.Field('status', decoder=Status.from_json,
_uri: str = jose.field('uri', omitempty=True, default=None)
_url: str = jose.field('url', omitempty=True, default=None)
status: 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,
validated: datetime.datetime = fields.rfc3339('validated', omitempty=True)
error: Error = jose.field('error', decoder=Error.from_json,
omitempty=True, default=None)
def __init__(self, **kwargs: Any) -> None:
@@ -511,16 +538,16 @@ class ChallengeResource(Resource):
"""Challenge Resource.
:ivar acme.messages.ChallengeBody body:
:ivar unicode authzr_uri: URI found in the 'up' ``Link`` header.
:ivar str authzr_uri: URI found in the 'up' ``Link`` header.
"""
body = jose.Field('body', decoder=ChallengeBody.from_json)
authzr_uri = jose.Field('authzr_uri')
body: ChallengeBody = jose.field('body', decoder=ChallengeBody.from_json)
authzr_uri: str = jose.field('authzr_uri')
@property
def uri(self) -> str:
"""The URL of the challenge body."""
return self.body.uri
return self.body.uri # pylint: disable=no-member
class Authorization(ResourceBody):
@@ -534,26 +561,26 @@ class Authorization(ResourceBody):
:ivar datetime.datetime expires:
"""
identifier = jose.Field('identifier', decoder=Identifier.from_json, omitempty=True)
challenges = jose.Field('challenges', omitempty=True)
combinations = jose.Field('combinations', omitempty=True)
identifier: Identifier = jose.field('identifier', decoder=Identifier.from_json, omitempty=True)
challenges: List[ChallengeBody] = jose.field('challenges', omitempty=True)
combinations: Tuple[Tuple[int, ...], ...] = jose.field('combinations', omitempty=True)
status = jose.Field('status', omitempty=True, decoder=Status.from_json)
status: Status = jose.field('status', omitempty=True, decoder=Status.from_json)
# TODO: 'expires' is allowed for Authorization Resources in
# general, but for Key Authorization '[t]he "expires" field MUST
# be absent'... then acme-spec gives example with 'expires'
# present... That's confusing!
expires = fields.RFC3339Field('expires', omitempty=True)
wildcard = jose.Field('wildcard', omitempty=True)
expires: datetime.datetime = fields.rfc3339('expires', omitempty=True)
wildcard: bool = jose.field('wildcard', omitempty=True)
# Mypy does not understand the josepy magic happening here, and falsely claims
# that challenge is redefined. Let's ignore the type check here.
@challenges.decoder # type: ignore
def challenges(value: List[Mapping[str, Any]]) -> Tuple[ChallengeBody, ...]: # pylint: disable=no-self-argument,missing-function-docstring
def challenges(value: List[Dict[str, Any]]) -> Tuple[ChallengeBody, ...]: # type: ignore[misc] # pylint: disable=no-self-argument,missing-function-docstring
return tuple(ChallengeBody.from_json(chall) for chall in value)
@property
def resolved_combinations(self) -> Tuple[Tuple[Dict[str, Any], ...], ...]:
def resolved_combinations(self) -> Tuple[Tuple[ChallengeBody, ...], ...]:
"""Combinations with challenges instead of indices."""
return tuple(tuple(self.challenges[idx] for idx in combo)
for combo in self.combinations) # pylint: disable=not-an-iterable
@@ -563,37 +590,37 @@ class Authorization(ResourceBody):
class NewAuthorization(ResourceMixin, Authorization):
"""New authorization."""
resource_type = 'new-authz'
resource = fields.Resource(resource_type)
resource: str = fields.resource(resource_type)
class UpdateAuthorization(ResourceMixin, Authorization):
"""Update authorization."""
resource_type = 'authz'
resource = fields.Resource(resource_type)
resource: str = fields.resource(resource_type)
class AuthorizationResource(ResourceWithURI):
"""Authorization Resource.
:ivar acme.messages.Authorization body:
:ivar unicode new_cert_uri: Deprecated. Do not use.
:ivar str new_cert_uri: Deprecated. Do not use.
"""
body = jose.Field('body', decoder=Authorization.from_json)
new_cert_uri = jose.Field('new_cert_uri', omitempty=True)
body: Authorization = jose.field('body', decoder=Authorization.from_json)
new_cert_uri: str = jose.field('new_cert_uri', omitempty=True)
@Directory.register
class CertificateRequest(ResourceMixin, jose.JSONObjectWithFields):
"""ACME new-cert request.
:ivar josepy.util.ComparableX509 csr:
:ivar jose.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)
resource: str = fields.resource(resource_type)
csr: jose.ComparableX509 = jose.field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
class CertificateResource(ResourceWithURI):
@@ -601,27 +628,27 @@ class CertificateResource(ResourceWithURI):
:ivar josepy.util.ComparableX509 body:
`OpenSSL.crypto.X509` wrapped in `.ComparableX509`
:ivar unicode cert_chain_uri: URI found in the 'up' ``Link`` header
:ivar str cert_chain_uri: URI found in the 'up' ``Link`` header
:ivar tuple authzrs: `tuple` of `AuthorizationResource`.
"""
cert_chain_uri = jose.Field('cert_chain_uri')
authzrs = jose.Field('authzrs')
cert_chain_uri: str = jose.field('cert_chain_uri')
authzrs: Tuple[AuthorizationResource, ...] = jose.field('authzrs')
@Directory.register
class Revocation(ResourceMixin, jose.JSONObjectWithFields):
"""Revocation message.
:ivar .ComparableX509 certificate: `OpenSSL.crypto.X509` wrapped in
`.ComparableX509`
:ivar jose.ComparableX509 certificate: `OpenSSL.crypto.X509` wrapped in
`jose.ComparableX509`
"""
resource_type = 'revoke-cert'
resource = fields.Resource(resource_type)
certificate = jose.Field(
resource: str = fields.resource(resource_type)
certificate: jose.ComparableX509 = jose.field(
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
reason = jose.Field('reason')
reason: int = jose.field('reason')
class Order(ResourceBody):
@@ -638,19 +665,18 @@ class Order(ResourceBody):
:ivar datetime.datetime expires: When the order expires.
:ivar ~.Error error: Any error that occurred during finalization, if applicable.
"""
identifiers = jose.Field('identifiers', omitempty=True)
status = jose.Field('status', decoder=Status.from_json,
omitempty=True)
authorizations = jose.Field('authorizations', omitempty=True)
certificate = jose.Field('certificate', omitempty=True)
finalize = jose.Field('finalize', omitempty=True)
expires = fields.RFC3339Field('expires', omitempty=True)
error = jose.Field('error', omitempty=True, decoder=Error.from_json)
identifiers: List[Identifier] = jose.field('identifiers', omitempty=True)
status: Status = jose.field('status', decoder=Status.from_json, omitempty=True)
authorizations: List[str] = jose.field('authorizations', omitempty=True)
certificate: str = jose.field('certificate', omitempty=True)
finalize: str = jose.field('finalize', omitempty=True)
expires: datetime.datetime = fields.rfc3339('expires', omitempty=True)
error: Error = jose.field('error', omitempty=True, decoder=Error.from_json)
# Mypy does not understand the josepy magic happening here, and falsely claims
# that identifiers is redefined. Let's ignore the type check here.
@identifiers.decoder # type: ignore
def identifiers(value: List[Mapping[str, Any]]) -> Tuple[Identifier, ...]: # pylint: disable=no-self-argument,missing-function-docstring
def identifiers(value: List[Dict[str, Any]]) -> Tuple[Identifier, ...]: # type: ignore[misc] # pylint: disable=no-self-argument,missing-function-docstring
return tuple(Identifier.from_json(identifier) for identifier in value)
@@ -658,7 +684,7 @@ class OrderResource(ResourceWithURI):
"""Order Resource.
:ivar acme.messages.Order body:
:ivar str csr_pem: The CSR this Order will be finalized with.
:ivar bytes csr_pem: The CSR this Order will be finalized with.
:ivar authorizations: Fully-fetched AuthorizationResource objects.
:vartype authorizations: `list` of `acme.messages.AuthorizationResource`
:ivar str fullchain_pem: The fetched contents of the certificate URL
@@ -668,11 +694,13 @@ class OrderResource(ResourceWithURI):
finalization.
:vartype alternative_fullchains_pem: `list` of `str`
"""
body = jose.Field('body', decoder=Order.from_json)
csr_pem = jose.Field('csr_pem', omitempty=True)
authorizations = jose.Field('authorizations')
fullchain_pem = jose.Field('fullchain_pem', omitempty=True)
alternative_fullchains_pem = jose.Field('alternative_fullchains_pem', omitempty=True)
body: Order = jose.field('body', decoder=Order.from_json)
csr_pem: bytes = jose.field('csr_pem', omitempty=True)
authorizations: List[AuthorizationResource] = jose.field('authorizations')
fullchain_pem: str = jose.field('fullchain_pem', omitempty=True)
alternative_fullchains_pem: List[str] = jose.field('alternative_fullchains_pem',
omitempty=True)
@Directory.register
class NewOrder(Order):

View File

@@ -8,6 +8,7 @@ import socket
import socketserver
import threading
from typing import Any
from typing import cast
from typing import List
from typing import Mapping
from typing import Optional
@@ -39,10 +40,10 @@ class TLSServer(socketserver.TCPServer):
super().__init__(*args, **kwargs)
def _wrap_sock(self) -> None:
self.socket = crypto_util.SSLSocket(
self.socket = cast(socket.socket, crypto_util.SSLSocket(
self.socket, cert_selection=self._cert_selection,
alpn_selection=getattr(self, '_alpn_selection', None),
method=self.method)
method=self.method))
def _cert_selection(self, connection: SSL.Connection
) -> Tuple[crypto.PKey, crypto.X509]: # pragma: no cover

View File

@@ -7,7 +7,7 @@ version = '1.23.0.dev0'
install_requires = [
'cryptography>=2.5.0',
'josepy>=1.9.0',
'josepy>=1.10.0',
'PyOpenSSL>=17.3.0',
'pyrfc3339',
'pytz',
@@ -24,6 +24,7 @@ docs_extras = [
test_extras = [
'pytest',
'pytest-xdist',
'typing-extensions',
]
setup(

View File

@@ -10,8 +10,8 @@ class FixedTest(unittest.TestCase):
"""Tests for acme.fields.Fixed."""
def setUp(self):
from acme.fields import Fixed
self.field = Fixed('name', 'x')
from acme.fields import fixed
self.field = fixed('name', 'x')
def test_decode(self):
self.assertEqual('x', self.field.decode('x'))