mirror of
https://github.com/certbot/certbot.git
synced 2026-06-06 07:12:54 -04:00
Merge remote-tracking branch 'github/master' into bugs/44
Conflicts: letsencrypt/client/client.py
This commit is contained in:
commit
0d6a482d32
6 changed files with 830 additions and 493 deletions
|
|
@ -1,13 +1,16 @@
|
|||
"""Validate JSON objects as ACME protocol messages."""
|
||||
"""ACME protocol messages."""
|
||||
import json
|
||||
import pkg_resources
|
||||
|
||||
import jsonschema
|
||||
|
||||
from letsencrypt.client import crypto_util
|
||||
from letsencrypt.client import le_util
|
||||
|
||||
SCHEMATA = {
|
||||
schema: json.load(open(pkg_resources.resource_filename(
|
||||
__name__, "schemata/%s.json" % schema))) for schema in [
|
||||
|
||||
SCHEMATA = dict([
|
||||
(schema, json.load(open(pkg_resources.resource_filename(
|
||||
__name__, "schemata/%s.json" % schema)))) for schema in [
|
||||
"authorization",
|
||||
"authorizationRequest",
|
||||
"certificate",
|
||||
|
|
@ -18,32 +21,149 @@ SCHEMATA = {
|
|||
"error",
|
||||
"revocation",
|
||||
"revocationRequest",
|
||||
"statusRequest"
|
||||
"statusRequest",
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
def acme_object_validate(j):
|
||||
"""Validate a JSON object against the ACME protocol using JSON Schema.
|
||||
def acme_object_validate(json_string, schemata=None):
|
||||
"""Validate a JSON string against the ACME protocol using JSON Schema.
|
||||
|
||||
:param json_string: Well-formed input JSON string.
|
||||
:type json_string: str
|
||||
|
||||
:param schemata: Mapping from type name to JSON Schema definition.
|
||||
Useful for testing.
|
||||
:type schemata: dict
|
||||
|
||||
:returns: None if validation was successful.
|
||||
:raises: jsonschema.ValidationError if validation was unsuccessful
|
||||
ValueError if the object cannot even be parsed as valid JSON
|
||||
|
||||
Success will return None; failure to validate will raise a
|
||||
jsonschema.ValidationError exception describing the reason that the
|
||||
object could not be validated successfully, or a ValueError exception
|
||||
if the object cannot even be parsed as valid JSON.
|
||||
"""
|
||||
j = json.loads(j)
|
||||
if not isinstance(j, dict):
|
||||
schemata = SCHEMATA if schemata is None else schemata
|
||||
json_object = json.loads(json_string)
|
||||
if not isinstance(json_object, dict):
|
||||
raise jsonschema.ValidationError("this is not a dictionary object")
|
||||
if "type" not in j:
|
||||
if "type" not in json_object:
|
||||
raise jsonschema.ValidationError("missing type field")
|
||||
if j["type"] not in SCHEMATA:
|
||||
raise jsonschema.ValidationError("unknown type %s" % j["type"])
|
||||
jsonschema.validate(j, SCHEMATA[j["type"]])
|
||||
if json_object["type"] not in schemata:
|
||||
raise jsonschema.ValidationError(
|
||||
"unknown type %s" % json_object["type"])
|
||||
jsonschema.validate(json_object, schemata[json_object["type"]])
|
||||
|
||||
|
||||
def pretty(json_string):
|
||||
"""Return a pretty-printed version of any JSON string.
|
||||
|
||||
Useful when printing out protocol messages for debugging purposes.
|
||||
|
||||
"""
|
||||
return json.dumps(json.loads(json_string), indent=4)
|
||||
|
||||
|
||||
def challenge_request(names):
|
||||
"""Create ACME "challengeRequest message.
|
||||
|
||||
TODO: Temporarily only enabling one name
|
||||
|
||||
:param names: TODO
|
||||
:type names: list
|
||||
|
||||
:returns: ACME "challengeRequest" message.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
return {
|
||||
"type": "challengeRequest",
|
||||
"identifier": names[0],
|
||||
}
|
||||
|
||||
|
||||
def authorization_request(req_id, name, server_nonce, responses, key_file):
|
||||
"""Create ACME "authoriazationRequest" message.
|
||||
|
||||
:param req_id: TODO
|
||||
:type req_id: TODO
|
||||
|
||||
:param name: TODO
|
||||
:type name: TODO
|
||||
|
||||
:param server_nonce: TODO
|
||||
:type server_nonce: TODO
|
||||
|
||||
:param responses: TODO
|
||||
:type response: TODO
|
||||
|
||||
:param key_file: TODO
|
||||
:type key_file: TODO
|
||||
|
||||
:returns: ACME "authoriazationRequest" message.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
return {
|
||||
"type": "authorizationRequest",
|
||||
"sessionID": req_id,
|
||||
"nonce": server_nonce,
|
||||
"responses": responses,
|
||||
"signature": crypto_util.create_sig(
|
||||
name + le_util.b64_url_dec(server_nonce), key_file),
|
||||
}
|
||||
|
||||
|
||||
def certificate_request(csr_der, key):
|
||||
"""Create ACME "certificateRequest" message.
|
||||
|
||||
:param csr_der: TODO
|
||||
:type csr_der: TODO
|
||||
|
||||
:param key: TODO
|
||||
:type key: TODO
|
||||
|
||||
:returns: ACME "certificateRequest" message.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
return {
|
||||
"type": "certificateRequest",
|
||||
"csr": le_util.b64_url_enc(csr_der),
|
||||
"signature": crypto_util.create_sig(csr_der, key),
|
||||
}
|
||||
|
||||
|
||||
def revocation_request(key_file, cert_der):
|
||||
"""Create ACME "revocationRequest" message.
|
||||
|
||||
:param key_file: Path to a file containing RSA key. Accepted formats
|
||||
are the same as for `Crypto.PublicKey.RSA.importKey`.
|
||||
:type key_file: str
|
||||
|
||||
:param cert_der: DER encoded certificate.
|
||||
:type cert_der: str
|
||||
|
||||
:returns: ACME "revocationRequest" message.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
return {
|
||||
"type": "revocationRequest",
|
||||
"certificate": le_util.b64_url_enc(cert_der),
|
||||
"signature": crypto_util.create_sig(cert_der, key_file),
|
||||
}
|
||||
|
||||
|
||||
def status_request(token):
|
||||
"""Create ACME "statusRequest" message.
|
||||
|
||||
:param token: Token provided in ACME "defer" message.
|
||||
:type token: str
|
||||
|
||||
:returns: ACME "statusRequest" message.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
return {
|
||||
"type": "statusRequest",
|
||||
"token": token,
|
||||
}
|
||||
|
|
|
|||
58
letsencrypt/client/acme_test.py
Normal file
58
letsencrypt/client/acme_test.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Tests for letsencrypt.client.acme."""
|
||||
import unittest
|
||||
|
||||
import jsonschema
|
||||
|
||||
|
||||
class ACMEObjectValidateTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.client.acme.acme_object_validate."""
|
||||
|
||||
def setUp(self):
|
||||
self.schemata = {
|
||||
'foo': {
|
||||
'type' : 'object',
|
||||
'properties' : {
|
||||
'price' : {'type' : 'number'},
|
||||
'name' : {'type' : 'string'},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _call(self, json_string):
|
||||
from letsencrypt.client.acme import acme_object_validate
|
||||
return acme_object_validate(json_string, self.schemata)
|
||||
|
||||
def _test_fails(self, json_string):
|
||||
self.assertRaises(jsonschema.ValidationError, self._call, json_string)
|
||||
|
||||
def test_non_dictionary_fails(self):
|
||||
self._test_fails('[]')
|
||||
|
||||
def test_dict_without_type_fails(self):
|
||||
self._test_fails('{}')
|
||||
|
||||
def test_unknown_type_fails(self):
|
||||
self._test_fails('{"type": "bar"}')
|
||||
|
||||
def test_valid_returns_none(self):
|
||||
self.assertTrue(self._call('{"type": "foo"}') is None)
|
||||
|
||||
def test_invalid_fails(self):
|
||||
self._test_fails('{"type": "foo", "price": "asd"}')
|
||||
|
||||
|
||||
class PrettyTest(unittest.TestCase):
|
||||
"""Tests for letsencrypt.client.acme.pretty."""
|
||||
|
||||
def _call(self, json_string):
|
||||
from letsencrypt.client.acme import pretty
|
||||
return pretty(json_string)
|
||||
|
||||
def test_it(self):
|
||||
self.assertEqual(
|
||||
self._call('{"foo": "bar", "foo2": "bar2"}'),
|
||||
'{\n "foo2": "bar2", \n "foo": "bar"\n}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
"""ACME challenge."""
|
||||
import sys
|
||||
|
||||
from letsencrypt.client import CONFIG
|
||||
from letsencrypt.client import logger
|
||||
|
||||
|
||||
class Challenge(object):
|
||||
|
||||
def __init__(self, configurator):
|
||||
|
|
@ -11,3 +18,119 @@ class Challenge(object):
|
|||
|
||||
def cleanup(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
def gen_challenge_path(challenges, combos=None):
|
||||
"""Generate a plan to get authority over the identity.
|
||||
|
||||
TODO: Make sure that the challenges are feasible...
|
||||
Example: Do you have the recovery key?
|
||||
|
||||
:param challenges: A list of challenges from ACME "challenge"
|
||||
server message to be fulfilled by the client
|
||||
in order to prove possession of the identifier.
|
||||
:type challenges: list
|
||||
|
||||
:param combos: A collection of sets of challenges from ACME
|
||||
"challenge" server message ("combinations"),
|
||||
each of which would be sufficient to prove
|
||||
possession of the identifier.
|
||||
:type combos: list or None
|
||||
|
||||
:returns: List of indices from `challenges`.
|
||||
:rtype: list
|
||||
|
||||
"""
|
||||
if combos:
|
||||
return _find_smart_path(challenges, combos)
|
||||
else:
|
||||
return _find_dumb_path(challenges)
|
||||
|
||||
|
||||
def _find_smart_path(challenges, combos):
|
||||
"""
|
||||
Can be called if combinations is included
|
||||
Function uses a simple ranking system to choose the combo with the
|
||||
lowest cost
|
||||
|
||||
:param challenges: A list of challenges from ACME "challenge"
|
||||
server message to be fulfilled by the client
|
||||
in order to prove possession of the identifier.
|
||||
:type challenges: list
|
||||
|
||||
:param combos: A collection of sets of challenges from ACME
|
||||
"challenge" server message ("combinations"),
|
||||
each of which would be sufficient to prove
|
||||
possession of the identifier.
|
||||
:type combos: list or None
|
||||
|
||||
:returns: List of indices from `challenges`.
|
||||
:rtype: list
|
||||
|
||||
"""
|
||||
chall_cost = {}
|
||||
max_cost = 0
|
||||
for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES):
|
||||
chall_cost[chall] = i
|
||||
max_cost += i
|
||||
|
||||
best_combo = []
|
||||
# Set above completing all of the available challenges
|
||||
best_combo_cost = max_cost + 1
|
||||
|
||||
combo_total = 0
|
||||
for combo in combos:
|
||||
for challenge_index in combo:
|
||||
combo_total += chall_cost.get(challenges[
|
||||
challenge_index]["type"], max_cost)
|
||||
if combo_total < best_combo_cost:
|
||||
best_combo = combo
|
||||
best_combo_cost = combo_total
|
||||
combo_total = 0
|
||||
|
||||
if not best_combo:
|
||||
logger.fatal("Client does not support any combination of "
|
||||
"challenges to satisfy ACME server")
|
||||
sys.exit(22)
|
||||
|
||||
return best_combo
|
||||
|
||||
|
||||
def _find_dumb_path(challenges):
|
||||
"""
|
||||
Should be called if the combinations hint is not included by the server
|
||||
This function returns the best path that does not contain multiple
|
||||
mutually exclusive challenges
|
||||
|
||||
:param challanges: A list of challenges from ACME "challenge"
|
||||
server message to be fulfilled by the client
|
||||
in order to prove possession of the identifier.
|
||||
:type challenges: list
|
||||
|
||||
:returns: List of indices from `challenges`.
|
||||
:rtype: list
|
||||
|
||||
"""
|
||||
# Add logic for a crappy server
|
||||
# Choose a DV
|
||||
path = []
|
||||
for pref_c in CONFIG.CHALLENGE_PREFERENCES:
|
||||
for i, offered_challenge in enumerate(challenges):
|
||||
if (pref_c == offered_challenge["type"] and
|
||||
is_preferred(offered_challenge["type"], path)):
|
||||
path.append((i, offered_challenge["type"]))
|
||||
|
||||
return [i for (i, _) in path]
|
||||
|
||||
|
||||
def is_preferred(offered_challenge_type, path):
|
||||
for _, challenge_type in path:
|
||||
for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES:
|
||||
# Second part is in case we eventually allow multiple names
|
||||
# to be challenges at the same time
|
||||
if (challenge_type in mutually_exclusive and
|
||||
offered_challenge_type in mutually_exclusive and
|
||||
challenge_type != offered_challenge_type):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -36,7 +36,9 @@ class Configurator(object):
|
|||
def get_all_certs_keys(self):
|
||||
"""Retrieve all certs and keys set in configuration.
|
||||
|
||||
returns: list of tuples with form [(cert, key, path)]
|
||||
:returns: List of tuples with form [(cert, key, path)].
|
||||
:rtype: list
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import M2Crypto
|
|||
|
||||
from letsencrypt.client import CONFIG
|
||||
from letsencrypt.client import le_util
|
||||
from letsencrypt.client import logger
|
||||
|
||||
|
||||
def b64_cert_to_pem(b64_der_cert):
|
||||
|
|
@ -18,36 +19,54 @@ def b64_cert_to_pem(b64_der_cert):
|
|||
le_util.b64_url_dec(b64_der_cert)).as_pem()
|
||||
|
||||
|
||||
def create_sig(msg, key_file, signer_nonce=None,
|
||||
signer_nonce_len=CONFIG.NONCE_SIZE):
|
||||
# DOES prepend signer_nonce to message
|
||||
# TODO: Change this over to M2Crypto... PKey
|
||||
# Protect against crypto unicode errors... is this sufficient?
|
||||
# Do I need to escape?
|
||||
def create_sig(msg, key_file, nonce=None, nonce_len=CONFIG.NONCE_SIZE):
|
||||
"""Create signature with nonce prepended to the message.
|
||||
|
||||
TODO: Change this over to M2Crypto... PKey
|
||||
Protect against crypto unicode errors... is this sufficient?
|
||||
Do I need to escape?
|
||||
|
||||
:param msg: Message to be signed
|
||||
:type msg: Anything with __str__ method
|
||||
|
||||
:param key_file: Path to a file containing RSA key. Accepted formats
|
||||
are the same as for `Crypto.PublicKey.RSA.importKey`.
|
||||
:type key_file: str
|
||||
|
||||
:param nonce: Nonce to be used. If None, nonce of `nonce_len` size
|
||||
will be randomly genereted.
|
||||
:type nonce: str or None
|
||||
|
||||
:param nonce_len: Size of the automaticaly generated nonce.
|
||||
:type nonce_len: int
|
||||
|
||||
:returns: Signature.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
msg = str(msg)
|
||||
key = Crypto.PublicKey.RSA.importKey(open(key_file).read())
|
||||
if signer_nonce is None:
|
||||
signer_nonce = Random.get_random_bytes(signer_nonce_len)
|
||||
hashed = Crypto.Hash.SHA256.new(signer_nonce + msg)
|
||||
signer = Crypto.Signature.PKCS1_v1_5.new(key)
|
||||
signature = signer.sign(hashed)
|
||||
#print "signing:", signer_nonce + msg
|
||||
#print "signature:", signature
|
||||
nonce = Random.get_random_bytes(nonce_len) if nonce is None else nonce
|
||||
|
||||
msg_with_nonce = nonce + msg
|
||||
hashed = Crypto.Hash.SHA256.new(msg_with_nonce)
|
||||
signature = Crypto.Signature.PKCS1_v1_5.new(key).sign(hashed)
|
||||
|
||||
logger.debug('%s signed as %s' % (msg_with_nonce, signature))
|
||||
|
||||
n_bytes = binascii.unhexlify(leading_zeros(hex(key.n)[2:].replace("L", "")))
|
||||
e_bytes = binascii.unhexlify(leading_zeros(hex(key.e)[2:].replace("L", "")))
|
||||
n_encoded = le_util.b64_url_enc(n_bytes)
|
||||
e_encoded = le_util.b64_url_enc(e_bytes)
|
||||
signer_nonce_encoded = le_util.b64_url_enc(signer_nonce)
|
||||
sig_encoded = le_util.b64_url_enc(signature)
|
||||
jwk = {"kty": "RSA", "n": n_encoded, "e": e_encoded}
|
||||
signature = {
|
||||
"nonce": signer_nonce_encoded,
|
||||
|
||||
return {
|
||||
"nonce": le_util.b64_url_enc(nonce),
|
||||
"alg": "RS256",
|
||||
"jwk": jwk,
|
||||
"sig": sig_encoded
|
||||
"jwk": {
|
||||
"kty": "RSA",
|
||||
"n": le_util.b64_url_enc(n_bytes),
|
||||
"e": le_util.b64_url_enc(e_bytes),
|
||||
},
|
||||
"sig": le_util.b64_url_enc(signature),
|
||||
}
|
||||
# return json.dumps(signature)
|
||||
return signature
|
||||
|
||||
|
||||
def leading_zeros(arg):
|
||||
|
|
@ -151,6 +170,14 @@ def make_ss_cert(key_file, domains):
|
|||
|
||||
|
||||
def get_cert_info(filename):
|
||||
"""Get certificate info.
|
||||
|
||||
:param filename: Name of file containing certificate in PEM format.
|
||||
:type filename: str
|
||||
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
# M2Crypto Library only supports RSA right now
|
||||
cert = M2Crypto.X509.load_cert(filename)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue