diff --git a/examples/restified.py b/examples/restified.py new file mode 100644 index 000000000..b4bd6c842 --- /dev/null +++ b/examples/restified.py @@ -0,0 +1,66 @@ +import httplib +import logging +import os +import pkg_resources +import requests + +from letsencrypt.acme import messages2 +from letsencrypt.acme import jose + + +logger = logging.getLogger() +logger.setLevel(logging.DEBUG) + +URL_ROOT = 'https://www.letsencrypt-demo.org' +NEW_AUTHZ_URL = URL_ROOT + '/acme/new-authz' +NEW_CERT_URL = URL_ROOT + '/acme/new-certz' + + +class Resource(jose.ImmutableMap): + __slots__ = ('body', 'location') + + +def send(resource, key, alg=jose.RS256): + dumps = resource.body.json_dumps() + logging.debug('Serialized JSON: %s', dumps) + sig = jose.JWS.sign(payload=dumps, key=key, alg=alg).json_dumps() + logging.debug('Serialized JWS: %s', sig) + + response = requests.post(resource.location, sig) + logging.debug('Received response %s: %s', response, response.text) + + if (response.status_code == httplib.OK or + response.status_code == httplib.CREATED): + pass + + # TODO: server might override NEW_AUTHZ_URI (after new-reg) or + # NEW_CERTZ_URI (after new-authz) and we should use it + # instead. Below code only prints the link. + if 'next' in response.links: + logging.debug('Link (next): %s', response.links['next']['url']) + if 'up' in response.links: + logging.debug('Link (up): %s', response.links['up']['url']) + + # TODO: new-cert response is not JSON + return Resource( + body=type(resource.body).from_json(response.json()), + location=response.headers['location']) + + +registration = messages2.Registration(contact=( + 'mailto:cert-admin@example.com', 'tel:+12025551212')) +key = jose.JWKRSA.load(pkg_resources.resource_string( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) + +authz = Resource(body=messages2.Authorization(identifier=messages2.Identifier( + typ=messages2.Identifier.FQDN, value="example1.com")), + location=NEW_AUTHZ_URL) + +authz2 = send(authz, key) +assert authz2.body.key == key.public() +assert authz2.body.identifier == authz.body.identifier +assert authz2.body.challenges is not None + +print authz2 +print +print requests.get(authz2.location).json() diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py new file mode 100644 index 000000000..3213e9aa3 --- /dev/null +++ b/letsencrypt/acme/messages2.py @@ -0,0 +1,139 @@ +"""ACME protocol v02 messages.""" +import jsonschema + +from letsencrypt.acme import challenges +from letsencrypt.acme import errors +from letsencrypt.acme import jose +from letsencrypt.acme import other +from letsencrypt.acme import util + + +class Resource(jose.JSONObjectWithFields): + """ACME Resource.""" + + +class Error(object): + """ACME error. + + https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 + + """ + + ERROR_TYPE_NAMESPACE = 'urn:acme:error:' + ERROR_TYPE_DESCRIPTIONS = { + "malformed": "The request message was malformed", + "unauthorized": "The client lacks sufficient authorization", + "serverInternal": "The server experienced an internal error", + "badCSR": "The CSR is unacceptable (e.g., due to a short key)", + } + + typ = jose.Field('type') + title = jose.Field('title', omitempty=True) + detail = jose.Field('detail') + instance = jose.Field('instance') + + @typ.encoder + def typ(value): + return ERROR_TYPE_NAMESPACE + value + + @typ.decoder + def typ(value): + if not value.startswith(ERROR_TYPE_NAMESPACE): + raise errors.DeserializationError('Unrecognized error type') + + return value[len(ERROR_TYPE_NAMESPACE):] + + @property + def description(self): + return self.ERROR_TYPE_DESCRIPTIONS[self.typ] + + +class Registration(Resource): + """Registration resource.""" + + # key will be ignored by server and taken from JWS instead + key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) + contact = jose.Field('contact', omitempty=True, default=()) + recovery_token = jose.Field('recoveryToken', omitempty=True) + + +class Identifier(jose.JSONObjectWithFields): + typ = jose.Field('type') + value = jose.Field('value') + + FQDN = 'dns' # TODO: acme-spec uses 'domain' in some examples, + # Boulder uses 'dns' though + +class ChallengeWithMeta(jose.JSONObjectWithFields): + + __slots__ = ('body',) + status = jose.Field('status') + validated = jose.Field('validated', omitempty=True) + uri = jose.Field('uri') + + def to_json(self): + jobj = super(ChallengeWithMeta, self).to_json() + jobj.update(self.body.to_json()) + return jobj + + @classmethod + def fields_from_json(cls, jobj): + fields = super(ChallengeWithMeta, cls).fields_from_json(jobj) + fields['body'] = challenges.Challenge.from_json(jobj) + return fields + +class Authorization(Resource): + class Status(object): + VALID = frozenset(['pending', 'valid', 'invalid']) + + identifier = jose.Field('identifier', decoder=Identifier.from_json) + + # acme-spec marks 'key' as 'required', but new-authz does not need + # to carry it, server will take 'key' from the 'jwk' found in the + # JWS + key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) + status = jose.Field('status', omitempty=True) + challenges = jose.Field('challenges', omitempty=True) + combinations = jose.Field('combinations', omitempty=True) + + # TODO: 'The client MAY provide contact information in the + # "contact" field in this or any subsequent request.' ??? + + # TODO: 'expires' is allowed for Authorization Resources in + # general, but for Authorization '[t]he "expires" field MUST be + # absent'... then acme-spec gives example with 'expires' + # present... That's confusing! + #expires = jose.Field('expires', omitempty=True) + + @property + def resolved_combinations(self): + """Combinations with challenges instead of indices.""" + return tuple(tuple(self.challenges[idx] for idx in combo) + for combo in self.combinations) + + @challenges.decoder + def challenges(value): # pylint: disable=missing-docstring,no-self-argument + # TODO: acme-spec examples use hybrid between a list and a + # dict: "challenges": [ "simpleHttps": {}, ... ], while + # Boulder uses (more sane): "challenges": [{"type": + # "simpleHttps", ...}, ...] + + # TODO: Server also returns the follwing: + # u'status': u'pending', u'completed': u'0001-01-01T00:00:00Z' + # "uri":"http://0.0.0.0:4000/acme/authz/vI_H5tJroyaGhappi8xBtpGYSYBvuIo3JIvakORaEJo?challenge=0" + tuple((chall['status'], chall.get('validated'), chall['uri']) + for chall in value) + + return tuple(ChallengeWithMeta.from_json(chall) for chall in value) + + +class NewCertificate(Resource): + """ACME new certificate resource request.""" + + csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr) + authorizations = jose.Field('authorizations', decoder=tuple) + + +class Revocation(Resource): + revoke = jose.Field('revoke') + authorizations = NewCertificate.authorizations