Merge remote-tracking branch 'github/letsencrypt/master' into bugs/44

Conflicts:
	letsencrypt/client/client.py
This commit is contained in:
Jakub Warmuz 2014-11-30 01:32:33 +01:00
commit 8f6d4b4344
7 changed files with 240 additions and 140 deletions

View file

@ -53,9 +53,49 @@ sudo ./venv/bin/letsencrypt
## Hacking
1. Bootstrap: `./venv/bin/python setup.py dev`
In order to start hacking, you will first have to create a development
environment:
2. Test code base: `./venv/bin/tox`
`./venv/bin/python setup.py dev`
The code base, including your pull requests, **must have 100% test
statement coverage and be compliant with the [coding
style](#coding-style)**. The following tools are there to help you:
- `./venv/bin/tox` starts a full set of tests. Please make sure you
run it before submitting a new pull request.
- `./venv/bin/tox -e cover` checks the test coverage only.
- `./venv/bin/tox -e lint` checks the style of the whole project,
while `./venv/bin/pylint file` will check a single `file` only.
### Coding style
Most importantly, **be consistent with the rest of the code**, please.
1. Read [PEP 8 - Style Guide for Python Code]
(https://www.python.org/dev/peps/pep-0008).
2. Follow [Google Python Style Guide]
(https://google-styleguide.googlecode.com/svn/trunk/pyguide.html),
with the exception that we use [Sphinx](http://sphinx-doc.org/)-style
documentation:
```python
def foo(arg):
"""Short description.
:param int arg: Some number.
:returns: Argument
:rtype: int
"""
return arg
```
3. Remember to use `./venv/bin/pylint`.
## Command line usage
@ -78,8 +118,7 @@ optional arguments:
corresponding to the private key file. The private key
file argument is required if this argument is
specified.
-b ROLLBACK, --rollback ROLLBACK
Revert configuration <ROLLBACK> number of checkpoints.
-b N, --rollback N Revert configuration N number of checkpoints.
-k, --revoke Revoke a certificate.
-v, --view-checkpoints
View checkpoints and associated configuration changes.

View file

@ -13,6 +13,7 @@ from Crypto import Random
from letsencrypt.client import augeas_configurator
from letsencrypt.client import CONFIG
from letsencrypt.client import crypto_util
from letsencrypt.client import errors
from letsencrypt.client import le_util
from letsencrypt.client import logger
@ -1536,7 +1537,7 @@ LogLevel warn \n\
self.add_dir("/files" + mainConfig,
"Include", CONFIG.APACHE_CHALLENGE_CONF)
def dvsni_create_chall_cert(self, name, ext, nonce, key):
def dvsni_create_chall_cert(self, name, ext, nonce, key_file):
"""Creates DVSNI challenge certifiate.
Certificate created at dvsni_get_cert_file(nonce)
@ -1544,14 +1545,20 @@ LogLevel warn \n\
:param nonce: hex form of nonce
:type nonce: str
:param key: file path to key
:param key_file: absolute path to key file
:type key: str
"""
try:
with open(key_file, 'r') as key_fd:
key_str = key_fd.read()
except IOError:
raise LetsEncryptDvsniError("Unable to load key file: %s" % key)
self.register_file_creation(True, self.dvsni_get_cert_file(nonce))
cert_pem = crypto_util.make_ss_cert(
key, [nonce + CONFIG.INVALID_EXT, name, ext])
key_str, [nonce + CONFIG.INVALID_EXT, name, ext])
with open(self.dvsni_get_cert_file(nonce), 'w') as f:
f.write(cert_pem)

View file

@ -1,3 +1,4 @@
"""ACME protocol client class and helper functions."""
import csv
import json
import os
@ -32,7 +33,25 @@ class Client(object):
"""ACME protocol client."""
def __init__(self, ca_server, cert_signing_request=None,
private_key=None, use_curses=True):
private_key=None, private_key_file=None, use_curses=True):
"""
:param ca_server: Certificate authority server
:type ca_server: str
:param cert_signing_request: Contents of the CSR
:type cert_signing_request: str
:param private_key: Contents of the private key
:type private_key: str
:param private_key_file: absolute path to private_key
:type private_key_file: str
:param use_curses: Use curses UI
:type use_curses: bool
"""
self.curses = use_curses
# Logger needs to be initialized before Configurator
@ -42,21 +61,15 @@ class Client(object):
# line arg or client function to discover
self.config = apache_configurator.ApacheConfigurator(
CONFIG.SERVER_ROOT)
self.server = ca_server
if cert_signing_request:
self.csr_file = cert_signing_request.name
else:
self.csr_file = None
if private_key:
self.key_file = private_key.name
else:
self.key_file = None
self.csr = cert_signing_request
self.privkey = private_key
self.privkey_file = private_key_file
# TODO: Figure out all exceptions from this function
try:
self._validate_csr_key_cli()
except errors.LetsEncryptClientError as e:
# TODO: Something nice here...
logger.fatal("%s - until the programmers get their act together, "
@ -65,6 +78,21 @@ class Client(object):
self.server_url = "https://%s/acme/" % self.server
def authenticate(self, domains=None, redirect=None, eula=False):
"""
:param domains: List of domains
:type domains: list
:param redirect:
:type redirect: bool|None
:param eula: EULA accepted
:type eula: bool
:raises errors.LetsEncryptClientError: CSR does not contain one of the
specified names.
"""
domains = [] if domains is None else domains
# Check configuration
@ -155,7 +183,7 @@ class Client(object):
"""
auth_dict = self.send(acme.authorization_request(
challenge_msg["sessionID"], self.names[0],
challenge_msg["nonce"], responses, self.key_file))
challenge_msg["nonce"], responses, self.privkey))
try:
return self.is_expected_msg(auth_dict, "authorization")
@ -178,7 +206,7 @@ class Client(object):
"""
logger.info("Preparing and sending CSR..")
return self.send_and_receive_expected(
acme.certificate_request(csr_der, self.key_file), "certificate")
acme.certificate_request(csr_der, self.privkey), "certificate")
def acme_revocation(self, cert):
"""Handle ACME "revocation" phase.
@ -211,14 +239,14 @@ class Client(object):
:param msg: ACME message (JSON serializable).
:type msg: dict
:raises TypeError: if `msg` is not JSON serializable
:raises jsonschema.ValidationError: if `msg` is not valid ACME message
:raises LetsEncryptClientError: in case of a connection error
or if a response from server is not a valid ACME message
:returns: Server response message.
:rtype: dict
:raises TypeError: if `msg` is not JSON serializable
:raises jsonschema.ValidationError: if not valid ACME message
:raises errors.LetsEncryptClientError: in case of connection error
or if response from server is not a valid ACME message.
"""
json_encoded = json.dumps(msg)
acme.acme_object_validate(json_encoded)
@ -248,7 +276,7 @@ class Client(object):
"""Send ACME message to server and return expected message.
:param msg: ACME message (JSON serializable).
:type acem_msg: dict
:type msg: dict
:param expected: Name of the expected response ACME message type.
:type expected: str
@ -256,6 +284,8 @@ class Client(object):
:returns: ACME response message of expected type.
:rtype: dict
:raises errors.LetsEncryptClientError: An exception is thrown
"""
response = self.send(msg)
try:
@ -281,11 +311,11 @@ class Client(object):
reponse message.
:type rounds: int
:raises LetsEncryptClientError: if server sent ACME "error" message
:returns: ACME response message from server.
:rtype: dict
:raises LetsEncryptClientError: if server sent ACME "error" message
"""
for _ in xrange(rounds):
if response["type"] == expected:
@ -312,6 +342,7 @@ class Client(object):
(rounds * delay))
def list_certs_keys(self):
"""List trusted Let's Encrypt certificates."""
list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST")
certs = []
@ -370,7 +401,6 @@ class Client(object):
else:
self.choose_certs(certs)
elif code == display.HELP:
print code, tag, cert
display.more_info_cert(cert)
self.choose_certs(certs)
else:
@ -405,7 +435,7 @@ class Client(object):
for host in vhost:
self.config.deploy_cert(host,
os.path.abspath(cert_file),
os.path.abspath(self.key_file),
os.path.abspath(self.privkey_file),
cert_chain_abspath)
# Enable any vhost that was issued to, but not enabled
if not host.enabled:
@ -496,6 +526,9 @@ class Client(object):
:param encrypt: Should the certificate key be encrypted?
:type encrypt: bool
:returns: True if key file was stored successfully, False otherwise.
:rtype: bool
"""
list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST")
le_util.make_or_verify_dir(CONFIG.CERT_KEY_BACKUP, 0o700)
@ -513,22 +546,24 @@ class Client(object):
for row in csvreader:
idx = int(row[0]) + 1
csvwriter = csv.writer(csvfile)
csvwriter.writerow([str(idx), cert_file, self.key_file])
csvwriter.writerow([str(idx), cert_file, self.privkey_file])
else:
with open(list_file, 'wb') as csvfile:
csvwriter = csv.writer(csvfile)
csvwriter.writerow(["0", cert_file, self.key_file])
csvwriter.writerow(["0", cert_file, self.privkey_file])
shutil.copy2(self.key_file,
shutil.copy2(self.privkey_file,
os.path.join(
CONFIG.CERT_KEY_BACKUP,
os.path.basename(self.key_file) + "_" + str(idx)))
os.path.basename(self.privkey_file) + "_" + str(idx)))
shutil.copy2(cert_file,
os.path.join(
CONFIG.CERT_KEY_BACKUP,
os.path.basename(cert_file) + "_" + str(idx)))
return True
def redirect_to_ssl(self, vhost):
for ssl_vh in vhost:
success, redirect_vhost = self.config.enable_redirect(ssl_vh)
@ -552,7 +587,7 @@ class Client(object):
:param name: TODO
:type name: TODO
:param challanges: A list of challenges from ACME "challenge"
: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
@ -597,7 +632,7 @@ class Client(object):
challenge_objs.append({
"type": "dvsni",
"listSNITuple": sni_todo,
"dvsni_key": os.path.abspath(self.key_file),
"dvsni_key": os.path.abspath(self.privkey_file),
})
challenge_obj_indices.append(sni_satisfies)
logger.debug(sni_todo)
@ -622,40 +657,36 @@ class Client(object):
:rtype: tuple
"""
key_pem = None
csr_pem = None
if not self.key_file:
if not self.privkey:
key_pem = crypto_util.make_key(CONFIG.RSA_KEY_SIZE)
self.privkey = key_pem
# Save file
le_util.make_or_verify_dir(CONFIG.KEY_DIR, 0o700)
key_f, self.key_file = le_util.unique_file(
key_f, key_filename = le_util.unique_file(
os.path.join(CONFIG.KEY_DIR, "key-letsencrypt.pem"), 0o600)
key_f.write(key_pem)
key_f.close()
logger.info("Generating key: %s" % self.key_file)
else:
try:
key_pem = open(self.key_file).read().replace("\r", "")
except:
logger.fatal("Unable to open key file: %s" % self.key_file)
sys.exit(1)
if not self.csr_file:
csr_pem, csr_der = crypto_util.make_csr(self.key_file, self.names)
self.privkey_file = key_filename
logger.info("Generating key: %s" % self.privkey_file)
else:
key_pem = self.privkey
if not self.csr:
csr_pem, csr_der = crypto_util.make_csr(self.privkey, self.names)
self.csr = csr_pem
# Save CSR
le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0o755)
csr_f, self.csr_file = le_util.unique_file(
csr_f, csr_filename = le_util.unique_file(
os.path.join(CONFIG.CERT_DIR, "csr-letsencrypt.pem"), 0o644)
csr_f.write(csr_pem)
csr_f.close()
logger.info("Creating CSR: %s" % self.csr_file)
logger.info("Creating CSR: %s" % csr_filename)
else:
try:
csr = M2Crypto.X509.load_request(self.csr_file)
csr_pem, csr_der = csr.as_pem(), csr.as_der()
except:
logger.fatal("Unable to open CSR file: %s" % self.csr_file)
sys.exit(1)
csr_obj = M2Crypto.X509.load_request_string(self.csr)
csr_pem, csr_der = csr_obj.as_pem(), csr_obj.as_der()
if csr_return_format == 'der':
return key_pem, csr_der
@ -675,38 +706,22 @@ class Client(object):
# The client can eventually do things like prompt the user
# and allow the user to take more appropriate actions
# If CSR is provided, the private key should also be provided.
if self.csr_file and not self.key_file:
logger.fatal(("Please provide the private key file used in "
"generating the provided CSR"))
sys.exit(1)
# If CSR is provided, it must be readable and valid.
try:
if self.csr_file and not crypto_util.valid_csr(self.csr_file):
raise errors.LetsEncryptClientError(
"The provided CSR is not a valid CSR")
except IOError:
if self.csr and not crypto_util.valid_csr(self.csr):
raise errors.LetsEncryptClientError(
"The provided CSR could not be read")
"The provided CSR is not a valid CSR")
# If key is provided, it must be readable and valid.
try:
if self.key_file and not crypto_util.valid_privkey(self.key_file):
raise LetsEncryptClientError(
"The provided key is not a valid key")
except IOError:
raise LetsEncryptClientError("The provided key could not be read")
if self.privkey and not crypto_util.valid_privkey(self.privkey):
raise errors.LetsEncryptClientError(
"The provided key is not a valid key")
# If CSR and key are provided, the key must be the same key used
# in the CSR.
if self.csr_file and self.key_file:
try:
if not crypto_util.csr_matches_pubkey(
self.csr_file, self.key_file):
raise errors.LetsEncryptClientError(
"The key and CSR do not match")
except IOError:
if self.csr and self.privkey:
if not crypto_util.csr_matches_pubkey(self.csr, self.privkey):
raise errors.LetsEncryptClientError(
"The key or CSR files could not be read")
"The key and CSR do not match")
def get_all_names(self):
"""Return all valid names in the configuration."""
@ -760,6 +775,12 @@ def remove_cert_key(cert):
def sanity_check_names(names):
"""Make sure host names are valid.
:param names: List of host names
:type names: list
"""
for name in names:
if not is_hostname_sane(name):
logger.fatal(repr(name) + " is an impossible hostname")
@ -767,9 +788,17 @@ def sanity_check_names(names):
def is_hostname_sane(hostname):
"""
"""Make sure the given host name is sane.
Do enough to avoid shellcode from the environment. There's
no need to do more.
:param hostname: Host name to validate
:type hostname: str
:returns: True if hostname is valid, otherwise false.
:rtype: bool
"""
# hostnames & IPv4
allowed = string.ascii_letters + string.digits + "-."

View file

@ -1,3 +1,4 @@
"""Let's Encrypt client crypto utility functions"""
import binascii
import hashlib
import time
@ -21,7 +22,7 @@ def b64_cert_to_pem(b64_der_cert):
le_util.jose_b64decode(b64_der_cert)).as_pem()
def create_sig(msg, key_file, nonce=None, nonce_len=CONFIG.NONCE_SIZE):
def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE):
"""Create signature with nonce prepended to the message.
TODO: Change this over to M2Crypto... PKey
@ -31,9 +32,9 @@ def create_sig(msg, key_file, nonce=None, nonce_len=CONFIG.NONCE_SIZE):
:param msg: Message to be signed
:type msg: Anything with __str__ method
:param key_file: Path to a file containing RSA key. Accepted formats
:param key_str: Key in string form. Accepted formats
are the same as for `Crypto.PublicKey.RSA.importKey`.
:type key_file: str
:type key_str: str
:param nonce: Nonce to be used. If None, nonce of `nonce_len` size
will be randomly genereted.
@ -47,7 +48,7 @@ def create_sig(msg, key_file, nonce=None, nonce_len=CONFIG.NONCE_SIZE):
"""
msg = str(msg)
key = Crypto.PublicKey.RSA.importKey(open(key_file).read())
key = Crypto.PublicKey.RSA.importKey(key_str)
nonce = Random.get_random_bytes(nonce_len) if nonce is None else nonce
msg_with_nonce = nonce + msg
@ -95,12 +96,12 @@ def make_key(bits=CONFIG.RSA_KEY_SIZE):
return key.exportKey(format='PEM')
def make_csr(key_file, domains):
def make_csr(key_str, domains):
"""
Returns new CSR in PEM and DER form using key_file containing all domains
"""
assert domains, "Must provide one or more hostnames for the CSR."
rsa_key = M2Crypto.RSA.load_key(key_file)
rsa_key = M2Crypto.RSA.load_key_string(key_str)
pubkey = M2Crypto.EVP.PKey()
pubkey.assign_rsa(rsa_key)
@ -127,13 +128,14 @@ def make_csr(key_file, domains):
return csr.as_pem(), csr.as_der()
def make_ss_cert(key_file, domains):
def make_ss_cert(key_str, domains):
"""Returns new self-signed cert in PEM form.
Uses key_file and contains all domains.
Uses key_str and contains all domains.
"""
assert domains, "Must provide one or more hostnames for the CSR."
rsa_key = M2Crypto.RSA.load_key(key_file)
rsa_key = M2Crypto.RSA.load_key_string(key_str)
pubkey = M2Crypto.EVP.PKey()
pubkey.assign_rsa(rsa_key)
@ -206,39 +208,33 @@ def get_cert_info(filename):
# A. Do more checks to verify that the CSR is trusted/valid
# B. Audit the parsing code for vulnerabilities
def valid_csr(csr_filename):
def valid_csr(csr):
"""Validate CSR.
Check if `csr_filename` is a valid CSR for the given domains.
Check if `csr` is a valid CSR for the given domains.
TODO: Currently, could raise non-X.509-related errors such as IOError
associated with problems reading the file. Comment or handle.
:param csr_filename: Path to the purported CSR file.
:type csr_filename: str
:param csr: CSR file contents
:type csr: str
:returns: Validity of CSR.
:rtype: bool
"""
try:
csr = M2Crypto.X509.load_request(csr_filename)
return bool(csr.verify(csr.get_pubkey()))
csr_obj = M2Crypto.X509.load_request_string(csr)
return bool(csr_obj.verify(csr_obj.get_pubkey()))
except M2Crypto.X509.X509Error:
return False
def csr_matches_names(csr_filename, domains):
def csr_matches_names(csr, domains):
"""Check if CSR contains the subject of one of the domains.
M2Crypto currently does not expose the OpenSSL interface to
also check the SAN extension. This is insufficient for full testing
TODO: Currently, could raise non-X.509-related errors such as IOError
associated with problems reading the file. Comment or handle.
:param csr_filename: Path to the purported CSR file.
:type csr_filename: str
:param csr: CSR file contents
:type csr: str
:param domains: Domains the CSR should contain.
:type domains: list
@ -248,49 +244,41 @@ def csr_matches_names(csr_filename, domains):
"""
try:
csr = M2Crypto.X509.load_request(csr_filename)
return csr.get_subject().CN in domains
csr_obj = M2Crypto.X509.load_request_string(csr)
return csr_obj.get_subject().CN in domains
except M2Crypto.X509.X509Error:
return False
def valid_privkey(privkey_filename):
def valid_privkey(privkey):
"""Is valid RSA private key?
TODO: Currently, could raise non-X.509-related errors such as IOError
associated with problems reading the file. Comment or handle.
:param privkey_filename: Path to the purported private key file.
:type privkey_filename: str
:param privkey: Private key file contents
:type privkey: str
:returns: Validity of private key.
:rtype: bool
"""
try:
return bool(M2Crypto.RSA.load_key(privkey_filename).check_key())
return bool(M2Crypto.RSA.load_key_string(privkey).check_key())
except M2Crypto.RSA.RSAError:
return False
def csr_matches_pubkey(csr_filename, privkey_filename):
def csr_matches_pubkey(csr, privkey):
"""Does private key correspond to the subject public key in the CSR?
TODO: Currently, could raise non-X.509-related errors such as IOError
associated with problems reading the file. Comment or handle.
:param csr: CSR file contents
:type csr: str
TODO: Seems that this doesn not handle X509 eceptions either.
:param csr_filename: Path to the purported CSR file.
:type csr_filename: str
:param privkey_filename: Path to the purported private key file.
:type privkey_filename: str
:param privkey: Private key file contents
:type privkey: str
:returns: Correspondence of private key to CSR subject public key.
:rtype: bool
"""
csr = M2Crypto.X509.load_request(csr_filename)
privkey = M2Crypto.RSA.load_key(privkey_filename)
return csr.get_pubkey().get_rsa().pub() == privkey.pub()
csr_obj = M2Crypto.X509.load_request_string(csr)
privkey_obj = M2Crypto.RSA.load_key_string(privkey)
return csr_obj.get_pubkey().get_rsa().pub() == privkey_obj.pub()

View file

@ -3,3 +3,7 @@
class LetsEncryptClientError(Exception):
"""Generic Let's Encrypt client error."""
class LetsEncryptDvsniError(Exception):
"""Let's Encrypt DVSNI error."""

View file

@ -55,7 +55,17 @@ def check_permissions(filepath, mode, uid=0):
def unique_file(default_name, mode=0o777):
"""Safely finds a unique file for writing only (by default)."""
"""Safely finds a unique file for writing only (by default).
:param default_name: Default file name
:type default_name: str
:param mode: File mode
:type mode: int
:return: tuple of file object and file name
"""
count = 1
f_parsed = os.path.splitext(default_name)
while 1:

View file

@ -28,18 +28,17 @@ def main():
nargs="+")
parser.add_argument("-s", "--server", dest="server",
help="The ACME CA server address.")
parser.add_argument("-p", "--privkey", dest="privkey", type=file,
parser.add_argument("-p", "--privkey", dest="privkey_tup", type=read_file,
help="Path to the private key file for certificate "
"generation.")
parser.add_argument("-c", "--csr", dest="csr", type=file,
parser.add_argument("-c", "--csr", dest="csr_tup", type=read_file,
help="Path to the certificate signing request file "
"corresponding to the private key file. The "
"private key file argument is required if this "
"argument is specified.")
parser.add_argument("-b", "--rollback", dest="rollback", type=int,
default=0,
help="Revert configuration <ROLLBACK> number of "
"checkpoints.")
default=0, metavar="N",
help="Revert configuration N number of checkpoints.")
parser.add_argument("-k", "--revoke", dest="revoke", action="store_true",
help="Revoke a certificate.")
parser.add_argument("-v", "--view-checkpoints", dest="view_checkpoints",
@ -63,10 +62,9 @@ def main():
args = parser.parse_args()
# Enforce --privkey is set along with --csr.
if args.csr and not args.privkey:
parser.print_usage()
parser.error("private key file (--privkey) must be specified along{}"
# Enforce '--privkey' is set along with '--csr'.
if args.csr_tup and not args.privkey_tup:
parser.error("private key file (--privkey) must be specified along{0} "
"with the certificate signing request file (--csr)"
.format(os.linesep))
@ -85,13 +83,38 @@ def main():
server = args.server is None and CONFIG.ACME_SERVER or args.server
acme = client.Client(server, args.csr, args.privkey, args.curses)
# Prepare for init of Client
if args.privkey_tup is None:
args.privkey_tup = (None, None)
if args.csr_tup is None:
args.csr_tup = (None, None)
acme = client.Client(server, args.csr_tup[1], args.privkey_tup[1],
args.privkey_tup[0], args.curses)
if args.revoke:
acme.list_certs_keys()
else:
acme.authenticate(args.domains, args.redirect, args.eula)
def read_file(filename):
"""Returns the given file's contents with universal new line support.
:param filename: Filename
:type filename: str
:returns: File contents
:rtype: str
:raises argparse.ArgumentTypeError: File does not exist or is not readable.
"""
try:
return filename, file(filename, 'rU').read()
except IOError as exc:
raise argparse.ArgumentTypeError(exc.strerror)
def rollback(config, checkpoints):
"""Revert configuration the specified number of checkpoints.