mirror of
https://github.com/certbot/certbot.git
synced 2026-01-21 19:01:07 +03:00
Initial impl. of v02, works with Boulder
This commit is contained in:
66
examples/restified.py
Normal file
66
examples/restified.py
Normal file
@@ -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()
|
||||
139
letsencrypt/acme/messages2.py
Normal file
139
letsencrypt/acme/messages2.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user