From 1b270451b2a1cef67048cec24517fb32b9c25d06 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 9 Dec 2014 22:25:01 +0100 Subject: [PATCH 01/66] virtualenv python2 (fixes #120) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 109da63de..a59aa7c8d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ sudo apt-get install python python-setuptools python-virtualenv \ ### Installation ``` -virtualenv --no-site-packages venv +virtualenv --no-site-packages -p python2 venv ./venv/bin/python setup.py install sudo ./venv/bin/letsencrypt ``` From d102c8be12d1a529939383869a78a64ca4f6e6d0 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 9 Dec 2014 23:04:09 +0100 Subject: [PATCH 02/66] Add tests for create_sig --- letsencrypt/client/tests/crypto_util_test.py | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 letsencrypt/client/tests/crypto_util_test.py diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py new file mode 100644 index 000000000..88fea4c11 --- /dev/null +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -0,0 +1,50 @@ +"""Tests for letsencrypt.client.crypto_util.""" +import unittest + + +class CreateSigTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.create_sig.""" + + def setUp(self): + self.privkey = """-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79 +vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn +elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc +mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp +Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj +8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq +6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/ +-----END RSA PRIVATE KEY-----""" + self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' + self.b64nonce = '7Nbyb1lI6xPVI3Hg3aKSqQ' + self.signature = { + 'nonce': self.b64nonce, + 'alg': 'RS256', + 'jwk': { + 'kty': 'RSA', + 'e': 'AQAB', + 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' + '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', + }, + 'sig': 'SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r' + 'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew', + } + + def _call(self, *args, **kwargs): + from letsencrypt.client.crypto_util import create_sig + return create_sig(*args, **kwargs) + + def test_it(self): + self.assertEqual( + self._call('message', self.privkey, self.nonce), self.signature) + + def test_random_nonce(self): + signature = self._call('message', self.privkey) + sig = signature.pop('sig') + nonce = signature.pop('nonce') + del self.signature['sig'] + del self.signature['nonce'] + self.assertEqual(signature, self.signature) + +if __name__ == '__main__': + unittest.main() From 2322266b98b683de84c45b15595ae35cb267b50e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 10 Dec 2014 15:46:07 +0100 Subject: [PATCH 03/66] Export RSA key to file --- letsencrypt/client/tests/acme_test.py | 12 +++--------- letsencrypt/client/tests/crypto_util_test.py | 12 +++--------- letsencrypt/client/tests/testdata/rsa256_key.pem | 9 +++++++++ 3 files changed, 15 insertions(+), 18 deletions(-) create mode 100644 letsencrypt/client/tests/testdata/rsa256_key.pem diff --git a/letsencrypt/client/tests/acme_test.py b/letsencrypt/client/tests/acme_test.py index df232c75a..808eefc1b 100644 --- a/letsencrypt/client/tests/acme_test.py +++ b/letsencrypt/client/tests/acme_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.acme.""" +import pkg_resources import unittest import jsonschema @@ -58,15 +59,8 @@ class MessageFactoriesTest(unittest.TestCase): """Tests for ACME message factories from letsencrypt.client.acme.""" def setUp(self): - self.privkey = """-----BEGIN RSA PRIVATE KEY----- -MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79 -vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn -elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc -mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp -Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj -8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq -6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/ ------END RSA PRIVATE KEY-----""" + self.privkey = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' self.b64nonce = '7Nbyb1lI6xPVI3Hg3aKSqQ' diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 88fea4c11..65b730df0 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.crypto_util.""" +import pkg_resources import unittest @@ -6,15 +7,8 @@ class CreateSigTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.create_sig.""" def setUp(self): - self.privkey = """-----BEGIN RSA PRIVATE KEY----- -MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79 -vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn -elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc -mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp -Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj -8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq -6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/ ------END RSA PRIVATE KEY-----""" + self.privkey = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' self.b64nonce = '7Nbyb1lI6xPVI3Hg3aKSqQ' self.signature = { diff --git a/letsencrypt/client/tests/testdata/rsa256_key.pem b/letsencrypt/client/tests/testdata/rsa256_key.pem new file mode 100644 index 000000000..610c8d315 --- /dev/null +++ b/letsencrypt/client/tests/testdata/rsa256_key.pem @@ -0,0 +1,9 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79 +vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn +elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc +mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp +Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj +8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq +6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/ +-----END RSA PRIVATE KEY----- From a0a81bf53398a93ecdf112d09766e18d5b00d490 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 10 Dec 2014 17:07:12 +0100 Subject: [PATCH 04/66] More coverage for crypto_util --- letsencrypt/client/crypto_util.py | 68 ++++++----- letsencrypt/client/tests/crypto_util_test.py | 114 ++++++++++++++++++ letsencrypt/client/tests/testdata/csr-san.der | Bin 0 -> 370 bytes letsencrypt/client/tests/testdata/csr-san.pem | 10 ++ letsencrypt/client/tests/testdata/csr.der | Bin 0 -> 353 bytes letsencrypt/client/tests/testdata/csr.pem | 10 ++ .../client/tests/testdata/rsa512_key.pem | 9 ++ 7 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 letsencrypt/client/tests/testdata/csr-san.der create mode 100644 letsencrypt/client/tests/testdata/csr-san.pem create mode 100644 letsencrypt/client/tests/testdata/csr.der create mode 100644 letsencrypt/client/tests/testdata/csr.pem create mode 100644 letsencrypt/client/tests/testdata/rsa512_key.pem diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index d19cbc0da..754557326 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -1,6 +1,5 @@ """Let's Encrypt client crypto utility functions""" import binascii -import hashlib import logging import time @@ -15,8 +14,6 @@ from letsencrypt.client import CONFIG from letsencrypt.client import le_util -# TODO: All of these functions need unit tests - def b64_cert_to_pem(b64_der_cert): return M2Crypto.X509.load_cert_der_string( le_util.jose_b64decode(b64_der_cert)).as_pem() @@ -76,27 +73,32 @@ def leading_zeros(arg): return arg -def sha256(arg): - return hashlib.sha256(arg).hexdigest() - - # based on M2Crypto unit test written by Toby Allsopp def make_key(bits=CONFIG.RSA_KEY_SIZE): + """Generate PEM encoded RSA key. + + :param int bits: Number of bits. + + :returns: new RSA key in PEM form with specified number of bits + :rtype: str + """ - Returns new RSA key in PEM form with specified bits - """ - # Python Crypto module doesn't produce any stdout - key = Crypto.PublicKey.RSA.generate(bits) # rsa = M2Crypto.RSA.gen_key(bits, 65537) # key_pem = rsa.as_pem(cipher=None) # rsa = None # should not be freed here - - return key.exportKey(format='PEM') + # Python Crypto module doesn't produce any stdout + return Crypto.PublicKey.RSA.generate(bits).exportKey(format='PEM') def make_csr(key_str, domains): - """ - Returns new CSR in PEM and DER form using key_file containing all domains + """Generate a CSR. + + :param str key_str: RSA key. + :param list domains: Domains included in the certificate. + + :returns: new CSR in PEM and DER form containing all domains + :rtype: tuple + """ assert domains, "Must provide one or more hostnames for the CSR." rsa_key = M2Crypto.RSA.load_key_string(key_str) @@ -115,7 +117,7 @@ def make_csr(key_str, domains): extstack = M2Crypto.X509.X509_Extension_Stack() ext = M2Crypto.X509.new_extension( - 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains])) + 'subjectAltName', ", ".join("DNS:%s" % d for d in domains)) extstack.push(ext) csr.add_extensions(extstack) @@ -210,7 +212,7 @@ def valid_csr(csr): Check if `csr` is a valid CSR for the given domains. - :param str csr: CSR file contents + :param str csr: CSR in PEM. :returns: Validity of CSR. :rtype: bool @@ -229,7 +231,7 @@ def csr_matches_names(csr, domains): M2Crypto currently does not expose the OpenSSL interface to also check the SAN extension. This is insufficient for full testing - :param str csr: CSR file contents + :param str csr: CSR in DER. :param list domains: Domains the CSR should contain. @@ -244,6 +246,21 @@ def csr_matches_names(csr, domains): return False +def csr_matches_pubkey(csr, privkey): + """Does private key correspond to the subject public key in the CSR? + + :param str csr: CSR in PEM. + :param str privkey: Private key file contents + + :returns: Correspondence of private key to CSR subject public key. + :rtype: bool + + """ + 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() + + def valid_privkey(privkey): """Is valid RSA private key? @@ -257,18 +274,3 @@ def valid_privkey(privkey): return bool(M2Crypto.RSA.load_key_string(privkey).check_key()) except M2Crypto.RSA.RSAError: return False - - -def csr_matches_pubkey(csr, privkey): - """Does private key correspond to the subject public key in the CSR? - - :param str csr: CSR file contents - :param str privkey: Private key file contents - - :returns: Correspondence of private key to CSR subject public key. - :rtype: bool - - """ - 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() diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 65b730df0..aad8ba1cf 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,5 +1,7 @@ """Tests for letsencrypt.client.crypto_util.""" +import os import pkg_resources +import tempfile import unittest @@ -40,5 +42,117 @@ class CreateSigTest(unittest.TestCase): del self.signature['nonce'] self.assertEqual(signature, self.signature) + +class MakeCSRTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.make_csr.""" + + def setUp(self): + self.key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + def test_single_domain(self): + from letsencrypt.client.crypto_util import make_csr + pem, der = make_csr(self.key, ['example.com']) + self.assertEqual(pem, pkg_resources.resource_string( + __name__, 'testdata/csr.pem')) + self.assertEqual(der, pkg_resources.resource_string( + __name__, 'testdata/csr.der')) + + def test_san(self): + from letsencrypt.client.crypto_util import make_csr + pem, der = make_csr(self.key, ['example.com', 'www.example.com']) + self.assertEqual(pem, pkg_resources.resource_string( + __name__, 'testdata/csr-san.pem')) + self.assertEqual(der, pkg_resources.resource_string( + __name__, 'testdata/csr-san.der')) + + +class ValidCSRTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.valid_csr.""" + + def _call(self, csr): + from letsencrypt.client.crypto_util import valid_csr + return valid_csr(csr) + + def _call_testdata(self, name): + return self._call(pkg_resources.resource_string( + __name__, os.path.join('testdata', name))) + + def test_valid_pem_true(self): + self.assertTrue(self._call_testdata('csr.pem')) + + def test_valid_pem_san_true(self): + self.assertTrue(self._call_testdata('csr-san.pem')) + + def test_valid_der_false(self): + self.assertFalse(self._call_testdata('csr.der')) + + def test_valid_der_san_false(self): + self.assertFalse(self._call_testdata('csr-san.der')) + + def test_empty_false(self): + self.assertFalse(self._call('')) + + def test_rubbis_false(self): + self.assertFalse(self._call('foo bar')) + + +class CSRMatchesNamesTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.csr_matches_names.""" + + def _call(self, csr, domains): + from letsencrypt.client.crypto_util import csr_matches_names + return csr_matches_names(csr, domains) + + def _call_testdata(self, name, domains): + return self._call(pkg_resources.resource_string( + __name__, os.path.join('testdata', name)), domains) + + def test_it(self): + self.assertTrue(self._call_testdata('csr.der', ['example.com'])) + self.assertFalse(self._call_testdata('csr.der', ['www.example.com'])) + self.assertFalse(self._call_testdata('csr.der', ['example'])) + + def test_san(self): + self.assertTrue(self._call_testdata('csr-san.der', ['example.com'])) + self.assertTrue(self._call_testdata('csr-san.der', ['www.example.com'])) + self.assertFalse(self._call_testdata('csr-san.der', ['example'])) + + +class CSRMatchesPubkeyTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.csr_matches_pubkey.""" + + def _call_testdata(self, name, privkey): + from letsencrypt.client.crypto_util import csr_matches_pubkey + return csr_matches_pubkey(pkg_resources.resource_string( + __name__, os.path.join('testdata', name)), privkey) + + def test_valid_true(self): + key = pkg_resources.resource_string(__name__, 'testdata/rsa256_key.pem') + self.assertTrue(self._call_testdata('csr.pem', key)) + + def test_invalid_false(self): + key = pkg_resources.resource_string(__name__, 'testdata/rsa512_key.pem') + self.assertFalse(self._call_testdata('csr.pem', key)) + + +class ValidPrivkeyTest(unittest.TestCase): + """Tests fro letsencrypt.client.crypto_util.valid_privkey.""" + + def _call(self, privkey): + from letsencrypt.client.crypto_util import valid_privkey + return valid_privkey(privkey) + + def test_valid_true(self): + self.assertTrue(self._call(pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem'))) + + def test_empty_false(self): + self.assertFalse(self._call('')) + + def test_rubbish_false(self): + self.assertFalse(self._call('foo bar')) + + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/testdata/csr-san.der b/letsencrypt/client/tests/testdata/csr-san.der new file mode 100644 index 0000000000000000000000000000000000000000..68fd38723ddd62a2d60fd6c27e67925d273d51f8 GIT binary patch literal 370 zcmXqLV$3sWVw7NFWH6{S`GxkDEsS*Ho7m>sw0v z-yV9(#LURRxWLN50&Y4dpP{yarhz)p5we0T3I=jb$PQ`ZFE20GLv|UGySRbwa%4zJ z@cA$|+wI(oIT5`GxkDEsS*Ho7m>sw0v z-yV9(#LURRxIoiD9d0@&pP`h2gn<~)5we0T{06*DC=Nk#1~ Date: Wed, 10 Dec 2014 18:50:54 +0100 Subject: [PATCH 05/66] wording --- letsencrypt/client/tests/crypto_util_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index aad8ba1cf..affccfed7 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -93,7 +93,7 @@ class ValidCSRTest(unittest.TestCase): def test_empty_false(self): self.assertFalse(self._call('')) - def test_rubbis_false(self): + def test_random_false(self): self.assertFalse(self._call('foo bar')) @@ -150,7 +150,7 @@ class ValidPrivkeyTest(unittest.TestCase): def test_empty_false(self): self.assertFalse(self._call('')) - def test_rubbish_false(self): + def test_random_false(self): self.assertFalse(self._call('foo bar')) From cc73f09745ff23ab79750c8980e8925ebbc8598c Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 11 Dec 2014 03:42:15 -0800 Subject: [PATCH 06/66] Refactor dvsni challenge to allow other configurators to reuse code --- letsencrypt/client/apache_configurator.py | 56 +++++----------------- letsencrypt/client/challenge_util.py | 58 +++++++++++++++++++++++ letsencrypt/client/client.py | 30 ++++++++++-- 3 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 letsencrypt/client/challenge_util.py diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache_configurator.py index a1e582d38..11a999e9b 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache_configurator.py @@ -12,6 +12,7 @@ import sys from Crypto import Random from letsencrypt.client import augeas_configurator +from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -1403,24 +1404,27 @@ LogLevel warn \n\ else: addresses.append(vhost.addrs) - # Generate S - dvsni_s = Random.get_random_bytes(CONFIG.S_SIZE) + responses = [] + # Create all of the challenge certs for tup in chall_dict["list_sni_tuple"]: - # Need to decode from base64 - dvsni_r = le_util.jose_b64decode(tup[1]) - ext = dvsni_gen_ext(dvsni_r, dvsni_s) - self.dvsni_create_chall_cert( - tup[0], ext, tup[2], chall_dict["dvsni_key"]) + cert_path = self.dvsni_get_cert_file(tup[2]) + self.register_file_creation(cert_path) + s_b64 = challenge_util.dvsni_gen_cert( + cert_path, tup[0], tup[1], tup[2], chall_dict["dvsni_key"]) + responses.append({"type": "dvsni", "s": s_b64}) + + # Setup the configuration self.dvsni_mod_config(chall_dict["list_sni_tuple"], chall_dict["dvsni_key"], addresses) + # Save reversible changes and restart the server self.save("SNI Challenge", True) self.restart(True) - return {"type": "dvsni", "s": le_util.jose_b64encode(dvsni_s)} + return responses def cleanup(self): """Revert all challenges.""" @@ -1485,25 +1489,6 @@ LogLevel warn \n\ self.add_dir(get_aug_path(main_config), "Include", CONFIG.APACHE_CHALLENGE_CONF) - def dvsni_create_chall_cert(self, name, ext, nonce, dvsni_key): - """Creates DVSNI challenge certifiate. - - Certificate created at self.dvsni_get_cert_file(nonce) - - :param str nonce: hex form of nonce - - :param dvsni_key: absolute path to key file - :type dvsni_key: `client.Client.Key` - - """ - self.register_file_creation(True, self.dvsni_get_cert_file(nonce)) - - cert_pem = crypto_util.make_ss_cert( - dvsni_key.pem, [nonce + CONFIG.INVALID_EXT, name, ext]) - - with open(self.dvsni_get_cert_file(nonce), 'w') as chall_cert_file: - chall_cert_file.write(cert_pem) - def get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text @@ -1693,23 +1678,6 @@ def strip_dir(path): return "" -def dvsni_gen_ext(dvsni_r, dvsni_s): - """Generates z extension to be placed in certificate extension. - - :param bytearray dvsni_r: DVSNI r value - :param bytearray dvsni_s: DVSNI s value - - :returns: z + CONFIG.INVALID_EXT - :rtype: str - - """ - z_base = hashlib.new('sha256') - z_base.update(dvsni_r) - z_base.update(dvsni_s) - - return z_base.hexdigest() + CONFIG.INVALID_EXT - - def main(): """Main function used for quick testing purposes""" diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py new file mode 100644 index 000000000..69f351f7d --- /dev/null +++ b/letsencrypt/client/challenge_util.py @@ -0,0 +1,58 @@ +"""Challenge specific utility functions.""" +import hashlib + +from Crypto import Random + +from letsencrypt.client import CONFIG +from letsencrypt.client import crypto_util +from letsencrypt.client import le_util + + +# DVSNI Challenge functions +def dvsni_gen_cert(filepath, name, r_b64, nonce, key): + """Generate a DVSNI cert and save it to filepath. + + :param str filepath: destination to save certificate. This will overwrite + any file that is currently at the location. + :param str name: domain to validate + :param str dvsni_r: jose base64 encoded dvsni r value + :param str nonce: hex value of nonce + + :param key: Key to perform challenge + :type key: :class:`letsencrypt.client.client.Client.Key` + + :returns: dvsni s value jose base64 encoded + :rtype: str + + """ + # Generate S + dvsni_s = Random.get_random_bytes(CONFIG.S_SIZE) + dvsni_r = le_util.jose_b64decode(r_b64) + + # Generate extension + ext = _dvsni_gen_ext(dvsni_r, dvsni_s) + + cert_pem = crypto_util.make_ss_cert( + key.pem, [nonce + CONFIG.INVALID_EXT, name, ext]) + + with open(filepath, 'w') as chall_cert_file: + chall_cert_file.write(cert_pem) + + return le_util.jose_b64encode(dvsni_s) + + +def _dvsni_gen_ext(dvsni_r, dvsni_s): + """Generates z extension to be placed in certificate extension. + + :param bytearray dvsni_r: DVSNI r value + :param bytearray dvsni_s: DVSNI s value + + :returns: z + CONFIG.INVALID_EXT + :rtype: str + + """ + z_base = hashlib.new('sha256') + z_base.update(dvsni_r) + z_base.update(dvsni_s) + + return z_base.hexdigest() + CONFIG.INVALID_EXT diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index b6f507688..11c8dbcc7 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -493,21 +493,41 @@ class Client(object): # Perform challenges for i, c_obj in enumerate(challenge_objs): - response = "null" + resp = "null" if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - response = self.config.perform(c_obj) + resp = self.config.perform(c_obj) else: # Handle RecoveryToken type challenges pass - - for index in indices[i]: - responses[index] = response + + self._assign_responses(resp, indices[i], responses) logging.info( "Configured Apache for challenges; waiting for verification...") + print responses + return responses, challenge_objs + def _assign_responses(self, resp, index_list, responses): + """Assign chall_response to appropriate places in response list. + + :param resp: responses from a challenge + :type resp: list of dicts or dict + + :param list index_list: respective challenges resp satisfies + :param list responses: master list of responses + + """ + if isinstance(resp, list): + assert(len(resp) == len(index_list)) + for j, index in enumerate(index_list): + responses[index] = resp[j] + else: + for index in index_list: + responses[index] = resp + + def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. From 40837e9d56ffee85e6894a10b5632a8b93adb983 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Dec 2014 23:53:24 +0100 Subject: [PATCH 07/66] More tests for crypto_util --- letsencrypt/client/crypto_util.py | 196 +++++++++--------- letsencrypt/client/tests/crypto_util_test.py | 85 ++++++-- .../client/tests/testdata/cert-san.pem | 14 ++ letsencrypt/client/tests/testdata/cert.pem | 13 ++ 4 files changed, 191 insertions(+), 117 deletions(-) create mode 100644 letsencrypt/client/tests/testdata/cert-san.pem create mode 100644 letsencrypt/client/tests/testdata/cert.pem diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 754557326..1d323cec2 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -14,11 +14,6 @@ from letsencrypt.client import CONFIG from letsencrypt.client import le_util -def b64_cert_to_pem(b64_der_cert): - return M2Crypto.X509.load_cert_der_string( - le_util.jose_b64decode(b64_der_cert)).as_pem() - - def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): """Create signature with nonce prepended to the message. @@ -73,23 +68,6 @@ def leading_zeros(arg): return arg -# based on M2Crypto unit test written by Toby Allsopp -def make_key(bits=CONFIG.RSA_KEY_SIZE): - """Generate PEM encoded RSA key. - - :param int bits: Number of bits. - - :returns: new RSA key in PEM form with specified number of bits - :rtype: str - - """ - # rsa = M2Crypto.RSA.gen_key(bits, 65537) - # key_pem = rsa.as_pem(cipher=None) - # rsa = None # should not be freed here - # Python Crypto module doesn't produce any stdout - return Crypto.PublicKey.RSA.generate(bits).exportKey(format='PEM') - - def make_csr(key_str, domains): """Generate a CSR. @@ -128,80 +106,6 @@ def make_csr(key_str, domains): return csr.as_pem(), csr.as_der() -def make_ss_cert(key_str, domains): - """Returns new self-signed cert in PEM form. - - Uses key_str and contains all domains. - """ - assert domains, "Must provide one or more hostnames for the CSR." - - rsa_key = M2Crypto.RSA.load_key_string(key_str) - pubkey = M2Crypto.EVP.PKey() - pubkey.assign_rsa(rsa_key) - - cert = M2Crypto.X509.X509() - cert.set_pubkey(pubkey) - cert.set_serial_number(1337) - cert.set_version(2) - - current_ts = long(time.time()) - current = M2Crypto.ASN1.ASN1_UTCTIME() - current.set_time(current_ts) - expire = M2Crypto.ASN1.ASN1_UTCTIME() - expire.set_time((7 * 24 * 60 * 60) + current_ts) - cert.set_not_before(current) - cert.set_not_after(expire) - - subject = cert.get_subject() - subject.C = "US" - subject.ST = "Michigan" - subject.L = "Ann Arbor" - subject.O = "University of Michigan and the EFF" - subject.CN = domains[0] - cert.set_issuer(cert.get_subject()) - - cert.add_ext(M2Crypto.X509.new_extension('basicConstraints', 'CA:FALSE')) - # cert.add_ext(M2Crypto.X509.new_extension( - # 'extendedKeyUsage', 'TLS Web Server Authentication')) - cert.add_ext(M2Crypto.X509.new_extension( - 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains]))) - - cert.sign(pubkey, 'sha256') - assert cert.verify(pubkey) - assert cert.verify() - # print check_purpose(,0 - return cert.as_pem() - - -def get_cert_info(filename): - """Get certificate info. - - :param str filename: Name of file containing certificate in PEM format. - - :rtype: dict - - """ - # M2Crypto Library only supports RSA right now - cert = M2Crypto.X509.load_cert(filename) - - try: - san = cert.get_ext("subjectAltName").get_value() - except: - san = "" - - return { - "not_before": cert.get_not_before().get_datetime(), - "not_after": cert.get_not_after().get_datetime(), - "subject": cert.get_subject().as_text(), - "cn": cert.get_subject().CN, - "issuer": cert.get_issuer().as_text(), - "fingerprint": cert.get_fingerprint(md='sha1'), - "san": san, - "serial": cert.get_serial_number(), - "pub_key": "RSA " + str(cert.get_pubkey().size() * 8), - } - - # WARNING: the csr and private key file are possible attack vectors for TOCTOU # We should either... # A. Do more checks to verify that the CSR is trusted/valid @@ -261,6 +165,23 @@ def csr_matches_pubkey(csr, privkey): return csr_obj.get_pubkey().get_rsa().pub() == privkey_obj.pub() +# based on M2Crypto unit test written by Toby Allsopp +def make_key(bits=CONFIG.RSA_KEY_SIZE): + """Generate PEM encoded RSA key. + + :param int bits: Number of bits. + + :returns: new RSA key in PEM form with specified number of bits + :rtype: str + + """ + # rsa = M2Crypto.RSA.gen_key(bits, 65537) + # key_pem = rsa.as_pem(cipher=None) + # rsa = None # should not be freed here + # Python Crypto module doesn't produce any stdout + return Crypto.PublicKey.RSA.generate(bits).exportKey(format='PEM') + + def valid_privkey(privkey): """Is valid RSA private key? @@ -274,3 +195,86 @@ def valid_privkey(privkey): return bool(M2Crypto.RSA.load_key_string(privkey).check_key()) except M2Crypto.RSA.RSAError: return False + + +def make_ss_cert(key_str, domains, not_before=None, + validity=(7 * 24 * 60 * 60)): + """Returns new self-signed cert in PEM form. + + Uses key_str and contains all domains. + + """ + assert domains, "Must provide one or more hostnames for the CSR." + + rsa_key = M2Crypto.RSA.load_key_string(key_str) + pubkey = M2Crypto.EVP.PKey() + pubkey.assign_rsa(rsa_key) + + cert = M2Crypto.X509.X509() + cert.set_pubkey(pubkey) + cert.set_serial_number(1337) + cert.set_version(2) + + current_ts = long(time.time() if not_before is None else not_before) + current = M2Crypto.ASN1.ASN1_UTCTIME() + current.set_time(current_ts) + expire = M2Crypto.ASN1.ASN1_UTCTIME() + expire.set_time(current_ts + validity) + cert.set_not_before(current) + cert.set_not_after(expire) + + subject = cert.get_subject() + subject.C = "US" + subject.ST = "Michigan" + subject.L = "Ann Arbor" + subject.O = "University of Michigan and the EFF" + subject.CN = domains[0] + cert.set_issuer(cert.get_subject()) + + if len(domains) > 1: + cert.add_ext(M2Crypto.X509.new_extension( + 'basicConstraints', 'CA:FALSE')) + # cert.add_ext(M2Crypto.X509.new_extension( + # 'extendedKeyUsage', 'TLS Web Server Authentication')) + cert.add_ext(M2Crypto.X509.new_extension( + 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains]))) + + cert.sign(pubkey, 'sha256') + assert cert.verify(pubkey) + assert cert.verify() + # print check_purpose(,0 + return cert.as_pem() + + +def get_cert_info(filename): + """Get certificate info. + + :param str filename: Name of file containing certificate in PEM format. + + :rtype: dict + + """ + # M2Crypto Library only supports RSA right now + cert = M2Crypto.X509.load_cert(filename) + + try: + san = cert.get_ext("subjectAltName").get_value() + except: + san = "" + + return { + "not_before": cert.get_not_before().get_datetime(), + "not_after": cert.get_not_after().get_datetime(), + "subject": cert.get_subject().as_text(), + "cn": cert.get_subject().CN, + "issuer": cert.get_issuer().as_text(), + "fingerprint": cert.get_fingerprint(md='sha1'), + "san": san, + "serial": cert.get_serial_number(), + "pub_key": "RSA " + str(cert.get_pubkey().size() * 8), + } + + +def b64_cert_to_pem(b64_der_cert): + return M2Crypto.X509.load_cert_der_string( + le_util.jose_b64decode(b64_der_cert)).as_pem() diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index affccfed7..d1f40f360 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,16 +1,20 @@ """Tests for letsencrypt.client.crypto_util.""" +import datetime import os import pkg_resources -import tempfile import unittest +import M2Crypto + + +RSA256_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa256_key.pem') +RSA512_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa512_key.pem') + class CreateSigTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.create_sig.""" def setUp(self): - self.privkey = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' self.b64nonce = '7Nbyb1lI6xPVI3Hg3aKSqQ' self.signature = { @@ -32,12 +36,12 @@ class CreateSigTest(unittest.TestCase): def test_it(self): self.assertEqual( - self._call('message', self.privkey, self.nonce), self.signature) + self._call('message', RSA256_KEY, self.nonce), self.signature) def test_random_nonce(self): - signature = self._call('message', self.privkey) - sig = signature.pop('sig') - nonce = signature.pop('nonce') + signature = self._call('message', RSA256_KEY) + signature.pop('sig') + signature.pop('nonce') del self.signature['sig'] del self.signature['nonce'] self.assertEqual(signature, self.signature) @@ -46,13 +50,9 @@ class CreateSigTest(unittest.TestCase): class MakeCSRTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.make_csr.""" - def setUp(self): - self.key = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') - def test_single_domain(self): from letsencrypt.client.crypto_util import make_csr - pem, der = make_csr(self.key, ['example.com']) + pem, der = make_csr(RSA256_KEY, ['example.com']) self.assertEqual(pem, pkg_resources.resource_string( __name__, 'testdata/csr.pem')) self.assertEqual(der, pkg_resources.resource_string( @@ -60,7 +60,7 @@ class MakeCSRTest(unittest.TestCase): def test_san(self): from letsencrypt.client.crypto_util import make_csr - pem, der = make_csr(self.key, ['example.com', 'www.example.com']) + pem, der = make_csr(RSA256_KEY, ['example.com', 'www.example.com']) self.assertEqual(pem, pkg_resources.resource_string( __name__, 'testdata/csr-san.pem')) self.assertEqual(der, pkg_resources.resource_string( @@ -108,7 +108,7 @@ class CSRMatchesNamesTest(unittest.TestCase): return self._call(pkg_resources.resource_string( __name__, os.path.join('testdata', name)), domains) - def test_it(self): + def test_single_domain(self): self.assertTrue(self._call_testdata('csr.der', ['example.com'])) self.assertFalse(self._call_testdata('csr.der', ['www.example.com'])) self.assertFalse(self._call_testdata('csr.der', ['example'])) @@ -128,24 +128,21 @@ class CSRMatchesPubkeyTest(unittest.TestCase): __name__, os.path.join('testdata', name)), privkey) def test_valid_true(self): - key = pkg_resources.resource_string(__name__, 'testdata/rsa256_key.pem') - self.assertTrue(self._call_testdata('csr.pem', key)) + self.assertTrue(self._call_testdata('csr.pem', RSA256_KEY)) def test_invalid_false(self): - key = pkg_resources.resource_string(__name__, 'testdata/rsa512_key.pem') - self.assertFalse(self._call_testdata('csr.pem', key)) + self.assertFalse(self._call_testdata('csr.pem', RSA512_KEY)) class ValidPrivkeyTest(unittest.TestCase): - """Tests fro letsencrypt.client.crypto_util.valid_privkey.""" + """Tests for letsencrypt.client.crypto_util.valid_privkey.""" def _call(self, privkey): from letsencrypt.client.crypto_util import valid_privkey return valid_privkey(privkey) def test_valid_true(self): - self.assertTrue(self._call(pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem'))) + self.assertTrue(self._call(RSA256_KEY)) def test_empty_false(self): self.assertFalse(self._call('')) @@ -154,5 +151,51 @@ class ValidPrivkeyTest(unittest.TestCase): self.assertFalse(self._call('foo bar')) +class MakeSSCertTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.make_ss_cert.""" + + def test_it(self): + from letsencrypt.client.crypto_util import make_ss_cert + make_ss_cert(RSA256_KEY, ['example.com', 'www.example.com']) + + +class GetCertInfoTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.get_cert_info.""" + + def setUp(self): + self.cert_info = { + 'not_before': datetime.datetime( + 2014, 12, 11, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), + 'not_after': datetime.datetime( + 2014, 12, 18, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), + 'subject': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' + 'of Michigan and the EFF, CN=example.com', + 'cn': 'example.com', + 'issuer': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' + 'of Michigan and the EFF, CN=example.com', + 'serial': 1337L, + 'pub_key': 'RSA 512', + } + + def _call(self, name): + from letsencrypt.client.crypto_util import get_cert_info + self.assertEqual(get_cert_info(pkg_resources.resource_filename( + __name__, os.path.join('testdata', name))), self.cert_info) + + def test_single_domain(self): + self.cert_info.update({ + 'san': '', + 'fingerprint': '9F8CE01450D288467C3326AC0457E351939C72E', + }) + self._call('cert.pem') + + def test_san(self): + self.cert_info.update({ + 'san': 'DNS:example.com, DNS:www.example.com', + 'fingerprint': '62F7110431B8E8F55905DBE5592518F9634AC50A', + }) + self._call('cert-san.pem') + + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/testdata/cert-san.pem b/letsencrypt/client/tests/testdata/cert-san.pem new file mode 100644 index 000000000..dcb835994 --- /dev/null +++ b/letsencrypt/client/tests/testdata/cert-san.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICFjCCAcCgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMx +ETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoM +IlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4 +YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkG +A1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3Ix +KzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDAS +BgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR +7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c ++pVE6K+EdE/twuUCAwEAAaM2MDQwCQYDVR0TBAIwADAnBgNVHREEIDAeggtleGFt +cGxlLmNvbYIPd3d3LmV4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA0EASuvNKFTF +nTJsvnSXn52f4BMZJJ2id/kW7+r+FJRm+L20gKQ1aqq8d3e/lzRUrv5SMf1TAOe7 +RDjyGMKy5ZgM2w== +-----END CERTIFICATE----- diff --git a/letsencrypt/client/tests/testdata/cert.pem b/letsencrypt/client/tests/testdata/cert.pem new file mode 100644 index 000000000..96c55cbf4 --- /dev/null +++ b/letsencrypt/client/tests/testdata/cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMx +ETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoM +IlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4 +YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkG +A1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3Ix +KzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDAS +BgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR +7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c ++pVE6K+EdE/twuUCAwEAATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksll +vr6zJepBH5fMndfk3XJp10jT6VE+14KNtjh02a56GoraAvJAT5/H67E8GvJ/ocNn +B/o= +-----END CERTIFICATE----- From 0b7121341f0c3124ac1061075ec3f8e2de4eb6ba Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 12 Dec 2014 10:37:53 +0100 Subject: [PATCH 08/66] Remove csr_matches_names. c.f. #127 and https://github.com/letsencrypt/lets-encrypt-preview/pull/127#discussion-diff-21613376 --- letsencrypt/client/client.py | 5 ----- letsencrypt/client/crypto_util.py | 21 ------------------- letsencrypt/client/tests/crypto_util_test.py | 22 -------------------- 3 files changed, 48 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index b6f507688..4765014ec 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -118,11 +118,6 @@ class Client(object): # Make sure we have key and csr to perform challenges self.init_key_csr() - # TODO: Handle this exception/problem - if not crypto_util.csr_matches_names(self.csr.data, self.names): - raise errors.LetsEncryptClientError( - "CSR subject does not contain one of the specified names") - # Perform Challenges responses, challenge_objs = self.verify_identity(challenge_msg) # Get Authorization diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 1d323cec2..bf9989495 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -129,27 +129,6 @@ def valid_csr(csr): return False -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 - - :param str csr: CSR in DER. - - :param list domains: Domains the CSR should contain. - - :returns: If the CSR subject contains one of the domains - :rtype: bool - - """ - try: - csr_obj = M2Crypto.X509.load_request_der_string(csr) - return csr_obj.get_subject().CN in domains - except M2Crypto.X509.X509Error: - return False - - def csr_matches_pubkey(csr, privkey): """Does private key correspond to the subject public key in the CSR? diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index d1f40f360..76cbc8310 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -97,28 +97,6 @@ class ValidCSRTest(unittest.TestCase): self.assertFalse(self._call('foo bar')) -class CSRMatchesNamesTest(unittest.TestCase): - """Tests for letsencrypt.client.crypto_util.csr_matches_names.""" - - def _call(self, csr, domains): - from letsencrypt.client.crypto_util import csr_matches_names - return csr_matches_names(csr, domains) - - def _call_testdata(self, name, domains): - return self._call(pkg_resources.resource_string( - __name__, os.path.join('testdata', name)), domains) - - def test_single_domain(self): - self.assertTrue(self._call_testdata('csr.der', ['example.com'])) - self.assertFalse(self._call_testdata('csr.der', ['www.example.com'])) - self.assertFalse(self._call_testdata('csr.der', ['example'])) - - def test_san(self): - self.assertTrue(self._call_testdata('csr-san.der', ['example.com'])) - self.assertTrue(self._call_testdata('csr-san.der', ['www.example.com'])) - self.assertFalse(self._call_testdata('csr-san.der', ['example'])) - - class CSRMatchesPubkeyTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.csr_matches_pubkey.""" From ccfeef3e8e5cf68985f7154b6fedd93c552a4b5b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 12 Dec 2014 10:54:17 +0100 Subject: [PATCH 09/66] Add tests for b64_cert_to_pem --- letsencrypt/client/tests/crypto_util_test.py | 11 +++++++++++ letsencrypt/client/tests/testdata/cert.b64jose | 1 + 2 files changed, 12 insertions(+) create mode 100644 letsencrypt/client/tests/testdata/cert.b64jose diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 76cbc8310..272555d80 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -175,5 +175,16 @@ class GetCertInfoTest(unittest.TestCase): self._call('cert-san.pem') +class B64CertToPEMTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.b64_cert_to_pem.""" + + def test_it(self): + from letsencrypt.client.crypto_util import b64_cert_to_pem + self.assertEqual( + b64_cert_to_pem(pkg_resources.resource_string( + __name__, 'testdata/cert.b64jose')), + pkg_resources.resource_string(__name__, 'testdata/cert.pem')) + + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/testdata/cert.b64jose b/letsencrypt/client/tests/testdata/cert.b64jose new file mode 100644 index 000000000..fa1abdb9f --- /dev/null +++ b/letsencrypt/client/tests/testdata/cert.b64jose @@ -0,0 +1 @@ +MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwEAATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMndfk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o \ No newline at end of file From 44a050a0610c0ad2745b64129b9c690f6bcb86ec Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 12 Dec 2014 11:16:17 +0100 Subject: [PATCH 10/66] Add tests for make_key --- letsencrypt/client/crypto_util.py | 2 +- letsencrypt/client/tests/crypto_util_test.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index bf9989495..554ccf684 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -148,7 +148,7 @@ def csr_matches_pubkey(csr, privkey): def make_key(bits=CONFIG.RSA_KEY_SIZE): """Generate PEM encoded RSA key. - :param int bits: Number of bits. + :param int bits: Number of bits, at least 1024. :returns: new RSA key in PEM form with specified number of bits :rtype: str diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 272555d80..ac4b59d37 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -112,6 +112,14 @@ class CSRMatchesPubkeyTest(unittest.TestCase): self.assertFalse(self._call_testdata('csr.pem', RSA512_KEY)) +class MakeKeyTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.make_key.""" + + def test_it(self): + from letsencrypt.client.crypto_util import make_key + M2Crypto.RSA.load_key_string(make_key(1024)) + + class ValidPrivkeyTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.valid_privkey.""" From a74bc582fff17a64103a162114c83a954961eb2d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 12 Dec 2014 18:03:18 -0800 Subject: [PATCH 11/66] Add unit test for challenge_util --- letsencrypt/client/client.py | 2 - .../client/tests/challenge_util_test.py | 63 +++++++++++++++++++ .../client/tests/testdata/rsa256_key.pem | 9 +++ 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 letsencrypt/client/tests/challenge_util_test.py create mode 100644 letsencrypt/client/tests/testdata/rsa256_key.pem diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 11c8dbcc7..1843a7839 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -505,8 +505,6 @@ class Client(object): logging.info( "Configured Apache for challenges; waiting for verification...") - print responses - return responses, challenge_objs def _assign_responses(self, resp, index_list, responses): diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py new file mode 100644 index 000000000..a03bc47b7 --- /dev/null +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -0,0 +1,63 @@ +"""Tests for challenge_util.""" +import M2Crypto +import mock +import os +import pkg_resources +import re +import unittest + +from letsencrypt.client import challenge_util +from letsencrypt.client import client +from letsencrypt.client import CONFIG +from letsencrypt.client import le_util + + +class DvnsiGenCertTest(unittest.TestCase): + """Tests for letsencrypt.client.challenge_util.dvsni_gen_cert.""" + + def test_standard(self): + """Basic test for straightline code.""" + + # This is a helper function that can be used for handling + # open context managers more elegantly. It avoids dealing with + # __enter__ and __exit__ calls. + # http://www.voidspace.org.uk/python/mock/helpers.html#mock.mock_open + m_open = mock.mock_open() + with mock.patch("letsencrypt.client.challenge_util.open", + m_open, create=True): + + domain = "example.com" + dvsni_r = "r_value" + r_b64 = le_util.jose_b64encode(dvsni_r) + pem = pkg_resources.resource_string( + __name__, os.path.join("testdata", "rsa256_key.pem")) + key = client.Client.Key("path", pem) + nonce = "12345ABCDE" + s_b64 = self._call("tmp.crt", domain, r_b64, nonce, key) + + self.assertTrue(m_open.called) + self.assertEqual(m_open.call_args[0], ("tmp.crt", 'w')) + self.assertEqual(m_open().write.call_count, 1) + + # pylint: disable=protected-access + ext = challenge_util._dvsni_gen_ext( + dvsni_r, le_util.jose_b64decode(s_b64)) + self._standard_check_cert( + m_open().write.call_args[0][0], domain, nonce, ext) + + def _standard_check_cert(self, pem, domain, nonce, ext): + dns_regex = r"DNS:([^, $]*)" + cert = M2Crypto.X509.load_cert_string(pem) + self.assertEqual( + cert.get_subject().CN, nonce + CONFIG.INVALID_EXT) + + sans = cert.get_ext("subjectAltName").get_value() + + exp_sans = set([nonce + CONFIG.INVALID_EXT, domain, ext]) + act_sans = set(re.findall(dns_regex, sans)) + + self.assertEqual(exp_sans, act_sans) + + def _call(self, filepath, name, r_b64, nonce, key): + from letsencrypt.client.challenge_util import dvsni_gen_cert + return dvsni_gen_cert(filepath, name, r_b64, nonce, key) diff --git a/letsencrypt/client/tests/testdata/rsa256_key.pem b/letsencrypt/client/tests/testdata/rsa256_key.pem new file mode 100644 index 000000000..610c8d315 --- /dev/null +++ b/letsencrypt/client/tests/testdata/rsa256_key.pem @@ -0,0 +1,9 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79 +vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn +elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc +mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp +Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj +8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq +6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/ +-----END RSA PRIVATE KEY----- From 52555c2337515416a8c0874cd2a5e066126b99c5 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sat, 13 Dec 2014 22:42:47 -0800 Subject: [PATCH 12/66] Integrate rising test coverage requirement --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 75941366e..013b19c6c 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ commands = [testenv:cover] commands = python setup.py dev - python setup.py nosetests --with-coverage --cover-min-percentage=100 + python setup.py nosetests --with-coverage --cover-min-percentage=44 [testenv:lint] commands = From d53889b617cb8ac2f106d8a8f65f3759fd6896b7 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 14 Dec 2014 02:51:17 -0800 Subject: [PATCH 13/66] Fixed up remaining pylint errors --- letsencrypt/client/tests/challenge_util_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index a03bc47b7..33358410a 100644 --- a/letsencrypt/client/tests/challenge_util_test.py +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -11,13 +11,12 @@ from letsencrypt.client import client from letsencrypt.client import CONFIG from letsencrypt.client import le_util - +# pylint: disable=too-few-public-methods class DvnsiGenCertTest(unittest.TestCase): """Tests for letsencrypt.client.challenge_util.dvsni_gen_cert.""" def test_standard(self): """Basic test for straightline code.""" - # This is a helper function that can be used for handling # open context managers more elegantly. It avoids dealing with # __enter__ and __exit__ calls. @@ -46,6 +45,7 @@ class DvnsiGenCertTest(unittest.TestCase): m_open().write.call_args[0][0], domain, nonce, ext) def _standard_check_cert(self, pem, domain, nonce, ext): + """Check the certificate fields.""" dns_regex = r"DNS:([^, $]*)" cert = M2Crypto.X509.load_cert_string(pem) self.assertEqual( @@ -58,6 +58,7 @@ class DvnsiGenCertTest(unittest.TestCase): self.assertEqual(exp_sans, act_sans) + # pylint: disable= no-self-use def _call(self, filepath, name, r_b64, nonce, key): from letsencrypt.client.challenge_util import dvsni_gen_cert return dvsni_gen_cert(filepath, name, r_b64, nonce, key) From 89ee6971ebe723cdd89ba9a8af7cc7a1988ed389 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 14 Dec 2014 03:15:19 -0800 Subject: [PATCH 14/66] Fix import grouping --- letsencrypt/client/tests/challenge_util_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index 33358410a..8bad21341 100644 --- a/letsencrypt/client/tests/challenge_util_test.py +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -1,11 +1,12 @@ """Tests for challenge_util.""" -import M2Crypto -import mock import os import pkg_resources import re import unittest +import M2Crypto +import mock + from letsencrypt.client import challenge_util from letsencrypt.client import client from letsencrypt.client import CONFIG From b8902c272d12b8c315bc505607314a9ef7a14301 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 14 Dec 2014 04:30:12 -0800 Subject: [PATCH 15/66] Remove MakeCSRTest --- letsencrypt/client/tests/crypto_util_test.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index ac4b59d37..e80988d83 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -47,26 +47,6 @@ class CreateSigTest(unittest.TestCase): self.assertEqual(signature, self.signature) -class MakeCSRTest(unittest.TestCase): - """Tests for letsencrypt.client.crypto_util.make_csr.""" - - def test_single_domain(self): - from letsencrypt.client.crypto_util import make_csr - pem, der = make_csr(RSA256_KEY, ['example.com']) - self.assertEqual(pem, pkg_resources.resource_string( - __name__, 'testdata/csr.pem')) - self.assertEqual(der, pkg_resources.resource_string( - __name__, 'testdata/csr.der')) - - def test_san(self): - from letsencrypt.client.crypto_util import make_csr - pem, der = make_csr(RSA256_KEY, ['example.com', 'www.example.com']) - self.assertEqual(pem, pkg_resources.resource_string( - __name__, 'testdata/csr-san.pem')) - self.assertEqual(der, pkg_resources.resource_string( - __name__, 'testdata/csr-san.der')) - - class ValidCSRTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.valid_csr.""" From 7b6081ac2918ca8a6c3400a3284129e1329c1cac Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 15 Dec 2014 23:52:18 -0800 Subject: [PATCH 16/66] Move out Apache specific Objects --- letsencrypt/client/apache_obj.py | 90 ++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 letsencrypt/client/apache_obj.py diff --git a/letsencrypt/client/apache_obj.py b/letsencrypt/client/apache_obj.py new file mode 100644 index 000000000..a43dadb7d --- /dev/null +++ b/letsencrypt/client/apache_obj.py @@ -0,0 +1,90 @@ +"""Module contains classes used by the Apache Configurator.""" + +class Addr(object): + """Represents an Apache VirtualHost address.""" + def __init__(self, addr): + """:param tuple addr: tuple of strings (ip, port)""" + self.tup = addr + + @classmethod + def fromstring(cls, str_addr): + """Initialize Addr from string.""" + tup = str_addr.partition(':') + return cls((tup[0], tup[2])) + + def __str__(self): + return ':'.join(self.tup) + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.tup == other.tup + return False + + def set_port(self, port): + """Set the port of the address. + + :param str port: new port + """ + self.tup = (self.tup[0], port) + + def get_addr(self): + """Return addr part of Addr object.""" + return self.tup[0] + + def get_port(self): + """Return port.""" + return self.tup[1] + + def get_ssl_addr_obj(self): + return cls((self.tup[0], "443")) + + def get_80_addr_obj(self): + return cls((self.tup[0], "80")) + + def get_addr_obj(self, port): + return cls((self.tup[0], port)) + +class VH(object): + """Represents an Apache Virtualhost. + + :ivar str filep: file path of VH + :ivar str path: Augeas path to virtual host + :ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`) + :ivar set names: Server names/aliases of vhost + (:class:`list` of :class:`str`) + + :ivar bool ssl: SSLEngine on in vhost + :ivar bool enabled: Virtual host is enabled + + """ + + def __init__(self, filep, path, addrs, ssl, enabled, names=None): + """Initialize a VH.""" + self.filep = filep + self.path = path + self.addrs = addrs + self.names = set() if names is None else names + self.ssl = ssl + self.enabled = enabled + + def add_name(self, name): + """Add name to vhost.""" + self.names.add(name) + + def __str__(self): + return ("file: %s\n" + "vh_path: %s\n" + "addrs: %s\n" + "names: %s\n" + "ssl: %s\n" + "enabled: %s" % (self.filep, self.path, self.addrs, + self.names, self.ssl, self.enabled)) + + def __eq__(self, other): + if isinstance(other, self.__class__): + return (self.filep == other.filep and self.path == other.path and + self.addrs == other.addrs and + self.names == other.names and + self.ssl == other.ssl and self.enabled == other.enabled) + + return False From 6cd67e652b7ba69ece68ffb6987a713a707bea7f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 16 Dec 2014 01:35:46 -0800 Subject: [PATCH 17/66] Refactor to use Addr objects, and sets --- letsencrypt/client/apache_configurator.py | 272 +++++++----------- letsencrypt/client/apache_obj.py | 19 +- .../client/tests/apache_configurator_test.py | 33 ++- 3 files changed, 131 insertions(+), 193 deletions(-) diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache_configurator.py index 11a999e9b..63c61250d 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache_configurator.py @@ -1,5 +1,4 @@ """Apache Configuration based off of Augeas Configurator.""" -import hashlib import logging import os import pkg_resources @@ -9,12 +8,10 @@ import socket import subprocess import sys -from Crypto import Random - +from letsencrypt.client import apache_obj from letsencrypt.client import augeas_configurator from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG -from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import le_util @@ -44,51 +41,6 @@ from letsencrypt.client import le_util # over before the updates are made to the existing files. NEW_FILES is # transactional due to the use of register_file_creation() -class VH(object): - """Represents an Apache Virtualhost. - - :ivar str filep: file path of VH - :ivar str path: Augeas path to virtual host - :ivar list addrs: Virtual Host addresses (:class:`list` of :class:`str`) - :ivar list names: Server names/aliases of vhost - (:class:`list` of :class:`str`) - - :ivar bool ssl: SSLEngine on in vhost - :ivar bool enabled: Virtual host is enabled - - """ - - def __init__(self, filep, path, addrs, ssl, enabled, names=None): - """Initialize a VH.""" - self.filep = filep - self.path = path - self.addrs = addrs - self.names = [] if names is None else names - self.ssl = ssl - self.enabled = enabled - - def add_name(self, name): - """Add name to vhost.""" - self.names.append(name) - - def __str__(self): - return ("file: %s\n" - "vh_path: %s\n" - "addrs: %s\n" - "names: %s\n" - "ssl: %s\n" - "enabled: %s" % (self.filep, self.path, self.addrs, - self.names, self.ssl, self.enabled)) - - def __eq__(self, other): - if isinstance(other, self.__class__): - return (self.filep == other.filep and self.path == other.path and - set(self.addrs) == set(other.addrs) and - set(self.names) == set(other.names) and - self.ssl == other.ssl and self.enabled == other.enabled) - - return False - class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Apache configurator. @@ -116,7 +68,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): with the configuration :ivar float version: version of Apache :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`VH`) + (:class:`list` of :class:`letsencrypt.client.apache_obj.VH`) :ivar dict assoc: Mapping between domains and vhosts @@ -206,7 +158,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): This shouldn't happen within letsencrypt though :param vhost: ssl vhost to deploy certificate - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache_obj.VH` :param str cert: certificate filename :param str key: private key filename @@ -264,7 +216,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str name: domain name :returns: ssl vhost associated with name - :rtype: :class:`VH` + :rtype: :class:`letsencrypt.client.apache_obj.VH` """ # Allows for domain names to be associated with a virtual host @@ -274,31 +226,24 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return vhost # Check for servernames/aliases for ssl hosts for vhost in self.vhosts: - if vhost.ssl: - for name in vhost.names: - if name == target_name: - return vhost + if vhost.ssl and target_name in vhost.names: + return vhost # Checking for domain name in vhost address # This technique is not recommended by Apache but is technically valid + target_addr = apache_obj.Addr((target_name, "443")) for vhost in self.vhosts: - for addr in vhost.addrs: - tup = addr.partition(":") - if tup[0] == target_name and tup[2] == "443": - return vhost + if target_addr in vhost.addrs: + return vhost # Check for non ssl vhosts with servernames/aliases == 'name' for vhost in self.vhosts: - if not vhost.ssl: - for name in vhost.names: - if name == target_name: - # When do we need to self.make_vhost_ssl(v) - return self.make_vhost_ssl(vhost) + if not vhost.ssl and target_name in vhost.names: + return self.make_vhost_ssl(vhost) # No matches, search for the default for vhost in self.vhosts: - for addr in vhost.addrs: - if addr == "_default_:443": - return vhost + if "_defualt_:443" in vhost.addrs: + return vhost return None def create_dn_server_assoc(self, domain, vhost): @@ -309,7 +254,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache_obj.VH` """ self.assoc[domain] = vhost @@ -332,13 +277,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): for vhost in self.vhosts: all_names.update(vhost.names) for addr in vhost.addrs: - a_tup = addr.partition(":") - # If it isn't a private IP, do a reverse DNS lookup - if not private_ips.match(a_tup[0]): + if not private_ips.match(addr.get_addr()): try: - socket.inet_aton(a_tup[0]) - all_names.add(socket.gethostbyaddr(a_tup[0])[0]) + socket.inet_aton(addr.get_addr()) + all_names.add(socket.gethostbyaddr(addr.get_addr())[0]) except (socket.error, socket.herror, socket.timeout): continue @@ -402,7 +345,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Helper function for get_virtual_hosts(). :param host: In progress vhost whose names will be added - :type host: :class:`VH` + :type host: :class:`letsencrypt.client.apache_obj.VH` """ name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " @@ -423,13 +366,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`VH` + :rtype: :class:`letsencrypt.client.apache_obj.VH` """ - addrs = [] + addrs = set() args = self.aug.match(path + "/arg") for arg in args: - addrs.append(self.aug.get(arg)) + addrs.add(apache_obj.Addr.fromstring(self.aug.get(arg))) is_ssl = False if self.find_directive( @@ -438,7 +381,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): filename = get_file_path(path) is_enabled = self.is_site_enabled(filename) - vhost = VH(filename, path, addrs, is_ssl, is_enabled) + vhost = apache_obj.VH(filename, path, addrs, is_ssl, is_enabled) self._add_servernames(vhost) return vhost @@ -446,7 +389,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def get_virtual_hosts(self): """Returns list of virtual hosts found in the Apache configuration. - :returns: List of :class:`VH` objects found in configuration + :returns: List of :class:`letsencrypt.client.apache_obj.VH` objects + found in configuration :rtype: list """ @@ -482,7 +426,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # note ip_addr can be FQDN although Apache does not recommend it return (self.version >= (2, 4) or self.find_directive( - case_i("NameVirtualHost"), case_i(target_addr))) + case_i("NameVirtualHost"), case_i(str(target_addr)))) def add_name_vhost(self, addr): """Adds NameVirtualHost directive for given address. @@ -491,7 +435,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ path = self._add_dir_to_ifmodssl( - get_aug_path(self.location["name"]), "NameVirtualHost", addr) + get_aug_path(self.location["name"]), "NameVirtualHost", str(addr)) self.save_notes += "Setting %s to be NameBasedVirtualHost\n" % addr self.save_notes += "\tDirective added to %s\n" % path @@ -542,7 +486,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Checks to see if the server is ready for SNI challenges. :param vhost: VHost to check SNI compatibility - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache_obj.VH` :param str default_addr: TODO - investigate function further @@ -552,8 +496,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Check for NameVirtualHost # First see if any of the vhost addresses is a _default_ addr for addr in vhost.addrs: - tup = addr.partition(":") - if tup[0] == "_default_": + if addr.get_addr() == "_default_": if not self.is_name_vhost(default_addr): logging.debug("Setting all VirtualHosts on %s to be " "name based vhosts", default_addr) @@ -745,14 +688,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): New vhost will reside as (nonssl_vhost.path) + CONFIG.LE_VHOST_EXT :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`VH` + :type nonssl_vhost: :class:`letsencrypt.client.apache_obj.VH` :returns: SSL vhost - :rtype: :class:`VH` + :rtype: :class:`letsencrypt.client.apache_obj.VH` """ avail_fp = nonssl_vhost.filep - # Copy file + # Get filepath of new ssl_vhost if avail_fp.endswith(".conf"): ssl_fp = avail_fp[:-(len(".conf"))] + CONFIG.LE_VHOST_EXT else: @@ -777,28 +720,20 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): new_file.close() self.aug.load() - # Delete the VH addresses because they may change here - del nonssl_vhost.addrs[:] - ssl_addrs = [] - # change address to address:443, address:80 + ssl_addrs = set() + + # change address to address:443 addr_match = "/files%s//* [label()=~regexp('%s')]/arg" ssl_addr_p = self.aug.match( addr_match % (ssl_fp, case_i('VirtualHost'))) - avail_addr_p = self.aug.match( - addr_match % (avail_fp, case_i('VirtualHost'))) - for i in range(len(avail_addr_p)): - avail_old_arg = str(self.aug.get(avail_addr_p[i])) - ssl_old_arg = str(self.aug.get(ssl_addr_p[i])) - avail_tup = avail_old_arg.partition(":") - ssl_tup = ssl_old_arg.partition(":") - avail_new_addr = avail_tup[0] + ":80" - ssl_new_addr = ssl_tup[0] + ":443" - self.aug.set(avail_addr_p[i], avail_new_addr) - self.aug.set(ssl_addr_p[i], ssl_new_addr) - nonssl_vhost.addrs.append(avail_new_addr) - ssl_addrs.append(ssl_new_addr) + for i in range(len(ssl_addr_p)): + ssl_addr_arg = apache_obj.Addr.fromstring( + str(self.aug.get(ssl_addr_p[i]))) + ssl_addr_arg.set_port("443") + self.aug.set(ssl_addr_p[i], str(ssl_addr_arg)) + ssl_addrs.add(ssl_addr_arg) # Add directives vh_p = self.aug.match(("/files%s//* [label()=~regexp('%s')]" % @@ -822,19 +757,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ssl_vhost = self._create_vhost(vh_p[0]) self.vhosts.append(ssl_vhost) - # Check if nonssl_vhost's address was NameVirtualHost # NOTE: Searches through Augeas seem to ruin changes to directives # The configuration must also be saved before being searched # for the new directives; For these reasons... this is tacked # on after fully creating the new vhost need_to_save = False - for i in range(len(nonssl_vhost.addrs)): - - if (self.is_name_vhost(nonssl_vhost.addrs[i]) and - not self.is_name_vhost(ssl_addrs[i])): - self.add_name_vhost(ssl_addrs[i]) - logging.info("Enabling NameVirtualHosts on %s", ssl_addrs[i]) - need_to_save = True + # See if the exact address appears in any other vhost + for addr in ssl_addrs: + for vhost in self.vhosts: + if (ssl_vhost.filep != vhost.filep and addr in vhost.addrs and + not self.is_name_vhost(addr)): + self.add_name_vhost(addr) + logging.info("Enabling NameVirtualHosts on %s", addr) + need_to_save = True if need_to_save: self.save() @@ -850,10 +785,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): The function then adds the directive :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`VH` + :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`VH`) + :rtype: (bool, :class:`letsencrypt.client.apache_obj.VH`) """ # TODO: Enable check to see if it is already there @@ -899,7 +834,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache_obj.VH` :returns: Success, code value... see documentation :rtype: bool, int @@ -930,10 +865,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`VH` + :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` :returns: Success, vhost - :rtype: (bool, :class:`VH`) + :rtype: (bool, :class:`letsencrypt.client.apache_obj.VH`) """ # Consider changing this to a dictionary check @@ -953,17 +888,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if size_n > 1: serveralias = " ".join(ssl_vhost.names[1:size_n]) serveralias = "ServerAlias " + serveralias - redirect_file = " \n\ -" + servername + "\n\ -" + serveralias + " \n\ -ServerSignature Off \n\ -\n\ -RewriteEngine On \n\ -RewriteRule ^.*$ https://%{SERVER_NAME}%{REQUEST_URI} [L,R=permanent]\n\ -\n\ -ErrorLog /var/log/apache2/redirect.error.log \n\ -LogLevel warn \n\ -\n" + redirect_file = ("\n" + "%s \n" + "%s \n" + "ServerSignature Off\n" + "\n" + "RewriteEngine On\n" + "RewriteRule %s\n" + "\n" + "ErrorLog /var/log/apache2/redirect.error.log\n" + "LogLevel warn\n" + "\n" + % (servername, serveralias, + " ".join(CONFIG.REWRITE_HTTPS_ARGS))) # Write out the file # This is the default name @@ -1014,7 +951,7 @@ LogLevel warn \n\ if not conflict: returns space separated list of new host addrs :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`VH` + :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` :returns: TODO :rtype: TODO @@ -1025,21 +962,15 @@ LogLevel warn \n\ for ssl_a in ssl_vhost.addrs: # Add space on each new addr, combine "VirtualHost"+redirect_addrs redirect_addrs = redirect_addrs + " " - ssl_tup = ssl_a.partition(":") - ssl_a_vhttp = ssl_tup[0] + ":80" + ssl_a_vhttp = ssl_a.get_addr_obj("80") # Search for a conflicting host... for vhost in self.vhosts: if vhost.enabled: - for addr in vhost.addrs: - # Convert :* to standard ip address - if addr.endswith(":*"): - addr = addr[:len(addr)-2] - # Would require NameBasedVirtualHosts,too complicated? - # Maybe do later... right now just return false - # or overlapping addresses... order matters - if addr == ssl_a_vhttp or addr == ssl_tup[0]: - # We have found a conflicting host... just return - return True, vhost + if (ssl_a_vhttp in vhost.addrs or + ssl_a.get_addr_obj("") in vhost.addrs or + ssl_a.get_addr_obj("*") in vhost.addrs): + # We have found a conflicting host... just return + return True, vhost redirect_addrs = redirect_addrs + ssl_a_vhttp @@ -1053,18 +984,18 @@ LogLevel warn \n\ Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`VH` + :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`VH` or None + :rtype: :class:`letsencrypt.client.apache_obj.VH` or None """ # _default_:443 check # Instead... should look for vhost of the form *:80 # Should we prompt the user? ssl_addrs = ssl_vhost.addrs - if ssl_addrs == ["_default_:443"]: - ssl_addrs = ["*:443"] + if ssl_addrs == apache_obj.Addr.fromstring("_default_:443"): + ssl_addrs = [apache_obj.Addr.fromstring("*:443")] for vhost in self.vhosts: found = 0 @@ -1072,20 +1003,18 @@ LogLevel warn \n\ if vhost != ssl_vhost and len(vhost.addrs) == len(ssl_vhost.addrs): # Find each address in ssl_host in test_host for ssl_a in ssl_addrs: - ssl_tup = ssl_a.partition(":") for test_a in vhost.addrs: - test_tup = test_a.partition(":") - if test_tup[0] == ssl_tup[0]: + if test_a.get_addr() == ssl_a.get_addr(): # Check if found... - if (test_tup[2] == "80" or - test_tup[2] == "" or - test_tup[2] == "*"): + if (test_a.get_port() == "80" or + test_a.get_port() == "" or + test_a.get_port() == "*"): found += 1 break # Check to make sure all addresses were found # and names are equal if (found == len(ssl_vhost.addrs) and - set(vhost.names) == set(ssl_vhost.names)): + vhost.names == ssl_vhost.names): return vhost return None @@ -1152,7 +1081,7 @@ LogLevel warn \n\ .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache_obj.VH` :returns: Success :rtype: bool @@ -1364,8 +1293,8 @@ LogLevel warn \n\ `chall_dict` composed of: list_sni_tuple: - List of tuples with form `(addr, r, nonce)`, where - `addr` (`str`), `r` (base64 `str`), `nonce` (hex `str`) + List of tuples with form `(name, r, nonce)`, where + `name` (`str`), `r` (base64 `str`), `nonce` (hex `str`) dvsni_key: DVSNI key (:class:`letsencrypt.client.client.Client.Key`) @@ -1398,11 +1327,11 @@ LogLevel warn \n\ self.make_server_sni_ready(vhost, default_addr) for addr in vhost.addrs: - if "_default_" in addr: + if "_default_" == addr.get_addr(): addresses.append([default_addr]) break else: - addresses.append(vhost.addrs) + addresses.append(list(vhost.addrs)) responses = [] @@ -1446,7 +1375,8 @@ LogLevel warn \n\ :param dvsni_key: DVSNI key :type dvsni_key: :class:`letsencrypt.client.client.Client.Key` - :param list ll_addrs: list of list of addresses to apply + :param list ll_addrs: list of list of + :class:`letsencrypt.client.apache_obj.Addr` to apply """ # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY @@ -1493,26 +1423,28 @@ LogLevel warn \n\ """Chocolate virtual server configuration text :param str nonce: hex form of nonce - :param str ip_addrs: addresses of challenged domain + :param list ip_addrs: addresses of challenged domain + :class:`list` of type :class:`letsencrypt.client.apache_obj.Addr` :param str dvsni_key_file: Path to key file :returns: virtual host configuration text :rtype: str """ - return (" \n" - "ServerName " + nonce + CONFIG.INVALID_EXT + " \n" - "UseCanonicalName on \n" - "SSLStrictSNIVHostCheck on \n" + ips = " ".join(str(i) for i in ip_addrs) + return ("\n" + "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" + "UseCanonicalName on\n" + "SSLStrictSNIVHostCheck on\n" "\n" - "LimitRequestBody 1048576 \n" + "LimitRequestBody 1048576\n" "\n" - "Include " + self.location["ssl_options"] + " \n" - "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + " \n" - "SSLCertificateKeyFile " + dvsni_key_file + " \n" + "Include " + self.location["ssl_options"] + "\n" + "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + "\n" + "SSLCertificateKeyFile " + dvsni_key_file + "\n" "\n" - "DocumentRoot " + self.direc["config"] + "challenge_page/ \n" - " \n\n") + "DocumentRoot " + self.direc["config"] + "challenge_page/\n" + "\n\n") def dvsni_get_cert_file(self, nonce): """Returns standardized name for challenge certificate. diff --git a/letsencrypt/client/apache_obj.py b/letsencrypt/client/apache_obj.py index a43dadb7d..b83066d81 100644 --- a/letsencrypt/client/apache_obj.py +++ b/letsencrypt/client/apache_obj.py @@ -1,11 +1,12 @@ """Module contains classes used by the Apache Configurator.""" + class Addr(object): """Represents an Apache VirtualHost address.""" def __init__(self, addr): """:param tuple addr: tuple of strings (ip, port)""" self.tup = addr - + @classmethod def fromstring(cls, str_addr): """Initialize Addr from string.""" @@ -13,13 +14,18 @@ class Addr(object): return cls((tup[0], tup[2])) def __str__(self): - return ':'.join(self.tup) + if self.tup[1] != "": + return ':'.join(self.tup) + return str(self.tup[0]) def __eq__(self, other): if isinstance(other, self.__class__): return self.tup == other.tup return False + def __hash__(self): + return hash(self.tup) + def set_port(self, port): """Set the port of the address. @@ -35,14 +41,9 @@ class Addr(object): """Return port.""" return self.tup[1] - def get_ssl_addr_obj(self): - return cls((self.tup[0], "443")) - - def get_80_addr_obj(self): - return cls((self.tup[0], "80")) - def get_addr_obj(self, port): - return cls((self.tup[0], port)) + return self.__class__((self.tup[0], port)) + class VH(object): """Represents an Apache Virtualhost. diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index 08c99cbeb..8e745b7d7 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -3,12 +3,12 @@ import os import pkg_resources import re import shutil -import sys import tempfile import unittest import mock +from letsencrypt.client import apache_obj from letsencrypt.client import apache_configurator from letsencrypt.client import CONFIG from letsencrypt.client import display @@ -62,22 +62,25 @@ class TwoVhost80Test(unittest.TestCase): self.temp_dir, "two_vhost_80/apache2/sites-available") aug_pre = "/files" + prefix self.vh_truth = [ - apache_configurator.VH( + apache_obj.VH( os.path.join(prefix, "encryption-example.conf"), os.path.join(aug_pre, "encryption-example.conf/VirtualHost"), - ["*:80"], False, True, ["encryption-example.demo"]), - apache_configurator.VH( + set([apache_obj.Addr.fromstring("*:80")]), + False, True, set(["encryption-example.demo"])), + apache_obj.VH( os.path.join(prefix, "default-ssl.conf"), os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), - ["_default_:443"], True, False), - apache_configurator.VH( + set([apache_obj.Addr.fromstring("_default_:443")]), True, False), + apache_obj.VH( os.path.join(prefix, "000-default.conf"), os.path.join(aug_pre, "000-default.conf/VirtualHost"), - ["*:80"], False, True, ["ip-172-30-0-17"]), - apache_configurator.VH( + set([apache_obj.Addr.fromstring("*:80")]), False, True, + set(["ip-172-30-0-17"])), + apache_obj.VH( os.path.join(prefix, "letsencrypt.conf"), os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), - ["*:80"], False, True, ["letsencrypt.demo"]), + set([apache_obj.Addr.fromstring("*:80")]), False, True, + set(["letsencrypt.demo"])), ] def tearDown(self): @@ -104,7 +107,7 @@ class TwoVhost80Test(unittest.TestCase): def test_get_all_names(self): names = self.config.get_all_names() - self.assertEqual(set(names), set( + self.assertEqual(names, set( ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) def test_find_directive(self): @@ -120,6 +123,7 @@ class TwoVhost80Test(unittest.TestCase): vhs = self.config.get_virtual_hosts() self.assertEqual(len(vhs), 4) found = 0 + for vhost in vhs: for truth in self.vh_truth: if vhost == truth: @@ -171,9 +175,10 @@ class TwoVhost80Test(unittest.TestCase): self.vh_truth[1].filep) def test_is_name_vhost(self): - self.assertTrue(self.config.is_name_vhost("*:80")) + addr = apache_obj.Addr.fromstring("*:80") + self.assertTrue(self.config.is_name_vhost(addr)) self.config.version = (2, 2) - self.assertFalse(self.config.is_name_vhost("*:80")) + self.assertFalse(self.config.is_name_vhost(addr)) def test_add_name_vhost(self): self.config.add_name_vhost("*:443") @@ -205,8 +210,8 @@ class TwoVhost80Test(unittest.TestCase): self.assertEqual(ssl_vhost.path, "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") - self.assertEqual(ssl_vhost.addrs, ["*:443"]) - self.assertEqual(ssl_vhost.names, ["encryption-example.demo"]) + self.assertEqual(ssl_vhost.addrs, set([apache_obj.Addr.fromstring("*:443")])) + self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) self.assertTrue(ssl_vhost.ssl) self.assertFalse(ssl_vhost.enabled) From 323aa350dc4b99616a4464f666631c5fd78ecd5f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 16 Dec 2014 21:00:14 -0800 Subject: [PATCH 18/66] Remove Apache parsing from configurator --- .../client/{apache_obj.py => apache/obj.py} | 2 + letsencrypt/client/apache/parser.py | 402 ++++++++++++ letsencrypt/client/apache_configurator.py | 615 +++--------------- letsencrypt/client/augeas_configurator.py | 2 - .../client/tests/apache_configurator_test.py | 74 ++- setup.py | 1 + 6 files changed, 526 insertions(+), 570 deletions(-) rename letsencrypt/client/{apache_obj.py => apache/obj.py} (95%) create mode 100644 letsencrypt/client/apache/parser.py diff --git a/letsencrypt/client/apache_obj.py b/letsencrypt/client/apache/obj.py similarity index 95% rename from letsencrypt/client/apache_obj.py rename to letsencrypt/client/apache/obj.py index b83066d81..b5bc97302 100644 --- a/letsencrypt/client/apache_obj.py +++ b/letsencrypt/client/apache/obj.py @@ -42,9 +42,11 @@ class Addr(object): return self.tup[1] def get_addr_obj(self, port): + """Return new address object with same addr and new port.""" return self.__class__((self.tup[0], port)) +# pylint: disable=too-few-public-methods class VH(object): """Represents an Apache Virtualhost. diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py new file mode 100644 index 000000000..409c82b35 --- /dev/null +++ b/letsencrypt/client/apache/parser.py @@ -0,0 +1,402 @@ +"""ApacheParser is a member object of the ApacheConfigurator class.""" +import os +import re + + +class ApacheParser(object): + """Class handles the fine details of parsing the Apache Configuration.""" + + def __init__(self, aug, root, ssl_options): + # Find configuration root and make sure augeas can parse it. + self.aug = aug + self.root = root + self.loc = self._set_locations(ssl_options) + self._parse_file(self.loc["root"]) + + # Must also attempt to parse sites-available or equivalent + # Sites-available is not included naturally in configuration + self._parse_file(os.path.join(self.root, "sites-available/*")) + + # This problem has been fixed in Augeas 1.0 + self.standardize_excl() + + def add_dir_to_ifmodssl(self, aug_conf_path, directive, val): + """Adds directive and value to IfMod ssl block. + + Adds given directive and value along configuration path within + an IfMod mod_ssl.c block. If the IfMod block does not exist in + the file, it is created. + + :param str aug_conf_path: Desired Augeas config path to add directive + :param str directive: Directive you would like to add + :param str val: Value of directive ie. Listen 443, 443 is the value + + """ + # TODO: Add error checking code... does the path given even exist? + # Does it throw exceptions? + if_mod_path = self._get_ifmod(aug_conf_path, "mod_ssl.c") + # IfModule can have only one valid argument, so append after + self.aug.insert(if_mod_path + "arg", "directive", False) + nvh_path = if_mod_path + "directive[1]" + self.aug.set(nvh_path, directive) + self.aug.set(nvh_path + "/arg", val) + + def _get_ifmod(self, aug_conf_path, mod): + """Returns the path to and creates one if it doesn't exist. + + :param str aug_conf_path: Augeas configuration path + :param str mod: module ie. mod_ssl.c + + """ + if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % + (aug_conf_path, mod))) + if len(if_mods) == 0: + self.aug.set("%s/IfModule[last() + 1]" % aug_conf_path, "") + self.aug.set("%s/IfModule[last()]/arg" % aug_conf_path, mod) + if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % + (aug_conf_path, mod))) + # Strip off "arg" at end of first ifmod path + return if_mods[0][:len(if_mods[0]) - 3] + + def add_dir(self, aug_conf_path, directive, arg): + """Appends directive to the end fo the file given by aug_conf_path. + + .. note:: Not added to AugeasConfigurator because it may depend + on the lens + + :param str aug_conf_path: Augeas configuration path to add directive + :param str directive: Directive to add + :param str arg: Value of the directive. ie. Listen 443, 443 is arg + + """ + self.aug.set(aug_conf_path + "/directive[last() + 1]", directive) + if type(arg) is not list: + self.aug.set(aug_conf_path + "/directive[last()]/arg", arg) + else: + for i in range(len(arg)): + self.aug.set("%s/directive[last()]/arg[%d]" % + (aug_conf_path, (i+1)), + arg[i]) + + def find_dir(self, directive, arg=None, start=None): + """Finds directive in the configuration. + + Recursively searches through config files to find directives + Directives should be in the form of a case insensitive regex currently + + .. todo:: Add order to directives returned. Last directive comes last.. + .. todo:: arg should probably be a list + + Note: Augeas is inherently case sensitive while Apache is case + insensitive. Augeas 1.0 allows case insensitive regexes like + regexp(/Listen/, 'i'), however the version currently supported + by Ubuntu 0.10 does not. Thus I have included my own case insensitive + transformation by calling case_i() on everything to maintain + compatibility. + + :param str directive: Directive to look for + + :param arg: Specific value direcitve must have, None if all should + be considered + :type arg: str or None + + :param str start: Beginning Augeas path to begin looking + + """ + # Cannot place member variable in the definition of the function so... + if not start: + start = get_aug_path(self.loc["root"]) + + # Debug code + # print "find_dir:", directive, "arg:", arg, " | Looking in:", start + # No regexp code + # if arg is None: + # matches = self.aug.match(start + + # "//*[self::directive='"+directive+"']/arg") + # else: + # matches = self.aug.match(start + + # "//*[self::directive='" + directive+"']/* [self::arg='" + arg + "']") + + # includes = self.aug.match(start + + # "//* [self::directive='Include']/* [label()='arg']") + + if arg is None: + matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg" + % (start, directive))) + else: + matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*" + "[self::arg=~regexp('%s')]" % + (start, directive, arg))) + + incl_regex = "(%s)|(%s)" % (case_i('Include'), + case_i('IncludeOptional')) + + includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* " + "[label()='arg']" % (start, incl_regex))) + + # for inc in includes: + # print inc, self.aug.get(inc) + + for include in includes: + # start[6:] to strip off /files + matches.extend(self.find_dir( + directive, arg, self._get_include_path( + strip_dir(start[6:]), self.aug.get(include)))) + + return matches + + def _get_include_path(self, cur_dir, arg): + """Converts an Apache Include directive into Augeas path. + + Converts an Apache Include directive argument into an Augeas + searchable path + + .. todo:: convert to use os.path.join() + + :param str cur_dir: current working directory + + :param str arg: Argument of Include directive + + :returns: Augeas path string + :rtype: str + + """ + # Sanity check argument - maybe + # Question: what can the attacker do with control over this string + # Effect parse file... maybe exploit unknown errors in Augeas + # If the attacker can Include anything though... and this function + # only operates on Apache real config data... then the attacker has + # already won. + # Perhaps it is better to simply check the permissions on all + # included files? + # check_config to validate apache config doesn't work because it + # would create a race condition between the check and this input + + # TODO: Maybe... although I am convinced we have lost if + # Apache files can't be trusted. The augeas include path + # should be made to be exact. + + # Check to make sure only expected characters are used <- maybe remove + # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") + # matchObj = validChars.match(arg) + # if matchObj.group() != arg: + # logging.error("Error: Invalid regexp characters in %s", arg) + # return [] + + # Standardize the include argument based on server root + if not arg.startswith("/"): + arg = cur_dir + arg + # conf/ is a special variable for ServerRoot in Apache + elif arg.startswith("conf/"): + arg = self.root + arg[5:] + # TODO: Test if Apache allows ../ or ~/ for Includes + + # Attempts to add a transform to the file if one does not already exist + self._parse_file(arg) + + # Argument represents an fnmatch regular expression, convert it + # Split up the path and convert each into an Augeas accepted regex + # then reassemble + if "*" in arg or "?" in arg: + split_arg = arg.split("/") + for idx, split in enumerate(split_arg): + # * and ? are the two special fnmatch characters + if "*" in split or "?" in split: + # Turn it into a augeas regex + # TODO: Can this instead be an augeas glob instead of regex + split_arg[idx] = ("* [label()=~regexp('%s')]" % + self.fnmatch_to_re(split)) + # Reassemble the argument + arg = "/".join(split_arg) + + # If the include is a directory, just return the directory as a file + if arg.endswith("/"): + return get_aug_path(arg[:len(arg)-1]) + return get_aug_path(arg) + + def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use + """Method converts Apache's basic fnmatch to regular expression. + + :param str clean_fn_match: Apache style filename match, similar to globs + + :returns: regex suitable for augeas + :rtype: str + + """ + regex = "" + for letter in clean_fn_match: + if letter == '.': + regex = regex + r"\." + elif letter == '*': + regex = regex + ".*" + # According to apache.org ? shouldn't appear + # but in case it is valid... + elif letter == '?': + regex = regex + "." + else: + regex = regex + letter + return regex + + def _parse_file(self, file_path): + """Parse file with Augeas + + Checks to see if file_path is parsed by Augeas + If file_path isn't parsed, the file is added and Augeas is reloaded + + :param str file_path: Apache config file path + + """ + # Test if augeas included file for Httpd.lens + # Note: This works for augeas globs, ie. *.conf + inc_test = self.aug.match( + "/augeas/load/Httpd/incl [. ='%s']" % file_path) + if not inc_test: + # Load up files + # self.httpd_incl.append(file_path) + # self.aug.add_transform("Httpd.lns", + # self.httpd_incl, None, self.httpd_excl) + self._add_httpd_transform(file_path) + self.aug.load() + + def standardize_excl(self): + """Standardize the excl arguments for the Httpd lens in Augeas. + + Note: Hack! + Standardize the excl arguments for the Httpd lens in Augeas + Servers sometimes give incorrect defaults + Note: This problem should be fixed in Augeas 1.0. Unfortunately, + Augeas 0.10 appears to be the most popular version currently. + + """ + # attempt to protect against augeas error in 0.10.0 - ubuntu + # *.augsave -> /*.augsave upon augeas.load() + # Try to avoid bad httpd files + # There has to be a better way... but after a day and a half of testing + # I had no luck + # This is a hack... work around... submit to augeas if still not fixed + + excl = ["*.augnew", "*.augsave", "*.dpkg-dist", "*.dpkg-bak", + "*.dpkg-new", "*.dpkg-old", "*.rpmsave", "*.rpmnew", + "*~", + self.root + "*.augsave", + self.root + "*~", + self.root + "*/*augsave", + self.root + "*/*~", + self.root + "*/*/*.augsave", + self.root + "*/*/*~"] + + for i in range(len(excl)): + self.aug.set("/augeas/load/Httpd/excl[%d]" % (i+1), excl[i]) + + self.aug.load() + + def _add_httpd_transform(self, incl): + """Add a transform to Augeas. + + This function will correctly add a transform to augeas + The existing augeas.add_transform in python is broken. + + :param str incl: TODO + + """ + last_include = self.aug.match("/augeas/load/Httpd/incl [last()]") + self.aug.insert(last_include[0], "incl", False) + self.aug.set("/augeas/load/Httpd/incl[last()]", incl) + + def _set_locations(self, ssl_options): + """Set default location for directives. + + Locations are given as file_paths + .. todo:: Make sure that files are included + + """ + root = self._find_config_root() + default = self._set_user_config_file() + + temp = os.path.join(self.root, "ports.conf") + if os.path.isfile(temp): + listen = temp + name = temp + else: + listen = default + name = default + + return {"root": root, "default": default, "listen": listen, + "name": name, "ssl_options": ssl_options} + + def _find_config_root(self): + """Find the Apache Configuration Root file.""" + location = ["apache2.conf", "httpd.conf"] + + for name in location: + if os.path.isfile(os.path.join(self.root, name)): + return os.path.join(self.root, name) + + raise errors.LetsEncryptConfiguratorError( + "Could not find configuration root") + + def _set_user_config_file(self, filename=''): + """Set the appropriate user configuration file + + .. todo:: This will have to be updated for other distros versions + + :param str filename: optional filename that will be used as the + user config + + """ + if filename: + return filename + else: + # Basic check to see if httpd.conf exists and + # in heirarchy via direct include + # httpd.conf was very common as a user file in Apache 2.2 + if (os.path.isfile(self.root + 'httpd.conf') and + self.find_dir( + case_i("Include"), case_i("httpd.conf"))): + return os.path.join(self.root, 'httpd.conf') + else: + return os.path.join(self.root + 'apache2.conf') + + +def case_i(string): + """Returns case insensitive regex. + + Returns a sloppy, but necessary version of a case insensitive regex. + Any string should be able to be submitted and the string is + escaped and then made case insensitive. + May be replaced by a more proper /i once augeas 1.0 is widely + supported. + + :param str string: string to make case i regex + + """ + return "".join(["["+c.upper()+c.lower()+"]" + if c.isalpha() else c for c in re.escape(string)]) + + +def get_aug_path(file_path): + """Return augeas path for full filepath. + + :param str file_path: Full filepath + + """ + return "/files%s" % file_path + + +def strip_dir(path): + """Returns directory of file path. + + .. todo:: Replace this with Python standard function + + :param str path: path is a file path. not an augeas section or + directive path + + :returns: directory + :rtype: str + + """ + index = path.rfind("/") + if index > 0: + return path[:index+1] + # No directory + return "" diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache_configurator.py index 63c61250d..6e7d76923 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache_configurator.py @@ -8,13 +8,14 @@ import socket import subprocess import sys -from letsencrypt.client import apache_obj from letsencrypt.client import augeas_configurator from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG from letsencrypt.client import errors from letsencrypt.client import le_util +from letsencrypt.client.apache import obj +from letsencrypt.client.apache import parser # Configurator should be turned into a Singleton @@ -28,7 +29,7 @@ from letsencrypt.client import le_util # Augeas views as an error. This will just # require another check_parsing_errors() after all files are included... # (after a find_directive search is executed currently). It can be a one -# time check however because all of Trustifies transactions will ensure +# time check however because all of LE's transactions will ensure # only properly formed sections are added. # Note: This protocol works for filenames with spaces in it, the sites are @@ -59,6 +60,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): parser automatically. .. todo:: Add support for config file variables Define rootDir /var/www/ + .. todo:: Add proper support for module configuration The API of this class will change in the coming weeks as the exact needs of client's are clarified with the new and developing protocol. @@ -68,7 +70,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): with the configuration :ivar float version: version of Apache :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`letsencrypt.client.apache_obj.VH`) + (:class:`list` of :class:`letsencrypt.client.apache.obj.VH`) :ivar dict assoc: Mapping between domains and vhosts @@ -95,8 +97,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): super(ApacheConfigurator, self).__init__(direc) - self.server_root = server_root - # See if any temporary changes need to be recovered # This needs to occur before VH objects are setup... # because this will change the underlying configuration and potential @@ -107,22 +107,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if os.geteuid() == 0: self.verify_setup() - # Find configuration root and make sure augeas can parse it. - self.location = self._set_locations(ssl_options) - self._parse_file(self.location["root"]) - - # Must also attempt to parse sites-available or equivalent - # Sites-available is not included naturally in configuration - self._parse_file(os.path.join(self.server_root, "sites-available/*")) + self.parser = parser.ApacheParser(self.aug, server_root, ssl_options) + # Check for errors in parsing files with Augeas + self.check_parsing_errors("httpd.aug") # Set Version self.version = self.get_version() if version is None else version - # Check for errors in parsing files with Augeas - self.check_parsing_errors("httpd.aug") - # This problem has been fixed in Augeas 1.0 - self.standardize_excl() - # Get all of the available vhosts self.vhosts = self.get_virtual_hosts() # Add name_server association dict @@ -158,7 +149,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): This shouldn't happen within letsencrypt though :param vhost: ssl vhost to deploy certificate - :type vhost: :class:`letsencrypt.client.apache_obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VH` :param str cert: certificate filename :param str key: private key filename @@ -170,15 +161,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ path = {} - path["cert_file"] = self.find_directive(case_i( + path["cert_file"] = self.parser.find_dir(parser.case_i( "SSLCertificateFile"), None, vhost.path) - path["cert_key"] = self.find_directive(case_i( + path["cert_key"] = self.parser.find_dir(parser.case_i( "SSLCertificateKeyFile"), None, vhost.path) # Only include if a certificate chain is specified if cert_chain is not None: - path["cert_chain"] = self.find_directive( - case_i("SSLCertificateChainFile"), None, vhost.path) + path["cert_chain"] = self.parser.find_dir( + parser.case_i("SSLCertificateChainFile"), None, vhost.path) if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: # Throw some "can't find all of the directives error" @@ -194,7 +185,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.aug.set(path["cert_key"][0], key) if cert_chain is not None: if len(path["cert_chain"]) == 0: - self.add_dir(vhost.path, "SSLCertificateChainFile", cert_chain) + self.parser.add_dir( + vhost.path, "SSLCertificateChainFile", cert_chain) else: self.aug.set(path["cert_chain"][0], cert_chain) @@ -216,7 +208,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str name: domain name :returns: ssl vhost associated with name - :rtype: :class:`letsencrypt.client.apache_obj.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VH` """ # Allows for domain names to be associated with a virtual host @@ -230,7 +222,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return vhost # Checking for domain name in vhost address # This technique is not recommended by Apache but is technically valid - target_addr = apache_obj.Addr((target_name, "443")) + target_addr = obj.Addr((target_name, "443")) for vhost in self.vhosts: if target_addr in vhost.addrs: return vhost @@ -254,7 +246,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`letsencrypt.client.apache_obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VH` """ self.assoc[domain] = vhost @@ -287,73 +279,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return all_names - def _set_locations(self, ssl_options): - """Set default location for directives. - - Locations are given as file_paths - .. todo:: Make sure that files are included - - """ - root = self._find_config_root() - default = self._set_user_config_file() - - temp = os.path.join(self.server_root, "ports.conf") - if os.path.isfile(temp): - listen = temp - name = temp - else: - listen = default - name = default - - return {"root": root, "default": default, "listen": listen, - "name": name, "ssl_options": ssl_options} - - def _find_config_root(self): - """Find the Apache Configuration Root file.""" - location = ["apache2.conf", "httpd.conf"] - - for name in location: - if os.path.isfile(os.path.join(self.server_root, name)): - return os.path.join(self.server_root, name) - - raise errors.LetsEncryptConfiguratorError( - "Could not find configuration root") - - def _set_user_config_file(self, filename=''): - """Set the appropriate user configuration file - - .. todo:: This will have to be updated for other distros versions - - :param str filename: optional filename that will be used as the - user config - - """ - if filename: - return filename - else: - # Basic check to see if httpd.conf exists and - # in heirarchy via direct include - # httpd.conf was very common as a user file in Apache 2.2 - if (os.path.isfile(self.server_root + 'httpd.conf') and - self.find_directive( - case_i("Include"), case_i("httpd.conf"))): - return os.path.join(self.server_root, 'httpd.conf') - else: - return os.path.join(self.server_root + 'apache2.conf') - def _add_servernames(self, host): """Helper function for get_virtual_hosts(). :param host: In progress vhost whose names will be added - :type host: :class:`letsencrypt.client.apache_obj.VH` + :type host: :class:`letsencrypt.client.apache.obj.VH` """ name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " "%s//*[self::directive=~regexp('%s')]" % (host.path, - case_i('ServerName'), + parser.case_i('ServerName'), host.path, - case_i('ServerAlias')))) + parser.case_i('ServerAlias')))) for name in name_match: args = self.aug.match(name + "/*") @@ -366,22 +304,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`letsencrypt.client.apache_obj.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VH` """ addrs = set() args = self.aug.match(path + "/arg") for arg in args: - addrs.add(apache_obj.Addr.fromstring(self.aug.get(arg))) + addrs.add(obj.Addr.fromstring(self.aug.get(arg))) is_ssl = False - if self.find_directive( - case_i("SSLEngine"), case_i("on"), path): + if self.parser.find_dir( + parser.case_i("SSLEngine"), parser.case_i("on"), path): is_ssl = True filename = get_file_path(path) is_enabled = self.is_site_enabled(filename) - vhost = apache_obj.VH(filename, path, addrs, is_ssl, is_enabled) + vhost = obj.VH(filename, path, addrs, is_ssl, is_enabled) self._add_servernames(vhost) return vhost @@ -389,7 +327,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def get_virtual_hosts(self): """Returns list of virtual hosts found in the Apache configuration. - :returns: List of :class:`letsencrypt.client.apache_obj.VH` objects + :returns: List of :class:`letsencrypt.client.apache.obj.VH` objects found in configuration :rtype: list @@ -397,7 +335,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Search sites-available, httpd.conf for possible virtual hosts paths = self.aug.match( ("/files%ssites-available//*[label()=~regexp('%s')]" % - (self.server_root, case_i('VirtualHost')))) + (self.parser.root, parser.case_i('VirtualHost')))) vhs = [] for path in paths: @@ -425,8 +363,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # search for NameVirtualHost directive for ip_addr # note ip_addr can be FQDN although Apache does not recommend it return (self.version >= (2, 4) or - self.find_directive( - case_i("NameVirtualHost"), case_i(str(target_addr)))) + self.parser.find_dir( + parser.case_i("NameVirtualHost"), + parser.case_i(str(target_addr)))) def add_name_vhost(self, addr): """Adds NameVirtualHost directive for given address. @@ -434,33 +373,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str addr: Address that will be added as NameVirtualHost directive """ - path = self._add_dir_to_ifmodssl( - get_aug_path(self.location["name"]), "NameVirtualHost", str(addr)) + path = self.parser.add_dir_to_ifmodssl( + parser.get_aug_path( + self.parser.loc["name"]), "NameVirtualHost", str(addr)) self.save_notes += "Setting %s to be NameBasedVirtualHost\n" % addr self.save_notes += "\tDirective added to %s\n" % path - def _add_dir_to_ifmodssl(self, aug_conf_path, directive, val): - """Adds directive and value to IfMod ssl block. - - Adds given directive and value along configuration path within - an IfMod mod_ssl.c block. If the IfMod block does not exist in - the file, it is created. - - :param str aug_conf_path: Desired Augeas config path to add directive - :param str directive: Directive you would like to add - :param str val: Value of directive ie. Listen 443, 443 is the value - - """ - # TODO: Add error checking code... does the path given even exist? - # Does it throw exceptions? - if_mod_path = self._get_ifmod(aug_conf_path, "mod_ssl.c") - # IfModule can have only one valid argument, so append after - self.aug.insert(if_mod_path + "arg", "directive", False) - nvh_path = if_mod_path + "directive[1]" - self.aug.set(nvh_path, directive) - self.aug.set(nvh_path + "/arg", val) - def _prepare_server_https(self): """Prepare the server for HTTPS. @@ -475,18 +394,18 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Check for Listen 443 # Note: This could be made to also look for ip:443 combo # TODO: Need to search only open directives and IfMod mod_ssl.c - if len(self.find_directive(case_i("Listen"), "443")) == 0: + if len(self.parser.find_dir(parser.case_i("Listen"), "443")) == 0: logging.debug("No Listen 443 directive found") logging.debug("Setting the Apache Server to Listen on port 443") - path = self._add_dir_to_ifmodssl( - get_aug_path(self.location["listen"]), "Listen", "443") + path = self.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.parser.loc["listen"]), "Listen", "443") self.save_notes += "Added Listen 443 directive to %s\n" % path def make_server_sni_ready(self, vhost, default_addr="*:443"): """Checks to see if the server is ready for SNI challenges. :param vhost: VHost to check SNI compatibility - :type vhost: :class:`letsencrypt.client.apache_obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VH` :param str default_addr: TODO - investigate function further @@ -509,178 +428,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "based virtual host", addr) self.add_name_vhost(addr) - def _get_ifmod(self, aug_conf_path, mod): - """Returns the path to and creates one if it doesn't exist. - - :param str aug_conf_path: Augeas configuration path - :param str mod: module ie. mod_ssl.c - - """ - if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % - (aug_conf_path, mod))) - if len(if_mods) == 0: - self.aug.set("%s/IfModule[last() + 1]" % aug_conf_path, "") - self.aug.set("%s/IfModule[last()]/arg" % aug_conf_path, mod) - if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % - (aug_conf_path, mod))) - # Strip off "arg" at end of first ifmod path - return if_mods[0][:len(if_mods[0]) - 3] - - def add_dir(self, aug_conf_path, directive, arg): - """Appends directive to the end fo the file given by aug_conf_path. - - .. note:: Not added to AugeasConfigurator because it may depend - on the lens - - :param str aug_conf_path: Augeas configuration path to add directive - :param str directive: Directive to add - :param str arg: Value of the directive. ie. Listen 443, 443 is arg - - """ - self.aug.set(aug_conf_path + "/directive[last() + 1]", directive) - if type(arg) is not list: - self.aug.set(aug_conf_path + "/directive[last()]/arg", arg) - else: - for i in range(len(arg)): - self.aug.set("%s/directive[last()]/arg[%d]" % - (aug_conf_path, (i+1)), - arg[i]) - - def find_directive(self, directive, arg=None, start=None): - """Finds directive in the configuration. - - Recursively searches through config files to find directives - Directives should be in the form of a case insensitive regex currently - - .. todo:: arg should probably be a list - - Note: Augeas is inherently case sensitive while Apache is case - insensitive. Augeas 1.0 allows case insensitive regexes like - regexp(/Listen/, 'i'), however the version currently supported - by Ubuntu 0.10 does not. Thus I have included my own case insensitive - transformation by calling case_i() on everything to maintain - compatibility. - - :param str directive: Directive to look for - - :param arg: Specific value direcitve must have, None if all should - be considered - :type arg: str or None - - :param str start: Beginning Augeas path to begin looking - - """ - # Cannot place member variable in the definition of the function so... - if not start: - start = get_aug_path(self.location["root"]) - - # Debug code - # print "find_dir:", directive, "arg:", arg, " | Looking in:", start - # No regexp code - # if arg is None: - # matches = self.aug.match(start + - # "//*[self::directive='"+directive+"']/arg") - # else: - # matches = self.aug.match(start + - # "//*[self::directive='" + directive+"']/* [self::arg='" + arg + "']") - - # includes = self.aug.match(start + - # "//* [self::directive='Include']/* [label()='arg']") - - if arg is None: - matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg" - % (start, directive))) - else: - matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*" - "[self::arg=~regexp('%s')]" % - (start, directive, arg))) - - incl_regex = "(%s)|(%s)" % (case_i('Include'), - case_i('IncludeOptional')) - - includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* " - "[label()='arg']" % (start, incl_regex))) - - # for inc in includes: - # print inc, self.aug.get(inc) - - for include in includes: - # start[6:] to strip off /files - matches.extend(self.find_directive( - directive, arg, self._get_include_path(strip_dir(start[6:]), - self.aug.get(include)))) - - return matches - - def _get_include_path(self, cur_dir, arg): - """Converts an Apache Include directive into Augeas path. - - Converts an Apache Include directive argument into an Augeas - searchable path - - .. todo:: convert to use os.path.join() - - :param str cur_dir: current working directory - - :param str arg: Argument of Include directive - - :returns: Augeas path string - :rtype: str - - """ - # Sanity check argument - maybe - # Question: what can the attacker do with control over this string - # Effect parse file... maybe exploit unknown errors in Augeas - # If the attacker can Include anything though... and this function - # only operates on Apache real config data... then the attacker has - # already won. - # Perhaps it is better to simply check the permissions on all - # included files? - # check_config to validate apache config doesn't work because it - # would create a race condition between the check and this input - - # TODO: Maybe... although I am convinced we have lost if - # Apache files can't be trusted. The augeas include path - # should be made to be exact. - - # Check to make sure only expected characters are used <- maybe remove - # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") - # matchObj = validChars.match(arg) - # if matchObj.group() != arg: - # logging.error("Error: Invalid regexp characters in %s", arg) - # return [] - - # Standardize the include argument based on server root - if not arg.startswith("/"): - arg = cur_dir + arg - # conf/ is a special variable for ServerRoot in Apache - elif arg.startswith("conf/"): - arg = self.server_root + arg[5:] - # TODO: Test if Apache allows ../ or ~/ for Includes - - # Attempts to add a transform to the file if one does not already exist - self._parse_file(arg) - - # Argument represents an fnmatch regular expression, convert it - # Split up the path and convert each into an Augeas accepted regex - # then reassemble - if "*" in arg or "?" in arg: - split_arg = arg.split("/") - for idx, split in enumerate(split_arg): - # * and ? are the two special fnmatch characters - if "*" in split or "?" in split: - # Turn it into a augeas regex - # TODO: Can this instead be an augeas glob instead of regex - split_arg[idx] = ("* [label()=~regexp('%s')]" % - self.fnmatch_to_re(split)) - # Reassemble the argument - arg = "/".join(split_arg) - - # If the include is a directory, just return the directory as a file - if arg.endswith("/"): - return get_aug_path(arg[:len(arg)-1]) - return get_aug_path(arg) - def make_vhost_ssl(self, nonssl_vhost): """Makes an ssl_vhost version of a nonssl_vhost. @@ -688,10 +435,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): New vhost will reside as (nonssl_vhost.path) + CONFIG.LE_VHOST_EXT :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`letsencrypt.client.apache_obj.VH` + :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VH` :returns: SSL vhost - :rtype: :class:`letsencrypt.client.apache_obj.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VH` """ avail_fp = nonssl_vhost.filep @@ -726,10 +473,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # change address to address:443 addr_match = "/files%s//* [label()=~regexp('%s')]/arg" ssl_addr_p = self.aug.match( - addr_match % (ssl_fp, case_i('VirtualHost'))) + addr_match % (ssl_fp, parser.case_i('VirtualHost'))) for i in range(len(ssl_addr_p)): - ssl_addr_arg = apache_obj.Addr.fromstring( + ssl_addr_arg = obj.Addr.fromstring( str(self.aug.get(ssl_addr_p[i]))) ssl_addr_arg.set_port("443") self.aug.set(ssl_addr_p[i], str(ssl_addr_arg)) @@ -737,16 +484,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Add directives vh_p = self.aug.match(("/files%s//* [label()=~regexp('%s')]" % - (ssl_fp, case_i('VirtualHost')))) + (ssl_fp, parser.case_i('VirtualHost')))) if len(vh_p) != 1: logging.error("Error: should only be one vhost in %s", avail_fp) sys.exit(1) - self.add_dir(vh_p[0], "SSLCertificateFile", - "/etc/ssl/certs/ssl-cert-snakeoil.pem") - self.add_dir(vh_p[0], "SSLCertificateKeyFile", - "/etc/ssl/private/ssl-cert-snakeoil.key") - self.add_dir(vh_p[0], "Include", self.location["ssl_options"]) + self.parser.add_dir(vh_p[0], "SSLCertificateFile", + "/etc/ssl/certs/ssl-cert-snakeoil.pem") + self.parser.add_dir(vh_p[0], "SSLCertificateKeyFile", + "/etc/ssl/private/ssl-cert-snakeoil.key") + self.parser.add_dir(vh_p[0], "Include", self.parser.loc["ssl_options"]) # Log actions and create save notes logging.info("Created an SSL vhost at %s", ssl_fp) @@ -785,10 +532,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): The function then adds the directive :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`letsencrypt.client.apache_obj.VH`) + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VH`) """ # TODO: Enable check to see if it is already there @@ -812,9 +559,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logging.debug("Unknown redirect exists for this vhost") return False, general_v # Add directives to server - self.add_dir(general_v.path, "RewriteEngine", "On") - self.add_dir(general_v.path, - "RewriteRule", CONFIG.REWRITE_HTTPS_ARGS) + self.parser.add_dir(general_v.path, "RewriteEngine", "On") + self.parser.add_dir( + general_v.path, "RewriteRule", CONFIG.REWRITE_HTTPS_ARGS) self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' % (general_v.filep, ssl_vhost.filep)) self.save() @@ -834,16 +581,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`letsencrypt.client.apache_obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VH` :returns: Success, code value... see documentation :rtype: bool, int """ - rewrite_path = self.find_directive( - case_i("RewriteRule"), None, vhost.path) - redirect_path = self.find_directive( - case_i("Redirect"), None, vhost.path) + rewrite_path = self.parser.find_dir( + parser.case_i("RewriteRule"), None, vhost.path) + redirect_path = self.parser.find_dir( + parser.case_i("Redirect"), None, vhost.path) if redirect_path: # "Existing Redirect directive for virtualhost" @@ -865,10 +612,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` :returns: Success, vhost - :rtype: (bool, :class:`letsencrypt.client.apache_obj.VH`) + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VH`) """ # Consider changing this to a dictionary check @@ -914,7 +661,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] redirect_filepath = ("%ssites-available/%s" % - (self.server_root, redirect_filename)) + (self.parser.root, redirect_filename)) # Register the new file that will be created # Note: always register the creation before writing to ensure file will @@ -928,8 +675,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.aug.load() # Make a new vhost data structure and add it to the lists - new_fp = self.server_root + "sites-available/" + redirect_filename - new_vhost = self._create_vhost(get_aug_path(new_fp)) + new_fp = self.parser.root + "sites-available/" + redirect_filename + new_vhost = self._create_vhost(parser.get_aug_path(new_fp)) self.vhosts.append(new_vhost) # Finally create documentation for the change @@ -951,7 +698,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not conflict: returns space separated list of new host addrs :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` :returns: TODO :rtype: TODO @@ -984,18 +731,18 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`letsencrypt.client.apache_obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`letsencrypt.client.apache_obj.VH` or None + :rtype: :class:`letsencrypt.client.apache.obj.VH` or None """ # _default_:443 check # Instead... should look for vhost of the form *:80 # Should we prompt the user? ssl_addrs = ssl_vhost.addrs - if ssl_addrs == apache_obj.Addr.fromstring("_default_:443"): - ssl_addrs = [apache_obj.Addr.fromstring("*:443")] + if ssl_addrs == obj.Addr.fromstring("_default_:443"): + ssl_addrs = [obj.Addr.fromstring("*:443")] for vhost in self.vhosts: found = 0 @@ -1038,10 +785,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): for vhost in self.vhosts: if vhost.ssl: - cert_path = self.find_directive( - case_i("SSLCertificateFile"), None, vhost.path) - key_path = self.find_directive( - case_i("SSLCertificateKeyFile"), None, vhost.path) + cert_path = self.parser.find_dir( + parser.case_i("SSLCertificateFile"), None, vhost.path) + key_path = self.parser.find_dir( + parser.case_i("SSLCertificateKeyFile"), None, vhost.path) # Can be removed once find directive can return ordered results if len(cert_path) != 1 or len(key_path) != 1: @@ -1066,7 +813,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :rtype: bool """ - enabled_dir = os.path.join(self.server_root, "sites-enabled/") + enabled_dir = os.path.join(self.parser.root, "sites-enabled/") for entry in os.listdir(enabled_dir): if os.path.realpath(enabled_dir + entry) == avail_fp: return True @@ -1081,7 +828,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`letsencrypt.client.apache_obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VH` :returns: Success :rtype: bool @@ -1092,7 +839,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if "/sites-available/" in vhost.filep: enabled_path = ("%ssites-enabled/%s" % - (self.server_root, os.path.basename(vhost.filep))) + (self.parser.root, os.path.basename(vhost.filep))) self.register_file_creation(False, enabled_path) os.symlink(vhost.filep, enabled_path) vhost.enabled = True @@ -1101,82 +848,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return True return False - def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use - """Method converts Apache's basic fnmatch to regular expression. - - :param str clean_fn_match: Apache style filename match, similar to globs - - :returns: regex suitable for augeas - :rtype: str - - """ - regex = "" - for letter in clean_fn_match: - if letter == '.': - regex = regex + r"\." - elif letter == '*': - regex = regex + ".*" - # According to apache.org ? shouldn't appear - # but in case it is valid... - elif letter == '?': - regex = regex + "." - else: - regex = regex + letter - return regex - - def _parse_file(self, file_path): - """Parse file with Augeas - - Checks to see if file_path is parsed by Augeas - If file_path isn't parsed, the file is added and Augeas is reloaded - - :param str file_path: Apache config file path - - """ - # Test if augeas included file for Httpd.lens - # Note: This works for augeas globs, ie. *.conf - inc_test = self.aug.match( - "/augeas/load/Httpd/incl [. ='%s']" % file_path) - if not inc_test: - # Load up files - # self.httpd_incl.append(file_path) - # self.aug.add_transform("Httpd.lns", - # self.httpd_incl, None, self.httpd_excl) - self._add_httpd_transform(file_path) - self.aug.load() - - def standardize_excl(self): - """Standardize the excl arguments for the Httpd lens in Augeas. - - Note: Hack! - Standardize the excl arguments for the Httpd lens in Augeas - Servers sometimes give incorrect defaults - Note: This problem should be fixed in Augeas 1.0. Unfortunately, - Augeas 0.10 appears to be the most popular version currently. - - """ - # attempt to protect against augeas error in 0.10.0 - ubuntu - # *.augsave -> /*.augsave upon augeas.load() - # Try to avoid bad httpd files - # There has to be a better way... but after a day and a half of testing - # I had no luck - # This is a hack... work around... submit to augeas if still not fixed - - excl = ["*.augnew", "*.augsave", "*.dpkg-dist", "*.dpkg-bak", - "*.dpkg-new", "*.dpkg-old", "*.rpmsave", "*.rpmnew", - "*~", - self.server_root + "*.augsave", - self.server_root + "*~", - self.server_root + "*/*augsave", - self.server_root + "*/*~", - self.server_root + "*/*/*.augsave", - self.server_root + "*/*/*~"] - - for i in range(len(excl)): - self.aug.set("/augeas/load/Httpd/excl[%d]" % (i+1), excl[i]) - - self.aug.load() - def restart(self, quiet=False): # pylint: disable=no-self-use """Restarts apache server. @@ -1186,19 +857,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ return apache_restart() - def _add_httpd_transform(self, incl): - """Add a transform to Augeas. - - This function will correctly add a transform to augeas - The existing augeas.add_transform in python is broken. - - :param str incl: TODO - - """ - last_include = self.aug.match("/augeas/load/Httpd/incl [last()]") - self.aug.insert(last_include[0], "incl", False) - self.aug.set("/augeas/load/Httpd/incl[last()]", incl) - def config_test(self): """Check the configuration of Apache for errors. @@ -1376,7 +1034,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :type dvsni_key: :class:`letsencrypt.client.client.Client.Key` :param list ll_addrs: list of list of - :class:`letsencrypt.client.apache_obj.Addr` to apply + :class:`letsencrypt.client.apache.obj.Addr` to apply """ # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY @@ -1398,7 +1056,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): list_sni_tuple[idx][2], lis, dvsni_key.file) config_text += " \n" - self.dvsni_conf_include_check(self.location["default"]) + self.dvsni_conf_include_check(self.parser.loc["default"]) self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: @@ -1413,18 +1071,18 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str main_config: file path to main user apache config file """ - if len(self.find_directive( - case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + if len(self.parser.find_dir( + parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: # print "Including challenge virtual host(s)" - self.add_dir(get_aug_path(main_config), - "Include", CONFIG.APACHE_CHALLENGE_CONF) + self.parser.add_dir(parser.get_aug_path(main_config), + "Include", CONFIG.APACHE_CHALLENGE_CONF) def get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text :param str nonce: hex form of nonce :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`letsencrypt.client.apache_obj.Addr` + :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` :param str dvsni_key_file: Path to key file :returns: virtual host configuration text @@ -1439,7 +1097,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "\n" "LimitRequestBody 1048576\n" "\n" - "Include " + self.location["ssl_options"] + "\n" + "Include " + self.parser.loc["ssl_options"] + "\n" "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + "\n" "SSLCertificateKeyFile " + dvsni_key_file + "\n" "\n" @@ -1538,22 +1196,6 @@ def apache_restart(): return True -def case_i(string): - """Returns case insensitive regex. - - Returns a sloppy, but necessary version of a case insensitive regex. - Any string should be able to be submitted and the string is - escaped and then made case insensitive. - May be replaced by a more proper /i once augeas 1.0 is widely - supported. - - :param str string: string to make case i regex - - """ - return "".join(["["+c.upper()+c.lower()+"]" - if c.isalpha() else c for c in re.escape(string)]) - - def get_file_path(vhost_path): """Get file path from augeas_vhost_path. @@ -1580,94 +1222,3 @@ def get_file_path(vhost_path): continue break return avail_fp - - -def get_aug_path(file_path): - """Return augeas path for full filepath. - - :param str file_path: Full filepath - - """ - return "/files%s" % file_path - - -def strip_dir(path): - """Returns directory of file path. - - .. todo:: Replace this with Python standard function - - :param str path: path is a file path. not an augeas section or - directive path - - :returns: directory - :rtype: str - - """ - index = path.rfind("/") - if index > 0: - return path[:index+1] - # No directory - return "" - - -def main(): - """Main function used for quick testing purposes""" - - config = ApacheConfigurator() - - # for v in config.vhosts: - # print v.filep - # print v.addrs - # for name in v.names: - # print name - - print config.find_directive( - case_i("NameVirtualHost"), case_i("holla:443")) - - # for m in config.find_directive("Listen", "443"): - # print "Directive Path:", m, "Value:", config.aug.get(m) - - # for v in config.vhosts: - # for a in v.addrs: - # print "Address:",a, "- Is name vhost?", config.is_name_vhost(a) - - # print config.get_all_names() - - # test_file = "/home/james/Desktop/ports_test.conf" - # config._parse_file(test_file) - - # config.aug.insert("/files"+test_file+"/IfModule[1]/arg","directive",False) - # config.aug.set("/files"+test_file+"/IfModule[1]/directive[1]", "Listen") - # config.aug.set( - # "/files" +test_file+ "/IfModule[1]/directive[1]/arg", "556") - - # #config.save_notes = "Added listen 431 for test" - # #config.register_file_creation("/home/james/Desktop/new_file.txt") - # #config.save("Testing Saves", False) - # #config.recover_checkpoint(1) - - # # config.display_checkpoints() - config.config_test() - - # # Testing redirection and make_vhost_ssl - # ssl_vh = None - # for vh in config.vhosts: - # if not vh.addrs: - # print vh.names - # print vh.filep - # if vh.addrs[0] == "23.20.47.131:80": - # print "Here we go" - # ssl_vh = config.make_vhost_ssl(vh) - - # config.enable_redirect(ssl_vh) - - # for vh in config.vhosts: - # if len(vh.names) > 0: - # config.deploy_cert( - # vh, - # "/home/james/Documents/apache_choc/req.pem", - # "/home/james/Documents/apache_choc/key.pem", - # "/home/james/Downloads/sub.class1.server.ca.pem") - -if __name__ == "__main__": - main() diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 0ad813c8a..15fb84b72 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -15,8 +15,6 @@ from letsencrypt.client import le_util class AugeasConfigurator(configurator.Configurator): """Base Augeas Configurator class. - .. todo:: Fix generic exception handling. - :ivar aug: Augeas object :type aug: :class:`augeas.Augeas` diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index 8e745b7d7..2c8742731 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -8,12 +8,12 @@ import unittest import mock -from letsencrypt.client import apache_obj from letsencrypt.client import apache_configurator from letsencrypt.client import CONFIG from letsencrypt.client import display from letsencrypt.client import errors - +from letsencrypt.client.apache import obj +from letsencrypt.client.apache import parser UBUNTU_CONFIGS = pkg_resources.resource_filename( __name__, "testdata/debian_apache_2_4") @@ -62,24 +62,24 @@ class TwoVhost80Test(unittest.TestCase): self.temp_dir, "two_vhost_80/apache2/sites-available") aug_pre = "/files" + prefix self.vh_truth = [ - apache_obj.VH( + obj.VH( os.path.join(prefix, "encryption-example.conf"), os.path.join(aug_pre, "encryption-example.conf/VirtualHost"), - set([apache_obj.Addr.fromstring("*:80")]), + set([obj.Addr.fromstring("*:80")]), False, True, set(["encryption-example.demo"])), - apache_obj.VH( + obj.VH( os.path.join(prefix, "default-ssl.conf"), os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), - set([apache_obj.Addr.fromstring("_default_:443")]), True, False), - apache_obj.VH( + set([obj.Addr.fromstring("_default_:443")]), True, False), + obj.VH( os.path.join(prefix, "000-default.conf"), os.path.join(aug_pre, "000-default.conf/VirtualHost"), - set([apache_obj.Addr.fromstring("*:80")]), False, True, + set([obj.Addr.fromstring("*:80")]), False, True, set(["ip-172-30-0-17"])), - apache_obj.VH( + obj.VH( os.path.join(prefix, "letsencrypt.conf"), os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), - set([apache_obj.Addr.fromstring("*:80")]), False, True, + set([obj.Addr.fromstring("*:80")]), False, True, set(["letsencrypt.demo"])), ] @@ -97,7 +97,9 @@ class TwoVhost80Test(unittest.TestCase): """ file_path = os.path.join( self.config_path, "sites-available", "letsencrypt.conf") - self.config._parse_file(file_path) # pylint: disable=protected-access + + # pylint: disable=protected-access + self.config.parser._parse_file(file_path) # search for the httpd incl matches = self.config.aug.match( @@ -110,12 +112,12 @@ class TwoVhost80Test(unittest.TestCase): self.assertEqual(names, set( ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) - def test_find_directive(self): - test = self.config.find_directive( - apache_configurator.case_i("Listen"), "443") + def test_find_dir(self): + test = self.config.parser.find_dir( + parser.case_i("Listen"), "443") # This will only look in enabled hosts - test2 = self.config.find_directive( - apache_configurator.case_i("documentroot")) + test2 = self.config.parser.find_dir( + parser.case_i("documentroot")) self.assertEqual(len(test), 2) self.assertEqual(len(test2), 3) @@ -139,26 +141,26 @@ class TwoVhost80Test(unittest.TestCase): self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep)) def test_add_dir(self): - aug_default = "/files" + self.config.location["default"] - self.config.add_dir( + aug_default = "/files" + self.config.parser.loc["default"] + self.config.parser.add_dir( aug_default, "AddDirective", "test") self.assertTrue( - self.config.find_directive("AddDirective", "test", aug_default)) + self.config.parser.find_dir("AddDirective", "test", aug_default)) def test_deploy_cert(self): self.config.deploy_cert( self.vh_truth[1], "example/cert.pem", "example/key.pem", "example/cert_chain.pem") - loc_cert = self.config.find_directive( - apache_configurator.case_i("sslcertificatefile"), + loc_cert = self.config.parser.find_dir( + parser.case_i("sslcertificatefile"), re.escape("example/cert.pem"), self.vh_truth[1].path) - loc_key = self.config.find_directive( - apache_configurator.case_i("sslcertificateKeyfile"), + loc_key = self.config.parser.find_dir( + parser.case_i("sslcertificateKeyfile"), re.escape("example/key.pem"), self.vh_truth[1].path) - loc_chain = self.config.find_directive( - apache_configurator.case_i("SSLCertificateChainFile"), + loc_chain = self.config.parser.find_dir( + parser.case_i("SSLCertificateChainFile"), re.escape("example/cert_chain.pem"), self.vh_truth[1].path) # Verify one directive was found in the correct file @@ -175,27 +177,27 @@ class TwoVhost80Test(unittest.TestCase): self.vh_truth[1].filep) def test_is_name_vhost(self): - addr = apache_obj.Addr.fromstring("*:80") + addr = obj.Addr.fromstring("*:80") self.assertTrue(self.config.is_name_vhost(addr)) self.config.version = (2, 2) self.assertFalse(self.config.is_name_vhost(addr)) def test_add_name_vhost(self): self.config.add_name_vhost("*:443") - # self.config.save(temporary=True) - self.assertTrue(self.config.find_directive( + self.assertTrue(self.config.parser.find_dir( "NameVirtualHost", re.escape("*:443"))) def test_add_dir_to_ifmodssl(self): - """test _add_dir_to_ifmodssl. + """test add_dir_to_ifmodssl. Path must be valid before attempting to add to augeas """ - self.config._add_dir_to_ifmodssl( # pylint: disable=protected-access - "/files" + self.config.location["default"], "FakeDirective", "123") + self.config.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.config.parser.loc["default"]), + "FakeDirective", "123") - matches = self.config.find_directive("FakeDirective", "123") + matches = self.config.parser.find_dir("FakeDirective", "123") self.assertEqual(len(matches), 1) self.assertTrue("IfModule" in matches[0]) @@ -210,16 +212,16 @@ class TwoVhost80Test(unittest.TestCase): self.assertEqual(ssl_vhost.path, "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") - self.assertEqual(ssl_vhost.addrs, set([apache_obj.Addr.fromstring("*:443")])) + self.assertEqual(ssl_vhost.addrs, set([obj.Addr.fromstring("*:443")])) self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) self.assertTrue(ssl_vhost.ssl) self.assertFalse(ssl_vhost.enabled) - self.assertTrue(self.config.find_directive( + self.assertTrue(self.config.parser.find_dir( "SSLCertificateFile", None, ssl_vhost.path)) - self.assertTrue(self.config.find_directive( + self.assertTrue(self.config.parser.find_dir( "SSLCertificateKeyFile", None, ssl_vhost.path)) - self.assertTrue(self.config.find_directive( + self.assertTrue(self.config.parser.find_dir( "Include", self.ssl_options, ssl_vhost.path)) self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), diff --git a/setup.py b/setup.py index 24ff3752d..e84906910 100755 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ setup( packages=[ 'letsencrypt', 'letsencrypt.client', + 'letsencrypt.client.apache', 'letsencrypt.scripts', ], install_requires=install_requires, From edbe0f451dacf95e52cc89418516b790be457f2c Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 16 Dec 2014 21:12:29 -0800 Subject: [PATCH 19/66] Add __init__.py to apache dir --- letsencrypt/client/apache/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 letsencrypt/client/apache/__init__.py diff --git a/letsencrypt/client/apache/__init__.py b/letsencrypt/client/apache/__init__.py new file mode 100644 index 000000000..f1b2c08e7 --- /dev/null +++ b/letsencrypt/client/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.apache.""" From 5e05638fefe7ad6b4e850891853c56cd0ec6ac49 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Dec 2014 08:14:35 +0100 Subject: [PATCH 20/66] Add client.tests to setup packages --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 24ff3752d..8a473ebb4 100755 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ setup( packages=[ 'letsencrypt', 'letsencrypt.client', + 'letsencrypt.client.tests', 'letsencrypt.scripts', ], install_requires=install_requires, From 1ef21672546a1b739ef8958d9440d686505e9cf6 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Dec 2014 09:14:27 +0100 Subject: [PATCH 21/66] pylint fixes --- letsencrypt/client/apache_configurator.py | 4 ---- letsencrypt/client/challenge_util.py | 2 +- letsencrypt/client/client.py | 12 ++++++------ letsencrypt/client/recovery_token_challenge.py | 3 +-- letsencrypt/client/tests/apache_configurator_test.py | 1 - letsencrypt/scripts/main.py | 2 +- 6 files changed, 9 insertions(+), 15 deletions(-) diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache_configurator.py index 11a999e9b..ad74cab74 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache_configurator.py @@ -1,5 +1,4 @@ """Apache Configuration based off of Augeas Configurator.""" -import hashlib import logging import os import pkg_resources @@ -9,12 +8,9 @@ import socket import subprocess import sys -from Crypto import Random - from letsencrypt.client import augeas_configurator from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG -from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import le_util diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 69f351f7d..46b0602be 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -36,7 +36,7 @@ def dvsni_gen_cert(filepath, name, r_b64, nonce, key): key.pem, [nonce + CONFIG.INVALID_EXT, name, ext]) with open(filepath, 'w') as chall_cert_file: - chall_cert_file.write(cert_pem) + chall_cert_file.write(cert_pem) return le_util.jose_b64encode(dvsni_s) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a68d8dd39..af6eb4a5b 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -306,8 +306,8 @@ class Client(object): response = self.send(acme.status_request(response["token"])) else: logging.fatal("Received unexpected message") - logging.fatal("Expected: %s" % expected) - logging.fatal("Received: " + response) + logging.fatal("Expected: %s", expected) + logging.fatal("Received: %s", response) sys.exit(33) logging.error( @@ -364,7 +364,7 @@ class Client(object): """ code, tag = display.display_certs(certs) - + if code == display.OK: cert = certs[tag] if display.confirm_revocation(cert): @@ -494,7 +494,7 @@ class Client(object): else: # Handle RecoveryToken type challenges pass - + self._assign_responses(resp, indices[i], responses) logging.info( @@ -513,10 +513,10 @@ class Client(object): """ if isinstance(resp, list): - assert(len(resp) == len(index_list)) + assert len(resp) == len(index_list) for j, index in enumerate(index_list): responses[index] = resp[j] - else: + else: for index in index_list: responses[index] = resp diff --git a/letsencrypt/client/recovery_token_challenge.py b/letsencrypt/client/recovery_token_challenge.py index 04a3d3ec9..56d401dad 100644 --- a/letsencrypt/client/recovery_token_challenge.py +++ b/letsencrypt/client/recovery_token_challenge.py @@ -3,9 +3,8 @@ .. note:: This challenge has not been implemented into the project yet """ -import display - from letsencrypt.client import challenge +from letsencrypt.client import display class RecoveryToken(challenge.Challenge): diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index 08c99cbeb..7f3b2def0 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -3,7 +3,6 @@ import os import pkg_resources import re import shutil -import sys import tempfile import unittest diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8cbda62dc..f829b4939 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -116,7 +116,7 @@ def read_file(filename): """ try: - return filename, file(filename, 'rU').read() + return filename, open(filename, 'rU').read() except IOError as exc: raise argparse.ArgumentTypeError(exc.strerror) From 20efe7b5334dc4dbbcb67ccd563aeef65646b5d7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Dec 2014 10:12:59 +0100 Subject: [PATCH 22/66] zope.interface --- docs/api/client/configurator.rst | 5 -- docs/api/client/interfaces.rst | 5 ++ docs/api/client/validator.rst | 5 -- letsencrypt/client/augeas_configurator.py | 6 +- letsencrypt/client/challenge.py | 27 +----- letsencrypt/client/interactive_challenge.py | 10 ++- .../client/{configurator.py => interfaces.py} | 89 ++++++++++++------- .../client/recovery_contact_challenge.py | 9 +- .../client/recovery_token_challenge.py | 12 +-- letsencrypt/client/validator.py | 14 --- setup.py | 1 + 11 files changed, 87 insertions(+), 96 deletions(-) delete mode 100644 docs/api/client/configurator.rst create mode 100644 docs/api/client/interfaces.rst delete mode 100644 docs/api/client/validator.rst rename letsencrypt/client/{configurator.py => interfaces.py} (60%) delete mode 100644 letsencrypt/client/validator.py diff --git a/docs/api/client/configurator.rst b/docs/api/client/configurator.rst deleted file mode 100644 index 7331f35ec..000000000 --- a/docs/api/client/configurator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.configurator` --------------------------------------- - -.. automodule:: letsencrypt.client.configurator - :members: diff --git a/docs/api/client/interfaces.rst b/docs/api/client/interfaces.rst new file mode 100644 index 000000000..e14daed7f --- /dev/null +++ b/docs/api/client/interfaces.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.interfaces` +------------------------------------ + +.. automodule:: letsencrypt.client.interfaces + :members: diff --git a/docs/api/client/validator.rst b/docs/api/client/validator.rst deleted file mode 100644 index 7f990e2a4..000000000 --- a/docs/api/client/validator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.validator` ------------------------------------ - -.. automodule:: letsencrypt.client.validator - :members: diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 0ad813c8a..080376793 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -6,13 +6,14 @@ import shutil import time import augeas +import zope.interface from letsencrypt.client import CONFIG -from letsencrypt.client import configurator +from letsencrypt.client import interfaces from letsencrypt.client import le_util -class AugeasConfigurator(configurator.Configurator): +class AugeasConfigurator(object): """Base Augeas Configurator class. .. todo:: Fix generic exception handling. @@ -24,6 +25,7 @@ class AugeasConfigurator(configurator.Configurator): :ivar dict direc: dictionary containing save directory paths """ + zope.interface.implements(interfaces.IConfigurator) def __init__(self, direc=None): """Initialize Augeas Configurator. diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py index 44aabcda4..b2eb33c53 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -5,29 +5,6 @@ import sys from letsencrypt.client import CONFIG -class Challenge(object): - """Let's Encrypt challenge.""" - - def __init__(self, configurator): - self.config = configurator - - def perform(self, quiet=True): - """Perform the challange. - - :param bool quiet: TODO - - """ - raise NotImplementedError() - - def generate_response(self): - """Generate response.""" - raise NotImplementedError() - - def cleanup(self): - """Cleanup.""" - raise NotImplementedError() - - def gen_challenge_path(challenges, combos=None): """Generate a plan to get authority over the identity. @@ -101,13 +78,13 @@ def _find_smart_path(challenges, combos): def _find_dumb_path(challenges): - """Find challange path without server hints. + """Find challenge path without server hints. 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 list challanges: A list of challenges from ACME "challenge" + :param list challenges: A list of challenges from ACME "challenge" server message to be fulfilled by the client in order to prove possession of the identifier. diff --git a/letsencrypt/client/interactive_challenge.py b/letsencrypt/client/interactive_challenge.py index 4f93f1e4f..c802ca191 100644 --- a/letsencrypt/client/interactive_challenge.py +++ b/letsencrypt/client/interactive_challenge.py @@ -1,12 +1,13 @@ import textwrap import dialog +import zope.interface -from letsencrypt.client import challenge +from letsencrypt.client import interfaces -class InteractiveChallenge(challenge.Challenge): - """Interactive challange. +class InteractiveChallenge(object): + """Interactive challenge. Interactive challenge displays the string sent by the CA formatted to fit on the screen of the client. The Challenge also adds proper @@ -14,9 +15,12 @@ class InteractiveChallenge(challenge.Challenge): process. """ + zope.interface.implements(interfaces.IChallenge) + BOX_SIZE = 70 def __init__(self, string): + super(InteractiveChallenge, self).__init__() self.string = string def perform(self, quiet=True): diff --git a/letsencrypt/client/configurator.py b/letsencrypt/client/interfaces.py similarity index 60% rename from letsencrypt/client/configurator.py rename to letsencrypt/client/interfaces.py index c47557289..cbe71cd4c 100644 --- a/letsencrypt/client/configurator.py +++ b/letsencrypt/client/interfaces.py @@ -1,16 +1,36 @@ -"""Configurator.""" +"""Let's Encrypt client interfaces.""" +import zope.interface + +# pylint: disable=no-self-argument,no-method-argument + +class IChallenge(zope.interface.Interface): + """Let's Encrypt challenge.""" + + def perform(quiet=True): + """Perform the challenge. + + :param bool quiet: TODO + + """ + + def generate_response(): + """Generate response.""" + + def cleanup(): + """Cleanup.""" -class Configurator(object): +class IConfigurator(zope.interface.Interface): """Generic Let's Encrypt configurator. Class represents all possible webservers and configuration editors This includes the generic webserver which wont have configuration files at all, but instead create a new process to handle the DVSNI and other challenges. + """ - def deploy_cert(self, vhost, cert, key, cert_chain=None): + def deploy_cert(vhost, cert, key, cert_chain=None): """Deploy certificate. :param vhost @@ -18,42 +38,34 @@ class Configurator(object): :param str key: Private key """ - raise NotImplementedError() - def choose_virtual_host(self, name): + def choose_virtual_host(name): """Chooses a virtual host based on a given domain name.""" - raise NotImplementedError() - def get_all_names(self): + def get_all_names(): """Returns all names found in the configuration.""" - raise NotImplementedError() - def enable_redirect(self, ssl_vhost): + def enable_redirect(ssl_vhost): """Redirect all traffic to the given ssl_vhost (port 80 => 443).""" - raise NotImplementedError() - def enable_hsts(self, ssl_vhost): + def enable_hsts(ssl_vhost): """Enable HSTS on the given ssl_vhost.""" - raise NotImplementedError() - def enable_ocsp_stapling(self, ssl_vhost): + def enable_ocsp_stapling(ssl_vhost): """Enable OCSP stapling on given ssl_vhost.""" - raise NotImplementedError() - def get_all_certs_keys(self): + def get_all_certs_keys(): """Retrieve all certs and keys set in configuration. :returns: List of tuples with form [(cert, key, path)]. :rtype: list """ - raise NotImplementedError() - def enable_site(self, vhost): + def enable_site(vhost): """Enable the site at the given vhost.""" - raise NotImplementedError() - def save(self, title=None, temporary=False): + def save(title=None, temporary=False): """Saves all changes to the configuration files. Both title and temporary are needed because a save may be @@ -66,33 +78,42 @@ class Configurator(object): :param bool temporary: Indicates whether the changes made will be quickly reversed in the future (challenges) + """ - raise NotImplementedError() - def revert_challenge_config(self): + def revert_challenge_config(): """Reload the users original configuration files.""" - raise NotImplementedError() - def rollback_checkpoints(self, rollback=1): + def rollback_checkpoints(rollback=1): """Revert `rollback` number of configuration checkpoints.""" - raise NotImplementedError() - def display_checkpoints(self): + def display_checkpoints(): """Display the saved configuration checkpoints.""" - raise NotImplementedError() - def config_test(self): + def config_test(): """Make sure the configuration is valid.""" - raise NotImplementedError() - def restart(self, quiet=False): + def restart(quiet=False): """Restart or refresh the server content.""" - raise NotImplementedError() - def perform(self, chall_dict): + def perform(chall_dict): """Perform the given challenge""" - raise NotImplementedError() - def cleanup(self): + def cleanup(): """Cleanup configuration changes from challenge.""" - raise NotImplementedError() + + +class IValidator(object): + """Configuration validator.""" + + def redirect(name): + pass + + def ocsp_stapling(name): + pass + + def https(names): + pass + + def hsts(name): + pass diff --git a/letsencrypt/client/recovery_contact_challenge.py b/letsencrypt/client/recovery_contact_challenge.py index 6b8f23e91..d5cd5f889 100644 --- a/letsencrypt/client/recovery_contact_challenge.py +++ b/letsencrypt/client/recovery_contact_challenge.py @@ -8,19 +8,22 @@ import time import dialog import requests +import zope.interface -from letsencrypt.client import challenge +from letsencrypt.client import interfaces -class RecoveryContact(challenge.Challenge): - """Recovery Contact Identitifier Validation Challange. +class RecoveryContact(object): + """Recovery Contact Identitifier Validation Challenge. Based on draft-barnes-acme, section 6.3. """ + zope.interface.implements(interfaces.IChallenge) def __init__(self, activation_url="", success_url="", contact="", poll_delay=3): + super(RecoveryContact, self).__init__() self.token = "" self.activation_url = activation_url self.success_url = success_url diff --git a/letsencrypt/client/recovery_token_challenge.py b/letsencrypt/client/recovery_token_challenge.py index 04a3d3ec9..abe8789b7 100644 --- a/letsencrypt/client/recovery_token_challenge.py +++ b/letsencrypt/client/recovery_token_challenge.py @@ -3,20 +3,22 @@ .. note:: This challenge has not been implemented into the project yet """ -import display +import zope.interface -from letsencrypt.client import challenge +from letsencrypt.client import display +from letsencrypt.client import interfaces -class RecoveryToken(challenge.Challenge): +class RecoveryToken(object): """Recovery Token Identifier Validation Challenge. Based on draft-barnes-acme, section 6.4. """ + zope.interface.implements(interfaces.IChallenge) - def __init__(self, configurator): - super(RecoveryToken, self).__init__(configurator) + def __init__(self): + super(RecoveryToken, self).__init__() self.token = "" def perform(self, quiet=True): diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py deleted file mode 100644 index 716d1528f..000000000 --- a/letsencrypt/client/validator.py +++ /dev/null @@ -1,14 +0,0 @@ -class Validator(object): - """Configuration validator.""" - - def redirect(self, name): - raise NotImplementedError() - - def ocsp_stapling(self, name): - raise NotImplementedError() - - def https(self, names): - raise NotImplementedError() - - def hsts(self, name): - raise NotImplementedError() diff --git a/setup.py b/setup.py index 8a473ebb4..f5190807c 100755 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ install_requires = [ 'python-augeas', 'python2-pythondialog', 'requests', + 'zope.interface', ] docs_extras = [ From 49914f3307d0f08b8015e5c49fcfcbd9dcc12185 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Dec 2014 11:35:11 +0100 Subject: [PATCH 23/66] display: width and height instance variables --- letsencrypt/client/client.py | 3 +-- letsencrypt/client/display.py | 45 +++++++++++++++++------------------ 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a68d8dd39..b69295a38 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -206,8 +206,7 @@ class Client(object): acme.revocation_request(cert_der, key), "revocation") display.generic_notification( - "You have successfully revoked the certificate for " - "%s" % cert["cn"], width=70, height=9) + "You have successfully revoked the certificate for %s" % cert["cn"]) remove_cert_key(cert) self.list_certs_keys() diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index ac1f9d819..e74148916 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -20,11 +20,10 @@ class SingletonD(object): class Display(SingletonD): """Generic display.""" - def generic_notification(self, message, width=WIDTH, height=HEIGHT): + def generic_notification(self, message): raise NotImplementedError() - def generic_menu(self, message, choices, input_text="", - width=WIDTH, height=HEIGHT): + def generic_menu(self, message, choices, input_text=""): raise NotImplementedError() def generic_input(self, message): @@ -51,23 +50,24 @@ class Display(SingletonD): class NcursesDisplay(Display): - def __init__(self): + def __init__(self, width=WIDTH, height=HEIGHT): self.dialog = dialog.Dialog() + self.width = width + self.height = height - def generic_notification(self, message, w=WIDTH, h=HEIGHT): - self.dialog.msgbox(message, width=w, height=h) + def generic_notification(self, message): + self.dialog.msgbox(message, width=self.width, height=self.height) - def generic_menu(self, message, choices, input_text="", width=WIDTH, - height=HEIGHT): + def generic_menu(self, message, choices, input_text=""): # Can accept either tuples or just the actual choices if choices and isinstance(choices[0], tuple): code, selection = self.dialog.menu( - message, choices=choices, width=WIDTH, height=HEIGHT) + message, choices=choices, width=self.width, height=self.height) return code, str(selection) else: choices = list(enumerate(choices, 1)) code, tag = self.dialog.menu( - message, choices=choices, width=WIDTH, height=HEIGHT) + message, choices=choices, width=self.width, height=self.height) return code(int(tag) - 1) @@ -76,7 +76,7 @@ class NcursesDisplay(Display): def generic_yesno(self, message, yes="Yes", no="No"): return self.dialog.DIALOG_OK == self.dialog.yesno( - message, HEIGHT, WIDTH, yes_label=yes, no_label=no) + message, self.height, self.width, yes_label=yes, no_label=no) def filter_names(self, names): choices = [(n, "", 0) for n in names] @@ -88,12 +88,12 @@ class NcursesDisplay(Display): def success_installation(self, domains): self.dialog.msgbox( "\nCongratulations! You have successfully enabled " - + gen_https_names(domains) + "!", width=WIDTH) + + gen_https_names(domains) + "!", width=self.width) def display_certs(self, certs): list_choices = [ (str(i+1), "%s | %s | %s" % - (str(c["cn"].ljust(WIDTH - 39)), + (str(c["cn"].ljust(self.width - 39)), c["not_before"].strftime("%m-%d-%y"), "Installed" if c["installed"] else "")) for i, c in enumerate(certs)] @@ -102,7 +102,7 @@ class NcursesDisplay(Display): "Which certificates would you like to revoke?", choices=list_choices, help_button=True, help_label="More Info", ok_label="Revoke", - width=WIDTH, height=HEIGHT) + width=self.width, height=self.height) if not tag: tag = -1 return code, (int(tag) - 1) @@ -113,13 +113,13 @@ class NcursesDisplay(Display): text += cert_info_frame(cert) text += "This action cannot be reversed!" return self.dialog.DIALOG_OK == self.dialog.yesno( - text, width=WIDTH, height=HEIGHT) + text, width=self.width, height=self.height) def more_info_cert(self, cert): text = "Certificate Information:\n" text += cert_info_frame(cert) print text - self.dialog.msgbox(text, width=WIDTH, height=HEIGHT) + self.dialog.msgbox(text, width=self.width, height=self.height) class FileDisplay(Display): @@ -127,15 +127,14 @@ class FileDisplay(Display): def __init__(self, outfile): self.outfile = outfile - def generic_notification(self, message, width=WIDTH, height=HEIGHT): + def generic_notification(self, message): side_frame = '-' * (79) wm = textwrap.fill(message, 80) text = "\n%s\n%s\n%s\n" % (side_frame, wm, side_frame) self.outfile.write(text) raw_input("Press Enter to Continue") - def generic_menu(self, message, choices, input_text="", - width=WIDTH, height=HEIGHT): + def generic_menu(self, message, choices, input_text=""): # Can take either tuples or single items in choices list if choices and isinstance(choices[0], tuple): choices = ["%s - %s" % (c[0], c[1]) for c in choices] @@ -243,12 +242,12 @@ def set_display(display_inst): display = display_inst -def generic_notification(message, width=WIDTH, height=HEIGHT): - display.generic_notification(message, width, height) +def generic_notification(message): + display.generic_notification(message) -def generic_menu(message, choices, input_text="", width=WIDTH, height=HEIGHT): - return display.generic_menu(message, choices, input_text, width, height) +def generic_menu(message, choices, input_text=""): + return display.generic_menu(message, choices, input_text) def generic_input(message): From 3a459c95c929304d79eeb6aaa07893736645ddd2 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Dec 2014 11:35:45 +0100 Subject: [PATCH 24/66] Remove SingletonD --- letsencrypt/client/display.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index e74148916..700d69f8e 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -7,17 +7,7 @@ WIDTH = 72 HEIGHT = 20 -class SingletonD(object): - _instance = None - - def __new__(cls, *args, **kwargs): - if not cls._instance: - cls._instance = super(SingletonD, cls).__new__( - cls, *args, **kwargs) - return cls._instance - - -class Display(SingletonD): +class Display(object): """Generic display.""" def generic_notification(self, message): From 7c3abe7ba738de25c981624bbed23bcabf4a73ab Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Dec 2014 11:44:31 +0100 Subject: [PATCH 25/66] IDisplay --- letsencrypt/client/display.py | 42 +++++++------------------------- letsencrypt/client/interfaces.py | 31 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index 700d69f8e..5fff122b9 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -1,46 +1,20 @@ import textwrap import dialog +import zope.interface + +from letsencrypt.client import interfaces WIDTH = 72 HEIGHT = 20 -class Display(object): - """Generic display.""" - - def generic_notification(self, message): - raise NotImplementedError() - - def generic_menu(self, message, choices, input_text=""): - raise NotImplementedError() - - def generic_input(self, message): - raise NotImplementedError() - - def generic_yesno(self, message, yes_label="Yes", no_label="No"): - raise NotImplementedError() - - def filter_names(self, names): - raise NotImplementedError() - - def success_installation(self, domains): - raise NotImplementedError() - - def display_certs(self, certs): - raise NotImplementedError() - - def confirm_revocation(self, cert): - raise NotImplementedError() - - def more_info_cert(self, cert): - raise NotImplementedError() - - -class NcursesDisplay(Display): +class NcursesDisplay(object): + zope.interface.implements(interfaces.IDisplay) def __init__(self, width=WIDTH, height=HEIGHT): + super(NcursesDisplay, self).__init__() self.dialog = dialog.Dialog() self.width = width self.height = height @@ -112,9 +86,11 @@ class NcursesDisplay(Display): self.dialog.msgbox(text, width=self.width, height=self.height) -class FileDisplay(Display): +class FileDisplay(object): + zope.interface.implements(interfaces.IDisplay) def __init__(self, outfile): + super(FileDisplay, self).__init__() self.outfile = outfile def generic_notification(self, message): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index cbe71cd4c..8f7f10e97 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -103,6 +103,37 @@ class IConfigurator(zope.interface.Interface): """Cleanup configuration changes from challenge.""" +class IDisplay(zope.interface.Interface): + """Generic display.""" + + def generic_notification(message): + pass + + def generic_menu(message, choices, input_text=""): + pass + + def generic_input(message): + pass + + def generic_yesno(message, yes_label="Yes", no_label="No"): + pass + + def filter_names(names): + pass + + def success_installation(domains): + pass + + def display_certs(certs): + pass + + def confirm_revocation(cert): + pass + + def more_info_cert(cert): + pass + + class IValidator(object): """Configuration validator.""" From 37a015f983cb5c954e6106c2f8c5b98b5dd42597 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Dec 2014 12:02:15 +0100 Subject: [PATCH 26/66] display using ZCA --- letsencrypt/client/client.py | 26 ++++--- letsencrypt/client/display.py | 75 ++++--------------- letsencrypt/client/interfaces.py | 3 + .../client/tests/apache_configurator_test.py | 3 +- letsencrypt/scripts/main.py | 7 +- setup.py | 1 + 6 files changed, 43 insertions(+), 72 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index b69295a38..d18a9331e 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -13,6 +13,7 @@ import time import jsonschema import M2Crypto import requests +import zope.component from letsencrypt.client import acme from letsencrypt.client import apache_configurator @@ -21,6 +22,7 @@ from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import display from letsencrypt.client import errors +from letsencrypt.client import interfaces from letsencrypt.client import le_util @@ -90,11 +92,13 @@ class Client(object): if not self.config.config_test(): sys.exit(1) + displayer = zope.component.getUtility(interfaces.IDisplay) + # Display preview warning if not eula: with open('EULA') as eula_file: - if not display.generic_yesno(eula_file.read(), - "Agree", "Cancel"): + if not displayer.generic_yesno( + eula_file.read(), "Agree", "Cancel"): sys.exit(0) # Display screen to select domains to validate @@ -105,7 +109,7 @@ class Client(object): # This function adds all names # found within the config to self.names # Then filters them based on user selection - code, self.names = display.filter_names(self.get_all_names()) + code, self.names = displayer.filter_names(self.get_all_names()) if code == display.OK and self.names: # TODO: Allow multiple names once it is setup self.names = [self.names[0]] @@ -205,7 +209,7 @@ class Client(object): revocation = self.send_and_receive_expected( acme.revocation_request(cert_der, key), "revocation") - display.generic_notification( + zope.component.getUtility(interface.IDisplay).generic_notification( "You have successfully revoked the certificate for %s" % cert["cn"]) remove_cert_key(cert) @@ -352,7 +356,7 @@ class Client(object): if certs: self.choose_certs(certs) else: - display.generic_notification( + zope.component.getUtility(interfaces.IDisplay).generic_notification( "There are not any trusted Let's Encrypt " "certificates for this server.") @@ -363,16 +367,18 @@ class Client(object): """ code, tag = display.display_certs(certs) + + displayer = zope.component.getUtility(interfaces.IDisplay) if code == display.OK: cert = certs[tag] - if display.confirm_revocation(cert): + if displayer.confirm_revocation(cert): self.acme_revocation(cert) else: self.choose_certs(certs) elif code == display.HELP: cert = certs[tag] - display.more_info_cert(cert) + displayer.more_info_cert(cert) self.choose_certs(certs) else: exit(0) @@ -416,7 +422,8 @@ class Client(object): # sites may have been enabled / final cleanup self.config.restart(quiet=self.use_curses) - display.success_installation(self.names) + zope.component.getUtility( + interfaces.IDisplay).success_installation(self.names) return cert_file @@ -432,7 +439,8 @@ class Client(object): """ # TODO: this should most definitely be moved to __init__ if redirect is None: - redirect = display.redirect_by_default() + redirect = zope.component.getUtility( + intefaces.IDisplay).redirect_by_default() if redirect: self.redirect_to_ssl(vhost) diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index 5fff122b9..9da2f78b7 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -85,6 +85,21 @@ class NcursesDisplay(object): print text self.dialog.msgbox(text, width=self.width, height=self.height) + def redirect_by_default(self): + choices = [ + ("Easy", "Allow both HTTP and HTTPS access to these sites"), + ("Secure", "Make all requests redirect to secure HTTPS access")] + + result = self.generic_menu( + "Please choose whether HTTPS access is required or optional.", + choices, "Please enter the appropriate number") + + if result[0] != OK: + return False + + # different answer for each type of display + return str(result[1]) == "Secure" or result[1] == 1 + class FileDisplay(object): zope.interface.implements(interfaces.IDisplay) @@ -197,41 +212,11 @@ class FileDisplay(object): self.outfile.write("\nCertificate Information:\n") self.outfile.write(cert_info_frame(cert)) -display = None OK = "ok" CANCEL = "cancel" HELP = "help" -def set_display(display_inst): - global display - display = display_inst - - -def generic_notification(message): - display.generic_notification(message) - - -def generic_menu(message, choices, input_text=""): - return display.generic_menu(message, choices, input_text) - - -def generic_input(message): - return display.generic_message(message) - - -def generic_yesno(message, yes_label="Yes", no_label="No"): - return display.generic_yesno(message, yes_label, no_label) - - -def filter_names(names): - return display.filter_names(names) - - -def display_certs(certs): - return display.display_certs(certs) - - def cert_info_frame(cert): text = "-" * (WIDTH - 4) + "\n" text += cert_info_string(cert) @@ -268,33 +253,3 @@ def gen_https_names(domains): result = result + "https://" + domains[len(domains)-1] return result - - -def success_installation(domains): - return display.success_installation(domains) - - -def redirect_by_default(): - choices = [ - ("Easy", "Allow both HTTP and HTTPS access to these sites"), - ("Secure", "Make all requests redirect to secure HTTPS access")] - - result = display.generic_menu("Please choose whether HTTPS access " + - "is required or optional.", - choices, - "Please enter the appropriate number", - width=WIDTH) - - if result[0] != OK: - return False - - # different answer for each type of display - return str(result[1]) == "Secure" or result[1] == 1 - - -def confirm_revocation(cert): - return display.confirm_revocation(cert) - - -def more_info_cert(cert): - return display.more_info_cert(cert) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 8f7f10e97..74eb50ada 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -133,6 +133,9 @@ class IDisplay(zope.interface.Interface): def more_info_cert(cert): pass + def redirect_by_default(): + pass + class IValidator(object): """Configuration validator.""" diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index 08c99cbeb..a11f0898e 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -8,6 +8,7 @@ import tempfile import unittest import mock +import zope.component from letsencrypt.client import apache_configurator from letsencrypt.client import CONFIG @@ -23,7 +24,7 @@ class TwoVhost80Test(unittest.TestCase): """Test two standard well configured HTTP vhosts.""" def setUp(self): - display.set_display(display.NcursesDisplay()) + zope.component.provideUtility(display.NcursesDisplay()) self.temp_dir = os.path.join( tempfile.mkdtemp("temp"), "debian_apache_2_4") diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8cbda62dc..96bca973b 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -5,6 +5,8 @@ import logging import os import sys +import zope.component + from letsencrypt.client import apache_configurator from letsencrypt.client import CONFIG from letsencrypt.client import client @@ -73,9 +75,10 @@ def main(): .format(os.linesep)) if args.use_curses: - display.set_display(display.NcursesDisplay()) + displayer = display.NcursesDisplay() else: - display.set_display(display.FileDisplay(sys.stdout)) + displayer = display.FileDisplay(sys.stdout) + zope.component.provideUtility(displayer) if args.rollback > 0: rollback(apache_configurator.ApacheConfigurator(), args.rollback) diff --git a/setup.py b/setup.py index f5190807c..902ca5204 100755 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ install_requires = [ 'python-augeas', 'python2-pythondialog', 'requests', + 'zope.component', 'zope.interface', ] From 083024cb86552328593356102bf3db80c885636f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 17 Dec 2014 06:27:21 -0800 Subject: [PATCH 27/66] Major refactor of client and main --- letsencrypt/client/apache_configurator.py | 24 +- letsencrypt/client/augeas_configurator.py | 8 +- letsencrypt/client/client.py | 362 ++++++++++------------ letsencrypt/client/configurator.py | 98 ------ letsencrypt/client/crypto_util.py | 17 +- letsencrypt/client/interfaces.py | 92 ++++++ letsencrypt/scripts/main.py | 99 +++++- setup.py | 1 + 8 files changed, 374 insertions(+), 327 deletions(-) delete mode 100644 letsencrypt/client/configurator.py create mode 100644 letsencrypt/client/interfaces.py diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache_configurator.py index 6e7d76923..d11caa51d 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache_configurator.py @@ -8,21 +8,18 @@ import socket import subprocess import sys +import zope.interface + from letsencrypt.client import augeas_configurator from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG from letsencrypt.client import errors +from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser -# Configurator should be turned into a Singleton - -# Note: Apache 2.4 NameVirtualHost directive is deprecated... all vhost twins -# are considered name based vhosts by default. The use of the directive will -# emit a warning. - # TODO: Augeas sections ie. , beginning and closing # tags need to be the same case, otherwise Augeas doesn't recognize them. # This is not able to be completely remedied by regular expressions because @@ -75,6 +72,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :ivar dict assoc: Mapping between domains and vhosts """ + zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + def __init__(self, server_root=CONFIG.SERVER_ROOT, direc=None, ssl_options=CONFIG.OPTIONS_SSL_CONF, version=None): """Initialize an Apache Configurator. @@ -131,9 +130,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # if it is desired. There may be instances where correct configuration # isn't required on startup. - # TODO: This function can be improved to ensure that the final directives - # are being modified whether that be in the include files or in the - # virtualhost declaration - these directives can be overwritten def deploy_cert(self, vhost, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. @@ -765,11 +761,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return vhost return None - # TODO - both of these + # TODO: Handle ths as outlined in Interfaces. def enable_ocsp_stapling(self, ssl_vhost): + """Enable OCSP Stapling.""" return False def enable_hsts(self, ssl_vhost): + """Enable HSTS.""" return False def get_all_certs_keys(self): @@ -848,7 +846,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return True return False - def restart(self, quiet=False): # pylint: disable=no-self-use + def restart(self): # pylint: disable=no-self-use """Restarts apache server. :returns: Success @@ -1009,7 +1007,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Save reversible changes and restart the server self.save("SNI Challenge", True) - self.restart(True) + self.restart() return responses @@ -1017,7 +1015,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Revert all challenges.""" self.revert_challenge_config() - self.restart(True) + self.restart() # TODO: Variable names def dvsni_mod_config(self, list_sni_tuple, dvsni_key, diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 15fb84b72..266fc60c5 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -8,11 +8,10 @@ import time import augeas from letsencrypt.client import CONFIG -from letsencrypt.client import configurator from letsencrypt.client import le_util -class AugeasConfigurator(configurator.Configurator): +class AugeasConfigurator(object): """Base Augeas Configurator class. :ivar aug: Augeas object @@ -289,8 +288,7 @@ class AugeasConfigurator(configurator.Configurator): for idx, path in enumerate(filepaths): shutil.copy2(os.path.join( cp_dir, - os.path.basename(path) + '_' + str(idx)), - path) + os.path.basename(path) + '_' + str(idx)), path) except (IOError, OSError): # This file is required in all checkpoints. logging.error("Unable to recover files from %s", cp_dir) @@ -327,7 +325,7 @@ class AugeasConfigurator(configurator.Configurator): return True, "" - # pylint: disable=no-self-use + # pylint: disable=no-self-use, anomalous-backslash-in-string def register_file_creation(self, temporary, *files): """Register the creation of all files during letsencrypt execution. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 1843a7839..1987edbaf 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -15,7 +15,6 @@ import M2Crypto import requests from letsencrypt.client import acme -from letsencrypt.client import apache_configurator from letsencrypt.client import challenge from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util @@ -30,120 +29,77 @@ from letsencrypt.client import le_util ALLOW_RAW_IPV6_SERVER = False +# TODO: Look up sphinx doc for an interface class Client(object): """ACME protocol client. - :ivar config: Configurator. - :type config: :class:`letsencrypt.client.configurator.Configurator` - :ivar str server: Certificate authority server :ivar str server_url: Full URL of the CSR server - - :ivar csr: Certificate Signing Request - :type csr: :class:`CSR` - :ivar list names: Domain names (:class:`list` of :class:`str`). - :ivar privkey: Private key - :type privkey: :class:`Key` + :ivar auth: Object that supports the IAuthenticator interface. + :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` - :ivar bool use_curses: Use curses UI + :ivar installer: Object supporting the IInstaller interface. + :type installer: :class:`letsencrypt.client.interfaces.IInstraller` """ Key = collections.namedtuple("Key", "file pem") - CSR = collections.namedtuple("CSR", "file data type") + CSR = collections.namedtuple("CSR", "file data form") - def __init__(self, server, csr=CSR(None, None, None), - privkey=Key(None, None), use_curses=True): + def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" self.server = server self.server_url = "https://%s/acme/" % self.server - self.names = [] - self.use_curses = use_curses + self.names = names + self.authkey = authkey - self.csr = csr - self.privkey = privkey - self._validate_csr_key_cli() # TODO: catch exceptions + sanity_check_names([server] + names) - # TODO: Can probably figure out which configurator to use - # without special packaging based on system info Command - # line arg or client function to discover - self.config = apache_configurator.ApacheConfigurator( - CONFIG.SERVER_ROOT) + self.auth = auth + self.installer = installer - def authenticate(self, domains=None, eula=False, redirect=None): - """ + def obtain_certificate(self, privkey, csr, + cert_path=CONFIG.CERT_PATH, + chain_path=CONFIG.CHAIN_PATH): + """Obtains a certificate from the ACME server. - :param list domains: List of domains - :param bool eula: EULA accepted + .. todo:: Check for case when privkey is not authkey and adjust + this function accordingly. - :param redirect: If traffic should be forwarded from HTTP to HTTPS. - :type redirect: bool or None + :param privkey: A valid private key that corresponds to the csr + :type privkey: :class:`Key` - :raises errors.LetsEncryptClientError: CSR does not contain one of the - specified names. + :param csr: A valid CSR in der format that corresponds to privkey + :type csr: :class:`CSR` + + :param str cert_path: Full desired path to end certificate. + :param str chain_path: Full desired path to end chain file. + + :returns: cert_file, chain_file (paths to respective files) + :rtype: `tuple` of `str` """ - domains = [] if domains is None else domains - - # Check configuration - if not self.config.config_test(): - sys.exit(1) - - # Display preview warning - if not eula: - with open('EULA') as eula_file: - if not display.generic_yesno(eula_file.read(), - "Agree", "Cancel"): - sys.exit(0) - - # Display screen to select domains to validate - if domains: - sanity_check_names([self.server] + domains) - self.names = domains - else: - # This function adds all names - # found within the config to self.names - # Then filters them based on user selection - code, self.names = display.filter_names(self.get_all_names()) - if code == display.OK and self.names: - # TODO: Allow multiple names once it is setup - self.names = [self.names[0]] - else: - sys.exit(0) - # Request Challenges challenge_msg = self.acme_challenge() - # Make sure we have key and csr to perform challenges - self.init_key_csr() - - # TODO: Handle this exception/problem - if not crypto_util.csr_matches_names(self.csr.data, self.names): - raise errors.LetsEncryptClientError( - "CSR subject does not contain one of the specified names") - # Perform Challenges responses, challenge_objs = self.verify_identity(challenge_msg) + # Get Authorization self.acme_authorization(challenge_msg, challenge_objs, responses) # Retrieve certificate - certificate_dict = self.acme_certificate(self.csr.data) + certificate_dict = self.acme_certificate(csr.data) - # Find set of virtual hosts to deploy certificates to - vhost = self.get_virtual_hosts(self.names) - - # Install Certificate - cert_file = self.install_certificate(certificate_dict, vhost) - - # Perform optimal config changes - self.optimize_config(vhost, redirect) - - self.config.save("Completed Let's Encrypt Authentication") + # Save Certificate + cert_file, chain_file = self.save_certificate( + certificate_dict, cert_path, chain_path) self.store_cert_key(cert_file, False) + return cert_file, chain_file + def acme_challenge(self): """Handle ACME "challenge" phase. @@ -161,7 +117,7 @@ class Client(object): :param dict challenge_msg: ACME "challenge" message. - :param chal_objs: TODO + :param chal_objs: TODO - this will be a new object... :param responses: TODO :returns: ACME "authorization" message. @@ -170,7 +126,7 @@ class Client(object): """ auth_dict = self.send(acme.authorization_request( challenge_msg["sessionID"], self.names[0], - challenge_msg["nonce"], responses, self.privkey.pem)) + challenge_msg["nonce"], responses, self.authkey.pem)) try: return self.is_expected_msg(auth_dict, "authorization") @@ -192,7 +148,7 @@ class Client(object): """ logging.info("Preparing and sending CSR...") return self.send_and_receive_expected( - acme.certificate_request(csr_der, self.privkey.pem), "certificate") + acme.certificate_request(csr_der, self.authkey.pem), "certificate") def acme_revocation(self, cert): """Handle ACME "revocation" phase. @@ -280,12 +236,9 @@ class Client(object): """Is reponse expected ACME message? :param dict response: ACME response message from server. - :param str expected: Name of the expected response ACME message type. - :param int delay: Number of seconds to delay before next round in case of ACME "defer" response message. - :param int rounds: Number of resend attempts in case of ACME "defer" reponse message. @@ -312,7 +265,7 @@ class Client(object): else: logging.fatal("Received unexpected message") logging.fatal("Expected: %s" % expected) - logging.fatal("Received: " + response) + logging.fatal("Received: %s" % response) sys.exit(33) logging.error( @@ -329,7 +282,7 @@ class Client(object): return c_sha1_vh = {} - for (cert, _, path) in self.config.get_all_certs_keys(): + for (cert, _, path) in self.installer.get_all_certs_keys(): try: c_sha1_vh[M2Crypto.X509.load_cert( cert).get_fingerprint(md='sha1')] = path @@ -369,7 +322,7 @@ class Client(object): """ code, tag = display.display_certs(certs) - + if code == display.OK: cert = certs[tag] if display.confirm_revocation(cert): @@ -383,15 +336,21 @@ class Client(object): else: exit(0) - def install_certificate(self, certificate_dict, vhost): - """Install certificate + def save_certificate(self, certificate_dict, cert_path, chain_path): + """Saves the certificate received from the ACME server. - :returns: Path to a certificate file. - :rtype: str + :param dict certificate_dict: certificate message from server + :param str cert_path: Path to attempt to save the cert file + :param str chain_path: Path to attempt to save the chain file + + :returns: cert_file, chain_file (absolute paths to the actual files) + :rtype: `tuple` of `str` + + :raises IOError: If unable to find room to write the cert files """ cert_chain_abspath = None - cert_fd, cert_file = le_util.unique_file(CONFIG.CERT_PATH, 0o644) + cert_fd, cert_file = le_util.unique_file(cert_path, 0o644) cert_fd.write( crypto_util.b64_cert_to_pem(certificate_dict["certificate"])) cert_fd.close() @@ -399,7 +358,7 @@ class Client(object): "Server issued certificate; certificate written to %s", cert_file) if certificate_dict.get("chain", None): - chain_fd, chain_fn = le_util.unique_file(CONFIG.CHAIN_PATH, 0o644) + chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644) for cert in certificate_dict.get("chain", []): chain_fd.write(crypto_util.b64_cert_to_pem(cert)) chain_fd.close() @@ -409,26 +368,43 @@ class Client(object): # This expects a valid chain file cert_chain_abspath = os.path.abspath(chain_fn) + return os.path.abspath(cert_file), cert_chain_abspath + + def deploy_certificate(self, privkey, cert_file, chain_file): + """Install certificate + + :returns: Path to a certificate file. + :rtype: str + + """ + # Find set of virtual hosts to deploy certificates to + vhost = self.get_virtual_hosts(self.names) + + chain = None if chain_file is None else os.path.abspath(chain_file) + for host in vhost: - self.config.deploy_cert(host, - os.path.abspath(cert_file), - os.path.abspath(self.privkey.file), - cert_chain_abspath) + self.installer.deploy_cert(host, + os.path.abspath(cert_file), + os.path.abspath(privkey.file), + chain) # Enable any vhost that was issued to, but not enabled if not host.enabled: logging.info("Enabling Site %s", host.filep) - self.config.enable_site(host) + self.installer.enable_site(host) + self.installer.save("Deployed Let's Encrypt Certificate") # sites may have been enabled / final cleanup - self.config.restart(quiet=self.use_curses) + self.installer.restart() display.success_installation(self.names) - return cert_file + return vhost def optimize_config(self, vhost, redirect=None): """Optimize the configuration. + .. todo:: Handle multiple vhosts + :param vhost: vhost to optimize :type vhost: :class:`apache_configurator.VH` @@ -436,13 +412,12 @@ class Client(object): :type redirect: bool or None """ - # TODO: this should most definitely be moved to __init__ if redirect is None: redirect = display.redirect_by_default() if redirect: self.redirect_to_ssl(vhost) - self.config.restart(quiet=self.use_curses) + self.installer.restart() # if self.ocsp_stapling is None: # q = ("Would you like to protect the privacy of your users " @@ -463,7 +438,7 @@ class Client(object): logging.info("Cleaning up challenges...") for chall in challenges: if chall["type"] in CONFIG.CONFIG_CHALLENGES: - self.config.cleanup() + self.auth.cleanup() else: # Handle other cleanup if needed pass @@ -495,11 +470,11 @@ class Client(object): for i, c_obj in enumerate(challenge_objs): resp = "null" if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - resp = self.config.perform(c_obj) + resp = self.auth.perform(c_obj) else: # Handle RecoveryToken type challenges pass - + self._assign_responses(resp, indices[i], responses) logging.info( @@ -518,14 +493,13 @@ class Client(object): """ if isinstance(resp, list): - assert(len(resp) == len(index_list)) + assert len(resp) == len(index_list) for j, index in enumerate(index_list): responses[index] = resp[j] - else: + else: for index in index_list: responses[index] = resp - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -554,17 +528,17 @@ class Client(object): for row in csvreader: idx = int(row[0]) + 1 csvwriter = csv.writer(csvfile) - csvwriter.writerow([str(idx), cert_file, self.privkey.file]) + csvwriter.writerow([str(idx), cert_file, self.authkey.file]) else: with open(list_file, 'wb') as csvfile: csvwriter = csv.writer(csvfile) - csvwriter.writerow(["0", cert_file, self.privkey.file]) + csvwriter.writerow(["0", cert_file, self.authkey.file]) - shutil.copy2(self.privkey.file, + shutil.copy2(self.authkey.file, os.path.join( CONFIG.CERT_KEY_BACKUP, - os.path.basename(self.privkey.file) + "_" + str(idx))) + os.path.basename(self.authkey.file) + "_" + str(idx))) shutil.copy2(cert_file, os.path.join( CONFIG.CERT_KEY_BACKUP, @@ -576,16 +550,16 @@ class Client(object): """Redirect all traffic from HTTP to HTTPS :param vhost: list of ssl_vhosts - :type vhost: :class:`apache_configurator.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VH` """ for ssl_vh in vhost: - success, redirect_vhost = self.config.enable_redirect(ssl_vh) + success, redirect_vhost = self.installer.enable_redirect(ssl_vh) logging.info( "\nRedirect vhost: %s - %s ", redirect_vhost.filep, success) # If successful, make sure redirect site is enabled if success: - self.config.enable_site(redirect_vhost) + self.installer.enable_site(redirect_vhost) def get_virtual_hosts(self, domains): """Retrieve the appropriate virtual host for the domain @@ -598,7 +572,7 @@ class Client(object): """ vhost = set() for name in domains: - host = self.config.choose_virtual_host(name) + host = self.installer.choose_virtual_host(name) if host is not None: vhost.add(host) return vhost @@ -651,108 +625,106 @@ class Client(object): challenge_objs.append({ "type": "dvsni", "list_sni_tuple": sni_todo, - "dvsni_key": self.privkey, + "dvsni_key": self.authkey, }) challenge_obj_indices.append(sni_satisfies) logging.debug(sni_todo) return challenge_objs, challenge_obj_indices - def init_key_csr(self): - """Initializes privkey and csr. - Inits key and CSR using provided files or generating new files - if necessary. Both will be saved in PEM format on the - filesystem. The CSR is placed into DER format to allow - the namedtuple to easily work with the protocol. +def validate_key_csr(privkey, csr, names): + """Validate CSR and key files. - """ - if not self.privkey.file: - key_pem = crypto_util.make_key(CONFIG.RSA_KEY_SIZE) + Verifies that the client key and csr arguments are valid and + correspond to one another. - # Save file - le_util.make_or_verify_dir(CONFIG.KEY_DIR, 0o700) - 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() + :raises LetsEncryptClientError: if validation fails - logging.info("Generating key: %s", key_filename) + """ + # TODO: Handle all of these problems appropriately + # The client can eventually do things like prompt the user + # and allow the user to take more appropriate actions - self.privkey = Client.Key(key_filename, key_pem) + if csr.form == "der": + csr_obj = M2Crypto.X509.load_request_der_string(csr.data) + csr = Client.CSR(csr.file, csr_obj.as_pem(), "der") - if not self.csr.file: - csr_pem, csr_der = crypto_util.make_csr( - self.privkey.pem, self.names) + # If CSR is provided, it must be readable and valid. + if csr.data and not crypto_util.valid_csr(csr.data): + raise errors.LetsEncryptClientError( + "The provided CSR is not a valid CSR") - # Save CSR - le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0o755) - 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() + # If key is provided, it must be readable and valid. + if privkey.pem and not crypto_util.valid_privkey(privkey.pem): + raise errors.LetsEncryptClientError( + "The provided key is not a valid key") - logging.info("Creating CSR: %s", csr_filename) - - self.csr = Client.CSR(csr_filename, csr_der, "der") - elif self.csr.type != "der": - # The user is going to pass in a pem format file - # That is why we must conver it to der since the - # protocol uses der exclusively. - csr_obj = M2Crypto.X509.load_request_string(self.csr.data) - self.csr = Client.CSR(self.csr.file, csr_obj.as_der(), "der") - - def _validate_csr_key_cli(self): - """Validate CSR and key files. - - Verifies that the client key and csr arguments are valid and - correspond to one another. - - :raises LetsEncryptClientError: if validation fails - - """ - # TODO: Handle all of these problems appropriately - # The client can eventually do things like prompt the user - # and allow the user to take more appropriate actions - - # If CSR is provided, it must be readable and valid. - if self.csr.data and not crypto_util.valid_csr(self.csr.data): + # If CSR and key are provided, the key must be the same key used + # in the CSR. + if csr.data and privkey.pem: + if not crypto_util.csr_matches_pubkey( + csr.data, privkey.pem): raise errors.LetsEncryptClientError( - "The provided CSR is not a valid CSR") + "The key and CSR do not match") - # If key is provided, it must be readable and valid. - if (self.privkey.pem and - not crypto_util.valid_privkey(self.privkey.pem)): - raise errors.LetsEncryptClientError( - "The provided key is not a valid key") + if not crypto_util.csr_matches_names(csr.data, names): + raise errors.LetsEncryptClientError( + "CSR subject does not contain one of the specified names") - # If CSR and key are provided, the key must be the same key used - # in the CSR. - if self.csr.data and self.privkey.pem: - if not crypto_util.csr_matches_pubkey( - self.csr.data, self.privkey.pem): - raise errors.LetsEncryptClientError( - "The key and CSR do not match") - def get_all_names(self): - """Return all valid names in the configuration.""" - names = list(self.config.get_all_names()) - sanity_check_names(names) +def init_key(): + """Initializes privkey. - if not names: - logging.fatal("No domain names were found in your apache config") - logging.fatal("Either specify which names you would like " - "letsencrypt to validate or add server names " - "to your virtual hosts") - sys.exit(1) + Inits key and CSR using provided files or generating new files + if necessary. Both will be saved in PEM format on the + filesystem. The CSR is placed into DER format to allow + the namedtuple to easily work with the protocol. - return names + """ + key_pem = crypto_util.make_key(CONFIG.RSA_KEY_SIZE) + + # Save file + le_util.make_or_verify_dir(CONFIG.KEY_DIR, 0o700) + 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() + + logging.info("Generating key: %s", key_filename) + + return Client.Key(key_filename, key_pem) + + +def init_csr(privkey, names): + """Initialize a CSR with the given private key.""" + + csr_pem, csr_der = crypto_util.make_csr( + privkey.pem, names) + + # Save CSR + le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0o755) + 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() + + logging.info("Creating CSR: %s", csr_filename) + + return Client.CSR(csr_filename, csr_der, "der") + + +def csr_pem_to_der(csr): + """Convert pem CSR to der.""" + + csr_obj = M2Crypto.X509.load_request_string(csr.data) + return Client.CSR(csr.file, csr_obj.as_der(), "der") def remove_cert_key(cert): - """Remove certificate key. + """Remove certificate and key. - :param dict cert: + :param dict cert: Cert dict used throughout revocation """ list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") diff --git a/letsencrypt/client/configurator.py b/letsencrypt/client/configurator.py deleted file mode 100644 index c47557289..000000000 --- a/letsencrypt/client/configurator.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Configurator.""" - - -class Configurator(object): - """Generic Let's Encrypt configurator. - - Class represents all possible webservers and configuration editors - This includes the generic webserver which wont have configuration - files at all, but instead create a new process to handle the DVSNI - and other challenges. - """ - - def deploy_cert(self, vhost, cert, key, cert_chain=None): - """Deploy certificate. - - :param vhost - :param str cert: CSR - :param str key: Private key - - """ - raise NotImplementedError() - - def choose_virtual_host(self, name): - """Chooses a virtual host based on a given domain name.""" - raise NotImplementedError() - - def get_all_names(self): - """Returns all names found in the configuration.""" - raise NotImplementedError() - - def enable_redirect(self, ssl_vhost): - """Redirect all traffic to the given ssl_vhost (port 80 => 443).""" - raise NotImplementedError() - - def enable_hsts(self, ssl_vhost): - """Enable HSTS on the given ssl_vhost.""" - raise NotImplementedError() - - def enable_ocsp_stapling(self, ssl_vhost): - """Enable OCSP stapling on given ssl_vhost.""" - raise NotImplementedError() - - def get_all_certs_keys(self): - """Retrieve all certs and keys set in configuration. - - :returns: List of tuples with form [(cert, key, path)]. - :rtype: list - - """ - raise NotImplementedError() - - def enable_site(self, vhost): - """Enable the site at the given vhost.""" - raise NotImplementedError() - - def save(self, title=None, temporary=False): - """Saves all changes to the configuration files. - - Both title and temporary are needed because a save may be - intended to be permanent, but the save is not ready to be a full - checkpoint - - :param str title: The title of the save. If a title is given, the - configuration will be saved as a new checkpoint and put in a - timestamped directory. `title` has no effect if temporary is true. - - :param bool temporary: Indicates whether the changes made will - be quickly reversed in the future (challenges) - """ - raise NotImplementedError() - - def revert_challenge_config(self): - """Reload the users original configuration files.""" - raise NotImplementedError() - - def rollback_checkpoints(self, rollback=1): - """Revert `rollback` number of configuration checkpoints.""" - raise NotImplementedError() - - def display_checkpoints(self): - """Display the saved configuration checkpoints.""" - raise NotImplementedError() - - def config_test(self): - """Make sure the configuration is valid.""" - raise NotImplementedError() - - def restart(self, quiet=False): - """Restart or refresh the server content.""" - raise NotImplementedError() - - def perform(self, chall_dict): - """Perform the given challenge""" - raise NotImplementedError() - - def cleanup(self): - """Cleanup configuration changes from challenge.""" - raise NotImplementedError() diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index d19cbc0da..7d80ed9a4 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -15,8 +15,6 @@ from letsencrypt.client import CONFIG from letsencrypt.client import le_util -# TODO: All of these functions need unit tests - def b64_cert_to_pem(b64_der_cert): return M2Crypto.X509.load_cert_der_string( le_util.jose_b64decode(b64_der_cert)).as_pem() @@ -55,8 +53,8 @@ def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): logging.debug('%s signed as %s', msg_with_nonce, signature) - n_bytes = binascii.unhexlify(leading_zeros(hex(key.n)[2:].rstrip("L"))) - e_bytes = binascii.unhexlify(leading_zeros(hex(key.e)[2:].rstrip("L"))) + n_bytes = binascii.unhexlify(_leading_zeros(hex(key.n)[2:].rstrip("L"))) + e_bytes = binascii.unhexlify(_leading_zeros(hex(key.e)[2:].rstrip("L"))) return { "nonce": le_util.jose_b64encode(nonce), @@ -70,7 +68,7 @@ def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): } -def leading_zeros(arg): +def _leading_zeros(arg): if len(arg) % 2: return "0" + arg return arg @@ -82,9 +80,8 @@ def sha256(arg): # based on M2Crypto unit test written by Toby Allsopp def make_key(bits=CONFIG.RSA_KEY_SIZE): - """ - Returns new RSA key in PEM form with specified bits - """ + """Returns new RSA key in PEM form with specified bits.""" + # Python Crypto module doesn't produce any stdout key = Crypto.PublicKey.RSA.generate(bits) # rsa = M2Crypto.RSA.gen_key(bits, 65537) @@ -229,7 +226,7 @@ def csr_matches_names(csr, domains): M2Crypto currently does not expose the OpenSSL interface to also check the SAN extension. This is insufficient for full testing - :param str csr: CSR file contents + :param str csr: CSR file contents in pem form :param list domains: Domains the CSR should contain. @@ -238,7 +235,7 @@ def csr_matches_names(csr, domains): """ try: - csr_obj = M2Crypto.X509.load_request_der_string(csr) + csr_obj = M2Crypto.X509.load_request_string(csr) return csr_obj.get_subject().CN in domains except M2Crypto.X509.X509Error: return False diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py new file mode 100644 index 000000000..da403169b --- /dev/null +++ b/letsencrypt/client/interfaces.py @@ -0,0 +1,92 @@ +"""Interfaces.""" + +import zope.interface + + +class IAuthenticator(zope.interface.Interface): + """Generic Let's Encrypt Authenticator. + + Class represents all possible tools processes that have the + ability to perform challenges and attain a certificate. + + """ + def perform(chall_dict): + """Perform the given challenge""" + + def cleanup(): + """Revert changes and shutdown after challenges complete.""" + + +class IInstaller(zope.interface.Interface): + """Generic Let's Encrypt Installer Interface. + + Represents any server that an X509 certificate can be placed. + With a focus on HTTPS optimizations. + + .. todo:: All optimizations should be of the form .enable("hsts") + This will make it general towards any optimization... we should also + define a function to glean what optimizations are available. + Perhaps with text that describes the optimizations... + + """ + def get_all_names(): + """Returns all names that may be authenticated.""" + + def deploy_cert(vhost, cert, key, cert_chain=None): + """Deploy certificate. + + :param vhost + :param str cert: CSR + :param str key: Private key + + """ + + def choose_virtual_host(name): + """Chooses a virtual host based on a given domain name.""" + + def enable_redirect(ssl_vhost): + """Redirect all traffic to the given ssl_vhost (port 80 => 443).""" + + def enable_hsts(ssl_vhost): + """Enable HSTS on the given ssl_vhost.""" + + def enable_ocsp_stapling(ssl_vhost): + """Enable OCSP stapling on given ssl_vhost.""" + + def get_all_certs_keys(): + """Retrieve all certs and keys set in configuration. + + :returns: List of tuples with form [(cert, key, path)]. + :rtype: list + + """ + + def enable_site(vhost): + """Enable the site at the given vhost.""" + + def save(title=None, temporary=False): + """Saves all changes to the configuration files. + + Both title and temporary are needed because a save may be + intended to be permanent, but the save is not ready to be a full + checkpoint + + :param str title: The title of the save. If a title is given, the + configuration will be saved as a new checkpoint and put in a + timestamped directory. `title` has no effect if temporary is true. + + :param bool temporary: Indicates whether the changes made will + be quickly reversed in the future (challenges) + """ + + def rollback_checkpoints(rollback=1): + """Revert `rollback` number of configuration checkpoints.""" + + def display_checkpoints(): + """Display the saved configuration checkpoints.""" + + def config_test(): + """Make sure the configuration is valid.""" + + def restart(): + """Restart or refresh the server content.""" diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8cbda62dc..dee5d3504 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -5,10 +5,14 @@ import logging import os import sys +import zope.interface + from letsencrypt.client import apache_configurator from letsencrypt.client import CONFIG from letsencrypt.client import client from letsencrypt.client import display +from letsencrypt.client import interfaces +from letsencrypt.client import errors from letsencrypt.client import log @@ -87,21 +91,105 @@ def main(): server = args.server is None and CONFIG.ACME_SERVER or args.server + if not args.eula: + display_eula() + + auth = determine_authenticator() + + # Use the same object if possible + if interfaces.IInstaller.providedBy(auth): + installer = auth + else: + installer = determine_installer() + + domains = choose_names(installer) if args.domains is None else args.domains + # Prepare for init of Client if args.privkey is None: - privkey = client.Client.Key(None, None) + privkey = client.init_key() else: privkey = client.Client.Key(args.privkey[0], args.privkey[1]) if args.csr is None: - csr = client.Client.CSR(None, None, None) + csr = client.init_csr(privkey, domains) else: - csr = client.Client.CSR(args.csr[0], args.csr[1], "pem") + csr = client.csr_pem_to_der( + client.Client.CSR(args.csr[0], args.csr[1], "pem")) - acme = client.Client(server, csr, privkey, args.use_curses) + acme = client.Client(server, domains, privkey, auth, installer) if args.revoke: acme.list_certs_keys() else: - acme.authenticate(args.domains, args.eula, args.redirect) + # Validate the key and csr + client.validate_key_csr(privkey, csr, domains) + + cert_file, chain_file = acme.obtain_certificate(privkey, csr) + vhost = acme.deploy_certificate(privkey, cert_file, chain_file) + acme.optimize_config(vhost, args.redirect) + + +def display_eula(): + """Displays the end user agreement.""" + with open('EULA') as eula_file: + if not display.generic_yesno( + eula_file.read(), "Agree", "Cancel"): + sys.exit(0) + + +def choose_names(installer): + """Display screen to select domains to validate. + + :param installer: An installer object + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + + """ + # This function adds all names + # found within the config to self.names + # Then filters them based on user selection + code, names = display.filter_names(get_all_names(installer)) + if code == display.OK and names: + # TODO: Allow multiple names once it is setup + return [names[0]] + else: + sys.exit(0) + + +def get_all_names(installer): + """Return all valid names in the configuration. + + :param installer: An installer object + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + + """ + names = list(installer.get_all_names()) + client.sanity_check_names(names) + + if not names: + logging.fatal("No domain names were found in your installation") + logging.fatal("Either specify which names you would like " + "letsencrypt to validate or add server names " + "to your virtual hosts") + sys.exit(1) + + return names + + +# This should be controlled by commandline parameters +def determine_authenticator(): + """Returns a valid authenticator.""" + + try: + return apache_configurator.ApacheConfigurator() + except errors.LetsEncryptConfiguratorError: + log.info("Unable to find a way to authenticate.") + + +def determine_installer(): + """Returns a valid installer if one exists.""" + + try: + return apache_configurator.ApacheConfigurator() + except errors.LetsEncryptConfiguratorError: + log.info("Unable to find a way to install the certificate.") def read_file(filename): @@ -143,6 +231,5 @@ def view_checkpoints(config): """ config.display_checkpoints() - if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index e84906910..3f8c6d5c8 100755 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ install_requires = [ 'python-augeas', 'python2-pythondialog', 'requests', + 'zope.interface', ] docs_extras = [ From 6d0d439acf0c4a45f623afc8f8f026f230e8119b Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 17 Dec 2014 19:23:43 -0800 Subject: [PATCH 28/66] Fix #137 find_directive uses the unitialized member location in set_user_config when httpd.conf is present. I changed set_user_config_file to use the root path as a start. --- letsencrypt/client/apache_configurator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache_configurator.py index 11a999e9b..2a2f77812 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache_configurator.py @@ -352,7 +352,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ root = self._find_config_root() - default = self._set_user_config_file() + default = self._set_user_config_file(root) temp = os.path.join(self.server_root, "ports.conf") if os.path.isfile(temp): @@ -376,7 +376,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): raise errors.LetsEncryptConfiguratorError( "Could not find configuration root") - def _set_user_config_file(self, filename=''): + def _set_user_config_file(self, root, filename=''): """Set the appropriate user configuration file .. todo:: This will have to be updated for other distros versions @@ -393,7 +393,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # httpd.conf was very common as a user file in Apache 2.2 if (os.path.isfile(self.server_root + 'httpd.conf') and self.find_directive( - case_i("Include"), case_i("httpd.conf"))): + case_i("Include"), case_i("httpd.conf"), + get_aug_path(root))): return os.path.join(self.server_root, 'httpd.conf') else: return os.path.join(self.server_root + 'apache2.conf') From cc999d365417f8178a7e06880efeeee2c60ec003 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 19 Dec 2014 15:49:29 -0800 Subject: [PATCH 29/66] Refactored/added tests --- .../configurator.py} | 57 ++++---- letsencrypt/client/apache/obj.py | 36 +++-- letsencrypt/client/apache/parser.py | 25 ++-- letsencrypt/client/augeas_configurator.py | 1 - letsencrypt/client/client.py | 14 +- letsencrypt/client/crypto_util.py | 1 - letsencrypt/client/interfaces.py | 1 - .../client/tests/apache_configurator_test.py | 133 +++--------------- letsencrypt/client/tests/apache_obj_test.py | 62 ++++++++ .../client/tests/apache_parser_test.py | 114 +++++++++++++++ letsencrypt/client/tests/config_util.py | 94 +++++++++++++ letsencrypt/scripts/main.py | 22 ++- setup.py | 1 + tox.ini | 2 +- 14 files changed, 362 insertions(+), 201 deletions(-) rename letsencrypt/client/{apache_configurator.py => apache/configurator.py} (96%) create mode 100644 letsencrypt/client/tests/apache_obj_test.py create mode 100644 letsencrypt/client/tests/apache_parser_test.py create mode 100644 letsencrypt/client/tests/config_util.py diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache/configurator.py similarity index 96% rename from letsencrypt/client/apache_configurator.py rename to letsencrypt/client/apache/configurator.py index d11caa51d..b4b5e4d20 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -67,7 +67,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): with the configuration :ivar float version: version of Apache :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`letsencrypt.client.apache.obj.VH`) + (:class:`list` of :class:`letsencrypt.client.apache.obj.VirtualHost`) :ivar dict assoc: Mapping between domains and vhosts @@ -97,7 +97,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): super(ApacheConfigurator, self).__init__(direc) # See if any temporary changes need to be recovered - # This needs to occur before VH objects are setup... + # This needs to occur before VirtualHost objects are setup... # because this will change the underlying configuration and potential # vhosts self.recovery_routine() @@ -145,7 +145,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): This shouldn't happen within letsencrypt though :param vhost: ssl vhost to deploy certificate - :type vhost: :class:`letsencrypt.client.apache.obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :param str cert: certificate filename :param str key: private key filename @@ -204,7 +204,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str name: domain name :returns: ssl vhost associated with name - :rtype: :class:`letsencrypt.client.apache.obj.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ # Allows for domain names to be associated with a virtual host @@ -242,7 +242,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`letsencrypt.client.apache.obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` """ self.assoc[domain] = vhost @@ -279,7 +279,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Helper function for get_virtual_hosts(). :param host: In progress vhost whose names will be added - :type host: :class:`letsencrypt.client.apache.obj.VH` + :type host: :class:`letsencrypt.client.apache.obj.VirtualHost` """ name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " @@ -300,7 +300,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`letsencrypt.client.apache.obj.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ addrs = set() @@ -315,7 +315,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): filename = get_file_path(path) is_enabled = self.is_site_enabled(filename) - vhost = obj.VH(filename, path, addrs, is_ssl, is_enabled) + vhost = obj.VirtualHost(filename, path, addrs, is_ssl, is_enabled) self._add_servernames(vhost) return vhost @@ -323,7 +323,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def get_virtual_hosts(self): """Returns list of virtual hosts found in the Apache configuration. - :returns: List of :class:`letsencrypt.client.apache.obj.VH` objects + :returns: List of + :class:`letsencrypt.client.apache.obj.VirtualHost` objects found in configuration :rtype: list @@ -400,8 +401,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def make_server_sni_ready(self, vhost, default_addr="*:443"): """Checks to see if the server is ready for SNI challenges. - :param vhost: VHost to check SNI compatibility - :type vhost: :class:`letsencrypt.client.apache.obj.VH` + :param vhost: VirtualHostost to check SNI compatibility + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :param str default_addr: TODO - investigate function further @@ -431,10 +432,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): New vhost will reside as (nonssl_vhost.path) + CONFIG.LE_VHOST_EXT :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VH` + :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: SSL vhost - :rtype: :class:`letsencrypt.client.apache.obj.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ avail_fp = nonssl_vhost.filep @@ -472,11 +473,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): addr_match % (ssl_fp, parser.case_i('VirtualHost'))) for i in range(len(ssl_addr_p)): - ssl_addr_arg = obj.Addr.fromstring( + old_addr = obj.Addr.fromstring( str(self.aug.get(ssl_addr_p[i]))) - ssl_addr_arg.set_port("443") - self.aug.set(ssl_addr_p[i], str(ssl_addr_arg)) - ssl_addrs.add(ssl_addr_arg) + ssl_addr = old_addr.get_addr_obj("443") + self.aug.set(ssl_addr_p[i], str(ssl_addr)) + ssl_addrs.add(ssl_addr) # Add directives vh_p = self.aug.match(("/files%s//* [label()=~regexp('%s')]" % @@ -528,10 +529,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): The function then adds the directive :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VH`) + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) """ # TODO: Enable check to see if it is already there @@ -577,7 +578,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`letsencrypt.client.apache.obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: Success, code value... see documentation :rtype: bool, int @@ -608,10 +609,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: Success, vhost - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VH`) + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) """ # Consider changing this to a dictionary check @@ -694,7 +695,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not conflict: returns space separated list of new host addrs :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: TODO :rtype: TODO @@ -727,10 +728,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`letsencrypt.client.apache.obj.VH` or None + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` or None """ # _default_:443 check @@ -826,7 +827,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`letsencrypt.client.apache.obj.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: Success :rtype: bool @@ -1048,11 +1049,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) # TODO: Use ip address of existing vhost instead of relying on FQDN - config_text = " \n" + config_text = "\n" for idx, lis in enumerate(ll_addrs): config_text += self.get_config_text( list_sni_tuple[idx][2], lis, dvsni_key.file) - config_text += " \n" + config_text += "\n" self.dvsni_conf_include_check(self.parser.loc["default"]) self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) diff --git a/letsencrypt/client/apache/obj.py b/letsencrypt/client/apache/obj.py index b5bc97302..aeddee443 100644 --- a/letsencrypt/client/apache/obj.py +++ b/letsencrypt/client/apache/obj.py @@ -2,10 +2,14 @@ class Addr(object): - """Represents an Apache VirtualHost address.""" - def __init__(self, addr): - """:param tuple addr: tuple of strings (ip, port)""" - self.tup = addr + """Represents an Apache VirtualHost address. + + :param str addr: addr part of vhost address + :param str port: port number or *, or "" + + """ + def __init__(self, tup): + self.tup = tup @classmethod def fromstring(cls, str_addr): @@ -14,25 +18,18 @@ class Addr(object): return cls((tup[0], tup[2])) def __str__(self): - if self.tup[1] != "": - return ':'.join(self.tup) - return str(self.tup[0]) + if self.tup[1]: + return "%s:%s" % self.tup + return self.tup[0] def __eq__(self, other): - if isinstance(other, self.__class__): - return self.tup == other.tup + if type(other) is type(self): + return self.__dict__ == other.__dict__ return False def __hash__(self): return hash(self.tup) - def set_port(self, port): - """Set the port of the address. - - :param str port: new port - """ - self.tup = (self.tup[0], port) - def get_addr(self): """Return addr part of Addr object.""" return self.tup[0] @@ -47,7 +44,7 @@ class Addr(object): # pylint: disable=too-few-public-methods -class VH(object): +class VirtualHost(object): """Represents an Apache Virtualhost. :ivar str filep: file path of VH @@ -66,7 +63,7 @@ class VH(object): self.filep = filep self.path = path self.addrs = addrs - self.names = set() if names is None else names + self.names = set() if names is None else set(names) self.ssl = ssl self.enabled = enabled @@ -75,12 +72,13 @@ class VH(object): self.names.add(name) def __str__(self): + addr_str = ", ".join(str(addr) for addr in self.addrs) return ("file: %s\n" "vh_path: %s\n" "addrs: %s\n" "names: %s\n" "ssl: %s\n" - "enabled: %s" % (self.filep, self.path, self.addrs, + "enabled: %s" % (self.filep, self.path, addr_str, self.names, self.ssl, self.enabled)) def __eq__(self, other): diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index 409c82b35..7a4691416 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -2,6 +2,8 @@ import os import re +from letsencrypt.client import errors + class ApacheParser(object): """Class handles the fine details of parsing the Apache Configuration.""" @@ -311,7 +313,7 @@ class ApacheParser(object): """ root = self._find_config_root() - default = self._set_user_config_file() + default = self._set_user_config_file(root) temp = os.path.join(self.root, "ports.conf") if os.path.isfile(temp): @@ -335,7 +337,7 @@ class ApacheParser(object): raise errors.LetsEncryptConfiguratorError( "Could not find configuration root") - def _set_user_config_file(self, filename=''): + def _set_user_config_file(self, root): """Set the appropriate user configuration file .. todo:: This will have to be updated for other distros versions @@ -344,18 +346,15 @@ class ApacheParser(object): user config """ - if filename: - return filename + # Basic check to see if httpd.conf exists and + # in heirarchy via direct include + # httpd.conf was very common as a user file in Apache 2.2 + if (os.path.isfile(self.root + 'httpd.conf') and + self.find_dir( + case_i("Include"), case_i("httpd.conf"), root)): + return os.path.join(self.root, 'httpd.conf') else: - # Basic check to see if httpd.conf exists and - # in heirarchy via direct include - # httpd.conf was very common as a user file in Apache 2.2 - if (os.path.isfile(self.root + 'httpd.conf') and - self.find_dir( - case_i("Include"), case_i("httpd.conf"))): - return os.path.join(self.root, 'httpd.conf') - else: - return os.path.join(self.root + 'apache2.conf') + return os.path.join(self.root + 'apache2.conf') def case_i(string): diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 266fc60c5..231faa99d 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -29,7 +29,6 @@ class AugeasConfigurator(object): (used mostly for testing) """ - super(AugeasConfigurator, self).__init__() if not direc: direc = {"backup": CONFIG.BACKUP_DIR, diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 1987edbaf..da6c55cc2 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -59,18 +59,13 @@ class Client(object): self.auth = auth self.installer = installer - def obtain_certificate(self, privkey, csr, + def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, chain_path=CONFIG.CHAIN_PATH): """Obtains a certificate from the ACME server. - .. todo:: Check for case when privkey is not authkey and adjust - this function accordingly. - - :param privkey: A valid private key that corresponds to the csr - :type privkey: :class:`Key` - - :param csr: A valid CSR in der format that corresponds to privkey + :param csr: A valid CSR in DER format for the certificate the client + intends to receive. :type csr: :class:`CSR` :param str cert_path: Full desired path to end certificate. @@ -699,8 +694,7 @@ def init_key(): def init_csr(privkey, names): """Initialize a CSR with the given private key.""" - csr_pem, csr_der = crypto_util.make_csr( - privkey.pem, names) + csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names) # Save CSR le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0o755) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 7d80ed9a4..dd581f892 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -81,7 +81,6 @@ def sha256(arg): # based on M2Crypto unit test written by Toby Allsopp def make_key(bits=CONFIG.RSA_KEY_SIZE): """Returns new RSA key in PEM form with specified bits.""" - # Python Crypto module doesn't produce any stdout key = Crypto.PublicKey.RSA.generate(bits) # rsa = M2Crypto.RSA.gen_key(bits, 65537) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index da403169b..06bb5e83e 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -1,5 +1,4 @@ """Interfaces.""" - import zope.interface diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index 2c8742731..7084d10c6 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -1,22 +1,19 @@ """Test for letsencrypt.client.apache_configurator.""" import os -import pkg_resources import re import shutil -import tempfile import unittest import mock -from letsencrypt.client import apache_configurator -from letsencrypt.client import CONFIG from letsencrypt.client import display from letsencrypt.client import errors + +from letsencrypt.client.apache import configurator from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser -UBUNTU_CONFIGS = pkg_resources.resource_filename( - __name__, "testdata/debian_apache_2_4") +from letsencrypt.client.tests import config_util class TwoVhost80Test(unittest.TestCase): @@ -25,102 +22,31 @@ class TwoVhost80Test(unittest.TestCase): def setUp(self): display.set_display(display.NcursesDisplay()) - self.temp_dir = os.path.join( - tempfile.mkdtemp("temp"), "debian_apache_2_4") - self.config_dir = tempfile.mkdtemp("config") - self.work_dir = tempfile.mkdtemp("work") + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") - shutil.copytree(UBUNTU_CONFIGS, self.temp_dir, symlinks=True) - - temp_options = pkg_resources.resource_filename( - "letsencrypt.client", os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile( - temp_options, os.path.join(self.config_dir, "options-ssl.conf")) + self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir) # Final slash is currently important - self.config_path = os.path.join(self.temp_dir, "two_vhost_80/apache2/") - self.ssl_options = os.path.join(self.config_dir, "options-ssl.conf") - backups = os.path.join(self.work_dir, "backups") + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") - with mock.patch("letsencrypt.client.apache_configurator." - "subprocess.Popen") as mock_popen: - # This just states that the ssl module is already loaded - mock_popen().communicate.return_value = ("ssl_module", "") - self.config = apache_configurator.ApacheConfigurator( - self.config_path, - { - "backup": backups, - "temp": os.path.join(self.work_dir, "temp_checkpoint"), - "progress": os.path.join(backups, "IN_PROGRESS"), - "config": self.config_dir, - "work": self.work_dir, - }, - self.ssl_options, - (2, 4, 7)) + self.config = config_util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, self.ssl_options) - prefix = os.path.join( - self.temp_dir, "two_vhost_80/apache2/sites-available") - aug_pre = "/files" + prefix - self.vh_truth = [ - obj.VH( - os.path.join(prefix, "encryption-example.conf"), - os.path.join(aug_pre, "encryption-example.conf/VirtualHost"), - set([obj.Addr.fromstring("*:80")]), - False, True, set(["encryption-example.demo"])), - obj.VH( - os.path.join(prefix, "default-ssl.conf"), - os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), - set([obj.Addr.fromstring("_default_:443")]), True, False), - obj.VH( - os.path.join(prefix, "000-default.conf"), - os.path.join(aug_pre, "000-default.conf/VirtualHost"), - set([obj.Addr.fromstring("*:80")]), False, True, - set(["ip-172-30-0-17"])), - obj.VH( - os.path.join(prefix, "letsencrypt.conf"), - os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), - set([obj.Addr.fromstring("*:80")]), False, True, - set(["letsencrypt.demo"])), - ] + self.vh_truth = config_util.get_vh_truth( + self.temp_dir, "debian_apache_2_4/two_vhost_80") def tearDown(self): shutil.rmtree(self.temp_dir) shutil.rmtree(self.config_dir) shutil.rmtree(self.work_dir) - def test_parse_file(self): - """Test parse_file. - - letsencrypt.conf is chosen as the test file as it will not be - included during the normal course of execution. - - """ - file_path = os.path.join( - self.config_path, "sites-available", "letsencrypt.conf") - - # pylint: disable=protected-access - self.config.parser._parse_file(file_path) - - # search for the httpd incl - matches = self.config.aug.match( - "/augeas/load/Httpd/incl [. ='%s']" % file_path) - - self.assertTrue(matches) - def test_get_all_names(self): names = self.config.get_all_names() self.assertEqual(names, set( ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) - def test_find_dir(self): - test = self.config.parser.find_dir( - parser.case_i("Listen"), "443") - # This will only look in enabled hosts - test2 = self.config.parser.find_dir( - parser.case_i("documentroot")) - self.assertEqual(len(test), 2) - self.assertEqual(len(test2), 3) - def test_get_virtual_hosts(self): vhs = self.config.get_virtual_hosts() self.assertEqual(len(vhs), 4) @@ -140,14 +66,6 @@ class TwoVhost80Test(unittest.TestCase): self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep)) - def test_add_dir(self): - aug_default = "/files" + self.config.parser.loc["default"] - self.config.parser.add_dir( - aug_default, "AddDirective", "test") - - self.assertTrue( - self.config.parser.find_dir("AddDirective", "test", aug_default)) - def test_deploy_cert(self): self.config.deploy_cert( self.vh_truth[1], @@ -165,15 +83,15 @@ class TwoVhost80Test(unittest.TestCase): # Verify one directive was found in the correct file self.assertEqual(len(loc_cert), 1) - self.assertEqual(apache_configurator.get_file_path(loc_cert[0]), + self.assertEqual(configurator.get_file_path(loc_cert[0]), self.vh_truth[1].filep) self.assertEqual(len(loc_key), 1) - self.assertEqual(apache_configurator.get_file_path(loc_key[0]), + self.assertEqual(configurator.get_file_path(loc_key[0]), self.vh_truth[1].filep) self.assertEqual(len(loc_chain), 1) - self.assertEqual(apache_configurator.get_file_path(loc_chain[0]), + self.assertEqual(configurator.get_file_path(loc_chain[0]), self.vh_truth[1].filep) def test_is_name_vhost(self): @@ -187,21 +105,6 @@ class TwoVhost80Test(unittest.TestCase): self.assertTrue(self.config.parser.find_dir( "NameVirtualHost", re.escape("*:443"))) - def test_add_dir_to_ifmodssl(self): - """test add_dir_to_ifmodssl. - - Path must be valid before attempting to add to augeas - - """ - self.config.parser.add_dir_to_ifmodssl( - parser.get_aug_path(self.config.parser.loc["default"]), - "FakeDirective", "123") - - matches = self.config.parser.find_dir("FakeDirective", "123") - - self.assertEqual(len(matches), 1) - self.assertTrue("IfModule" in matches[0]) - def test_make_vhost_ssl(self): ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) @@ -212,7 +115,8 @@ class TwoVhost80Test(unittest.TestCase): self.assertEqual(ssl_vhost.path, "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") - self.assertEqual(ssl_vhost.addrs, set([obj.Addr.fromstring("*:443")])) + self.assertEqual(len(ssl_vhost.addrs), 1) + self.assertTrue(set([obj.Addr.fromstring("*:443")]) == ssl_vhost.addrs) self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) self.assertTrue(ssl_vhost.ssl) self.assertFalse(ssl_vhost.enabled) @@ -229,7 +133,7 @@ class TwoVhost80Test(unittest.TestCase): self.assertEqual(len(self.config.vhosts), 5) - @mock.patch("letsencrypt.client.apache_configurator." + @mock.patch("letsencrypt.client.apache.configurator." "subprocess.Popen") def test_get_version(self, mock_popen): mock_popen().communicate.return_value = ( @@ -254,6 +158,5 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) - if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/apache_obj_test.py b/letsencrypt/client/tests/apache_obj_test.py new file mode 100644 index 000000000..fb13b051a --- /dev/null +++ b/letsencrypt/client/tests/apache_obj_test.py @@ -0,0 +1,62 @@ +import unittest + +from letsencrypt.client.apache import obj + + +class AddrTest(unittest.TestCase): + """Test the Addr class.""" + def setUp(self): + self.addr1 = obj.Addr.fromstring("192.168.1.1") + self.addr2 = obj.Addr.fromstring("192.168.1.1:*") + self.addr3 = obj.Addr.fromstring("192.168.1.1:80") + + def test_fromstring(self): + self.assertEqual(self.addr1.get_addr(), "192.168.1.1") + self.assertEqual(self.addr1.get_port(), "") + self.assertEqual(self.addr2.get_addr(), "192.168.1.1") + self.assertEqual(self.addr2.get_port(), "*") + self.assertEqual(self.addr3.get_addr(), "192.168.1.1") + self.assertEqual(self.addr3.get_port(), "80") + + def test_str(self): + self.assertEqual(str(self.addr1), "192.168.1.1") + self.assertEqual(str(self.addr2), "192.168.1.1:*") + self.assertEqual(str(self.addr3), "192.168.1.1:80") + + def test_get_addr_obj(self): + self.assertEqual(str(self.addr1.get_addr_obj("443")), "192.168.1.1:443") + self.assertEqual(str(self.addr2.get_addr_obj("")), "192.168.1.1") + self.assertEqual(str(self.addr1.get_addr_obj("*")), "192.168.1.1:*") + + def test_eq(self): + self.assertEqual(self.addr1, self.addr2.get_addr_obj("")) + self.assertNotEqual(self.addr1, self.addr2) + # This is specifically designed to hit line 28 but coverage denies me + # the satisfaction :( + self.assertNotEqual(self.addr1, 3333) + + def test_set_inclusion(self): + set_a = set([self.addr1, self.addr2]) + addr1b = obj.Addr.fromstring("192.168.1.1") + addr2b = obj.Addr.fromstring("192.168.1.1:*") + set_b = set([addr1b, addr2b]) + + self.assertTrue(addr1b in set_a) + self.assertEqual(set_a, set_b) + + +class VirtualHostTest(unittest.TestCase): + """Test the VirtualHost class.""" + def setUp(self): + self.vhost1 = obj.VirtualHost( + "filep", "vh_path", + set([obj.Addr.fromstring("localhost")]), False, False) + + def test_eq(self): + vhost1b = obj.VirtualHost( + "filep", "vh_path", + set([obj.Addr.fromstring("localhost")]), False, False) + + self.assertEqual(vhost1b, self.vhost1) + self.assertEqual(str(vhost1b), str(self.vhost1)) + self.assertTrue(vhost1b != 1234) diff --git a/letsencrypt/client/tests/apache_parser_test.py b/letsencrypt/client/tests/apache_parser_test.py new file mode 100644 index 000000000..259d974e1 --- /dev/null +++ b/letsencrypt/client/tests/apache_parser_test.py @@ -0,0 +1,114 @@ +import os +import shutil +import sys +import unittest + +import augeas +import mock + +from letsencrypt.client import display +from letsencrypt.client import errors +from letsencrypt.client.apache import parser +from letsencrypt.client.tests import config_util + + +class BasicParseTests(unittest.TestCase): + + def setUp(self): + display.set_display(display.FileDisplay(sys.stdout)) + + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") + + self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir) + + # Final slash is currently important + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") + + self.parser = parser.ApacheParser( + augeas.Augeas(flags=augeas.Augeas.NONE), + self.config_path, self.ssl_options) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + def test_parse_file(self): + """Test parse_file. + + letsencrypt.conf is chosen as the test file as it will not be + included during the normal course of execution. + + """ + file_path = os.path.join( + self.config_path, "sites-available", "letsencrypt.conf") + + # pylint: disable=protected-access + self.parser._parse_file(file_path) + + # search for the httpd incl + matches = self.parser.aug.match( + "/augeas/load/Httpd/incl [. ='%s']" % file_path) + + self.assertTrue(matches) + + def test_find_dir(self): + test = self.parser.find_dir( + parser.case_i("Listen"), "443") + # This will only look in enabled hosts + test2 = self.parser.find_dir( + parser.case_i("documentroot")) + self.assertEqual(len(test), 2) + self.assertEqual(len(test2), 3) + + def test_add_dir(self): + aug_default = "/files" + self.parser.loc["default"] + self.parser.add_dir( + aug_default, "AddDirective", "test") + + self.assertTrue( + self.parser.find_dir("AddDirective", "test", aug_default)) + + self.parser.add_dir(aug_default, "AddList", ["1", "2", "3", "4"]) + matches = self.parser.find_dir("AddList", None, aug_default) + for i, match in enumerate(matches): + self.assertEqual(self.parser.aug.get(match), str(i + 1)) + + def test_add_dir_to_ifmodssl(self): + """test add_dir_to_ifmodssl. + + Path must be valid before attempting to add to augeas + + """ + self.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.parser.loc["default"]), + "FakeDirective", "123") + + matches = self.parser.find_dir("FakeDirective", "123") + + self.assertEqual(len(matches), 1) + self.assertTrue("IfModule" in matches[0]) + + def test_get_aug_path(self): + self.assertEqual( + "/files/etc/apache", parser.get_aug_path("/etc/apache")) + + def test_set_locations(self): + with mock.patch("letsencrypt.client.apache.parser." + "os.path") as mock_path: + + mock_path.isfile.return_value = False + + # pylint: disable=protected-access + self.assertRaises(errors.LetsEncryptConfiguratorError, + self.parser._set_locations, self.ssl_options) + + mock_path.isfile.side_effect = [True, False, False] + + # pylint: disable=protected-access + results = self.parser._set_locations(self.ssl_options) + + self.assertEqual(results["default"], results["listen"]) + self.assertEqual(results["default"], results["name"]) diff --git a/letsencrypt/client/tests/config_util.py b/letsencrypt/client/tests/config_util.py new file mode 100644 index 000000000..4683ab0c2 --- /dev/null +++ b/letsencrypt/client/tests/config_util.py @@ -0,0 +1,94 @@ +import os +import pkg_resources +import shutil +import tempfile + +import mock + +from letsencrypt.client import CONFIG +from letsencrypt.client.apache import configurator +from letsencrypt.client.apache import obj +from letsencrypt.client.apache import parser + + +def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): + """Setup the directories necessary for the configurator.""" + temp_dir = tempfile.mkdtemp("temp") + config_dir = tempfile.mkdtemp("config") + work_dir = tempfile.mkdtemp("work") + + test_configs = pkg_resources.resource_filename( + __name__, "testdata/%s" % test_dir) + + shutil.copytree( + test_configs, os.path.join(temp_dir, test_dir), symlinks=True) + + return temp_dir, config_dir, work_dir + + +def setup_apache_ssl_options(config_dir): + """Move the ssl_options into position and return the path.""" + option_path = os.path.join(config_dir, "options-ssl.conf") + temp_options = pkg_resources.resource_filename( + "letsencrypt.client", os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + shutil.copyfile( + temp_options, option_path) + + return option_path + + +def get_apache_configurator( + config_path, config_dir, work_dir, ssl_options, version=(2, 4, 7)): + """Create an Apache Configurator with the specified options.""" + + backups = os.path.join(work_dir, "backups") + + with mock.patch("letsencrypt.client.apache.configurator." + "subprocess.Popen") as mock_popen: + # This just states that the ssl module is already loaded + mock_popen().communicate.return_value = ("ssl_module", "") + config = configurator.ApacheConfigurator( + config_path, + { + "backup": backups, + "temp": os.path.join(work_dir, "temp_checkpoint"), + "progress": os.path.join(backups, "IN_PROGRESS"), + "config": config_dir, + "work": work_dir, + }, + ssl_options, + version) + + return config + + +def get_vh_truth(temp_dir, config_name): + """Return the ground truth for the specified directory.""" + if config_name == "debian_apache_2_4/two_vhost_80": + prefix = os.path.join( + temp_dir, config_name, "apache2/sites-available") + aug_pre = "/files" + prefix + vh_truth = [ + obj.VirtualHost( + os.path.join(prefix, "encryption-example.conf"), + os.path.join(aug_pre, "encryption-example.conf/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), + False, True, set(["encryption-example.demo"])), + obj.VirtualHost( + os.path.join(prefix, "default-ssl.conf"), + os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), + set([obj.Addr.fromstring("_default_:443")]), True, False), + obj.VirtualHost( + os.path.join(prefix, "000-default.conf"), + os.path.join(aug_pre, "000-default.conf/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), False, True, + set(["ip-172-30-0-17"])), + obj.VirtualHost( + os.path.join(prefix, "letsencrypt.conf"), + os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), False, True, + set(["letsencrypt.demo"])), + ] + return vh_truth + + return None diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index dee5d3504..aa18541ee 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -5,9 +5,6 @@ import logging import os import sys -import zope.interface - -from letsencrypt.client import apache_configurator from letsencrypt.client import CONFIG from letsencrypt.client import client from letsencrypt.client import display @@ -15,6 +12,8 @@ from letsencrypt.client import interfaces from letsencrypt.client import errors from letsencrypt.client import log +from letsencrypt.client.apache import configurator + def main(): """Command line argument parsing and main script execution.""" @@ -82,11 +81,11 @@ def main(): display.set_display(display.FileDisplay(sys.stdout)) if args.rollback > 0: - rollback(apache_configurator.ApacheConfigurator(), args.rollback) + rollback(configurator.ApacheConfigurator(), args.rollback) sys.exit() if args.view_checkpoints: - view_checkpoints(apache_configurator.ApacheConfigurator()) + view_checkpoints(configurator.ApacheConfigurator()) sys.exit() server = args.server is None and CONFIG.ACME_SERVER or args.server @@ -122,7 +121,7 @@ def main(): # Validate the key and csr client.validate_key_csr(privkey, csr, domains) - cert_file, chain_file = acme.obtain_certificate(privkey, csr) + cert_file, chain_file = acme.obtain_certificate(csr) vhost = acme.deploy_certificate(privkey, cert_file, chain_file) acme.optimize_config(vhost, args.redirect) @@ -176,20 +175,19 @@ def get_all_names(installer): # This should be controlled by commandline parameters def determine_authenticator(): """Returns a valid authenticator.""" - try: - return apache_configurator.ApacheConfigurator() + return configurator.ApacheConfigurator() except errors.LetsEncryptConfiguratorError: - log.info("Unable to find a way to authenticate.") + logging.info("Unable to find a way to authenticate.") def determine_installer(): """Returns a valid installer if one exists.""" - try: - return apache_configurator.ApacheConfigurator() + print "shouldn't ha;ppppen!!!!!!!!!!!!!!!!!!!" + return configurator.ApacheConfigurator() except errors.LetsEncryptConfiguratorError: - log.info("Unable to find a way to install the certificate.") + logging.info("Unable to find a way to install the certificate.") def read_file(filename): diff --git a/setup.py b/setup.py index 3f8c6d5c8..f6d8f2880 100755 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ setup( 'letsencrypt', 'letsencrypt.client', 'letsencrypt.client.apache', + 'letsencrypt.client.tests', 'letsencrypt.scripts', ], install_requires=install_requires, diff --git a/tox.ini b/tox.ini index 013b19c6c..4ebe69305 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ commands = [testenv:cover] commands = python setup.py dev - python setup.py nosetests --with-coverage --cover-min-percentage=44 + python setup.py nosetests --with-coverage --cover-min-percentage=47 [testenv:lint] commands = From d7a3ea443c3c7370915eedcdf5c1a59f041991eb Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sat, 20 Dec 2014 03:43:39 -0800 Subject: [PATCH 30/66] Formatting fixes --- letsencrypt/client/apache/configurator.py | 4 ++-- letsencrypt/client/apache/obj.py | 4 ++-- letsencrypt/client/apache/parser.py | 2 +- letsencrypt/client/tests/apache_configurator_test.py | 2 +- letsencrypt/client/tests/apache_obj_test.py | 3 +-- letsencrypt/client/tests/apache_parser_test.py | 8 +++----- letsencrypt/scripts/main.py | 1 - 7 files changed, 10 insertions(+), 14 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index b4b5e4d20..20d568dba 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -480,8 +480,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ssl_addrs.add(ssl_addr) # Add directives - vh_p = self.aug.match(("/files%s//* [label()=~regexp('%s')]" % - (ssl_fp, parser.case_i('VirtualHost')))) + vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" % + (ssl_fp, parser.case_i('VirtualHost'))) if len(vh_p) != 1: logging.error("Error: should only be one vhost in %s", avail_fp) sys.exit(1) diff --git a/letsencrypt/client/apache/obj.py b/letsencrypt/client/apache/obj.py index aeddee443..c4a481acd 100644 --- a/letsencrypt/client/apache/obj.py +++ b/letsencrypt/client/apache/obj.py @@ -23,8 +23,8 @@ class Addr(object): return self.tup[0] def __eq__(self, other): - if type(other) is type(self): - return self.__dict__ == other.__dict__ + if isinstance(other, self.__class__): + return self.tup == other.tup return False def __hash__(self): diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index 7a4691416..9d92e9271 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -349,7 +349,7 @@ class ApacheParser(object): # Basic check to see if httpd.conf exists and # in heirarchy via direct include # httpd.conf was very common as a user file in Apache 2.2 - if (os.path.isfile(self.root + 'httpd.conf') and + if (os.path.isfile(os.path.join(self.root, 'httpd.conf')) and self.find_dir( case_i("Include"), case_i("httpd.conf"), root)): return os.path.join(self.root, 'httpd.conf') diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index 7084d10c6..20eb6e0c9 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -116,7 +116,7 @@ class TwoVhost80Test(unittest.TestCase): self.assertEqual(ssl_vhost.path, "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") self.assertEqual(len(ssl_vhost.addrs), 1) - self.assertTrue(set([obj.Addr.fromstring("*:443")]) == ssl_vhost.addrs) + self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs) self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) self.assertTrue(ssl_vhost.ssl) self.assertFalse(ssl_vhost.enabled) diff --git a/letsencrypt/client/tests/apache_obj_test.py b/letsencrypt/client/tests/apache_obj_test.py index fb13b051a..46e76d4bd 100644 --- a/letsencrypt/client/tests/apache_obj_test.py +++ b/letsencrypt/client/tests/apache_obj_test.py @@ -41,7 +41,6 @@ class AddrTest(unittest.TestCase): addr2b = obj.Addr.fromstring("192.168.1.1:*") set_b = set([addr1b, addr2b]) - self.assertTrue(addr1b in set_a) self.assertEqual(set_a, set_b) @@ -59,4 +58,4 @@ class VirtualHostTest(unittest.TestCase): self.assertEqual(vhost1b, self.vhost1) self.assertEqual(str(vhost1b), str(self.vhost1)) - self.assertTrue(vhost1b != 1234) + self.assertNotEqual(vhost1b, 1234) diff --git a/letsencrypt/client/tests/apache_parser_test.py b/letsencrypt/client/tests/apache_parser_test.py index 259d974e1..baf5c746e 100644 --- a/letsencrypt/client/tests/apache_parser_test.py +++ b/letsencrypt/client/tests/apache_parser_test.py @@ -12,7 +12,7 @@ from letsencrypt.client.apache import parser from letsencrypt.client.tests import config_util -class BasicParseTests(unittest.TestCase): +class ApacheParserTest(unittest.TestCase): def setUp(self): display.set_display(display.FileDisplay(sys.stdout)) @@ -55,8 +55,7 @@ class BasicParseTests(unittest.TestCase): self.assertTrue(matches) def test_find_dir(self): - test = self.parser.find_dir( - parser.case_i("Listen"), "443") + test = self.parser.find_dir(parser.case_i("Listen"), "443") # This will only look in enabled hosts test2 = self.parser.find_dir( parser.case_i("documentroot")) @@ -65,8 +64,7 @@ class BasicParseTests(unittest.TestCase): def test_add_dir(self): aug_default = "/files" + self.parser.loc["default"] - self.parser.add_dir( - aug_default, "AddDirective", "test") + self.parser.add_dir(aug_default, "AddDirective", "test") self.assertTrue( self.parser.find_dir("AddDirective", "test", aug_default)) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index aa18541ee..1bc77bfa7 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -184,7 +184,6 @@ def determine_authenticator(): def determine_installer(): """Returns a valid installer if one exists.""" try: - print "shouldn't ha;ppppen!!!!!!!!!!!!!!!!!!!" return configurator.ApacheConfigurator() except errors.LetsEncryptConfiguratorError: logging.info("Unable to find a way to install the certificate.") From e2957797b56b1000b7a6696b6f2cff3160068a1b Mon Sep 17 00:00:00 2001 From: Francois Marier Date: Sun, 21 Dec 2014 14:23:14 +1300 Subject: [PATCH 31/66] Fix the sh path and make script executable --- letsencrypt/client/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 letsencrypt/client/setup.sh diff --git a/letsencrypt/client/setup.sh b/letsencrypt/client/setup.sh old mode 100644 new mode 100755 index fb35eb4f7..9db81cbd2 --- a/letsencrypt/client/setup.sh +++ b/letsencrypt/client/setup.sh @@ -1,2 +1,2 @@ -#!/usr/bin/sh +#!/bin/sh cp options-ssl.conf /etc/letsencrypt/options-ssl.conf From bfa04c1942092e5a3f62758e2eba6fd7bee2c38e Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 21 Dec 2014 03:37:29 -0800 Subject: [PATCH 32/66] Refactor for revocation --- letsencrypt/client/apache/configurator.py | 2 +- letsencrypt/client/client.py | 247 ++-------------------- letsencrypt/client/network.py | 119 +++++++++++ letsencrypt/client/revoker.py | 137 ++++++++++++ letsencrypt/scripts/main.py | 76 +++---- 5 files changed, 312 insertions(+), 269 deletions(-) create mode 100644 letsencrypt/client/network.py create mode 100644 letsencrypt/client/revoker.py diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 20d568dba..2e2a02238 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -481,7 +481,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Add directives vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" % - (ssl_fp, parser.case_i('VirtualHost'))) + (ssl_fp, parser.case_i('VirtualHost'))) if len(vh_p) != 1: logging.error("Error: should only be one vhost in %s", avail_fp) sys.exit(1) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index da6c55cc2..1e15438c9 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,18 +1,14 @@ """ACME protocol client class and helper functions.""" import collections import csv -import json import logging import os import shutil import socket import string import sys -import time -import jsonschema import M2Crypto -import requests from letsencrypt.client import acme from letsencrypt.client import challenge @@ -21,6 +17,7 @@ from letsencrypt.client import crypto_util from letsencrypt.client import display from letsencrypt.client import errors from letsencrypt.client import le_util +from letsencrypt.client import network # it's weird to point to chocolate servers via raw IPv6 addresses, and @@ -33,8 +30,9 @@ ALLOW_RAW_IPV6_SERVER = False class Client(object): """ACME protocol client. - :ivar str server: Certificate authority server - :ivar str server_url: Full URL of the CSR server + :ivar network: Network object for sending and receiving messages + :type network: :class:`letsencrypt.client.network.Network` + :ivar list names: Domain names (:class:`list` of :class:`str`). :ivar auth: Object that supports the IAuthenticator interface. @@ -49,8 +47,7 @@ class Client(object): def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" - self.server = server - self.server_url = "https://%s/acme/" % self.server + self.network = network.Network(server) self.names = names self.authkey = authkey @@ -104,7 +101,7 @@ class Client(object): :rtype: dict """ - return self.send_and_receive_expected( + return self.network.send_and_receive_expected( acme.challenge_request(self.names[0]), "challenge") def acme_authorization(self, challenge_msg, chal_objs, responses): @@ -119,13 +116,14 @@ class Client(object): :rtype: dict """ - auth_dict = self.send(acme.authorization_request( - challenge_msg["sessionID"], self.names[0], - challenge_msg["nonce"], responses, self.authkey.pem)) - try: - return self.is_expected_msg(auth_dict, "authorization") - except: + return self.network.send_and_receive_expected( + acme.authorization_request( + challenge_msg["sessionID"], self.names[0], + challenge_msg["nonce"], responses, self.authkey.pem), + "authorization") + except errors.LetsEncryptClientError as err: + logging.fatal(str(err)) logging.fatal( "Failed Authorization procedure - cleaning up challenges") sys.exit(1) @@ -142,195 +140,10 @@ class Client(object): """ logging.info("Preparing and sending CSR...") - return self.send_and_receive_expected( + return self.network.send_and_receive_expected( acme.certificate_request(csr_der, self.authkey.pem), "certificate") - def acme_revocation(self, cert): - """Handle ACME "revocation" phase. - - :param dict cert: TODO - - :returns: ACME "revocation" message. - :rtype: dict - - """ - cert_der = M2Crypto.X509.load_cert(cert["backup_cert_file"]).as_der() - with open(cert["backup_key_file"], 'rU') as backup_key_file: - key = backup_key_file.read() - - revocation = self.send_and_receive_expected( - acme.revocation_request(cert_der, key), "revocation") - - display.generic_notification( - "You have successfully revoked the certificate for " - "%s" % cert["cn"], width=70, height=9) - - remove_cert_key(cert) - self.list_certs_keys() - - return revocation - - def send(self, msg): - """Send ACME message to server. - - :param dict msg: ACME message (JSON serializable). - - :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) - - try: - response = requests.post( - self.server_url, - data=json_encoded, - headers={"Content-Type": "application/json"}, - ) - except requests.exceptions.RequestException as error: - raise errors.LetsEncryptClientError( - 'Sending ACME message to server has failed: %s' % error) - - try: - acme.acme_object_validate(response.content) - except ValueError: - raise errors.LetsEncryptClientError( - 'Server did not send JSON serializable message') - except jsonschema.ValidationError as error: - raise errors.LetsEncryptClientError( - 'Response from server is not a valid ACME message') - - return response.json() - - def send_and_receive_expected(self, msg, expected): - """Send ACME message to server and return expected message. - - :param dict msg: ACME message (JSON serializable). - :param str expected: Name of the expected response ACME message type. - - :returns: ACME response message of expected type. - :rtype: dict - - :raises errors.LetsEncryptClientError: An exception is thrown - - """ - response = self.send(msg) - try: - return self.is_expected_msg(response, expected) - except: # TODO: too generic exception - raise errors.LetsEncryptClientError( - 'Expected message (%s) not received' % expected) - - def is_expected_msg(self, response, expected, delay=3, rounds=20): - """Is reponse expected ACME message? - - :param dict response: ACME response message from server. - :param str expected: Name of the expected response ACME message type. - :param int delay: Number of seconds to delay before next round - in case of ACME "defer" response message. - :param int rounds: Number of resend attempts in case of ACME "defer" - reponse 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: - return response - - elif response["type"] == "error": - logging.error( - "%s: %s - More Info: %s", response["error"], - response.get("message", ""), response.get("moreInfo", "")) - raise errors.LetsEncryptClientError(response["error"]) - - elif response["type"] == "defer": - logging.info("Waiting for %d seconds...", delay) - time.sleep(delay) - response = self.send(acme.status_request(response["token"])) - else: - logging.fatal("Received unexpected message") - logging.fatal("Expected: %s" % expected) - logging.fatal("Received: %s" % response) - sys.exit(33) - - logging.error( - "Server has deferred past the max of %d seconds", rounds * delay) - - def list_certs_keys(self): - """List trusted Let's Encrypt certificates.""" - list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - certs = [] - - if not os.path.isfile(list_file): - logging.info( - "You don't have any certificates saved from letsencrypt") - return - - c_sha1_vh = {} - for (cert, _, path) in self.installer.get_all_certs_keys(): - try: - c_sha1_vh[M2Crypto.X509.load_cert( - cert).get_fingerprint(md='sha1')] = path - except: - continue - - with open(list_file, 'rb') as csvfile: - csvreader = csv.reader(csvfile) - for row in csvreader: - cert = crypto_util.get_cert_info(row[1]) - - b_k = os.path.join(CONFIG.CERT_KEY_BACKUP, - os.path.basename(row[2]) + "_" + row[0]) - b_c = os.path.join(CONFIG.CERT_KEY_BACKUP, - os.path.basename(row[1]) + "_" + row[0]) - - cert.update({ - "orig_key_file": row[2], - "orig_cert_file": row[1], - "idx": int(row[0]), - "backup_key_file": b_k, - "backup_cert_file": b_c, - "installed": c_sha1_vh.get(cert["fingerprint"], ""), - }) - certs.append(cert) - if certs: - self.choose_certs(certs) - else: - display.generic_notification( - "There are not any trusted Let's Encrypt " - "certificates for this server.") - - def choose_certs(self, certs): - """Display choose certificates menu. - - :param list certs: List of cert dicts. - - """ - code, tag = display.display_certs(certs) - - if code == display.OK: - cert = certs[tag] - if display.confirm_revocation(cert): - self.acme_revocation(cert) - else: - self.choose_certs(certs) - elif code == display.HELP: - cert = certs[tag] - display.more_info_cert(cert) - self.choose_certs(certs) - else: - exit(0) - + # pylint: disable=no-self-use def save_certificate(self, certificate_dict, cert_path, chain_path): """Saves the certificate received from the ACME server. @@ -477,6 +290,7 @@ class Client(object): return responses, challenge_objs + # pylint: disable=no-self-use def _assign_responses(self, resp, index_list, responses): """Assign chall_response to appropriate places in response list. @@ -715,33 +529,6 @@ def csr_pem_to_der(csr): return Client.CSR(csr.file, csr_obj.as_der(), "der") -def remove_cert_key(cert): - """Remove certificate and key. - - :param dict cert: Cert dict used throughout revocation - - """ - list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - list_file2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") - - with open(list_file, 'rb') as orgfile: - csvreader = csv.reader(orgfile) - - with open(list_file2, 'wb') as newfile: - csvwriter = csv.writer(newfile) - - for row in csvreader: - if not (row[0] == str(cert["idx"]) and - row[1] == cert["orig_cert_file"] and - row[2] == cert["orig_key_file"]): - csvwriter.writerow(row) - - shutil.copy2(list_file2, list_file) - os.remove(list_file2) - os.remove(cert["backup_cert_file"]) - os.remove(cert["backup_key_file"]) - - def sanity_check_names(names): """Make sure host names are valid. @@ -779,5 +566,5 @@ def is_hostname_sane(hostname): # is this a valid IPv6 address? socket.getaddrinfo(hostname, 443, socket.AF_INET6) return True - except: + except socket.error: return False diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py new file mode 100644 index 000000000..855008b6b --- /dev/null +++ b/letsencrypt/client/network.py @@ -0,0 +1,119 @@ +"""Network Module.""" +import json +import logging +import sys +import time + +import jsonschema +import requests + +from letsencrypt.client import acme +from letsencrypt.client import errors + + +class Network(object): + """Class for communicating with ACME servers. + + :ivar str server: Certificate authority server + :ivar str server_url: Full URL of the CSR server + + """ + def __init__(self, server): + self.server = server + self.server_url = "https://%s/acme/" % self.server + + def send(self, msg): + """Send ACME message to server. + + :param dict msg: ACME message (JSON serializable). + + :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) + + try: + response = requests.post( + self.server_url, + data=json_encoded, + headers={"Content-Type": "application/json"}, + ) + except requests.exceptions.RequestException as error: + raise errors.LetsEncryptClientError( + 'Sending ACME message to server has failed: %s' % error) + + try: + acme.acme_object_validate(response.content) + except ValueError: + raise errors.LetsEncryptClientError( + 'Server did not send JSON serializable message') + except jsonschema.ValidationError as error: + raise errors.LetsEncryptClientError( + 'Response from server is not a valid ACME message') + + return response.json() + + def send_and_receive_expected(self, msg, expected): + """Send ACME message to server and return expected message. + + :param dict msg: ACME message (JSON serializable). + :param str expected: Name of the expected response ACME message type. + + :returns: ACME response message of expected type. + :rtype: dict + + :raises errors.LetsEncryptClientError: An exception is thrown + + """ + response = self.send(msg) + try: + return self.is_expected_msg(response, expected) + except: # TODO: too generic exception + raise errors.LetsEncryptClientError( + 'Expected message (%s) not received' % expected) + + def is_expected_msg(self, response, expected, delay=3, rounds=20): + """Is reponse expected ACME message? + + :param dict response: ACME response message from server. + :param str expected: Name of the expected response ACME message type. + :param int delay: Number of seconds to delay before next round + in case of ACME "defer" response message. + :param int rounds: Number of resend attempts in case of ACME "defer" + reponse 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: + return response + + elif response["type"] == "error": + logging.error( + "%s: %s - More Info: %s", response["error"], + response.get("message", ""), response.get("moreInfo", "")) + raise errors.LetsEncryptClientError(response["error"]) + + elif response["type"] == "defer": + logging.info("Waiting for %d seconds...", delay) + time.sleep(delay) + response = self.send(acme.status_request(response["token"])) + else: + logging.fatal("Received unexpected message") + logging.fatal("Expected: %s", expected) + logging.fatal("Received: %s", response) + sys.exit(33) + + logging.error( + "Server has deferred past the max of %d seconds", rounds * delay) diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py new file mode 100644 index 000000000..7fda722b8 --- /dev/null +++ b/letsencrypt/client/revoker.py @@ -0,0 +1,137 @@ +"""Revoker module to enable LE revocations.""" +import csv +import logging +import os +import shutil + +import M2Crypto + +from letsencrypt.client import acme +from letsencrypt.client import CONFIG +from letsencrypt.client import crypto_util +from letsencrypt.client import display +from letsencrypt.client import network + + +class Revoker(object): + """A revocation class for LE.""" + def __init__(self, server, installer): + self.network = network.Network(server) + self.installer = installer + + def acme_revocation(self, cert): + """Handle ACME "revocation" phase. + + :param dict cert: TODO + + :returns: ACME "revocation" message. + :rtype: dict + + """ + cert_der = M2Crypto.X509.load_cert(cert["backup_cert_file"]).as_der() + with open(cert["backup_key_file"], 'rU') as backup_key_file: + key = backup_key_file.read() + + revocation = self.network.send_and_receive_expected( + acme.revocation_request(cert_der, key), "revocation") + + display.generic_notification( + "You have successfully revoked the certificate for " + "%s" % cert["cn"], width=70, height=9) + + self.remove_cert_key(cert) + self.list_certs_keys() + + return revocation + + def list_certs_keys(self): + """List trusted Let's Encrypt certificates.""" + list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + certs = [] + + if not os.path.isfile(list_file): + logging.info( + "You don't have any certificates saved from letsencrypt") + return + + c_sha1_vh = {} + for (cert, _, path) in self.installer.get_all_certs_keys(): + try: + c_sha1_vh[M2Crypto.X509.load_cert( + cert).get_fingerprint(md='sha1')] = path + except: + continue + + with open(list_file, 'rb') as csvfile: + csvreader = csv.reader(csvfile) + for row in csvreader: + cert = crypto_util.get_cert_info(row[1]) + + b_k = os.path.join(CONFIG.CERT_KEY_BACKUP, + os.path.basename(row[2]) + "_" + row[0]) + b_c = os.path.join(CONFIG.CERT_KEY_BACKUP, + os.path.basename(row[1]) + "_" + row[0]) + + cert.update({ + "orig_key_file": row[2], + "orig_cert_file": row[1], + "idx": int(row[0]), + "backup_key_file": b_k, + "backup_cert_file": b_c, + "installed": c_sha1_vh.get(cert["fingerprint"], ""), + }) + certs.append(cert) + if certs: + self.choose_certs(certs) + else: + display.generic_notification( + "There are not any trusted Let's Encrypt " + "certificates for this server.") + + def choose_certs(self, certs): + """Display choose certificates menu. + + :param list certs: List of cert dicts. + + """ + code, tag = display.display_certs(certs) + + if code == display.OK: + cert = certs[tag] + if display.confirm_revocation(cert): + self.acme_revocation(cert) + else: + self.choose_certs(certs) + elif code == display.HELP: + cert = certs[tag] + display.more_info_cert(cert) + self.choose_certs(certs) + else: + exit(0) + + # pylint: disable=no-self-use + def remove_cert_key(self, cert): + """Remove certificate and key. + + :param dict cert: Cert dict used throughout revocation + + """ + list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + list_file2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") + + with open(list_file, 'rb') as orgfile: + csvreader = csv.reader(orgfile) + + with open(list_file2, 'wb') as newfile: + csvwriter = csv.writer(newfile) + + for row in csvreader: + if not (row[0] == str(cert["idx"]) and + row[1] == cert["orig_cert_file"] and + row[2] == cert["orig_key_file"]): + csvwriter.writerow(row) + + shutil.copy2(list_file2, list_file) + os.remove(list_file2) + os.remove(cert["backup_cert_file"]) + os.remove(cert["backup_key_file"]) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 1bc77bfa7..46fab2779 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -11,7 +11,7 @@ from letsencrypt.client import display from letsencrypt.client import interfaces from letsencrypt.client import errors from letsencrypt.client import log - +from letsencrypt.client import revoker from letsencrypt.client.apache import configurator @@ -65,9 +65,39 @@ def main(): # Set up logging logger = logging.getLogger() - logger.setLevel(logging.INFO) # TODO: --log + logger.setLevel(logging.INFO) if args.use_curses: logger.addHandler(log.DialogHandler()) + display.set_display(display.NcursesDisplay()) + else: + display.set_display(display.FileDisplay(sys.stdout)) + + installer = determine_installer() + server = args.server is None and CONFIG.ACME_SERVER or args.server + + if args.revoke: + revoc = revoker.Revoker(server, installer) + revoc.list_certs_keys() + sys.exit() + + if args.rollback > 0: + rollback(installer, args.rollback) + sys.exit() + + if args.view_checkpoints: + view_checkpoints(installer) + sys.exit() + + # Use the same object if possible + if interfaces.IAuthenticator.providedBy(installer): + auth = installer + else: + auth = determine_authenticator() + + if not args.eula: + display_eula() + + domains = choose_names(installer) if args.domains is None else args.domains # Enforce '--privkey' is set along with '--csr'. if args.csr and not args.privkey: @@ -75,34 +105,6 @@ def main(): "with the certificate signing request file (--csr)" .format(os.linesep)) - if args.use_curses: - display.set_display(display.NcursesDisplay()) - else: - display.set_display(display.FileDisplay(sys.stdout)) - - if args.rollback > 0: - rollback(configurator.ApacheConfigurator(), args.rollback) - sys.exit() - - if args.view_checkpoints: - view_checkpoints(configurator.ApacheConfigurator()) - sys.exit() - - server = args.server is None and CONFIG.ACME_SERVER or args.server - - if not args.eula: - display_eula() - - auth = determine_authenticator() - - # Use the same object if possible - if interfaces.IInstaller.providedBy(auth): - installer = auth - else: - installer = determine_installer() - - domains = choose_names(installer) if args.domains is None else args.domains - # Prepare for init of Client if args.privkey is None: privkey = client.init_key() @@ -115,15 +117,13 @@ def main(): client.Client.CSR(args.csr[0], args.csr[1], "pem")) acme = client.Client(server, domains, privkey, auth, installer) - if args.revoke: - acme.list_certs_keys() - else: - # Validate the key and csr - client.validate_key_csr(privkey, csr, domains) - cert_file, chain_file = acme.obtain_certificate(csr) - vhost = acme.deploy_certificate(privkey, cert_file, chain_file) - acme.optimize_config(vhost, args.redirect) + # Validate the key and csr + client.validate_key_csr(privkey, csr, domains) + + cert_file, chain_file = acme.obtain_certificate(csr) + vhost = acme.deploy_certificate(privkey, cert_file, chain_file) + acme.optimize_config(vhost, args.redirect) def display_eula(): From fe1b858dffeb5ff7b2c275dd203bac6f766df976 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 21 Dec 2014 04:25:48 -0800 Subject: [PATCH 33/66] Fix documentation --- letsencrypt/client/client.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index e711a6efc..36116f10e 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -34,6 +34,9 @@ class Client(object): :ivar list names: Domain names (:class:`list` of :class:`str`). + :ivar authkey: Authorization Key + :type authkey: :class:`letsencrypt.client.client.Client.Key` + :ivar auth: Object that supports the IAuthenticator interface. :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` @@ -213,7 +216,7 @@ class Client(object): .. todo:: Handle multiple vhosts :param vhost: vhost to optimize - :type vhost: :class:`apache_configurator.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :param redirect: If traffic should be forwarded from HTTP to HTTPS. :type redirect: bool or None @@ -358,7 +361,7 @@ class Client(object): """Redirect all traffic from HTTP to HTTPS :param vhost: list of ssl_vhosts - :type vhost: :class:`letsencrypt.client.apache.obj.VH` + :type vhost: :class:`letsencrypt.client.interfaces.IInstaller` """ for ssl_vh in vhost: @@ -375,7 +378,7 @@ class Client(object): :param list domains: Domains to find ssl vhosts for :returns: associated vhosts - :rtype: :class:`apache_configurator.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ vhost = set() @@ -476,10 +479,6 @@ def validate_key_csr(privkey, csr, names): raise errors.LetsEncryptClientError( "The key and CSR do not match") - if not crypto_util.csr_matches_names(csr.data, names): - raise errors.LetsEncryptClientError( - "CSR subject does not contain one of the specified names") - def init_key(): """Initializes privkey. From e78a2884e25eb770d711e58b7b5359b048076b3d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 21 Dec 2014 16:32:01 -0800 Subject: [PATCH 34/66] Final cleanup --- letsencrypt/client/tests/config_util.py | 1 - letsencrypt/scripts/main.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/config_util.py b/letsencrypt/client/tests/config_util.py index 4683ab0c2..41e29031a 100644 --- a/letsencrypt/client/tests/config_util.py +++ b/letsencrypt/client/tests/config_util.py @@ -8,7 +8,6 @@ import mock from letsencrypt.client import CONFIG from letsencrypt.client.apache import configurator from letsencrypt.client.apache import obj -from letsencrypt.client.apache import parser def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 46fab2779..9030d8f69 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -73,7 +73,7 @@ def main(): display.set_display(display.FileDisplay(sys.stdout)) installer = determine_installer() - server = args.server is None and CONFIG.ACME_SERVER or args.server + server = CONFIG.ACME_SERVER if args.server is None else args.server if args.revoke: revoc = revoker.Revoker(server, installer) From 7860db63cf66b453256b24f1e77fb3858858b1a0 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 21 Dec 2014 21:41:12 -0800 Subject: [PATCH 35/66] Make necessary fixes to pull request --- letsencrypt/client/client.py | 3 +-- letsencrypt/client/display.py | 2 +- letsencrypt/client/revoker.py | 2 +- letsencrypt/scripts/main.py | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 0eab27f42..8c5f4525f 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -9,7 +9,6 @@ import string import sys import M2Crypto -import requests import zope.component from letsencrypt.client import acme @@ -227,7 +226,7 @@ class Client(object): """ if redirect is None: redirect = zope.component.getUtility( - intefaces.IDisplay).redirect_by_default() + interfaces.IDisplay).redirect_by_default() if redirect: self.redirect_to_ssl(vhost) diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index 9da2f78b7..c6d90b5f0 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -20,7 +20,7 @@ class NcursesDisplay(object): self.height = height def generic_notification(self, message): - self.dialog.msgbox(message, width=self.width, height=self.height) + self.dialog.msgbox(message, width=self.width) def generic_menu(self, message, choices, input_text=""): # Can accept either tuples or just the actual choices diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index b7eb69216..073362501 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -39,7 +39,7 @@ class Revoker(object): zope.component.getUtility(interfaces.IDisplay).generic_notification( "You have successfully revoked the certificate for " - "%s" % cert["cn"], width=70, height=9) + "%s" % cert["cn"]) self.remove_cert_key(cert) self.list_certs_keys() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 152db8c03..8aeb43136 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -132,7 +132,7 @@ def main(): def display_eula(): """Displays the end user agreement.""" with open('EULA') as eula_file: - if not zope_component.getUtility(interfaces.IDisplay).generic_yesno( + if not zope.component.getUtility(interfaces.IDisplay).generic_yesno( eula_file.read(), "Agree", "Cancel"): sys.exit(0) @@ -147,7 +147,7 @@ def choose_names(installer): # This function adds all names # found within the config to self.names # Then filters them based on user selection - code, names = zope_component.getUtility( + code, names = zope.component.getUtility( interfaces.IDisplay).filter_names(get_all_names(installer)) if code == display.OK and names: # TODO: Allow multiple names once it is setup From eb99571a981e97e80f6c62b47ebd3e3a2a1d9a50 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 22 Dec 2014 00:29:33 -0800 Subject: [PATCH 36/66] Use DVSNI_Chall namedtuple --- letsencrypt/client/apache/configurator.py | 44 +++++++++++------------ letsencrypt/client/client.py | 21 +++++++---- letsencrypt/scripts/main.py | 2 +- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 2e2a02238..028157349 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -949,12 +949,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): `chall_dict` composed of: - list_sni_tuple: - List of tuples with form `(name, r, nonce)`, where - `name` (`str`), `r` (base64 `str`), `nonce` (hex `str`) + `type`: `dvsni` (`str`) - dvsni_key: - DVSNI key (:class:`letsencrypt.client.client.Client.Key`) + `dvsni_chall`: + List of DVSNI_Chall namedtuples + (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) + where DVSNI_Chall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) :param dict chall_dict: dvsni challenge - see documentation @@ -964,18 +966,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.save() # Do weak validation that challenge is of expected type - if not ("list_sni_tuple" in chall_dict and "dvsni_key" in chall_dict): + if "dvsni_chall" not in chall_dict: logging.fatal("Incorrect parameter given to Apache DVSNI challenge") logging.fatal("Chall dict: %s", chall_dict) sys.exit(1) addresses = [] default_addr = "*:443" - for tup in chall_dict["list_sni_tuple"]: - vhost = self.choose_virtual_host(tup[0]) + for chall in chall_dict["dvsni_chall"]: + vhost = self.choose_virtual_host(chall.domain) if vhost is None: logging.error( - "No vhost exists with servername or alias of: %s", tup[0]) + "No vhost exists with servername or alias of: %s", + chall.domain) logging.error("No _default_:443 vhost exists") logging.error("Please specify servernames in the Apache config") return None @@ -993,18 +996,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): responses = [] # Create all of the challenge certs - for tup in chall_dict["list_sni_tuple"]: - cert_path = self.dvsni_get_cert_file(tup[2]) + for chall in chall_dict["dvsni_chall"]: + cert_path = self.dvsni_get_cert_file(chall.nonce) self.register_file_creation(cert_path) s_b64 = challenge_util.dvsni_gen_cert( - cert_path, tup[0], tup[1], tup[2], chall_dict["dvsni_key"]) + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) responses.append({"type": "dvsni", "s": s_b64}) # Setup the configuration - self.dvsni_mod_config(chall_dict["list_sni_tuple"], - chall_dict["dvsni_key"], - addresses) + self.dvsni_mod_config(chall_dict["dvsni_chall"], addresses) # Save reversible changes and restart the server self.save("SNI Challenge", True) @@ -1019,18 +1020,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() # TODO: Variable names - def dvsni_mod_config(self, list_sni_tuple, dvsni_key, - ll_addrs): + def dvsni_mod_config(self, dvsni_chall, ll_addrs): """Modifies Apache config files to include challenge vhosts. Result: Apache config includes virtual servers for issued challs - :param list list_sni_tuple: list of tuples with the form - `(addr, y, nonce)`, where `addr` is `str`, `y` is `bytearray`, - and nonce is hex `str` - - :param dvsni_key: DVSNI key - :type dvsni_key: :class:`letsencrypt.client.client.Client.Key` + :param list dvsni_chall: list of + :class:`letsencrypt.client.client.Client.DVSNI_Chall` :param list ll_addrs: list of list of :class:`letsencrypt.client.apache.obj.Addr` to apply @@ -1052,7 +1048,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): config_text = "\n" for idx, lis in enumerate(ll_addrs): config_text += self.get_config_text( - list_sni_tuple[idx][2], lis, dvsni_key.file) + dvsni_chall[idx].nonce, lis, dvsni_chall[idx].key.file) config_text += "\n" self.dvsni_conf_include_check(self.parser.loc["default"]) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 8c5f4525f..fdb8f542c 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -47,6 +47,7 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") + DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -419,8 +420,9 @@ class Client(object): if chall["type"] == "dvsni": logging.info(" DVSNI challenge for name %s.", name) sni_satisfies.append(index) - sni_todo.append((str(name), str(chall["r"]), - str(chall["nonce"]))) + sni_todo.append(Client.DVSNI_Chall( + str(name), str(chall["r"]), + str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": logging.info("\tRecovery Token Challenge for name: %s.", name) @@ -438,8 +440,7 @@ class Client(object): # one "challenge object" is issued for all sni_challenges challenge_objs.append({ "type": "dvsni", - "list_sni_tuple": sni_todo, - "dvsni_key": self.authkey, + "dvsni_chall": sni_todo }) challenge_obj_indices.append(sni_satisfies) logging.debug(sni_todo) @@ -447,11 +448,17 @@ class Client(object): return challenge_objs, challenge_obj_indices -def validate_key_csr(privkey, csr, names): +def validate_key_csr(privkey, csr): """Validate CSR and key files. - Verifies that the client key and csr arguments are valid and - correspond to one another. + Verifies that the client key and csr arguments are valid and correspond to + one another. This does not currently check the names in the CSR. + + :param privkey: Key associated with CSR + :type privkey: :class:`letsencrypt.client.client.Client.Key` + + :param csr: CSR + :type csr: :class:`letsencrypt.client.client.Client.CSR` :raises LetsEncryptClientError: if validation fails diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8aeb43136..211525ed8 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -122,7 +122,7 @@ def main(): acme = client.Client(server, domains, privkey, auth, installer) # Validate the key and csr - client.validate_key_csr(privkey, csr, domains) + client.validate_key_csr(privkey, csr) cert_file, chain_file = acme.obtain_certificate(csr) vhost = acme.deploy_certificate(privkey, cert_file, chain_file) From c025f2536739769c1f38041ead67386a9fc5acbb Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 22 Dec 2014 00:41:54 -0800 Subject: [PATCH 37/66] Remove nginx_configurator from master --- letsencrypt/client/nginx_configurator.py | 203 ----------------------- 1 file changed, 203 deletions(-) delete mode 100644 letsencrypt/client/nginx_configurator.py diff --git a/letsencrypt/client/nginx_configurator.py b/letsencrypt/client/nginx_configurator.py deleted file mode 100644 index 24bf4529b..000000000 --- a/letsencrypt/client/nginx_configurator.py +++ /dev/null @@ -1,203 +0,0 @@ -from letsencrypt.client import CONFIG -from letsencrypt.client import augeas_configurator - - -# This might be helpful... but feel free to use whatever you want -# class VH(object): -# def __init__(self, filename_path, vh_path, vh_addrs, is_ssl, is_enabled): -# self.file = filename_path -# self.path = vh_path -# self.addrs = vh_addrs -# self.names = [] -# self.ssl = is_ssl -# self.enabled = is_enabled - -# def set_names(self, listOfNames): -# self.names = listOfNames - -# def add_name(self, name): -# self.names.append(name) - -class NginxConfigurator(augeas_configurator.AugeasConfigurator): - - def __init__(self, server_root=CONFIG.SERVER_ROOT): - super(NginxConfigurator, self).__init__() - self.server_root = server_root - - # See if any temporary changes need to be recovered - # This needs to occur before VH objects are setup... - # because this will change the underlying configuration and potential - # vhosts - self.recovery_routine() - # Check for errors in parsing files with Augeas - # TODO - insert nginx lens info here??? - #self.check_parsing_errors("httpd.aug") - - def deploy_cert(self, vhost, cert, key, cert_chain=None): - """Deploy cert in nginx""" - - def choose_virtual_host(self, name): - """Chooses a virtual host based on the given domain name""" - - def get_all_names(self): - """Returns all names found in the nginx configuration""" - return set() - - # Might be helpful... I know nothing about nginx lens - # def get_include_path(self, cur_dir, arg): - # """ - # Converts an Apache Include directive argument into an Augeas - # searchable path - # Returns path string - # """ - # # Sanity check argument - maybe - # # Question: what can the attacker do with control over this string - # # Effect parse file... maybe exploit unknown errors in Augeas - # # If the attacker can Include anything though... and this function - # # only operates on Apache real config data... then the attacker has - # # already won. - # # Perhaps it is better to simply check the permissions on all - # # included files? - # # check_config to validate apache config doesn't work because it - # # would create a race condition between the check and this input - - # # TODO: Fix this - # # Check to make sure only expected characters are used, maybe remove - # # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") - # # matchObj = validChars.match(arg) - # # if matchObj.group() != arg: - # # logging.error("Error: Invalid regexp characters in %s", arg) - # # return [] - - # # Standardize the include argument based on server root - # if not arg.startswith("/"): - # arg = cur_dir + arg - # # conf/ is a special variable for ServerRoot in Apache - # elif arg.startswith("conf/"): - # arg = self.server_root + arg[5:] - # # TODO: Test if Apache allows ../ or ~/ for Includes - - # # Attempts to add a transform to the file if one does not already - # # exist - # self.parse_file(arg) - - # # Argument represents an fnmatch regular expression, convert it - # # Split up the path and convert each into an Augeas accepted regex - # # then reassemble - # if "*" in arg or "?" in arg: - # postfix = "" - # splitArg = arg.split("/") - # for idx, split in enumerate(splitArg): - # # * and ? are the two special fnmatch characters - # if "*" in split or "?" in split: - # # Turn it into a augeas regex - # # TODO: Can this be an augeas glob instead of regex - # splitArg[idx] = ("* [label()=~regexp('%s')]" % - # self.fnmatch_to_re(split) - # # Reassemble the argument - # arg = "/".join(splitArg) - - # # If the include is a directory, just return the directory as a file - # if arg.endswith("/"): - # return "/files" + arg[:len(arg)-1] - # return "/files"+arg - - def enable_redirect(self, ssl_vhost): - """ - Adds Redirect directive to the port 80 equivalent of ssl_vhost - First the function attempts to find the vhost with equivalent - ip addresses that serves on non-ssl ports - The function then adds the directive - """ - return - - def enable_ocsp_stapling(self, ssl_vhost): - return False - - def enable_hsts(self, ssl_vhost): - return False - - def get_all_certs_keys(self): - """ - Retrieve all certs and keys set in VirtualHosts on the Apache server - returns: list of tuples with form [(cert, key, path)] - """ - return None - - # Probably helpful reference - # def get_file_path(self, vhost_path): - # """ - # Takes in Augeas path and returns the file name - # """ - # # Strip off /files - # avail_fp = vhost_path[6:] - # # This can be optimized... - # while True: - # # Cast both to lowercase to be case insensitive - # find_if = avail_fp.lower().find("/ifmodule") - # if find_if != -1: - # avail_fp = avail_fp[:find_if] - # continue - # find_vh = avail_fp.lower().find("/virtualhost") - # if find_vh != -1: - # avail_fp = avail_fp[:find_vh] - # continue - # break - # return avail_fp - - def enable_site(self, vhost): - """Enables an available site, Apache restart required""" - return False - - # Might be a usefule reference - # def parse_file(self, file_path): - # """ - # Checks to see if file_path is parsed by Augeas - # If file_path isn't parsed, the file is added and Augeas is reloaded - # """ - # # Test if augeas included file for Httpd.lens - # # Note: This works for augeas globs, ie. *.conf - # incTest = self.aug.match( - # "/augeas/load/Httpd/incl [. ='" + file_path + "']") - # if not incTest: - # # Load up files - # #self.httpd_incl.append(file_path) - # #self.aug.add_transform( - # # "Httpd.lns", self.httpd_incl, None, self.httpd_excl) - # self.__add_httpd_transform(file_path) - # self.aug.load() - - # Helpful reference? - # def verify_setup(self): - # """ - # Make sure that files/directories are setup with appropriate - # permissions. Aim for defensive coding... make sure all input files - # have permissions of root - # """ - # le_util.make_or_verify_dir(CONFIG.CONFIG_DIR, 0o755) - # le_util.make_or_verify_dir(CONFIG.WORK_DIR, 0o755) - # le_util.make_or_verify_dir(CONFIG.BACKUP_DIR, 0o755) - - def restart(self, quiet=False): - """Restarts nginx server""" - - # May be of use? - # def __add_httpd_transform(self, incl): - # """ - # This function will correctly add a transform to augeas - # The existing augeas.add_transform in python is broken - # """ - # lastInclude = self.aug.match("/augeas/load/Httpd/incl [last()]") - # self.aug.insert(lastInclude[0], "incl", False) - # self.aug.set("/augeas/load/Httpd/incl[last()]", incl) - - def config_test(self): - """Check Configuration""" - return False - - -def main(): - return - -if __name__ == "__main__": - main() From 2893b25db1d1b5631c98a6d99cd290bccc2284d0 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 23 Dec 2014 10:59:33 +0000 Subject: [PATCH 38/66] Update API docs --- docs/api/client/apache.rst | 23 +++++++++++++++++++++++ docs/api/client/apache_configurator.rst | 5 ----- docs/api/client/challenge_util.rst | 5 +++++ docs/api/client/nginx_configurator.rst | 5 ----- docs/api/client/revoker.rst | 5 +++++ letsencrypt/client/apache/obj.py | 2 +- 6 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 docs/api/client/apache.rst delete mode 100644 docs/api/client/apache_configurator.rst create mode 100644 docs/api/client/challenge_util.rst delete mode 100644 docs/api/client/nginx_configurator.rst create mode 100644 docs/api/client/revoker.rst diff --git a/docs/api/client/apache.rst b/docs/api/client/apache.rst new file mode 100644 index 000000000..dfa1edad6 --- /dev/null +++ b/docs/api/client/apache.rst @@ -0,0 +1,23 @@ +:mod:`letsencrypt.client.apache` +-------------------------------- + +.. automodule:: letsencrypt.client.apache + :members: + +:mod:`letsencrypt.client.apache.configurator` +============================================= + +.. automodule:: letsencrypt.client.apache.configurator + :members: + +:mod:`letsencrypt.client.apache.obj` +==================================== + +.. automodule:: letsencrypt.client.apache.obj + :members: + +:mod:`letsencrypt.client.apache.parser` +======================================= + +.. automodule:: letsencrypt.client.apache.parser + :members: diff --git a/docs/api/client/apache_configurator.rst b/docs/api/client/apache_configurator.rst deleted file mode 100644 index 76818e05a..000000000 --- a/docs/api/client/apache_configurator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.apache_configurator` ---------------------------------------------- - -.. automodule:: letsencrypt.client.apache_configurator - :members: diff --git a/docs/api/client/challenge_util.rst b/docs/api/client/challenge_util.rst new file mode 100644 index 000000000..3866230a5 --- /dev/null +++ b/docs/api/client/challenge_util.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.challenge_util` +---------------------------------------- + +.. automodule:: letsencrypt.client.challenge_util + :members: diff --git a/docs/api/client/nginx_configurator.rst b/docs/api/client/nginx_configurator.rst deleted file mode 100644 index efcef3ffe..000000000 --- a/docs/api/client/nginx_configurator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.nginx_configurator` --------------------------------------------- - -.. automodule:: letsencrypt.client.nginx_configurator - :members: diff --git a/docs/api/client/revoker.rst b/docs/api/client/revoker.rst new file mode 100644 index 000000000..e0a7db533 --- /dev/null +++ b/docs/api/client/revoker.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.revoker` +--------------------------------- + +.. automodule:: letsencrypt.client.revoker + :members: diff --git a/letsencrypt/client/apache/obj.py b/letsencrypt/client/apache/obj.py index c4a481acd..df2f36ec4 100644 --- a/letsencrypt/client/apache/obj.py +++ b/letsencrypt/client/apache/obj.py @@ -5,7 +5,7 @@ class Addr(object): """Represents an Apache VirtualHost address. :param str addr: addr part of vhost address - :param str port: port number or *, or "" + :param str port: port number or \*, or "" """ def __init__(self, tup): From 05d803ddd3aeff364252616142de9b1326c501ba Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 23 Dec 2014 03:54:30 -0800 Subject: [PATCH 39/66] Turn DVSNI into module, add more appropriate challenges/api --- letsencrypt/client/apache/configurator.py | 205 +++------------------- letsencrypt/client/apache/dvsni.py | 193 ++++++++++++++++++++ letsencrypt/client/challenge_util.py | 4 + letsencrypt/client/client.py | 91 ++++------ letsencrypt/client/interfaces.py | 17 +- 5 files changed, 274 insertions(+), 236 deletions(-) create mode 100644 letsencrypt/client/apache/dvsni.py diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 028157349..dcff472f6 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -1,9 +1,7 @@ """Apache Configuration based off of Augeas Configurator.""" import logging import os -import pkg_resources import re -import shutil import socket import subprocess import sys @@ -117,6 +115,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.vhosts = self.get_virtual_hosts() # Add name_server association dict self.assoc = dict() + # Add number of outstanding challenges + self.chall_out = 0 # Enable mod_ssl if it isn't already enabled # This is Let's Encrypt... we enable mod_ssl on initialization :) @@ -125,11 +125,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # on initialization self._prepare_server_https() - # Note: initialization doesn't check to see if the config is correct - # by Apache's standards. This should be done by the client (client.py) - # if it is desired. There may be instances where correct configuration - # isn't required on startup. - def deploy_cert(self, vhost, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. @@ -929,186 +924,41 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### - - # TODO: Change list_sni_tuple to namedtuple. Also include key within tuple. - # This allows the keys to be different for each SNI challenge - - def perform(self, chall_dict): + def perform(self, chall_list): """Perform the configuration related challenge. - :param dict chall_dict: Dictionary representing a challenge. + This function currently assumes all challenges will be fulfilled. + If this turns out not to be the case in the future. Cleanup and + outstanding challenges will have to be designed better. + + :param dict chall_list: List of challenges to be + fulfilled by configurator. """ + self.chall_out += len(chall_list) + responses = [None] * len(chall_list) + apache_dvsni = dvsni.ApacheDVSNI(self) - if chall_dict.get("type", "") == 'dvsni': - return self.dvsni_perform(chall_dict) - return None + for i, chall in enumerate(chall_list): + if isinstance(chall, challenge_util.DVSNI_Chall): + apache_dvsni.add_chall(chall, i) - def dvsni_perform(self, chall_dict): - """Peform a DVSNI challenge. - - `chall_dict` composed of: - - `type`: `dvsni` (`str`) - - `dvsni_chall`: - List of DVSNI_Chall namedtuples - (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) - where DVSNI_Chall tuples have the following fields - `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) - `key` (:class:`letsencrypt.client.client.Client.Key`) - - :param dict chall_dict: dvsni challenge - see documentation - - """ - # Save any changes to the configuration as a precaution - # About to make temporary changes to the config - self.save() - - # Do weak validation that challenge is of expected type - if "dvsni_chall" not in chall_dict: - logging.fatal("Incorrect parameter given to Apache DVSNI challenge") - logging.fatal("Chall dict: %s", chall_dict) - sys.exit(1) - - addresses = [] - default_addr = "*:443" - for chall in chall_dict["dvsni_chall"]: - vhost = self.choose_virtual_host(chall.domain) - if vhost is None: - logging.error( - "No vhost exists with servername or alias of: %s", - chall.domain) - logging.error("No _default_:443 vhost exists") - logging.error("Please specify servernames in the Apache config") - return None - - # TODO - @jdkasten review this code to make sure it makes sense - self.make_server_sni_ready(vhost, default_addr) - - for addr in vhost.addrs: - if "_default_" == addr.get_addr(): - addresses.append([default_addr]) - break - else: - addresses.append(list(vhost.addrs)) - - responses = [] - - # Create all of the challenge certs - for chall in chall_dict["dvsni_chall"]: - cert_path = self.dvsni_get_cert_file(chall.nonce) - self.register_file_creation(cert_path) - s_b64 = challenge_util.dvsni_gen_cert( - cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) - - responses.append({"type": "dvsni", "s": s_b64}) - - # Setup the configuration - self.dvsni_mod_config(chall_dict["dvsni_chall"], addresses) - - # Save reversible changes and restart the server - self.save("SNI Challenge", True) + sni_response = apache_dvsni.perform() + # Must restart in order to activate the challenges. + # Handled here because we may be able to load up other challenge types self.restart() + for i, resp in enumerate(sni_response): + responses[apache_dvsni.indices[i]] = resp + return responses - def cleanup(self): + def cleanup(self, chall_list): """Revert all challenges.""" - - self.revert_challenge_config() - self.restart() - - # TODO: Variable names - def dvsni_mod_config(self, dvsni_chall, ll_addrs): - """Modifies Apache config files to include challenge vhosts. - - Result: Apache config includes virtual servers for issued challs - - :param list dvsni_chall: list of - :class:`letsencrypt.client.client.Client.DVSNI_Chall` - - :param list ll_addrs: list of list of - :class:`letsencrypt.client.apache.obj.Addr` to apply - - """ - # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY - # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER - # AND TAKEN OUT BEFORE RELEASE, INSTEAD - # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM - - # Check to make sure options-ssl.conf is installed - # pylint: disable=no-member - if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): - dist_conf = pkg_resources.resource_filename( - __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) - - # TODO: Use ip address of existing vhost instead of relying on FQDN - config_text = "\n" - for idx, lis in enumerate(ll_addrs): - config_text += self.get_config_text( - dvsni_chall[idx].nonce, lis, dvsni_chall[idx].key.file) - config_text += "\n" - - self.dvsni_conf_include_check(self.parser.loc["default"]) - self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) - - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: - new_conf.write(config_text) - - def dvsni_conf_include_check(self, main_config): - """Adds DVSNI challenge conf file into configuration. - - Adds DVSNI challenge include file if it does not already exist - within mainConfig - - :param str main_config: file path to main user apache config file - - """ - if len(self.parser.find_dir( - parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: - # print "Including challenge virtual host(s)" - self.parser.add_dir(parser.get_aug_path(main_config), - "Include", CONFIG.APACHE_CHALLENGE_CONF) - - def get_config_text(self, nonce, ip_addrs, dvsni_key_file): - """Chocolate virtual server configuration text - - :param str nonce: hex form of nonce - :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` - :param str dvsni_key_file: Path to key file - - :returns: virtual host configuration text - :rtype: str - - """ - ips = " ".join(str(i) for i in ip_addrs) - return ("\n" - "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" - "UseCanonicalName on\n" - "SSLStrictSNIVHostCheck on\n" - "\n" - "LimitRequestBody 1048576\n" - "\n" - "Include " + self.parser.loc["ssl_options"] + "\n" - "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + "\n" - "SSLCertificateKeyFile " + dvsni_key_file + "\n" - "\n" - "DocumentRoot " + self.direc["config"] + "challenge_page/\n" - "\n\n") - - def dvsni_get_cert_file(self, nonce): - """Returns standardized name for challenge certificate. - - :param str nonce: hex form of nonce - - :returns: certificate file name - :rtype: str - - """ - return self.direc["work"] + nonce + ".crt" + self.chall_out -= len(chall_list) + if self.chall_out <= 0: + self.revert_challenge_config() + self.restart() def enable_mod(mod_name): @@ -1217,3 +1067,6 @@ def get_file_path(vhost_path): continue break return avail_fp + + +from letsencrypt.client.apache import dvsni diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py new file mode 100644 index 000000000..b37fd0b1c --- /dev/null +++ b/letsencrypt/client/apache/dvsni.py @@ -0,0 +1,193 @@ +"""ApacheDVSNI""" +import logging +import os +import pkg_resources +import shutil + +from letsencrypt.client import challenge_util +from letsencrypt.client import CONFIG + +from letsencrypt.client.apache import parser + +class ApacheDVSNI(object): + """Class performs DVSNI challenges within the Apache configurator. + + :ivar config: ApacheConfigurator object + :type config: :class:`letsencrypt.client.apache.configurator` + + :ivar dvsni_chall: Data required for challenges. + where DVSNI_Chall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) + :type dvsni_chall: `list` of + :class:`letsencrypt.client.challenge_util.DVSNI_Chall` + + """ + def __init__(self, config): + self.config = config + self.dvsni_chall = [] + self.indices = [] + # self.completed = 0 + + def add_chall(self, chall, idx=None): + """Add challenge to DVSNI object to perform at once. + + :param chall: DVSNI challenge info + :type chall: :class:`letsencrypt.client.challenge_util.DVSNI_Chall` + + :param int idx: index to challenge in a larger array + + """ + self.dvsni_chall.append(chall) + if idx is not None: + self.indices.append(idx) + + def perform(self): + """Peform a DVSNI challenge.""" + if not self.dvsni_chall: + return dict() + # Save any changes to the configuration as a precaution + # About to make temporary changes to the config + self.config.save() + + addresses = [] + default_addr = "*:443" + for chall in self.dvsni_chall: + vhost = self.config.choose_virtual_host(chall.domain) + if vhost is None: + logging.error( + "No vhost exists with servername or alias of: %s", + chall.domain) + logging.error("No _default_:443 vhost exists") + logging.error("Please specify servernames in the Apache config") + return None + + # TODO - @jdkasten review this code to make sure it makes sense + self.config.make_server_sni_ready(vhost, default_addr) + + for addr in vhost.addrs: + if "_default_" == addr.get_addr(): + addresses.append([default_addr]) + break + else: + addresses.append(list(vhost.addrs)) + + responses = [] + + # Create all of the challenge certs + for chall in self.dvsni_chall: + cert_path = self.get_cert_file(chall.nonce) + self.config.register_file_creation(cert_path) + s_b64 = challenge_util.dvsni_gen_cert( + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) + + responses.append({"type": "dvsni", "s": s_b64}) + + # Setup the configuration + self.mod_config(addresses) + + # Save reversible changes + self.config.save("SNI Challenge", True) + + return responses + + # def chall_complete(self, chall): + # """Used by Authenticator to notify the DVSNI challenge. + + # :param chall: Challenge info + # :type chall: :class:`letsencrypt.client.client.Client.DVSNI_Chall` + + # """ + # self.completed += 1 + # if self.completed < len(self.dvsni_chall): + # return False + # return True + + # TODO: Variable names + def mod_config(self, ll_addrs): + """Modifies Apache config files to include challenge vhosts. + + Result: Apache config includes virtual servers for issued challs + + :param list ll_addrs: list of list of + :class:`letsencrypt.client.apache.obj.Addr` to apply + + """ + # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY + # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER + # AND TAKEN OUT BEFORE RELEASE, INSTEAD + # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM + + # Check to make sure options-ssl.conf is installed + # pylint: disable=no-member + if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): + dist_conf = pkg_resources.resource_filename( + __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) + + # TODO: Use ip address of existing vhost instead of relying on FQDN + config_text = "\n" + for idx, lis in enumerate(ll_addrs): + config_text += self.get_config_text( + self.dvsni_chall[idx].nonce, lis, + self.dvsni_chall[idx].key.file) + config_text += "\n" + + self.conf_include_check(self.config.parser.loc["default"]) + self.config.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) + + with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: + new_conf.write(config_text) + + def conf_include_check(self, main_config): + """Adds DVSNI challenge conf file into configuration. + + Adds DVSNI challenge include file if it does not already exist + within mainConfig + + :param str main_config: file path to main user apache config file + + """ + if len(self.config.parser.find_dir( + parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + # print "Including challenge virtual host(s)" + self.config.parser.add_dir(parser.get_aug_path(main_config), + "Include", CONFIG.APACHE_CHALLENGE_CONF) + + def get_config_text(self, nonce, ip_addrs, dvsni_key_file): + """Chocolate virtual server configuration text + + :param str nonce: hex form of nonce + :param list ip_addrs: addresses of challenged domain + :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` + :param str dvsni_key_file: Path to key file + + :returns: virtual host configuration text + :rtype: str + + """ + ips = " ".join(str(i) for i in ip_addrs) + return ("\n" + "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" + "UseCanonicalName on\n" + "SSLStrictSNIVHostCheck on\n" + "\n" + "LimitRequestBody 1048576\n" + "\n" + "Include " + self.config.parser.loc["ssl_options"] + "\n" + "SSLCertificateFile " + self.get_cert_file(nonce) + "\n" + "SSLCertificateKeyFile " + dvsni_key_file + "\n" + "\n" + "DocumentRoot " + self.config.direc["config"] + "dvsni_page/\n" + "\n\n") + + def get_cert_file(self, nonce): + """Returns standardized name for challenge certificate. + + :param str nonce: hex form of nonce + + :returns: certificate file name + :rtype: str + + """ + return self.config.direc["work"] + nonce + ".crt" diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 46b0602be..86b1cab04 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -1,4 +1,5 @@ """Challenge specific utility functions.""" +import collections import hashlib from Crypto import Random @@ -8,6 +9,9 @@ from letsencrypt.client import crypto_util from letsencrypt.client import le_util +DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") + + # DVSNI Challenge functions def dvsni_gen_cert(filepath, name, r_b64, nonce, key): """Generate a DVSNI cert and save it to filepath. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index fdb8f542c..4a698bd48 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -13,6 +13,7 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import challenge +from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -47,7 +48,6 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") - DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -80,10 +80,10 @@ class Client(object): challenge_msg = self.acme_challenge() # Perform Challenges - responses, challenge_objs = self.verify_identity(challenge_msg) + responses, auth_c, client_c = self.verify_identity(challenge_msg) # Get Authorization - self.acme_authorization(challenge_msg, challenge_objs, responses) + self.acme_authorization(challenge_msg, auth_c, client_c, responses) # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -108,7 +108,7 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(self.names[0]), "challenge") - def acme_authorization(self, challenge_msg, chal_objs, responses): + def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. :param dict challenge_msg: ACME "challenge" message. @@ -132,7 +132,7 @@ class Client(object): "Failed Authorization procedure - cleaning up challenges") sys.exit(1) finally: - self.cleanup_challenges(chal_objs) + self.cleanup_challenges(auth_c, client_c) def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -243,19 +243,16 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, challenges): + def cleanup_challenges(self, auth_c, client_c): """Cleanup configuration challenges :param dict challenges: challenges from a challenge message """ logging.info("Cleaning up challenges...") - for chall in challenges: - if chall["type"] in CONFIG.CONFIG_CHALLENGES: - self.auth.cleanup() - else: - # Handle other cleanup if needed - pass + self.auth.cleanup(auth_c) + # should cleanup client_c + assert not client_c def verify_identity(self, challenge_msg): """Verify identity. @@ -275,45 +272,37 @@ class Client(object): # challenges in the master list the challenge object satisfies # Single Challenge objects that can satisfy multiple server challenges # mess up the order of the challenges, thus requiring the indices - challenge_objs, indices = self.challenge_factory( + auth_c, auth_i, client_c, client_i = self.challenge_factory( self.names[0], challenge_msg["challenges"], path) responses = ["null"] * len(challenge_msg["challenges"]) - # Perform challenges - for i, c_obj in enumerate(challenge_objs): - resp = "null" - if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - resp = self.auth.perform(c_obj) - else: - # Handle RecoveryToken type challenges - pass - - self._assign_responses(resp, indices[i], responses) + # Do client centric challenges here... + # Since this isn't implemented yet... + assert not client_i + auth_resp = self.auth.perform(auth_c) + self._assign_responses(auth_resp, auth_i, responses) logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, challenge_objs + return responses, auth_c, client_c # pylint: disable=no-self-use def _assign_responses(self, resp, index_list, responses): """Assign chall_response to appropriate places in response list. :param resp: responses from a challenge - :type resp: list of dicts or dict + :type resp: list of dicts :param list index_list: respective challenges resp satisfies :param list responses: master list of responses """ - if isinstance(resp, list): - assert len(resp) == len(index_list) - for j, index in enumerate(index_list): - responses[index] = resp[j] - else: - for index in index_list: - responses[index] = resp + assert len(resp) == len(index_list) + for j, index in enumerate(index_list): + responses[index] = resp[j] + def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -392,10 +381,10 @@ class Client(object): vhost.add(host) return vhost - def challenge_factory(self, name, challenges, path): + def challenge_factory(self, domain, challenges, path): """ - :param name: TODO + :param str domain: domain of the enrollee :param list challenges: A list of challenges from ACME "challenge" server message to be fulfilled by the client in order to prove @@ -407,27 +396,27 @@ class Client(object): :rtype: tuple """ - sni_todo = [] + auth_chall = [] # Since a single invocation of SNI challenge can satisfy multiple # challenges. We must keep track of all the challenges it satisfies - sni_satisfies = [] + auth_satisfies = [] - challenge_objs = [] - challenge_obj_indices = [] + client_chall = [] + client_satisfies = [] for index in path: chall = challenges[index] if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", name) - sni_satisfies.append(index) - sni_todo.append(Client.DVSNI_Chall( - str(name), str(chall["r"]), + logging.info(" DVSNI challenge for name %s.", domain) + auth_satisfies.append(index) + auth_chall.append(challenge_util.DVSNI_Chall( + str(domain), str(chall["r"]), str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": - logging.info("\tRecovery Token Challenge for name: %s.", name) - challenge_obj_indices.append(index) - challenge_objs.append({ + logging.info(" Recovery Token Challenge for name: %s.", domain) + client_satisfies.append(index) + client_chall.append({ type: "recoveryToken", }) @@ -435,17 +424,7 @@ class Client(object): logging.fatal("Challenge not currently supported") sys.exit(82) - if sni_todo: - # SNI_Challenge can satisfy many sni challenges at once so only - # one "challenge object" is issued for all sni_challenges - challenge_objs.append({ - "type": "dvsni", - "dvsni_chall": sni_todo - }) - challenge_obj_indices.append(sni_satisfies) - logging.debug(sni_todo) - - return challenge_objs, challenge_obj_indices + return auth_chall, auth_satisfies, client_chall, client_satisfies def validate_key_csr(privkey, csr): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 910ec29c8..7b72d9a46 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,17 +11,26 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ - def perform(chall_dict): - """Perform the given challenge""" + def perform(chall_list): + """Perform the given challenge. - def cleanup(): + :param list chall_list: List of challenge types defined in client.py + + :returns: List of responses + If the challenge cant be completed... + None - Authenticator can perform challenge, but can't at this time + False - Authenticator will never be able to perform (error) + :rtype: `list` of dicts + + """ + def cleanup(chall_list): """Revert changes and shutdown after challenges complete.""" class IChallenge(zope.interface.Interface): """Let's Encrypt challenge.""" - def perform(quiet=True): + def perform(): """Perform the challenge. :param bool quiet: TODO From 5584bb4e6fc5747b4ee91eab1266d2876a515588 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 24 Dec 2014 04:20:14 +0100 Subject: [PATCH 40/66] fix param names in docstrings to match actual param names --- letsencrypt/client/apache/configurator.py | 4 ++-- letsencrypt/client/apache/parser.py | 3 +-- letsencrypt/client/challenge_util.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 2e2a02238..12dac92a3 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -201,7 +201,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: This should maybe return list if no obvious answer is presented. - :param str name: domain name + :param str target_name: domain name :returns: ssl vhost associated with name :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` @@ -348,7 +348,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): now NameVirtualHosts. If version is earlier than 2.4, check if addr has a NameVirtualHost directive in the Apache config - :param str addr: vhost address ie. \*:443 + :param str target_addr: vhost address ie. \*:443 :returns: Success :rtype: bool diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index 9d92e9271..8a0a9aff9 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -342,8 +342,7 @@ class ApacheParser(object): .. todo:: This will have to be updated for other distros versions - :param str filename: optional filename that will be used as the - user config + :param str root: pathname which contains the user config """ # Basic check to see if httpd.conf exists and diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 46b0602be..26266cda1 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -15,7 +15,7 @@ def dvsni_gen_cert(filepath, name, r_b64, nonce, key): :param str filepath: destination to save certificate. This will overwrite any file that is currently at the location. :param str name: domain to validate - :param str dvsni_r: jose base64 encoded dvsni r value + :param str r_b64: jose base64 encoded dvsni r value :param str nonce: hex value of nonce :param key: Key to perform challenge From 992830f2c239469173b525edbd072df8864d005d Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 24 Dec 2014 04:42:03 +0100 Subject: [PATCH 41/66] fix typos, including a severe one --- letsencrypt/client/apache/parser.py | 4 ++-- letsencrypt/client/display.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index 8a0a9aff9..f2516a49e 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -346,14 +346,14 @@ class ApacheParser(object): """ # Basic check to see if httpd.conf exists and - # in heirarchy via direct include + # in hierarchy via direct include # httpd.conf was very common as a user file in Apache 2.2 if (os.path.isfile(os.path.join(self.root, 'httpd.conf')) and self.find_dir( case_i("Include"), case_i("httpd.conf"), root)): return os.path.join(self.root, 'httpd.conf') else: - return os.path.join(self.root + 'apache2.conf') + return os.path.join(self.root, 'apache2.conf') def case_i(string): diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index c6d90b5f0..29b646794 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -182,7 +182,7 @@ class FileDisplay(object): else: try: selection = int(ans) - # TODO add check to make sure it is liess than max + # TODO add check to make sure it is less than max if selection < 0: self.outfile.write(e_msg) continue From 3efca70a56cf5ade67fc39bb89ed3d453026874a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 24 Dec 2014 06:56:14 +0000 Subject: [PATCH 42/66] Add API docs for client.network --- docs/api/client/network.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/api/client/network.rst diff --git a/docs/api/client/network.rst b/docs/api/client/network.rst new file mode 100644 index 000000000..7b4ec633a --- /dev/null +++ b/docs/api/client/network.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.network` +--------------------------------- + +.. automodule:: letsencrypt.client.network + :members: From 3cc15ee90934d3fe49fa6753af25703af4da1afa Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 24 Dec 2014 16:45:57 +0100 Subject: [PATCH 43/66] fix typos, including a severe one bugfix see configurator.py:233 --- letsencrypt/client/apache/configurator.py | 10 +++++----- letsencrypt/client/apache/parser.py | 2 +- letsencrypt/client/crypto_util.py | 4 ++-- letsencrypt/client/le_util.py | 8 ++++---- letsencrypt/client/network.py | 4 ++-- letsencrypt/client/recovery_contact_challenge.py | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 12dac92a3..8b32cf7a7 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -60,7 +60,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: Add proper support for module configuration The API of this class will change in the coming weeks as the exact - needs of client's are clarified with the new and developing protocol. + needs of clients are clarified with the new and developing protocol. :ivar str server_root: Path to Apache root directory :ivar dict location: Path to various files associated @@ -230,7 +230,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # No matches, search for the default for vhost in self.vhosts: - if "_defualt_:443" in vhost.addrs: + if "_default_:443" in vhost.addrs: return vhost return None @@ -762,7 +762,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return vhost return None - # TODO: Handle ths as outlined in Interfaces. + # TODO: Handle this as outlined in Interfaces. def enable_ocsp_stapling(self, ssl_vhost): """Enable OCSP Stapling.""" return False @@ -772,7 +772,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return False def get_all_certs_keys(self): - """ Find all existing keys, certs from configuration. + """Find all existing keys, certs from configuration. Retrieve all certs and keys set in VirtualHosts on the Apache server @@ -945,7 +945,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return None def dvsni_perform(self, chall_dict): - """Peform a DVSNI challenge. + """Perform a DVSNI challenge. `chall_dict` composed of: diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index f2516a49e..d902bfe19 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -98,7 +98,7 @@ class ApacheParser(object): :param str directive: Directive to look for - :param arg: Specific value direcitve must have, None if all should + :param arg: Specific value directive must have, None if all should be considered :type arg: str or None diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 04a8ac373..c11719343 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -28,10 +28,10 @@ def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): :param str msg: Message to be signed :param nonce: Nonce to be used. If None, nonce of `nonce_len` size - will be randomly genereted. + will be randomly generated. :type nonce: str or None - :param int nonce_len: Size of the automaticaly generated nonce. + :param int nonce_len: Size of the automatically generated nonce. :returns: Signature. :rtype: dict diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index 42f88bc5d..d96dc8c09 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -10,8 +10,8 @@ from letsencrypt.client import errors def make_or_verify_dir(directory, mode=0o755, uid=0): """Make sure directory exists with proper permissions. - :param str directory: Path to a directry. - :param int mode: Diretory mode. + :param str directory: Path to a directory. + :param int mode: Directory mode. :param int uid: Directory owner. :raises LetsEncryptClientError: if a directory already exists, @@ -28,7 +28,7 @@ def make_or_verify_dir(directory, mode=0o755, uid=0): if exception.errno == errno.EEXIST: if not check_permissions(directory, mode, uid): raise errors.LetsEncryptClientError( - '%s exists and does not contain the proper ' + '%s exists, but does not have the proper ' 'permissions or owner' % directory) else: raise @@ -107,7 +107,7 @@ def jose_b64decode(data): :returns: Decoded data. :raises TypeError: if input is of incorrect type - :raises ValueError: if unput is unicode with non-ASCII characters + :raises ValueError: if input is unicode with non-ASCII characters """ if isinstance(data, unicode): diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index 855008b6b..b1548b687 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -80,14 +80,14 @@ class Network(object): 'Expected message (%s) not received' % expected) def is_expected_msg(self, response, expected, delay=3, rounds=20): - """Is reponse expected ACME message? + """Is response expected ACME message? :param dict response: ACME response message from server. :param str expected: Name of the expected response ACME message type. :param int delay: Number of seconds to delay before next round in case of ACME "defer" response message. :param int rounds: Number of resend attempts in case of ACME "defer" - reponse message. + response message. :returns: ACME response message from server. :rtype: dict diff --git a/letsencrypt/client/recovery_contact_challenge.py b/letsencrypt/client/recovery_contact_challenge.py index d5cd5f889..6bafed829 100644 --- a/letsencrypt/client/recovery_contact_challenge.py +++ b/letsencrypt/client/recovery_contact_challenge.py @@ -14,7 +14,7 @@ from letsencrypt.client import interfaces class RecoveryContact(object): - """Recovery Contact Identitifier Validation Challenge. + """Recovery Contact Identifier Validation Challenge. Based on draft-barnes-acme, section 6.3. From 59ec8aa2808eac40bd939665f7e9e28fd0098988 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 1 Jan 2015 08:20:33 -0800 Subject: [PATCH 44/66] Moved options-ssl.conf into apache dir --- letsencrypt/client/{ => apache}/options-ssl.conf | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename letsencrypt/client/{ => apache}/options-ssl.conf (100%) diff --git a/letsencrypt/client/options-ssl.conf b/letsencrypt/client/apache/options-ssl.conf similarity index 100% rename from letsencrypt/client/options-ssl.conf rename to letsencrypt/client/apache/options-ssl.conf From 96f288861bfd96ad092aae5513d391fdc85b5096 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 2 Jan 2015 14:25:17 -0800 Subject: [PATCH 45/66] Fix #155 --- letsencrypt/client/tests/config_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/tests/config_util.py b/letsencrypt/client/tests/config_util.py index 41e29031a..691a394f4 100644 --- a/letsencrypt/client/tests/config_util.py +++ b/letsencrypt/client/tests/config_util.py @@ -29,7 +29,7 @@ def setup_apache_ssl_options(config_dir): """Move the ssl_options into position and return the path.""" option_path = os.path.join(config_dir, "options-ssl.conf") temp_options = pkg_resources.resource_filename( - "letsencrypt.client", os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + "letsencrypt.client.apache", os.path.basename(CONFIG.OPTIONS_SSL_CONF)) shutil.copyfile( temp_options, option_path) From f089449bf2fb00c0ad646afc12061f3671bce10b Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 6 Jan 2015 01:57:07 -0800 Subject: [PATCH 46/66] Initial challenge refactor/allow multiple names --- letsencrypt/client/CONFIG.py | 5 +- letsencrypt/client/apache/configurator.py | 14 +- letsencrypt/client/apache/dvsni.py | 20 +-- letsencrypt/client/augeas_configurator.py | 4 +- letsencrypt/client/challenge.py | 14 +- letsencrypt/client/client.py | 144 ++++++++++++++---- letsencrypt/client/interfaces.py | 13 +- letsencrypt/client/network.py | 3 + .../client/tests/apache_configurator_test.py | 37 ++++- letsencrypt/client/tests/le_util_test.py | 3 + letsencrypt/scripts/main.py | 3 +- 11 files changed, 196 insertions(+), 64 deletions(-) diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 3cc9d09a6..6392911fb 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -47,9 +47,6 @@ OPTIONS_SSL_CONF = os.path.join(CONFIG_DIR, "options-ssl.conf") LE_VHOST_EXT = "-le-ssl.conf" """Let's Encrypt SSL vhost configuration extension""" -APACHE_CHALLENGE_CONF = os.path.join(CONFIG_DIR, "le_dvsni_cert_challenge.conf") -"""Temporary file for challenge virtual hosts""" - CERT_PATH = CERT_DIR + "cert-letsencrypt.pem" """Let's Encrypt cert file.""" @@ -60,7 +57,7 @@ INVALID_EXT = ".acme.invalid" """Invalid Extension""" # Challenge Information -CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] +#CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] """Challenge Preferences Dict for currently supported challenges""" EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index dcff472f6..12e97f7cc 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -15,9 +15,11 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util +from letsencrypt.client.apache import dvsni from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser + # TODO: Augeas sections ie. , beginning and closing # tags need to be the same case, otherwise Augeas doesn't recognize them. # This is not able to be completely remedied by regular expressions because @@ -924,6 +926,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### + def get_chall_pref(self): # pylint: disable=no-self-use + """Return list of challenge preferences.""" + + return ["dvsni"] + def perform(self, chall_list): """Perform the configuration related challenge. @@ -934,6 +941,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param dict chall_list: List of challenges to be fulfilled by configurator. + :returns: list of responses. A None response indicates the challenge + was not perfromed. + :rtype: list + """ self.chall_out += len(chall_list) responses = [None] * len(chall_list) @@ -1067,6 +1078,3 @@ def get_file_path(vhost_path): continue break return avail_fp - - -from letsencrypt.client.apache import dvsni diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index b37fd0b1c..37b0b7426 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -1,8 +1,6 @@ """ApacheDVSNI""" import logging import os -import pkg_resources -import shutil from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG @@ -27,6 +25,8 @@ class ApacheDVSNI(object): self.config = config self.dvsni_chall = [] self.indices = [] + self.challenge_conf = os.path.join( + config.direc["config"], "le_dvsni_cert_challenge.conf") # self.completed = 0 def add_chall(self, chall, idx=None): @@ -120,10 +120,10 @@ class ApacheDVSNI(object): # Check to make sure options-ssl.conf is installed # pylint: disable=no-member - if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): - dist_conf = pkg_resources.resource_filename( - __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) + # if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): + # dist_conf = pkg_resources.resource_filename( + # __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + # shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) # TODO: Use ip address of existing vhost instead of relying on FQDN config_text = "\n" @@ -134,9 +134,9 @@ class ApacheDVSNI(object): config_text += "\n" self.conf_include_check(self.config.parser.loc["default"]) - self.config.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) + self.config.register_file_creation(True, self.challenge_conf) - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: + with open(self.challenge_conf, 'w') as new_conf: new_conf.write(config_text) def conf_include_check(self, main_config): @@ -149,10 +149,10 @@ class ApacheDVSNI(object): """ if len(self.config.parser.find_dir( - parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + parser.case_i("Include"), self.challenge_conf)) == 0: # print "Including challenge virtual host(s)" self.config.parser.add_dir(parser.get_aug_path(main_config), - "Include", CONFIG.APACHE_CHALLENGE_CONF) + "Include", self.challenge_conf) def get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 231faa99d..5d02329de 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -343,7 +343,7 @@ class AugeasConfigurator(object): else: cp_dir = self.direc["progress"] - le_util.make_or_verify_dir(cp_dir) + le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) try: with open(os.path.join(cp_dir, "NEW_FILES"), 'a') as new_fd: for file_path in files: @@ -400,7 +400,7 @@ class AugeasConfigurator(object): else: logging.warn( "File: %s - Could not be found to be deleted\n" - "Program was probably shut down unexpectedly, ") + "LE probably shut down unexpectedly", path) except (IOError, OSError): logging.fatal( "Unable to remove filepaths contained within %s", file_list) diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py index b2eb33c53..b0452c0ff 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -5,7 +5,7 @@ import sys from letsencrypt.client import CONFIG -def gen_challenge_path(challenges, combos=None): +def gen_challenge_path(challenges, preferences, combos=None): """Generate a plan to get authority over the identity. .. todo:: Make sure that the challenges are feasible... @@ -25,12 +25,12 @@ def gen_challenge_path(challenges, combos=None): """ if combos: - return _find_smart_path(challenges, combos) + return _find_smart_path(challenges, preferences, combos) else: - return _find_dumb_path(challenges) + return _find_dumb_path(challenges, preferences) -def _find_smart_path(challenges, combos): +def _find_smart_path(challenges, preferences, combos): """Find challenge path with server hints. Can be called if combinations is included. Function uses a simple @@ -51,7 +51,7 @@ def _find_smart_path(challenges, combos): """ chall_cost = {} max_cost = 0 - for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES): + for i, chall in enumerate(preferences): chall_cost[chall] = i max_cost += i @@ -77,7 +77,7 @@ def _find_smart_path(challenges, combos): return best_combo -def _find_dumb_path(challenges): +def _find_dumb_path(challenges, preferences): """Find challenge path without server hints. Should be called if the combinations hint is not included by the @@ -95,7 +95,7 @@ def _find_dumb_path(challenges): # Add logic for a crappy server # Choose a DV path = [] - for pref_c in CONFIG.CHALLENGE_PREFERENCES: + for pref_c in preferences: for i, offered_challenge in enumerate(challenges): if (pref_c == offered_challenge["type"] and is_preferred(offered_challenge["type"], path)): diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 4a698bd48..a9305f221 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -60,6 +60,15 @@ class Client(object): self.auth = auth self.installer = installer + # Client challenges and Authenticator challenges should be separate + # and really should not be conflicting along the same path. + # I have chosen to make client challenges preferred + # as the client challenges should be able to be completely handled + # by this module and does not require outside config changes. + # (which may be costly) + self.preferences = ["recoveryToken"] + self.preferences.extend(auth.get_chall_pref()) + def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, chain_path=CONFIG.CHAIN_PATH): @@ -76,14 +85,45 @@ class Client(object): :rtype: `tuple` of `str` """ + challenge_msgs = [] # Request Challenges - challenge_msg = self.acme_challenge() + for name in self.names: + # Maintaining order of challenge_msgs to names is important + challenge_msgs.append(self.acme_challenge(name)) # Perform Challenges - responses, auth_c, client_c = self.verify_identity(challenge_msg) + # Make sure at least one challenge is solved every round + progress = True + # This outer loop handles cases where the Authenticator cannot solve + # all challenge_msgs at once + while challenge_msgs and progress: + responses, auth_c, client_c = self.verify_identities(challenge_msgs) + progress = False - # Get Authorization - self.acme_authorization(challenge_msg, auth_c, client_c, responses) + i = 0 + while i < len(responses): + # Get Authorization + if responses[i] is not None: + print "client chall_msgs:", challenge_msgs[i] + print "client responses:", responses[i] + print "client auth_c:", auth_c[i] + print "client client_c:", client_c[i] + self.acme_authorization( + challenge_msgs[i], auth_c[i], client_c[i], responses[i]) + # Received authorization, remove challenge from list + # We have also cleaned up challenges... keep index + # in sync + del challenge_msgs[i] + del auth_c[i] + del client_c[i] + del responses[i] + progress = True + else: + i += 1 + + if not progress: + raise errors.LetsEncryptClientError( + "Unable to solve challenges for requested names.") # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -96,17 +136,15 @@ class Client(object): return cert_file, chain_file - def acme_challenge(self): + def acme_challenge(self, domain): """Handle ACME "challenge" phase. - .. todo:: Handle more than one domain name in self.names - :returns: ACME "challenge" message. :rtype: dict """ return self.network.send_and_receive_expected( - acme.challenge_request(self.names[0]), "challenge") + acme.challenge_request(domain), "challenge") def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. @@ -254,55 +292,101 @@ class Client(object): # should cleanup client_c assert not client_c - def verify_identity(self, challenge_msg): - """Verify identity. + def verify_identities(self, challenge_msgs): + """Verify identities. - :param dict challenge_msg: ACME "challenge" message. + This is greatly complicated by the fact that the Authenticator can + oftentimes solve many challenges at once. The strategy is to give + the authenticator all of the appropriate challenges at once to + speed up the process. This creates indexing issues as the challenges + can come from many different messages and are not in an exact order + because of the optimal path decision. All of this complicated indexing + will be completely hidden from the authenticator and all the + authenticator must do is return a list of responses in the same order + the challenges were given. + + :param list challenge_msgs: List of ACME "challenge" messages. :returns: TODO - :rtype: dict + :rtype: TODO """ - path = challenge.gen_challenge_path( - challenge_msg["challenges"], challenge_msg.get("combinations", [])) + # Every msg's responses are a list within this list + responses = [] + # Every msg's desired path + paths = [] - logging.info("Performing the following challenges:") + auth_chall = [] + client_chall = [] - # Every indices element is a list of integers referring to which - # challenges in the master list the challenge object satisfies - # Single Challenge objects that can satisfy multiple server challenges - # mess up the order of the challenges, thus requiring the indices - auth_c, auth_i, client_c, client_i = self.challenge_factory( - self.names[0], challenge_msg["challenges"], path) + auth_idx = [] + client_idx = [] - responses = ["null"] * len(challenge_msg["challenges"]) + for i, msg in enumerate(challenge_msgs): + paths.append(challenge.gen_challenge_path( + msg["challenges"], + self.preferences, + msg.get("combinations", []))) + + logging.info("Performing the following challenges:") + + auth_c, auth_i, client_c, client_i = self.challenge_factory( + self.names[i], msg["challenges"], paths[-1]) + + auth_chall.append(auth_c) + auth_idx.append(auth_i) + client_chall.append(client_c) + client_idx.append(client_i) + + responses.append(["null"] * len(msg["challenges"])) # Do client centric challenges here... # Since this isn't implemented yet... + # Client challenge responses should be cached... + # The client should be able to solve all challenges the first time assert not client_i - auth_resp = self.auth.perform(auth_c) - self._assign_responses(auth_resp, auth_i, responses) + # Flatten list for authenticator + auth_resp = self.auth.perform( + [chall for sublist in auth_chall for chall in sublist]) + self._assign_responses(auth_resp, auth_idx, responses) + + print 'auth_resp:', auth_resp + print 'auth_idx:', auth_idx + print 'auth_responses:', responses + + for i in range(len(paths)): + # If challenges failed to complete... zero them out + if not self._path_satisfied(responses[i], paths[i]): + responses[i] = None + auth_chall[i] = None + client_chall[i] = None logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, auth_c, client_c + return responses, auth_chall, client_chall # pylint: disable=no-self-use - def _assign_responses(self, resp, index_list, responses): + def _assign_responses(self, flat_resp, idx_list, responses): """Assign chall_response to appropriate places in response list. :param resp: responses from a challenge :type resp: list of dicts - :param list index_list: respective challenges resp satisfies + :param list idx_list: respective challenges flat_resp satisfies :param list responses: master list of responses """ - assert len(resp) == len(index_list) - for j, index in enumerate(index_list): - responses[index] = resp[j] + flat_index = 0 + # Every authorization_request message + for msg_num in range(len(responses)): + for idx in idx_list[msg_num]: + responses[msg_num][idx] = flat_resp[flat_index] + flat_index += 1 + def _path_satisfied(self, responses, path): + """Returns whether a path has been completely satisfied.""" + return all("null" != responses[i] for i in path) def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 7b72d9a46..5586960de 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,6 +11,13 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ + def get_chall_pref(): + """Return list of challenge preferences. + + :returns: list of strings with the most preferred challenges first. + :rtype: list + + """ def perform(chall_list): """Perform the given challenge. @@ -31,11 +38,7 @@ class IChallenge(zope.interface.Interface): """Let's Encrypt challenge.""" def perform(): - """Perform the challenge. - - :param bool quiet: TODO - - """ + """Perform the challenge.""" def generate_response(): """Generate response.""" diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index 855008b6b..92f2933ec 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -11,6 +11,9 @@ from letsencrypt.client import acme from letsencrypt.client import errors +logging.getLogger("requests").setLevel(logging.WARNING) + + class Network(object): """Class for communicating with ACME servers. diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index e1fd718a2..a1bb50004 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -1,5 +1,6 @@ -"""Test for letsencrypt.client.apache_configurator.""" +"""Test for letsencrypt.client.apache.configurator.""" import os +import pkg_resources import re import shutil import unittest @@ -7,6 +8,8 @@ import unittest import mock import zope.component +from letsencrypt.client import challenge_util +from letsencrypt.client import client from letsencrypt.client import display from letsencrypt.client import errors @@ -159,5 +162,37 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) + @mock.patch("letsencrypt.client.apache.dvsni") + def test_perform(self, mock_dvsni, mock_restart): + # Only tests functionality specific to configurator.perform + # Note: As more challenges are offered this will have to be expanded + rsa256_file = pkg_resources.resource_filename( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + chall1 = challenge_util.DVSNI_Chall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + chall2 = challenge_util.DVSNI_Chall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key) + + dvsni_ret_val = [ + {"type": "dvsni", "s": "randomS1"}, + {"type": "dvsni", "s": "randomS2"} + ] + + mock_dvsni().perform.return_value = dvsni_ret_val + responses = self.config.perform([chall1, chall2]) + + self.assertEqual(mock_dvsni.perform.call_count, 1) + self.assertEqual(responses, dvsni_ret_val) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/le_util_test.py b/letsencrypt/client/tests/le_util_test.py index 2432a6d65..f6c58ac0b 100644 --- a/letsencrypt/client/tests/le_util_test.py +++ b/letsencrypt/client/tests/le_util_test.py @@ -83,6 +83,9 @@ class UniqueFileTest(unittest.TestCase): self.root_path = tempfile.mkdtemp() self.default_name = os.path.join(self.root_path, 'foo.txt') + def tearDown(self): + shutil.rmtree(self.root_path, ignore_errors=True) + def _call(self, mode=0o600): from letsencrypt.client.le_util import unique_file return unique_file(self.default_name, mode) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 211525ed8..12db6e33d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -150,8 +150,7 @@ def choose_names(installer): code, names = zope.component.getUtility( interfaces.IDisplay).filter_names(get_all_names(installer)) if code == display.OK and names: - # TODO: Allow multiple names once it is setup - return [names[0]] + return names else: sys.exit(0) From 7c23a2f2aad9eb7a3bcee1555d56d511d070c4fa Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 22 Dec 2014 00:29:33 -0800 Subject: [PATCH 47/66] Use DVSNI_Chall namedtuple --- letsencrypt/client/apache/configurator.py | 44 +++++++++++------------ letsencrypt/client/client.py | 21 +++++++---- letsencrypt/scripts/main.py | 2 +- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 8b32cf7a7..752651104 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -949,12 +949,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): `chall_dict` composed of: - list_sni_tuple: - List of tuples with form `(name, r, nonce)`, where - `name` (`str`), `r` (base64 `str`), `nonce` (hex `str`) + `type`: `dvsni` (`str`) - dvsni_key: - DVSNI key (:class:`letsencrypt.client.client.Client.Key`) + `dvsni_chall`: + List of DVSNI_Chall namedtuples + (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) + where DVSNI_Chall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) :param dict chall_dict: dvsni challenge - see documentation @@ -964,18 +966,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.save() # Do weak validation that challenge is of expected type - if not ("list_sni_tuple" in chall_dict and "dvsni_key" in chall_dict): + if "dvsni_chall" not in chall_dict: logging.fatal("Incorrect parameter given to Apache DVSNI challenge") logging.fatal("Chall dict: %s", chall_dict) sys.exit(1) addresses = [] default_addr = "*:443" - for tup in chall_dict["list_sni_tuple"]: - vhost = self.choose_virtual_host(tup[0]) + for chall in chall_dict["dvsni_chall"]: + vhost = self.choose_virtual_host(chall.domain) if vhost is None: logging.error( - "No vhost exists with servername or alias of: %s", tup[0]) + "No vhost exists with servername or alias of: %s", + chall.domain) logging.error("No _default_:443 vhost exists") logging.error("Please specify servernames in the Apache config") return None @@ -993,18 +996,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): responses = [] # Create all of the challenge certs - for tup in chall_dict["list_sni_tuple"]: - cert_path = self.dvsni_get_cert_file(tup[2]) + for chall in chall_dict["dvsni_chall"]: + cert_path = self.dvsni_get_cert_file(chall.nonce) self.register_file_creation(cert_path) s_b64 = challenge_util.dvsni_gen_cert( - cert_path, tup[0], tup[1], tup[2], chall_dict["dvsni_key"]) + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) responses.append({"type": "dvsni", "s": s_b64}) # Setup the configuration - self.dvsni_mod_config(chall_dict["list_sni_tuple"], - chall_dict["dvsni_key"], - addresses) + self.dvsni_mod_config(chall_dict["dvsni_chall"], addresses) # Save reversible changes and restart the server self.save("SNI Challenge", True) @@ -1019,18 +1020,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() # TODO: Variable names - def dvsni_mod_config(self, list_sni_tuple, dvsni_key, - ll_addrs): + def dvsni_mod_config(self, dvsni_chall, ll_addrs): """Modifies Apache config files to include challenge vhosts. Result: Apache config includes virtual servers for issued challs - :param list list_sni_tuple: list of tuples with the form - `(addr, y, nonce)`, where `addr` is `str`, `y` is `bytearray`, - and nonce is hex `str` - - :param dvsni_key: DVSNI key - :type dvsni_key: :class:`letsencrypt.client.client.Client.Key` + :param list dvsni_chall: list of + :class:`letsencrypt.client.client.Client.DVSNI_Chall` :param list ll_addrs: list of list of :class:`letsencrypt.client.apache.obj.Addr` to apply @@ -1052,7 +1048,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): config_text = "\n" for idx, lis in enumerate(ll_addrs): config_text += self.get_config_text( - list_sni_tuple[idx][2], lis, dvsni_key.file) + dvsni_chall[idx].nonce, lis, dvsni_chall[idx].key.file) config_text += "\n" self.dvsni_conf_include_check(self.parser.loc["default"]) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 8c5f4525f..fdb8f542c 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -47,6 +47,7 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") + DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -419,8 +420,9 @@ class Client(object): if chall["type"] == "dvsni": logging.info(" DVSNI challenge for name %s.", name) sni_satisfies.append(index) - sni_todo.append((str(name), str(chall["r"]), - str(chall["nonce"]))) + sni_todo.append(Client.DVSNI_Chall( + str(name), str(chall["r"]), + str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": logging.info("\tRecovery Token Challenge for name: %s.", name) @@ -438,8 +440,7 @@ class Client(object): # one "challenge object" is issued for all sni_challenges challenge_objs.append({ "type": "dvsni", - "list_sni_tuple": sni_todo, - "dvsni_key": self.authkey, + "dvsni_chall": sni_todo }) challenge_obj_indices.append(sni_satisfies) logging.debug(sni_todo) @@ -447,11 +448,17 @@ class Client(object): return challenge_objs, challenge_obj_indices -def validate_key_csr(privkey, csr, names): +def validate_key_csr(privkey, csr): """Validate CSR and key files. - Verifies that the client key and csr arguments are valid and - correspond to one another. + Verifies that the client key and csr arguments are valid and correspond to + one another. This does not currently check the names in the CSR. + + :param privkey: Key associated with CSR + :type privkey: :class:`letsencrypt.client.client.Client.Key` + + :param csr: CSR + :type csr: :class:`letsencrypt.client.client.Client.CSR` :raises LetsEncryptClientError: if validation fails diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8aeb43136..211525ed8 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -122,7 +122,7 @@ def main(): acme = client.Client(server, domains, privkey, auth, installer) # Validate the key and csr - client.validate_key_csr(privkey, csr, domains) + client.validate_key_csr(privkey, csr) cert_file, chain_file = acme.obtain_certificate(csr) vhost = acme.deploy_certificate(privkey, cert_file, chain_file) From a2a64e94100fdb7b1139a540ad98a637e613b2de Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 23 Dec 2014 03:54:30 -0800 Subject: [PATCH 48/66] Turn DVSNI into module, add more appropriate challenges/api --- letsencrypt/client/apache/configurator.py | 203 +++------------------- letsencrypt/client/apache/dvsni.py | 193 ++++++++++++++++++++ letsencrypt/client/challenge_util.py | 4 + letsencrypt/client/client.py | 91 ++++------ letsencrypt/client/interfaces.py | 17 +- 5 files changed, 272 insertions(+), 236 deletions(-) create mode 100644 letsencrypt/client/apache/dvsni.py diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 752651104..92c45bdf2 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -1,9 +1,7 @@ """Apache Configuration based off of Augeas Configurator.""" import logging import os -import pkg_resources import re -import shutil import socket import subprocess import sys @@ -17,6 +15,7 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util +from letsencrypt.client.apache import dvsni from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser @@ -117,6 +116,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.vhosts = self.get_virtual_hosts() # Add name_server association dict self.assoc = dict() + # Add number of outstanding challenges + self.chall_out = 0 # Enable mod_ssl if it isn't already enabled # This is Let's Encrypt... we enable mod_ssl on initialization :) @@ -125,11 +126,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # on initialization self._prepare_server_https() - # Note: initialization doesn't check to see if the config is correct - # by Apache's standards. This should be done by the client (client.py) - # if it is desired. There may be instances where correct configuration - # isn't required on startup. - def deploy_cert(self, vhost, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. @@ -929,186 +925,41 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### - - # TODO: Change list_sni_tuple to namedtuple. Also include key within tuple. - # This allows the keys to be different for each SNI challenge - - def perform(self, chall_dict): + def perform(self, chall_list): """Perform the configuration related challenge. - :param dict chall_dict: Dictionary representing a challenge. + This function currently assumes all challenges will be fulfilled. + If this turns out not to be the case in the future. Cleanup and + outstanding challenges will have to be designed better. + + :param list chall_list: List of challenges to be + fulfilled by configurator. """ + self.chall_out += len(chall_list) + responses = [None] * len(chall_list) + apache_dvsni = dvsni.ApacheDVSNI(self) - if chall_dict.get("type", "") == 'dvsni': - return self.dvsni_perform(chall_dict) - return None + for i, chall in enumerate(chall_list): + if isinstance(chall, challenge_util.DVSNI_Chall): + apache_dvsni.add_chall(chall, i) - def dvsni_perform(self, chall_dict): - """Perform a DVSNI challenge. - - `chall_dict` composed of: - - `type`: `dvsni` (`str`) - - `dvsni_chall`: - List of DVSNI_Chall namedtuples - (:class:`letsencrypt.client.client.Client.DVSNI_Chall`) - where DVSNI_Chall tuples have the following fields - `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) - `key` (:class:`letsencrypt.client.client.Client.Key`) - - :param dict chall_dict: dvsni challenge - see documentation - - """ - # Save any changes to the configuration as a precaution - # About to make temporary changes to the config - self.save() - - # Do weak validation that challenge is of expected type - if "dvsni_chall" not in chall_dict: - logging.fatal("Incorrect parameter given to Apache DVSNI challenge") - logging.fatal("Chall dict: %s", chall_dict) - sys.exit(1) - - addresses = [] - default_addr = "*:443" - for chall in chall_dict["dvsni_chall"]: - vhost = self.choose_virtual_host(chall.domain) - if vhost is None: - logging.error( - "No vhost exists with servername or alias of: %s", - chall.domain) - logging.error("No _default_:443 vhost exists") - logging.error("Please specify servernames in the Apache config") - return None - - # TODO - @jdkasten review this code to make sure it makes sense - self.make_server_sni_ready(vhost, default_addr) - - for addr in vhost.addrs: - if "_default_" == addr.get_addr(): - addresses.append([default_addr]) - break - else: - addresses.append(list(vhost.addrs)) - - responses = [] - - # Create all of the challenge certs - for chall in chall_dict["dvsni_chall"]: - cert_path = self.dvsni_get_cert_file(chall.nonce) - self.register_file_creation(cert_path) - s_b64 = challenge_util.dvsni_gen_cert( - cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) - - responses.append({"type": "dvsni", "s": s_b64}) - - # Setup the configuration - self.dvsni_mod_config(chall_dict["dvsni_chall"], addresses) - - # Save reversible changes and restart the server - self.save("SNI Challenge", True) + sni_response = apache_dvsni.perform() + # Must restart in order to activate the challenges. + # Handled here because we may be able to load up other challenge types self.restart() + for i, resp in enumerate(sni_response): + responses[apache_dvsni.indices[i]] = resp + return responses - def cleanup(self): + def cleanup(self, chall_list): """Revert all challenges.""" - - self.revert_challenge_config() - self.restart() - - # TODO: Variable names - def dvsni_mod_config(self, dvsni_chall, ll_addrs): - """Modifies Apache config files to include challenge vhosts. - - Result: Apache config includes virtual servers for issued challs - - :param list dvsni_chall: list of - :class:`letsencrypt.client.client.Client.DVSNI_Chall` - - :param list ll_addrs: list of list of - :class:`letsencrypt.client.apache.obj.Addr` to apply - - """ - # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY - # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER - # AND TAKEN OUT BEFORE RELEASE, INSTEAD - # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM - - # Check to make sure options-ssl.conf is installed - # pylint: disable=no-member - if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): - dist_conf = pkg_resources.resource_filename( - __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) - - # TODO: Use ip address of existing vhost instead of relying on FQDN - config_text = "\n" - for idx, lis in enumerate(ll_addrs): - config_text += self.get_config_text( - dvsni_chall[idx].nonce, lis, dvsni_chall[idx].key.file) - config_text += "\n" - - self.dvsni_conf_include_check(self.parser.loc["default"]) - self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) - - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: - new_conf.write(config_text) - - def dvsni_conf_include_check(self, main_config): - """Adds DVSNI challenge conf file into configuration. - - Adds DVSNI challenge include file if it does not already exist - within mainConfig - - :param str main_config: file path to main user apache config file - - """ - if len(self.parser.find_dir( - parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: - # print "Including challenge virtual host(s)" - self.parser.add_dir(parser.get_aug_path(main_config), - "Include", CONFIG.APACHE_CHALLENGE_CONF) - - def get_config_text(self, nonce, ip_addrs, dvsni_key_file): - """Chocolate virtual server configuration text - - :param str nonce: hex form of nonce - :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` - :param str dvsni_key_file: Path to key file - - :returns: virtual host configuration text - :rtype: str - - """ - ips = " ".join(str(i) for i in ip_addrs) - return ("\n" - "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" - "UseCanonicalName on\n" - "SSLStrictSNIVHostCheck on\n" - "\n" - "LimitRequestBody 1048576\n" - "\n" - "Include " + self.parser.loc["ssl_options"] + "\n" - "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + "\n" - "SSLCertificateKeyFile " + dvsni_key_file + "\n" - "\n" - "DocumentRoot " + self.direc["config"] + "challenge_page/\n" - "\n\n") - - def dvsni_get_cert_file(self, nonce): - """Returns standardized name for challenge certificate. - - :param str nonce: hex form of nonce - - :returns: certificate file name - :rtype: str - - """ - return self.direc["work"] + nonce + ".crt" + self.chall_out -= len(chall_list) + if self.chall_out <= 0: + self.revert_challenge_config() + self.restart() def enable_mod(mod_name): diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py new file mode 100644 index 000000000..b37fd0b1c --- /dev/null +++ b/letsencrypt/client/apache/dvsni.py @@ -0,0 +1,193 @@ +"""ApacheDVSNI""" +import logging +import os +import pkg_resources +import shutil + +from letsencrypt.client import challenge_util +from letsencrypt.client import CONFIG + +from letsencrypt.client.apache import parser + +class ApacheDVSNI(object): + """Class performs DVSNI challenges within the Apache configurator. + + :ivar config: ApacheConfigurator object + :type config: :class:`letsencrypt.client.apache.configurator` + + :ivar dvsni_chall: Data required for challenges. + where DVSNI_Chall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) + :type dvsni_chall: `list` of + :class:`letsencrypt.client.challenge_util.DVSNI_Chall` + + """ + def __init__(self, config): + self.config = config + self.dvsni_chall = [] + self.indices = [] + # self.completed = 0 + + def add_chall(self, chall, idx=None): + """Add challenge to DVSNI object to perform at once. + + :param chall: DVSNI challenge info + :type chall: :class:`letsencrypt.client.challenge_util.DVSNI_Chall` + + :param int idx: index to challenge in a larger array + + """ + self.dvsni_chall.append(chall) + if idx is not None: + self.indices.append(idx) + + def perform(self): + """Peform a DVSNI challenge.""" + if not self.dvsni_chall: + return dict() + # Save any changes to the configuration as a precaution + # About to make temporary changes to the config + self.config.save() + + addresses = [] + default_addr = "*:443" + for chall in self.dvsni_chall: + vhost = self.config.choose_virtual_host(chall.domain) + if vhost is None: + logging.error( + "No vhost exists with servername or alias of: %s", + chall.domain) + logging.error("No _default_:443 vhost exists") + logging.error("Please specify servernames in the Apache config") + return None + + # TODO - @jdkasten review this code to make sure it makes sense + self.config.make_server_sni_ready(vhost, default_addr) + + for addr in vhost.addrs: + if "_default_" == addr.get_addr(): + addresses.append([default_addr]) + break + else: + addresses.append(list(vhost.addrs)) + + responses = [] + + # Create all of the challenge certs + for chall in self.dvsni_chall: + cert_path = self.get_cert_file(chall.nonce) + self.config.register_file_creation(cert_path) + s_b64 = challenge_util.dvsni_gen_cert( + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) + + responses.append({"type": "dvsni", "s": s_b64}) + + # Setup the configuration + self.mod_config(addresses) + + # Save reversible changes + self.config.save("SNI Challenge", True) + + return responses + + # def chall_complete(self, chall): + # """Used by Authenticator to notify the DVSNI challenge. + + # :param chall: Challenge info + # :type chall: :class:`letsencrypt.client.client.Client.DVSNI_Chall` + + # """ + # self.completed += 1 + # if self.completed < len(self.dvsni_chall): + # return False + # return True + + # TODO: Variable names + def mod_config(self, ll_addrs): + """Modifies Apache config files to include challenge vhosts. + + Result: Apache config includes virtual servers for issued challs + + :param list ll_addrs: list of list of + :class:`letsencrypt.client.apache.obj.Addr` to apply + + """ + # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY + # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER + # AND TAKEN OUT BEFORE RELEASE, INSTEAD + # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM + + # Check to make sure options-ssl.conf is installed + # pylint: disable=no-member + if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): + dist_conf = pkg_resources.resource_filename( + __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) + + # TODO: Use ip address of existing vhost instead of relying on FQDN + config_text = "\n" + for idx, lis in enumerate(ll_addrs): + config_text += self.get_config_text( + self.dvsni_chall[idx].nonce, lis, + self.dvsni_chall[idx].key.file) + config_text += "\n" + + self.conf_include_check(self.config.parser.loc["default"]) + self.config.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) + + with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: + new_conf.write(config_text) + + def conf_include_check(self, main_config): + """Adds DVSNI challenge conf file into configuration. + + Adds DVSNI challenge include file if it does not already exist + within mainConfig + + :param str main_config: file path to main user apache config file + + """ + if len(self.config.parser.find_dir( + parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + # print "Including challenge virtual host(s)" + self.config.parser.add_dir(parser.get_aug_path(main_config), + "Include", CONFIG.APACHE_CHALLENGE_CONF) + + def get_config_text(self, nonce, ip_addrs, dvsni_key_file): + """Chocolate virtual server configuration text + + :param str nonce: hex form of nonce + :param list ip_addrs: addresses of challenged domain + :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` + :param str dvsni_key_file: Path to key file + + :returns: virtual host configuration text + :rtype: str + + """ + ips = " ".join(str(i) for i in ip_addrs) + return ("\n" + "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" + "UseCanonicalName on\n" + "SSLStrictSNIVHostCheck on\n" + "\n" + "LimitRequestBody 1048576\n" + "\n" + "Include " + self.config.parser.loc["ssl_options"] + "\n" + "SSLCertificateFile " + self.get_cert_file(nonce) + "\n" + "SSLCertificateKeyFile " + dvsni_key_file + "\n" + "\n" + "DocumentRoot " + self.config.direc["config"] + "dvsni_page/\n" + "\n\n") + + def get_cert_file(self, nonce): + """Returns standardized name for challenge certificate. + + :param str nonce: hex form of nonce + + :returns: certificate file name + :rtype: str + + """ + return self.config.direc["work"] + nonce + ".crt" diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 26266cda1..6eee4d3f9 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -1,4 +1,5 @@ """Challenge specific utility functions.""" +import collections import hashlib from Crypto import Random @@ -8,6 +9,9 @@ from letsencrypt.client import crypto_util from letsencrypt.client import le_util +DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") + + # DVSNI Challenge functions def dvsni_gen_cert(filepath, name, r_b64, nonce, key): """Generate a DVSNI cert and save it to filepath. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index fdb8f542c..4a698bd48 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -13,6 +13,7 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import challenge +from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -47,7 +48,6 @@ class Client(object): """ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") - DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" @@ -80,10 +80,10 @@ class Client(object): challenge_msg = self.acme_challenge() # Perform Challenges - responses, challenge_objs = self.verify_identity(challenge_msg) + responses, auth_c, client_c = self.verify_identity(challenge_msg) # Get Authorization - self.acme_authorization(challenge_msg, challenge_objs, responses) + self.acme_authorization(challenge_msg, auth_c, client_c, responses) # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -108,7 +108,7 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(self.names[0]), "challenge") - def acme_authorization(self, challenge_msg, chal_objs, responses): + def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. :param dict challenge_msg: ACME "challenge" message. @@ -132,7 +132,7 @@ class Client(object): "Failed Authorization procedure - cleaning up challenges") sys.exit(1) finally: - self.cleanup_challenges(chal_objs) + self.cleanup_challenges(auth_c, client_c) def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -243,19 +243,16 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, challenges): + def cleanup_challenges(self, auth_c, client_c): """Cleanup configuration challenges :param dict challenges: challenges from a challenge message """ logging.info("Cleaning up challenges...") - for chall in challenges: - if chall["type"] in CONFIG.CONFIG_CHALLENGES: - self.auth.cleanup() - else: - # Handle other cleanup if needed - pass + self.auth.cleanup(auth_c) + # should cleanup client_c + assert not client_c def verify_identity(self, challenge_msg): """Verify identity. @@ -275,45 +272,37 @@ class Client(object): # challenges in the master list the challenge object satisfies # Single Challenge objects that can satisfy multiple server challenges # mess up the order of the challenges, thus requiring the indices - challenge_objs, indices = self.challenge_factory( + auth_c, auth_i, client_c, client_i = self.challenge_factory( self.names[0], challenge_msg["challenges"], path) responses = ["null"] * len(challenge_msg["challenges"]) - # Perform challenges - for i, c_obj in enumerate(challenge_objs): - resp = "null" - if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - resp = self.auth.perform(c_obj) - else: - # Handle RecoveryToken type challenges - pass - - self._assign_responses(resp, indices[i], responses) + # Do client centric challenges here... + # Since this isn't implemented yet... + assert not client_i + auth_resp = self.auth.perform(auth_c) + self._assign_responses(auth_resp, auth_i, responses) logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, challenge_objs + return responses, auth_c, client_c # pylint: disable=no-self-use def _assign_responses(self, resp, index_list, responses): """Assign chall_response to appropriate places in response list. :param resp: responses from a challenge - :type resp: list of dicts or dict + :type resp: list of dicts :param list index_list: respective challenges resp satisfies :param list responses: master list of responses """ - if isinstance(resp, list): - assert len(resp) == len(index_list) - for j, index in enumerate(index_list): - responses[index] = resp[j] - else: - for index in index_list: - responses[index] = resp + assert len(resp) == len(index_list) + for j, index in enumerate(index_list): + responses[index] = resp[j] + def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -392,10 +381,10 @@ class Client(object): vhost.add(host) return vhost - def challenge_factory(self, name, challenges, path): + def challenge_factory(self, domain, challenges, path): """ - :param name: TODO + :param str domain: domain of the enrollee :param list challenges: A list of challenges from ACME "challenge" server message to be fulfilled by the client in order to prove @@ -407,27 +396,27 @@ class Client(object): :rtype: tuple """ - sni_todo = [] + auth_chall = [] # Since a single invocation of SNI challenge can satisfy multiple # challenges. We must keep track of all the challenges it satisfies - sni_satisfies = [] + auth_satisfies = [] - challenge_objs = [] - challenge_obj_indices = [] + client_chall = [] + client_satisfies = [] for index in path: chall = challenges[index] if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", name) - sni_satisfies.append(index) - sni_todo.append(Client.DVSNI_Chall( - str(name), str(chall["r"]), + logging.info(" DVSNI challenge for name %s.", domain) + auth_satisfies.append(index) + auth_chall.append(challenge_util.DVSNI_Chall( + str(domain), str(chall["r"]), str(chall["nonce"]), self.authkey)) elif chall["type"] == "recoveryToken": - logging.info("\tRecovery Token Challenge for name: %s.", name) - challenge_obj_indices.append(index) - challenge_objs.append({ + logging.info(" Recovery Token Challenge for name: %s.", domain) + client_satisfies.append(index) + client_chall.append({ type: "recoveryToken", }) @@ -435,17 +424,7 @@ class Client(object): logging.fatal("Challenge not currently supported") sys.exit(82) - if sni_todo: - # SNI_Challenge can satisfy many sni challenges at once so only - # one "challenge object" is issued for all sni_challenges - challenge_objs.append({ - "type": "dvsni", - "dvsni_chall": sni_todo - }) - challenge_obj_indices.append(sni_satisfies) - logging.debug(sni_todo) - - return challenge_objs, challenge_obj_indices + return auth_chall, auth_satisfies, client_chall, client_satisfies def validate_key_csr(privkey, csr): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 910ec29c8..7b72d9a46 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,17 +11,26 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ - def perform(chall_dict): - """Perform the given challenge""" + def perform(chall_list): + """Perform the given challenge. - def cleanup(): + :param list chall_list: List of challenge types defined in client.py + + :returns: List of responses + If the challenge cant be completed... + None - Authenticator can perform challenge, but can't at this time + False - Authenticator will never be able to perform (error) + :rtype: `list` of dicts + + """ + def cleanup(chall_list): """Revert changes and shutdown after challenges complete.""" class IChallenge(zope.interface.Interface): """Let's Encrypt challenge.""" - def perform(quiet=True): + def perform(): """Perform the challenge. :param bool quiet: TODO From 73ec1311c0479085977b6669f23316fcb01383ff Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 6 Jan 2015 01:57:07 -0800 Subject: [PATCH 49/66] Initial challenge refactor/allow multiple names --- letsencrypt/client/CONFIG.py | 5 +- letsencrypt/client/apache/configurator.py | 10 ++ letsencrypt/client/apache/dvsni.py | 20 +-- letsencrypt/client/augeas_configurator.py | 4 +- letsencrypt/client/challenge.py | 14 +- letsencrypt/client/client.py | 144 ++++++++++++++---- letsencrypt/client/interfaces.py | 13 +- letsencrypt/client/network.py | 3 + .../client/tests/apache_configurator_test.py | 37 ++++- letsencrypt/client/tests/le_util_test.py | 3 + letsencrypt/scripts/main.py | 3 +- 11 files changed, 195 insertions(+), 61 deletions(-) diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 3cc9d09a6..6392911fb 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -47,9 +47,6 @@ OPTIONS_SSL_CONF = os.path.join(CONFIG_DIR, "options-ssl.conf") LE_VHOST_EXT = "-le-ssl.conf" """Let's Encrypt SSL vhost configuration extension""" -APACHE_CHALLENGE_CONF = os.path.join(CONFIG_DIR, "le_dvsni_cert_challenge.conf") -"""Temporary file for challenge virtual hosts""" - CERT_PATH = CERT_DIR + "cert-letsencrypt.pem" """Let's Encrypt cert file.""" @@ -60,7 +57,7 @@ INVALID_EXT = ".acme.invalid" """Invalid Extension""" # Challenge Information -CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] +#CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] """Challenge Preferences Dict for currently supported challenges""" EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 92c45bdf2..74ef227fc 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -19,6 +19,7 @@ from letsencrypt.client.apache import dvsni from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser + # TODO: Augeas sections ie. , beginning and closing # tags need to be the same case, otherwise Augeas doesn't recognize them. # This is not able to be completely remedied by regular expressions because @@ -925,6 +926,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### + def get_chall_pref(self): # pylint: disable=no-self-use + """Return list of challenge preferences.""" + + return ["dvsni"] + def perform(self, chall_list): """Perform the configuration related challenge. @@ -935,6 +941,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param list chall_list: List of challenges to be fulfilled by configurator. + :returns: list of responses. A None response indicates the challenge + was not perfromed. + :rtype: list + """ self.chall_out += len(chall_list) responses = [None] * len(chall_list) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index b37fd0b1c..37b0b7426 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -1,8 +1,6 @@ """ApacheDVSNI""" import logging import os -import pkg_resources -import shutil from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG @@ -27,6 +25,8 @@ class ApacheDVSNI(object): self.config = config self.dvsni_chall = [] self.indices = [] + self.challenge_conf = os.path.join( + config.direc["config"], "le_dvsni_cert_challenge.conf") # self.completed = 0 def add_chall(self, chall, idx=None): @@ -120,10 +120,10 @@ class ApacheDVSNI(object): # Check to make sure options-ssl.conf is installed # pylint: disable=no-member - if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): - dist_conf = pkg_resources.resource_filename( - __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) + # if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): + # dist_conf = pkg_resources.resource_filename( + # __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + # shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) # TODO: Use ip address of existing vhost instead of relying on FQDN config_text = "\n" @@ -134,9 +134,9 @@ class ApacheDVSNI(object): config_text += "\n" self.conf_include_check(self.config.parser.loc["default"]) - self.config.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) + self.config.register_file_creation(True, self.challenge_conf) - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: + with open(self.challenge_conf, 'w') as new_conf: new_conf.write(config_text) def conf_include_check(self, main_config): @@ -149,10 +149,10 @@ class ApacheDVSNI(object): """ if len(self.config.parser.find_dir( - parser.case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: + parser.case_i("Include"), self.challenge_conf)) == 0: # print "Including challenge virtual host(s)" self.config.parser.add_dir(parser.get_aug_path(main_config), - "Include", CONFIG.APACHE_CHALLENGE_CONF) + "Include", self.challenge_conf) def get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 231faa99d..5d02329de 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -343,7 +343,7 @@ class AugeasConfigurator(object): else: cp_dir = self.direc["progress"] - le_util.make_or_verify_dir(cp_dir) + le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) try: with open(os.path.join(cp_dir, "NEW_FILES"), 'a') as new_fd: for file_path in files: @@ -400,7 +400,7 @@ class AugeasConfigurator(object): else: logging.warn( "File: %s - Could not be found to be deleted\n" - "Program was probably shut down unexpectedly, ") + "LE probably shut down unexpectedly", path) except (IOError, OSError): logging.fatal( "Unable to remove filepaths contained within %s", file_list) diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py index b2eb33c53..b0452c0ff 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -5,7 +5,7 @@ import sys from letsencrypt.client import CONFIG -def gen_challenge_path(challenges, combos=None): +def gen_challenge_path(challenges, preferences, combos=None): """Generate a plan to get authority over the identity. .. todo:: Make sure that the challenges are feasible... @@ -25,12 +25,12 @@ def gen_challenge_path(challenges, combos=None): """ if combos: - return _find_smart_path(challenges, combos) + return _find_smart_path(challenges, preferences, combos) else: - return _find_dumb_path(challenges) + return _find_dumb_path(challenges, preferences) -def _find_smart_path(challenges, combos): +def _find_smart_path(challenges, preferences, combos): """Find challenge path with server hints. Can be called if combinations is included. Function uses a simple @@ -51,7 +51,7 @@ def _find_smart_path(challenges, combos): """ chall_cost = {} max_cost = 0 - for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES): + for i, chall in enumerate(preferences): chall_cost[chall] = i max_cost += i @@ -77,7 +77,7 @@ def _find_smart_path(challenges, combos): return best_combo -def _find_dumb_path(challenges): +def _find_dumb_path(challenges, preferences): """Find challenge path without server hints. Should be called if the combinations hint is not included by the @@ -95,7 +95,7 @@ def _find_dumb_path(challenges): # Add logic for a crappy server # Choose a DV path = [] - for pref_c in CONFIG.CHALLENGE_PREFERENCES: + for pref_c in preferences: for i, offered_challenge in enumerate(challenges): if (pref_c == offered_challenge["type"] and is_preferred(offered_challenge["type"], path)): diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 4a698bd48..a9305f221 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -60,6 +60,15 @@ class Client(object): self.auth = auth self.installer = installer + # Client challenges and Authenticator challenges should be separate + # and really should not be conflicting along the same path. + # I have chosen to make client challenges preferred + # as the client challenges should be able to be completely handled + # by this module and does not require outside config changes. + # (which may be costly) + self.preferences = ["recoveryToken"] + self.preferences.extend(auth.get_chall_pref()) + def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, chain_path=CONFIG.CHAIN_PATH): @@ -76,14 +85,45 @@ class Client(object): :rtype: `tuple` of `str` """ + challenge_msgs = [] # Request Challenges - challenge_msg = self.acme_challenge() + for name in self.names: + # Maintaining order of challenge_msgs to names is important + challenge_msgs.append(self.acme_challenge(name)) # Perform Challenges - responses, auth_c, client_c = self.verify_identity(challenge_msg) + # Make sure at least one challenge is solved every round + progress = True + # This outer loop handles cases where the Authenticator cannot solve + # all challenge_msgs at once + while challenge_msgs and progress: + responses, auth_c, client_c = self.verify_identities(challenge_msgs) + progress = False - # Get Authorization - self.acme_authorization(challenge_msg, auth_c, client_c, responses) + i = 0 + while i < len(responses): + # Get Authorization + if responses[i] is not None: + print "client chall_msgs:", challenge_msgs[i] + print "client responses:", responses[i] + print "client auth_c:", auth_c[i] + print "client client_c:", client_c[i] + self.acme_authorization( + challenge_msgs[i], auth_c[i], client_c[i], responses[i]) + # Received authorization, remove challenge from list + # We have also cleaned up challenges... keep index + # in sync + del challenge_msgs[i] + del auth_c[i] + del client_c[i] + del responses[i] + progress = True + else: + i += 1 + + if not progress: + raise errors.LetsEncryptClientError( + "Unable to solve challenges for requested names.") # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -96,17 +136,15 @@ class Client(object): return cert_file, chain_file - def acme_challenge(self): + def acme_challenge(self, domain): """Handle ACME "challenge" phase. - .. todo:: Handle more than one domain name in self.names - :returns: ACME "challenge" message. :rtype: dict """ return self.network.send_and_receive_expected( - acme.challenge_request(self.names[0]), "challenge") + acme.challenge_request(domain), "challenge") def acme_authorization(self, challenge_msg, auth_c, client_c, responses): """Handle ACME "authorization" phase. @@ -254,55 +292,101 @@ class Client(object): # should cleanup client_c assert not client_c - def verify_identity(self, challenge_msg): - """Verify identity. + def verify_identities(self, challenge_msgs): + """Verify identities. - :param dict challenge_msg: ACME "challenge" message. + This is greatly complicated by the fact that the Authenticator can + oftentimes solve many challenges at once. The strategy is to give + the authenticator all of the appropriate challenges at once to + speed up the process. This creates indexing issues as the challenges + can come from many different messages and are not in an exact order + because of the optimal path decision. All of this complicated indexing + will be completely hidden from the authenticator and all the + authenticator must do is return a list of responses in the same order + the challenges were given. + + :param list challenge_msgs: List of ACME "challenge" messages. :returns: TODO - :rtype: dict + :rtype: TODO """ - path = challenge.gen_challenge_path( - challenge_msg["challenges"], challenge_msg.get("combinations", [])) + # Every msg's responses are a list within this list + responses = [] + # Every msg's desired path + paths = [] - logging.info("Performing the following challenges:") + auth_chall = [] + client_chall = [] - # Every indices element is a list of integers referring to which - # challenges in the master list the challenge object satisfies - # Single Challenge objects that can satisfy multiple server challenges - # mess up the order of the challenges, thus requiring the indices - auth_c, auth_i, client_c, client_i = self.challenge_factory( - self.names[0], challenge_msg["challenges"], path) + auth_idx = [] + client_idx = [] - responses = ["null"] * len(challenge_msg["challenges"]) + for i, msg in enumerate(challenge_msgs): + paths.append(challenge.gen_challenge_path( + msg["challenges"], + self.preferences, + msg.get("combinations", []))) + + logging.info("Performing the following challenges:") + + auth_c, auth_i, client_c, client_i = self.challenge_factory( + self.names[i], msg["challenges"], paths[-1]) + + auth_chall.append(auth_c) + auth_idx.append(auth_i) + client_chall.append(client_c) + client_idx.append(client_i) + + responses.append(["null"] * len(msg["challenges"])) # Do client centric challenges here... # Since this isn't implemented yet... + # Client challenge responses should be cached... + # The client should be able to solve all challenges the first time assert not client_i - auth_resp = self.auth.perform(auth_c) - self._assign_responses(auth_resp, auth_i, responses) + # Flatten list for authenticator + auth_resp = self.auth.perform( + [chall for sublist in auth_chall for chall in sublist]) + self._assign_responses(auth_resp, auth_idx, responses) + + print 'auth_resp:', auth_resp + print 'auth_idx:', auth_idx + print 'auth_responses:', responses + + for i in range(len(paths)): + # If challenges failed to complete... zero them out + if not self._path_satisfied(responses[i], paths[i]): + responses[i] = None + auth_chall[i] = None + client_chall[i] = None logging.info( "Configured Apache for challenges; waiting for verification...") - return responses, auth_c, client_c + return responses, auth_chall, client_chall # pylint: disable=no-self-use - def _assign_responses(self, resp, index_list, responses): + def _assign_responses(self, flat_resp, idx_list, responses): """Assign chall_response to appropriate places in response list. :param resp: responses from a challenge :type resp: list of dicts - :param list index_list: respective challenges resp satisfies + :param list idx_list: respective challenges flat_resp satisfies :param list responses: master list of responses """ - assert len(resp) == len(index_list) - for j, index in enumerate(index_list): - responses[index] = resp[j] + flat_index = 0 + # Every authorization_request message + for msg_num in range(len(responses)): + for idx in idx_list[msg_num]: + responses[msg_num][idx] = flat_resp[flat_index] + flat_index += 1 + def _path_satisfied(self, responses, path): + """Returns whether a path has been completely satisfied.""" + return all("null" != responses[i] for i in path) def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 7b72d9a46..5586960de 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,6 +11,13 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ + def get_chall_pref(): + """Return list of challenge preferences. + + :returns: list of strings with the most preferred challenges first. + :rtype: list + + """ def perform(chall_list): """Perform the given challenge. @@ -31,11 +38,7 @@ class IChallenge(zope.interface.Interface): """Let's Encrypt challenge.""" def perform(): - """Perform the challenge. - - :param bool quiet: TODO - - """ + """Perform the challenge.""" def generate_response(): """Generate response.""" diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index b1548b687..8ee9ae206 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -11,6 +11,9 @@ from letsencrypt.client import acme from letsencrypt.client import errors +logging.getLogger("requests").setLevel(logging.WARNING) + + class Network(object): """Class for communicating with ACME servers. diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index e1fd718a2..a1bb50004 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -1,5 +1,6 @@ -"""Test for letsencrypt.client.apache_configurator.""" +"""Test for letsencrypt.client.apache.configurator.""" import os +import pkg_resources import re import shutil import unittest @@ -7,6 +8,8 @@ import unittest import mock import zope.component +from letsencrypt.client import challenge_util +from letsencrypt.client import client from letsencrypt.client import display from letsencrypt.client import errors @@ -159,5 +162,37 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) + @mock.patch("letsencrypt.client.apache.dvsni") + def test_perform(self, mock_dvsni, mock_restart): + # Only tests functionality specific to configurator.perform + # Note: As more challenges are offered this will have to be expanded + rsa256_file = pkg_resources.resource_filename( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + chall1 = challenge_util.DVSNI_Chall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + chall2 = challenge_util.DVSNI_Chall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key) + + dvsni_ret_val = [ + {"type": "dvsni", "s": "randomS1"}, + {"type": "dvsni", "s": "randomS2"} + ] + + mock_dvsni().perform.return_value = dvsni_ret_val + responses = self.config.perform([chall1, chall2]) + + self.assertEqual(mock_dvsni.perform.call_count, 1) + self.assertEqual(responses, dvsni_ret_val) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/le_util_test.py b/letsencrypt/client/tests/le_util_test.py index 2432a6d65..f6c58ac0b 100644 --- a/letsencrypt/client/tests/le_util_test.py +++ b/letsencrypt/client/tests/le_util_test.py @@ -83,6 +83,9 @@ class UniqueFileTest(unittest.TestCase): self.root_path = tempfile.mkdtemp() self.default_name = os.path.join(self.root_path, 'foo.txt') + def tearDown(self): + shutil.rmtree(self.root_path, ignore_errors=True) + def _call(self, mode=0o600): from letsencrypt.client.le_util import unique_file return unique_file(self.default_name, mode) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 211525ed8..12db6e33d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -150,8 +150,7 @@ def choose_names(installer): code, names = zope.component.getUtility( interfaces.IDisplay).filter_names(get_all_names(installer)) if code == display.OK and names: - # TODO: Allow multiple names once it is setup - return [names[0]] + return names else: sys.exit(0) From 21b8e10560dba69bdf86367133cb295e10990c59 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 6 Jan 2015 02:15:24 -0800 Subject: [PATCH 50/66] Add dvsni documentation --- docs/api/client/apache.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api/client/apache.rst b/docs/api/client/apache.rst index dfa1edad6..e69826cf9 100644 --- a/docs/api/client/apache.rst +++ b/docs/api/client/apache.rst @@ -10,6 +10,12 @@ .. automodule:: letsencrypt.client.apache.configurator :members: +:mod:`letsencrypt.client.apache.dvsni` +============================================= + +.. automodule:: letsencrypt.client.apache.dvsni + :members: + :mod:`letsencrypt.client.apache.obj` ==================================== From 0bef6769ba8f88f6dba886937710adf0439a6f30 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 9 Jan 2015 05:30:15 -0800 Subject: [PATCH 51/66] cleanup challenge infrastructure --- letsencrypt/client/CONFIG.py | 17 +- letsencrypt/client/apache/configurator.py | 9 +- letsencrypt/client/apache/dvsni.py | 22 +- letsencrypt/client/apache/parser.py | 1 + letsencrypt/client/challenge.py | 1 + letsencrypt/client/challenge_util.py | 11 +- letsencrypt/client/client.py | 171 ++++++++--- letsencrypt/client/interfaces.py | 8 +- letsencrypt/client/recovery_token.py | 82 +++++ .../client/recovery_token_challenge.py | 37 --- letsencrypt/client/tests/acme_util.py | 112 +++++++ .../client/tests/apache_configurator_test.py | 17 +- letsencrypt/client/tests/apache_dvsni_test.py | 120 ++++++++ letsencrypt/client/tests/client_test.py | 282 ++++++++++++++++++ .../client/tests/recovery_token_test.py | 64 ++++ tox.ini | 2 +- 16 files changed, 837 insertions(+), 119 deletions(-) create mode 100644 letsencrypt/client/recovery_token.py delete mode 100644 letsencrypt/client/recovery_token_challenge.py create mode 100644 letsencrypt/client/tests/acme_util.py create mode 100644 letsencrypt/client/tests/apache_dvsni_test.py create mode 100644 letsencrypt/client/tests/client_test.py create mode 100644 letsencrypt/client/tests/recovery_token_test.py diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 6392911fb..9a850778c 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -30,9 +30,10 @@ IN_PROGRESS_DIR = os.path.join(BACKUP_DIR, "IN_PROGRESS/") """Directory used before a permanent checkpoint is finalized""" CERT_KEY_BACKUP = os.path.join(WORK_DIR, "keys-certs/") -"""Directory where all certificates/keys are stored. +"""Directory where all certificates/keys are stored. Used for easy revocation""" -Used for easy revocation""" +REV_TOKENS_DIR = os.path.join(WORK_DIR, "revocation_tokens/") +"""Directory where all revocation tokens are saved.""" KEY_DIR = os.path.join(SERVER_ROOT, "ssl/") """Where all keys should be stored""" @@ -56,15 +57,15 @@ CHAIN_PATH = CERT_DIR + "chain-letsencrypt.pem" INVALID_EXT = ".acme.invalid" """Invalid Extension""" -# Challenge Information -#CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] -"""Challenge Preferences Dict for currently supported challenges""" - EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] """Mutually Exclusive Challenges - only solve 1""" -CONFIG_CHALLENGES = frozenset(["dvsni", "simpleHttps"]) -"""These are challenges that must be solved by a Configurator object""" +AUTH_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) +"""These are challenges that must be solved by an Authenticator object""" + +CLIENT_CHALLENGES = frozenset( + ["recoveryToken", "recoveryContact", "proofOfPossession"]) +"""These are challenges that are handled by client.py""" # Challenge Constants S_SIZE = 32 diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 74ef227fc..229718e63 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -811,7 +811,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ enabled_dir = os.path.join(self.parser.root, "sites-enabled/") for entry in os.listdir(enabled_dir): - if os.path.realpath(enabled_dir + entry) == avail_fp: + if os.path.realpath(os.path.join(enabled_dir, entry)) == avail_fp: return True return False @@ -926,7 +926,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### # Challenges Section ########################################################################### - def get_chall_pref(self): # pylint: disable=no-self-use + # pylint: disable=no-self-use, unused-argument + def get_chall_pref(self, domain): """Return list of challenge preferences.""" return ["dvsni"] @@ -948,10 +949,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ self.chall_out += len(chall_list) responses = [None] * len(chall_list) - apache_dvsni = dvsni.ApacheDVSNI(self) + apache_dvsni = dvsni.ApacheDvsni(self) for i, chall in enumerate(chall_list): - if isinstance(chall, challenge_util.DVSNI_Chall): + if isinstance(chall, challenge_util.DvsniChall): apache_dvsni.add_chall(chall, i) sni_response = apache_dvsni.perform() diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index 37b0b7426..4f5549ffc 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -7,18 +7,19 @@ from letsencrypt.client import CONFIG from letsencrypt.client.apache import parser -class ApacheDVSNI(object): + +class ApacheDvsni(object): """Class performs DVSNI challenges within the Apache configurator. :ivar config: ApacheConfigurator object :type config: :class:`letsencrypt.client.apache.configurator` :ivar dvsni_chall: Data required for challenges. - where DVSNI_Chall tuples have the following fields + where DvsniChall tuples have the following fields `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) `key` (:class:`letsencrypt.client.client.Client.Key`) :type dvsni_chall: `list` of - :class:`letsencrypt.client.challenge_util.DVSNI_Chall` + :class:`letsencrypt.client.challenge_util.DvsniChall` """ def __init__(self, config): @@ -33,7 +34,7 @@ class ApacheDVSNI(object): """Add challenge to DVSNI object to perform at once. :param chall: DVSNI challenge info - :type chall: :class:`letsencrypt.client.challenge_util.DVSNI_Chall` + :type chall: :class:`letsencrypt.client.challenge_util.DvsniChall` :param int idx: index to challenge in a larger array @@ -91,19 +92,6 @@ class ApacheDVSNI(object): return responses - # def chall_complete(self, chall): - # """Used by Authenticator to notify the DVSNI challenge. - - # :param chall: Challenge info - # :type chall: :class:`letsencrypt.client.client.Client.DVSNI_Chall` - - # """ - # self.completed += 1 - # if self.completed < len(self.dvsni_chall): - # return False - # return True - - # TODO: Variable names def mod_config(self, ll_addrs): """Modifies Apache config files to include challenge vhosts. diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index d902bfe19..792257b5a 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -225,6 +225,7 @@ class ApacheParser(object): :rtype: str """ + # Checkout fnmatch.py in venv/local/lib/python2.7/fnmatch.py regex = "" for letter in clean_fn_match: if letter == '.': diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py index b0452c0ff..5abb78684 100644 --- a/letsencrypt/client/challenge.py +++ b/letsencrypt/client/challenge.py @@ -105,6 +105,7 @@ def _find_dumb_path(challenges, preferences): def is_preferred(offered_challenge_type, path): + """Return whether or not the challenge is preferred in path.""" for _, challenge_type in path: for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES: # Second part is in case we eventually allow multiple names diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 6eee4d3f9..fb7b8d267 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -8,8 +8,17 @@ from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import le_util +# Authenticator Challenges +DvsniChall = collections.namedtuple("DvsniChall", "domain, r_b64, nonce, key") +SimpleHttpsChall = collections.namedtuple( + "SimpleHttpsChall", "domain, token, key") +DnsChall = collections.namedtuple("DnsChall", "domain, token, key") -DVSNI_Chall = collections.namedtuple("DVSNI_Chall", "domain, r_b64, nonce, key") +# Client Challenges +RecContactChall = collections.namedtuple( + "RecContactChall", "domain, a_url, s_url, contact") +RecTokenChall = collections.namedtuple("RecTokenChall", "domain") +PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints") # DVSNI Challenge functions diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a9305f221..92da4540d 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -20,6 +20,7 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network +from letsencrypt.client import recovery_token # it's weird to point to chocolate servers via raw IPv6 addresses, and @@ -40,12 +41,15 @@ class Client(object): :type authkey: :class:`letsencrypt.client.client.Client.Key` :ivar auth: Object that supports the IAuthenticator interface. + `auth` is used specifically for CONFIG.AUTH_CHALLENGES :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` :ivar installer: Object supporting the IInstaller interface. :type installer: :class:`letsencrypt.client.interfaces.IInstraller` """ + zope.interface.implements(interfaces.IAuthenticator) + Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") @@ -60,14 +64,7 @@ class Client(object): self.auth = auth self.installer = installer - # Client challenges and Authenticator challenges should be separate - # and really should not be conflicting along the same path. - # I have chosen to make client challenges preferred - # as the client challenges should be able to be completely handled - # by this module and does not require outside config changes. - # (which may be costly) - self.preferences = ["recoveryToken"] - self.preferences.extend(auth.get_chall_pref()) + self.rec_token = recovery_token.RecoveryToken(server) def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, @@ -104,12 +101,9 @@ class Client(object): while i < len(responses): # Get Authorization if responses[i] is not None: - print "client chall_msgs:", challenge_msgs[i] - print "client responses:", responses[i] - print "client auth_c:", auth_c[i] - print "client client_c:", client_c[i] self.acme_authorization( - challenge_msgs[i], auth_c[i], client_c[i], responses[i]) + challenge_msgs[i], self.names[i], + auth_c[i], client_c[i], responses[i]) # Received authorization, remove challenge from list # We have also cleaned up challenges... keep index # in sync @@ -146,13 +140,15 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(domain), "challenge") - def acme_authorization(self, challenge_msg, auth_c, client_c, responses): + def acme_authorization( + self, challenge_msg, domain, auth_c, client_c, responses): """Handle ACME "authorization" phase. :param dict challenge_msg: ACME "challenge" message. - - :param chal_objs: TODO - this will be a new object... - :param responses: TODO + :param str domain: domain that is requesting authorization + :param list auth_c: auth challenges + :param list client_c: client challenges + :param list responses: Responses to all challenges in challenge_msg :returns: ACME "authorization" message. :rtype: dict @@ -161,7 +157,7 @@ class Client(object): try: return self.network.send_and_receive_expected( acme.authorization_request( - challenge_msg["sessionID"], self.names[0], + challenge_msg["sessionID"], domain, challenge_msg["nonce"], responses, self.authkey.pem), "authorization") except errors.LetsEncryptClientError as err: @@ -322,10 +318,20 @@ class Client(object): auth_idx = [] client_idx = [] + # Client challenges and Authenticator challenges should be separate + # and really should not be conflicting along the same path. + # I have chosen to make client challenges preferred + # as the client challenges should be able to be completely handled + # by this module and does not require outside config changes. + # (which may be costly) + for i, msg in enumerate(challenge_msgs): + prefs = self.get_chall_pref(self.names[i]) + prefs.extend(self.auth.get_chall_pref(self.names[i])) + paths.append(challenge.gen_challenge_path( msg["challenges"], - self.preferences, + prefs, msg.get("combinations", []))) logging.info("Performing the following challenges:") @@ -340,20 +346,16 @@ class Client(object): responses.append(["null"] * len(msg["challenges"])) - # Do client centric challenges here... - # Since this isn't implemented yet... - # Client challenge responses should be cached... - # The client should be able to solve all challenges the first time - assert not client_i - # Flatten list for authenticator + # Flatten list for client authenticator functions + client_resp = self.perform( + [chall for sublist in client_chall for chall in sublist]) + self._assign_responses(client_resp, client_idx, responses) + + # Flatten list for auth authenticator auth_resp = self.auth.perform( [chall for sublist in auth_chall for chall in sublist]) self._assign_responses(auth_resp, auth_idx, responses) - print 'auth_resp:', auth_resp - print 'auth_idx:', auth_idx - print 'auth_responses:', responses - for i in range(len(paths)): # If challenges failed to complete... zero them out if not self._path_satisfied(responses[i], paths[i]): @@ -476,9 +478,17 @@ class Client(object): :param list path: List of indices from `challenges`. - :returns: A pair of TODO + :returns: auth_chall, list of `collections.namedtuples` + auth_satisfies, list of indices, each associated auth_chall + satisfieswithin the challenge_msg + client_chall, list of `collections.namedtuples` + client_satisfies, list of indices each associated client_chall + satisfies within the challenge_msg :rtype: tuple + :raises errors.LetsEncryptClientError: If Challenge type is not + recognized + """ auth_chall = [] # Since a single invocation of SNI challenge can satisfy multiple @@ -487,29 +497,106 @@ class Client(object): client_chall = [] client_satisfies = [] + domain = str(domain) + for index in path: chall = challenges[index] - if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", domain) + # Authenticator Challenges + if chall["type"] in CONFIG.AUTH_CHALLENGES: + auth_chall.append(self._construct_auth_chall(chall, domain)) auth_satisfies.append(index) - auth_chall.append(challenge_util.DVSNI_Chall( - str(domain), str(chall["r"]), - str(chall["nonce"]), self.authkey)) - elif chall["type"] == "recoveryToken": - logging.info(" Recovery Token Challenge for name: %s.", domain) + # Client Challenges + elif chall["type"] in CONFIG.CLIENT_CHALLENGES: + client_chall.append(self._construct_client_chall(chall, domain)) client_satisfies.append(index) - client_chall.append({ - type: "recoveryToken", - }) else: - logging.fatal("Challenge not currently supported") - sys.exit(82) + raise errors.LetsEncryptClientError( + "Received unrecognized challenge of type: " + "%s" % chall["type"]) return auth_chall, auth_satisfies, client_chall, client_satisfies + def _construct_auth_chall(self, chall, domain): + """Construct Auth Type Challenges. + + :param dict chall: Single challenge + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "dvsni": + logging.info(" DVSNI challenge for name %s.", domain) + return challenge_util.DvsniChall( + domain, str(chall["r"]), str(chall["nonce"]), self.authkey) + + elif chall["type"] == "simpleHttps": + logging.info(" SimpleHTTPS challenge for name %s.", domain) + return challenge_util.SimpleHttpsChall( + domain, str(chall["token"]), self.authkey) + + elif chall["type"] == "dns": + logging.info(" DNS challenge for name %s.", domain) + return challenge_util.DnsChall( + domain, str(chall["token"]), self.authkey) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Auth Challenge: %s" % chall["type"]) + + def _construct_client_chall(self, chall, domain): + """Construct Client Type Challenges. + + :param dict chall: Single challenge + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "recoveryToken": + logging.info(" Recovery Token Challenge for name: %s.", domain) + return challenge_util.RecTokenChall(domain) + + elif chall["type"] == "recoveryContact": + logging.info(" Recovery Contact Challenge for name: %s.", domain) + return challenge_util.RecContactChall( + domain, + chall.get("activationURL", None), + chall.get("successURL", None), + chall.get("contact", None)) + + elif chall["type"] == "proofOfPossession": + logging.info(" Proof-of-Possession Challenge for name: " + "%s", domain) + return challenge_util.PopChall( + domain, chall["alg"], chall["nonce"], chall["hints"]) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Client Challenge: %s" % chall["type"]) + + # pylint: disable=unused-argument + def get_chall_pref(self, domain): + """Return list of challenge preferences.""" + return ["recoveryToken"] + + def perform(self, chall_list): + """Perform client specific challenges.""" + responses = [] + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + responses.append(self.rec_token.perform(chall)) + else: + raise errors.LetsEncryptClientError("Unexpected Challenge") + return responses + def validate_key_csr(privkey, csr): """Validate CSR and key files. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 5586960de..be3c6e09f 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -11,9 +11,11 @@ class IAuthenticator(zope.interface.Interface): ability to perform challenges and attain a certificate. """ - def get_chall_pref(): + def get_chall_pref(domain): """Return list of challenge preferences. - + + :param str domain: Domain for which challenge preferences are sought. + :returns: list of strings with the most preferred challenges first. :rtype: list @@ -22,7 +24,7 @@ class IAuthenticator(zope.interface.Interface): """Perform the given challenge. :param list chall_list: List of challenge types defined in client.py - + :returns: List of responses If the challenge cant be completed... None - Authenticator can perform challenge, but can't at this time diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py new file mode 100644 index 000000000..b6111aea1 --- /dev/null +++ b/letsencrypt/client/recovery_token.py @@ -0,0 +1,82 @@ +"""Recovery Token Identifier Validation Challenge.""" +import errno +import os + +import zope.component +# import zope.interface + +from letsencrypt.client import CONFIG +from letsencrypt.client import le_util +from letsencrypt.client import interfaces + + +class RecoveryToken(object): + """Recovery Token Identifier Validation Challenge. + + Based on draft-barnes-acme, section 6.4. + + """ + # zope.interface.implements(interfaces.IChallenge) + + def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR): + # super(RecoveryToken, self).__init__() + self.token_dir = os.path.join(direc, server) + + def perform(self, chall): + """Perform the Recovery Token Challenge. + + :param chall: Recovery Token Challenge + :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + + :returns: response + :rtype: dict + + """ + token_fp = os.path.join(self.token_dir, chall.domain) + if os.path.isfile(token_fp): + with open(token_fp) as token_fd: + return self.generate_response(token_fd.read()) + + cancel, token = zope.component.getUtility( + interfaces.IDisplay).generic_input( + "%s - Input Recovery Token: " % chall.domain) + if cancel != 1: + return self.generate_response(token) + + return None + + def cleanup(self, chall): + """Cleanup the saved recovery token if it exists. + + :param chall: Recovery Token Challenge + :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + + """ + try: + os.remove(os.path.join(self.token_dir, chall.domain)) + except OSError as err: + if err.errno != errno.ENOENT: + raise + + def generate_response(self, token): # pylint: disable=no-self-use + """Generate json response.""" + return { + "type": "recoveryToken", + "token": token, + } + + def requires_human(self, domain): + """Indicates whether or not domain can be auto solved.""" + return not os.path.isfile(os.path.join(self.token_dir, domain)) + + def store_token(self, domain, token): + """Store token for later automatic use. + + :param str domain: domain associated with the token + :param str token: token from authorization + + """ + le_util.make_or_verify_dir(self.token_dir, 0o700, os.geteuid()) + + with open(os.path.join(self.token_dir, domain), 'w') as token_fd: + token_fd.write(str(token)) diff --git a/letsencrypt/client/recovery_token_challenge.py b/letsencrypt/client/recovery_token_challenge.py deleted file mode 100644 index b10b24da2..000000000 --- a/letsencrypt/client/recovery_token_challenge.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Recovery Token Identifier Validation Challenge. - -.. note:: This challenge has not been implemented into the project yet - -""" -import zope.component -import zope.interface - -from letsencrypt.client import interfaces - - -class RecoveryToken(object): - """Recovery Token Identifier Validation Challenge. - - Based on draft-barnes-acme, section 6.4. - - """ - zope.interface.implements(interfaces.IChallenge) - - def __init__(self): - super(RecoveryToken, self).__init__() - self.token = "" - - def perform(self, quiet=True): - cancel, self.token = zope.component.getUtility( - interfaces.IDisplay).generic_input( - "Please Input Recovery Token: ") - return cancel != 1 - - def cleanup(self): - pass - - def generate_response(self): - return { - "type": "recoveryToken", - "token": self.token, - } diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py new file mode 100644 index 000000000..086733bd8 --- /dev/null +++ b/letsencrypt/client/tests/acme_util.py @@ -0,0 +1,112 @@ +"""Class helps construct valid ACME messages for testing.""" +from letsencrypt.client import CONFIG + + +CHALLENGES = { + "simpleHttps": + { + "type": "simpleHttps", + "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA" + }, + "dvsni": + { + "type": "dvsni", + "r": "Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI", + "nonce": "a82d5ff8ef740d12881f6d3c2277ab2e" + }, + "dns": + { + "type": "dns", + "token": "17817c66b60ce2e4012dfad92657527a" + }, + "recoveryContact": + { + "type": "recoveryContact", + "activationURL": "https://example.ca/sendrecovery/a5bd99383fb0", + "successURL": "https://example.ca/confirmrecovery/bb1b9928932", + "contact": "c********n@example.com" + }, + "recoveryTokent": + { + "type": "recoveryToken" + }, + "proofOfPossession": + { + "type": "proofOfPossession", + "alg": "RS256", + "nonce": "eET5udtV7aoX8Xl8gYiZIA", + "hints": { + "jwk": { + "kty": "RSA", + "e": "AQAB", + "n": "KxITJ0rNlfDMAtfDr8eAw...fSSoehDFNZKQKzTZPtQ" + }, + "certFingerprints": [ + "93416768eb85e33adc4277f4c9acd63e7418fcfe", + "16d95b7b63f1972b980b14c20291f3c0d1855d95", + "48b46570d9fc6358108af43ad1649484def0debf" + ], + "subjectKeyIdentifiers": + ["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"], + "serialNumbers": [34234239832, 23993939911, 17], + "issuers": [ + "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA", + "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure" + ], + "authorizedFor": ["www.example.com", "example.net"] + } + } +} + + +def get_auth_challenges(): + """Returns all auth challenges.""" + return [chall for typ, chall in CHALLENGES.iteritems() + if typ in CONFIG.AUTH_CHALLENGES] + + +def get_client_challenges(): + """Returns all client challenges.""" + return [chall for typ, chall in CHALLENGES.iteritems() + if typ in CONFIG.CLIENT_CHALLENGES] + + +def get_challenges(): + """Returns all challenges.""" + return [chall for chall in CHALLENGES.itervalues()] + + +def gen_combos(challs): + """Generate natural combinations for challs.""" + dv_chall = [] + renewal_chall = [] + combos = [] + + for i, chall in enumerate(challs): + if chall["type"] in CONFIG.AUTH_CHALLENGES: + dv_chall.append(i) + else: + renewal_chall.append(i) + + # Gen combos for 1 of each type + for i in range(len(dv_chall)): + for j in range(len(renewal_chall)): + combos.append([i, j]) + + return combos + + +def get_chall_msg(iden, nonce, challenges, combos=None): + """Produce an ACME challenge message.""" + chall_msg = { + "type": "challenge", + "sessionID": iden, + "nonce": nonce, + "challenges": challenges + } + + if combos is None: + return chall_msg + + chall_msg["combinations"] = combos + return chall_msg diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index a1bb50004..5426409b5 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -162,8 +162,11 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) - @mock.patch("letsencrypt.client.apache.dvsni") - def test_perform(self, mock_dvsni, mock_restart): + @mock.patch("letsencrypt.client.apache.configurator." + "dvsni.ApacheDvsni.perform") + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + def test_perform(self, mock_restart, mock_dvsni_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded rsa256_file = pkg_resources.resource_filename( @@ -172,12 +175,12 @@ class TwoVhost80Test(unittest.TestCase): __name__, 'testdata/rsa256_key.pem') auth_key = client.Client.Key(rsa256_file, rsa256_pem) - chall1 = challenge_util.DVSNI_Chall( + chall1 = challenge_util.DvsniChall( "encryption-example.demo", "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", "37bc5eb75d3e00a19b4f6355845e5a18", auth_key) - chall2 = challenge_util.DVSNI_Chall( + chall2 = challenge_util.DvsniChall( "letsencrypt.demo", "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", "59ed014cac95f77057b1d7a1b2c596ba", @@ -188,11 +191,13 @@ class TwoVhost80Test(unittest.TestCase): {"type": "dvsni", "s": "randomS2"} ] - mock_dvsni().perform.return_value = dvsni_ret_val + mock_dvsni_perform.return_value = dvsni_ret_val responses = self.config.perform([chall1, chall2]) - self.assertEqual(mock_dvsni.perform.call_count, 1) + self.assertEqual(mock_dvsni_perform.call_count, 1) self.assertEqual(responses, dvsni_ret_val) + self.assertEqual(mock_restart.call_count, 1) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/apache_dvsni_test.py b/letsencrypt/client/tests/apache_dvsni_test.py new file mode 100644 index 000000000..b50997541 --- /dev/null +++ b/letsencrypt/client/tests/apache_dvsni_test.py @@ -0,0 +1,120 @@ +"""Test for letsencrypt.client.apache.dvsni.""" +import os +import pkg_resources +import unittest +import shutil + +import mock +import zope.component + +from letsencrypt.client import challenge_util +from letsencrypt.client import client +from letsencrypt.client import CONFIG +from letsencrypt.client import display + +from letsencrypt.client.apache import obj + +from letsencrypt.client.tests import config_util + + +class DvsniPerformTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.client.apache import dvsni + zope.component.provideUtility(display.NcursesDisplay()) + + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") + + self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir) + + # Final slash is currently important + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") + + config = config_util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, self.ssl_options) + + self.sni = dvsni.ApacheDvsni(config) + + rsa256_file = pkg_resources.resource_filename( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + self.chall1 = challenge_util.DvsniChall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + self.chall2 = challenge_util.DvsniChall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key) + + self.sni.add_chall(self.chall1) + self.sni.add_chall(self.chall2) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") + def test_perform(self, mock_dvsni_gen_cert, mock_restart): + mock_dvsni_gen_cert.side_effect = ["randomS1", "randomS2"] + responses = self.sni.perform() + + self.assertEqual(mock_dvsni_gen_cert.call_count, 2) + calls = mock_dvsni_gen_cert.call_args_list + expected_call_list = [ + (self.sni.get_cert_file(self.chall1.nonce), self.chall1.domain, + self.chall1.r_b64, self.chall1.nonce, self.chall1.key), + (self.sni.get_cert_file(self.chall2.nonce), self.chall2.domain, + self.chall2.r_b64, self.chall2.nonce, self.chall2.key) + ] + + for i in range(len(expected_call_list)): + for j in range(len(expected_call_list[0])): + self.assertEqual(calls[i][0][j], expected_call_list[i][j]) + + self.assertEqual( + len(self.sni.config.parser.find_dir( + "Include", self.sni.challenge_conf)), + 1) + self.assertEqual(len(responses), 2) + self.assertEqual(responses[0]["s"], "randomS1") + self.assertEqual(responses[1]["s"], "randomS2") + + def test_mod_config(self): + v_addr1 = [obj.Addr(("1.2.3.4", "443")), obj.Addr(("5.6.7.8", "443"))] + v_addr2 = [obj.Addr(("127.0.0.1", "443"))] + ll_addr = [] + ll_addr.append(v_addr1) + ll_addr.append(v_addr2) + self.sni.mod_config(ll_addr) + self.sni.config.save() + + self.sni.config.parser.find_dir("Include", self.sni.challenge_conf) + vh_match = self.sni.config.aug.match( + "/files" + self.sni.challenge_conf + "//VirtualHost") + + vhs = [] + for match in vh_match: + # pylint: disable=protected-access + vhs.append(self.sni.config._create_vhost(match)) + self.assertEqual(len(vhs), 2) + for vhost in vhs: + if vhost.addrs == set(v_addr1): + self.assertEqual( + vhost.names, + set([str(self.chall1.nonce + CONFIG.INVALID_EXT)])) + else: + self.assertEqual(vhost.addrs, set(v_addr2)) + self.assertEqual( + vhost.names, + set([str(self.chall2.nonce + CONFIG.INVALID_EXT)])) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py new file mode 100644 index 000000000..e22a95c64 --- /dev/null +++ b/letsencrypt/client/tests/client_test.py @@ -0,0 +1,282 @@ +"""Test client.py.""" +import unittest +import mock +import pkg_resources + +from letsencrypt.client.tests import acme_util + + +class VerifyIdentityTest(unittest.TestCase): + """verify_identities test.""" + def setUp(self): + from letsencrypt.client.client import Client + from letsencrypt.client import CONFIG + + rsa256_file = pkg_resources.resource_filename( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = Client.Key(rsa256_file, rsa256_pem) + + self.mock_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_auth.get_chall_pref.return_value = ["dvsni"] + self.mock_auth.perform.side_effect = gen_auth_resp + + self.client = Client( + CONFIG.ACME_SERVER, ["0", "1", "2", "3", "4"], + auth_key, self.mock_auth, None) + self.client.perform = mock.MagicMock( + name='perform', side_effect=gen_auth_resp) + + def test_name1_dvsni1(self): + self.client.names = ["0"] + challenge = [acme_util.CHALLENGES["dvsni"]] + msgs = [acme_util.get_chall_msg("0", "nonce0", challenge)] + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 1) + self.assertEqual(len(responses[0]), 1) + + self.assertEqual("DvsniChall0", responses[0][0]) + self.assertEqual(len(auth_c), 1) + self.assertEqual(len(client_c), 1) + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 0) + + def test_name5_dvsni5(self): + challenge = [acme_util.CHALLENGES["dvsni"]] + msgs = [] + for i in range(5): + msgs.append( + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge)) + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 5) + self.assertEqual(len(auth_c), 5) + self.assertEqual(len(client_c), 5) + # Each message contains 1 auth, 0 client + for i in range(5): + self.assertEqual(len(responses[i]), 1) + self.assertEqual(responses[i][0], "DvsniChall%d" % i) + self.assertEqual(len(auth_c[i]), 1) + self.assertEqual(len(client_c[i]), 0) + self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name1_auth(self, mock_chall_path): + self.client.names = ["0"] + + challenges = acme_util.get_auth_challenges() + combos = acme_util.gen_combos(challenges) + msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] + + path = gen_path(["simpleHttps"], challenges) + mock_chall_path.return_value = path + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 1) + self.assertEqual(len(responses[0]), len(challenges)) + self.assertEqual(len(auth_c), 1) + self.assertEqual(len(client_c), 1) + + self.assertEqual( + responses[0], + self._get_exp_response("0", path, challenges)) + + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 0) + self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name1_all(self, mock_chall_path): + self.client.names = ["0"] + + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] + + path = gen_path(["simpleHttps", "recoveryToken"], challenges) + mock_chall_path.return_value = path + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 1) + self.assertEqual(len(responses[0]), len(challenges)) + self.assertEqual(len(auth_c), 1) + self.assertEqual(len(client_c), 1) + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 1) + + self.assertEqual( + responses[0], + self._get_exp_response("0", path, challenges)) + self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") + self.assertEqual(type(client_c[0][0]).__name__, "RecTokenChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name5_all(self, mock_chall_path): + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + msgs = [] + for i in range(5): + msgs.append( + acme_util.get_chall_msg( + str(i), "nonce%d" % i, challenges, combos)) + + path = gen_path(["dvsni", "recoveryContact"], challenges) + mock_chall_path.return_value = path + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 5) + for i in range(5): + self.assertEqual(len(responses[i]), len(challenges)) + self.assertEqual(len(auth_c), 5) + self.assertEqual(len(client_c), 5) + + for i in range(5): + self.assertEqual( + responses[i], self._get_exp_response(i, path, challenges)) + self.assertEqual(len(auth_c[0]), 1) + self.assertEqual(len(client_c[0]), 1) + + self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") + self.assertEqual(type(client_c[i][0]).__name__, "RecContactChall") + + @mock.patch("letsencrypt.client.client." + "challenge.gen_challenge_path") + def test_name5_mix(self, mock_chall_path): + paths = [] + msgs = [] + chosen_chall = [["dns"], + ["dvsni"], + ["simpleHttps", "proofOfPossession"], + ["simpleHttps"], + ["dns", "recoveryToken"]] + challenge_list = [acme_util.get_auth_challenges(), + [acme_util.CHALLENGES["dvsni"]], + acme_util.get_challenges(), + acme_util.get_auth_challenges(), + acme_util.get_challenges()] + + # Combos doesn't matter since I am overriding the gen_path function + for i in range(5): + paths.append(gen_path(chosen_chall[i], challenge_list[i])) + msgs.append( + acme_util.get_chall_msg( + str(i), "nonce%d" % i, challenge_list[i])) + + mock_chall_path.side_effect = paths + + responses, auth_c, client_c = self.client.verify_identities(msgs) + + self.assertEqual(len(responses), 5) + self.assertEqual(len(auth_c), 5) + self.assertEqual(len(client_c), 5) + + for i in range(5): + resp = self._get_exp_response(i, paths[i], challenge_list[i]) + self.assertEqual(responses[i], resp) + self.assertEqual(len(auth_c[i]), 1) + self.assertEqual(len(client_c[i]), len(chosen_chall[i]) - 1) + + self.assertEqual(type(auth_c[0][0]).__name__, "DnsChall") + self.assertEqual(type(auth_c[1][0]).__name__, "DvsniChall") + self.assertEqual(type(auth_c[2][0]).__name__, "SimpleHttpsChall") + self.assertEqual(type(auth_c[3][0]).__name__, "SimpleHttpsChall") + self.assertEqual(type(auth_c[4][0]).__name__, "DnsChall") + + self.assertEqual(type(client_c[2][0]).__name__, "PopChall") + self.assertEqual(type(client_c[4][0]).__name__, "RecTokenChall") + + def _get_exp_response(self, domain, path, challenges): + exp_resp = ["null"] * len(challenges) + for i in path: + exp_resp[i] = translate[challenges[i]["type"]] + str(domain) + + return exp_resp + + +class ClientPerformTest(unittest.TestCase): + """Test client perform function.""" + def setUp(self): + from letsencrypt.client.client import Client + from letsencrypt.client import CONFIG + + rsa256_file = pkg_resources.resource_filename( + __name__, 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + + auth_key = Client.Key(rsa256_file, rsa256_pem) + + self.client = Client( + CONFIG.ACME_SERVER, ["example.com"], auth_key, None, None) + self.client.rec_token.perform = mock.MagicMock( + name="rec_token_perform", side_effect=gen_client_resp) + + def test_rec_token1(self): + from letsencrypt.client.challenge_util import RecTokenChall + token = RecTokenChall("0") + + responses = self.client.perform([token]) + + self.assertEqual(responses, ["RecTokenChall0"]) + + def test_rec_token5(self): + from letsencrypt.client.challenge_util import RecTokenChall + tokens = [] + for i in range(5): + tokens.append(RecTokenChall(str(i))) + + responses = self.client.perform(tokens) + + self.assertEqual(len(responses), 5) + for i in range(5): + self.assertEqual(responses[i], "RecTokenChall%d" % i) + + def test_unexpected(self): + from letsencrypt.client.challenge_util import DvsniChall + from letsencrypt.client.errors import LetsEncryptClientError + unexpected = DvsniChall("0", "rb64", "123", "invalid_key") + + self.assertRaises( + LetsEncryptClientError, self.client.perform, [unexpected]) + + +translate = {"dvsni": "DvsniChall", + "simpleHttps": "SimpleHttpsChall", + "dns": "DnsChall", + "recoveryToken": "RecTokenChall", + "recoveryContact": "RecContactChall", + "proofOfPossession": "PopChall"} + + +def gen_auth_resp(chall_list): + return ["%s%s" % (type(chall).__name__, chall.domain) + for chall in chall_list] + + +def gen_client_resp(chall): + return "%s%s" % (type(chall).__name__, chall.domain) + + +def gen_path(str_list, challenges): + path = [] + for i, chall in enumerate(challenges): + for str_chall in str_list: + if chall["type"] == str_chall: + path.append(i) + continue + return path + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py new file mode 100644 index 000000000..240f60cf7 --- /dev/null +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -0,0 +1,64 @@ +"""Tests for recovery_token.py.""" +import os +import unittest +import shutil +import tempfile + +import mock + + +class RecoveryTokenTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.recovery_token import RecoveryToken + server = "demo_server" + self.base_dir = tempfile.mkdtemp("tokens") + self.token_dir = os.path.join(self.base_dir, server) + self.rec_token = RecoveryToken(server, self.base_dir) + + def tearDown(self): + shutil.rmtree(self.base_dir) + + def test_store_token(self): + self.rec_token.store_token("example.com", 111) + path = os.path.join(self.token_dir, "example.com") + self.assertTrue(os.path.isfile(path)) + with open(path) as token_fd: + self.assertEqual(token_fd.read(), "111") + + def test_requires_human(self): + self.rec_token.store_token("example2.com", 222) + self.assertFalse(self.rec_token.requires_human("example2.com")) + self.assertTrue(self.rec_token.requires_human("example3.com")) + + def test_cleanup(self): + from letsencrypt.client.challenge_util import RecTokenChall + self.rec_token.store_token("example3.com", 333) + self.assertFalse(self.rec_token.requires_human("example3.com")) + + self.rec_token.cleanup(RecTokenChall("example3.com")) + self.assertTrue(self.rec_token.requires_human("example3.com")) + + # Shouldn't throw an error + self.rec_token.cleanup(RecTokenChall("example4.com")) + + def test_perform_stored(self): + from letsencrypt.client.challenge_util import RecTokenChall + self.rec_token.store_token("example4.com", 444) + response = self.rec_token.perform(RecTokenChall("example4.com")) + + self.assertEqual(response, {"type": "recoveryToken", "token": "444"}) + + @mock.patch("letsencrypt.client.recovery_token.zope.component.getUtility") + def test_perform_not_stored(self, mock_input): + from letsencrypt.client.challenge_util import RecTokenChall + + mock_input().generic_input.side_effect = [(0, "555"), (1, "000")] + response = self.rec_token.perform(RecTokenChall("example5.com")) + self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) + + response = self.rec_token.perform(RecTokenChall("example6.com")) + self.assertEqual(response, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini index 4ebe69305..dc6b05e31 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ commands = [testenv:cover] commands = python setup.py dev - python setup.py nosetests --with-coverage --cover-min-percentage=47 + python setup.py nosetests --with-coverage --cover-min-percentage=60 [testenv:lint] commands = From ca7628caae3110a945850bce195e3c4f98c613f7 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 9 Jan 2015 22:25:36 -0800 Subject: [PATCH 52/66] Small changes/renaming --- letsencrypt/client/apache/configurator.py | 2 +- letsencrypt/client/apache/dvsni.py | 24 +++-- letsencrypt/client/tests/apache/__init__.py | 1 + .../client/tests/{ => apache}/config_util.py | 2 +- .../configurator_test.py} | 9 +- .../dvsni_test.py} | 88 +++++++++++++------ .../obj_test.py} | 28 +++--- .../parser_test.py} | 2 +- .../client/tests/recovery_token_test.py | 2 +- tox.ini | 2 +- 10 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 letsencrypt/client/tests/apache/__init__.py rename letsencrypt/client/tests/{ => apache}/config_util.py (98%) rename letsencrypt/client/tests/{apache_configurator_test.py => apache/configurator_test.py} (96%) rename letsencrypt/client/tests/{apache_dvsni_test.py => apache/dvsni_test.py} (55%) rename letsencrypt/client/tests/{apache_obj_test.py => apache/obj_test.py} (67%) rename letsencrypt/client/tests/{apache_parser_test.py => apache/parser_test.py} (98%) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 229718e63..488730dbf 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -801,7 +801,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def is_site_enabled(self, avail_fp): """Checks to see if the given site is enabled. - .. todo:: fix hardcoded sites-enabled + .. todo:: fix hardcoded sites-enabled, check os.path.samefile :param str avail_fp: Complete file path of available site diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index 4f5549ffc..a9d08da27 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -21,6 +21,16 @@ class ApacheDvsni(object): :type dvsni_chall: `list` of :class:`letsencrypt.client.challenge_util.DvsniChall` + :param list indicies: Meant to hold indices of challenges in a + larger array. ApacheDvsni is capable of solving many challenges + at once which causes an indexing issue within ApacheConfigurator + who must return all responses in order. Imagine ApacheConfigurator + maintaining state about where all of the SimpleHttps Challenges, + Dvsni Challenges belong in the response array. This is an optional + utility. + + :param str challenge_conf: location of the challenge config file + """ def __init__(self, config): self.config = config @@ -46,7 +56,7 @@ class ApacheDvsni(object): def perform(self): """Peform a DVSNI challenge.""" if not self.dvsni_chall: - return dict() + return None # Save any changes to the configuration as a precaution # About to make temporary changes to the config self.config.save() @@ -85,14 +95,14 @@ class ApacheDvsni(object): responses.append({"type": "dvsni", "s": s_b64}) # Setup the configuration - self.mod_config(addresses) + self._mod_config(addresses) # Save reversible changes self.config.save("SNI Challenge", True) return responses - def mod_config(self, ll_addrs): + def _mod_config(self, ll_addrs): """Modifies Apache config files to include challenge vhosts. Result: Apache config includes virtual servers for issued challs @@ -116,18 +126,18 @@ class ApacheDvsni(object): # TODO: Use ip address of existing vhost instead of relying on FQDN config_text = "\n" for idx, lis in enumerate(ll_addrs): - config_text += self.get_config_text( + config_text += self._get_config_text( self.dvsni_chall[idx].nonce, lis, self.dvsni_chall[idx].key.file) config_text += "\n" - self.conf_include_check(self.config.parser.loc["default"]) + self._conf_include_check(self.config.parser.loc["default"]) self.config.register_file_creation(True, self.challenge_conf) with open(self.challenge_conf, 'w') as new_conf: new_conf.write(config_text) - def conf_include_check(self, main_config): + def _conf_include_check(self, main_config): """Adds DVSNI challenge conf file into configuration. Adds DVSNI challenge include file if it does not already exist @@ -142,7 +152,7 @@ class ApacheDvsni(object): self.config.parser.add_dir(parser.get_aug_path(main_config), "Include", self.challenge_conf) - def get_config_text(self, nonce, ip_addrs, dvsni_key_file): + def _get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text :param str nonce: hex form of nonce diff --git a/letsencrypt/client/tests/apache/__init__.py b/letsencrypt/client/tests/apache/__init__.py new file mode 100644 index 000000000..2c0849a3d --- /dev/null +++ b/letsencrypt/client/tests/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Apache Tests""" diff --git a/letsencrypt/client/tests/config_util.py b/letsencrypt/client/tests/apache/config_util.py similarity index 98% rename from letsencrypt/client/tests/config_util.py rename to letsencrypt/client/tests/apache/config_util.py index 691a394f4..ad38818ab 100644 --- a/letsencrypt/client/tests/config_util.py +++ b/letsencrypt/client/tests/apache/config_util.py @@ -17,7 +17,7 @@ def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): work_dir = tempfile.mkdtemp("work") test_configs = pkg_resources.resource_filename( - __name__, "testdata/%s" % test_dir) + "letsencrypt.client.tests", "testdata/%s" % test_dir) shutil.copytree( test_configs, os.path.join(temp_dir, test_dir), symlinks=True) diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py similarity index 96% rename from letsencrypt/client/tests/apache_configurator_test.py rename to letsencrypt/client/tests/apache/configurator_test.py index 5426409b5..00560c970 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -10,21 +10,20 @@ import zope.component from letsencrypt.client import challenge_util from letsencrypt.client import client -from letsencrypt.client import display from letsencrypt.client import errors from letsencrypt.client.apache import configurator from letsencrypt.client.apache import obj from letsencrypt.client.apache import parser -from letsencrypt.client.tests import config_util +from letsencrypt.client.tests.apache import config_util class TwoVhost80Test(unittest.TestCase): """Test two standard well configured HTTP vhosts.""" def setUp(self): - zope.component.provideUtility(display.NcursesDisplay()) + #zope.component.provideUtility(display.NcursesDisplay()) self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( "debian_apache_2_4/two_vhost_80") @@ -170,9 +169,9 @@ class TwoVhost80Test(unittest.TestCase): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded rsa256_file = pkg_resources.resource_filename( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') auth_key = client.Client.Key(rsa256_file, rsa256_pem) chall1 = challenge_util.DvsniChall( diff --git a/letsencrypt/client/tests/apache_dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py similarity index 55% rename from letsencrypt/client/tests/apache_dvsni_test.py rename to letsencrypt/client/tests/apache/dvsni_test.py index b50997541..6beb07b37 100644 --- a/letsencrypt/client/tests/apache_dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -10,18 +10,15 @@ import zope.component from letsencrypt.client import challenge_util from letsencrypt.client import client from letsencrypt.client import CONFIG -from letsencrypt.client import display -from letsencrypt.client.apache import obj - -from letsencrypt.client.tests import config_util +from letsencrypt.client.tests.apache import config_util class DvsniPerformTest(unittest.TestCase): def setUp(self): from letsencrypt.client.apache import dvsni - zope.component.provideUtility(display.NcursesDisplay()) + #zope.component.provideUtility(display.NcursesDisplay()) self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( "debian_apache_2_4/two_vhost_80") @@ -38,44 +35,46 @@ class DvsniPerformTest(unittest.TestCase): self.sni = dvsni.ApacheDvsni(config) rsa256_file = pkg_resources.resource_filename( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') auth_key = client.Client.Key(rsa256_file, rsa256_pem) - self.chall1 = challenge_util.DvsniChall( + self.challs = [] + self.challs.append(challenge_util.DvsniChall( "encryption-example.demo", "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", "37bc5eb75d3e00a19b4f6355845e5a18", - auth_key) - self.chall2 = challenge_util.DvsniChall( + auth_key)) + self.challs.append(challenge_util.DvsniChall( "letsencrypt.demo", "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", "59ed014cac95f77057b1d7a1b2c596ba", - auth_key) - - self.sni.add_chall(self.chall1) - self.sni.add_chall(self.chall2) + auth_key)) def tearDown(self): shutil.rmtree(self.temp_dir) shutil.rmtree(self.config_dir) shutil.rmtree(self.work_dir) + def test_perform0(self): + resp = self.sni.perform() + self.assertIs(resp, None) + @mock.patch("letsencrypt.client.apache.configurator." "ApacheConfigurator.restart") @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") - def test_perform(self, mock_dvsni_gen_cert, mock_restart): - mock_dvsni_gen_cert.side_effect = ["randomS1", "randomS2"] + def test_perform1(self, mock_dvsni_gen_cert, mock_restart): + chall = self.challs[0] + self.sni.add_chall(chall) + mock_dvsni_gen_cert.return_value = "randomS1" responses = self.sni.perform() - self.assertEqual(mock_dvsni_gen_cert.call_count, 2) + self.assertEqual(mock_dvsni_gen_cert.call_count, 1) calls = mock_dvsni_gen_cert.call_args_list expected_call_list = [ - (self.sni.get_cert_file(self.chall1.nonce), self.chall1.domain, - self.chall1.r_b64, self.chall1.nonce, self.chall1.key), - (self.sni.get_cert_file(self.chall2.nonce), self.chall2.domain, - self.chall2.r_b64, self.chall2.nonce, self.chall2.key) + (self.sni.get_cert_file(chall.nonce), chall.domain, + chall.r_b64, chall.nonce, chall.key) ] for i in range(len(expected_call_list)): @@ -86,17 +85,50 @@ class DvsniPerformTest(unittest.TestCase): len(self.sni.config.parser.find_dir( "Include", self.sni.challenge_conf)), 1) - self.assertEqual(len(responses), 2) + self.assertEqual(len(responses), 1) self.assertEqual(responses[0]["s"], "randomS1") - self.assertEqual(responses[1]["s"], "randomS2") + + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") + def test_perform2(self, mock_dvsni_gen_cert, mock_restart): + for chall in self.challs: + self.sni.add_chall(chall) + + mock_dvsni_gen_cert.side_effect = ["randomS0", "randomS1"] + responses = self.sni.perform() + + self.assertEqual(mock_dvsni_gen_cert.call_count, 2) + calls = mock_dvsni_gen_cert.call_args_list + expected_call_list = [] + + for chall in self.challs: + expected_call_list.append( + (self.sni.get_cert_file(chall.nonce), chall.domain, + chall.r_b64, chall.nonce, chall.key)) + + for i in range(len(expected_call_list)): + for j in range(len(expected_call_list[0])): + self.assertEqual(calls[i][0][j], expected_call_list[i][j]) + + self.assertEqual( + len(self.sni.config.parser.find_dir( + "Include", self.sni.challenge_conf)), + 1) + self.assertEqual(len(responses), 2) + for i in range(2): + self.assertEqual(responses[i]["s"], "randomS%d" % i) def test_mod_config(self): - v_addr1 = [obj.Addr(("1.2.3.4", "443")), obj.Addr(("5.6.7.8", "443"))] - v_addr2 = [obj.Addr(("127.0.0.1", "443"))] + from letsencrypt.client.apache.obj import Addr + for chall in self.challs: + self.sni.add_chall(chall) + v_addr1 = [Addr(("1.2.3.4", "443")), Addr(("5.6.7.8", "443"))] + v_addr2 = [Addr(("127.0.0.1", "443"))] ll_addr = [] ll_addr.append(v_addr1) ll_addr.append(v_addr2) - self.sni.mod_config(ll_addr) + self.sni._mod_config(ll_addr) # pylint: disable=protected-access self.sni.config.save() self.sni.config.parser.find_dir("Include", self.sni.challenge_conf) @@ -112,9 +144,9 @@ class DvsniPerformTest(unittest.TestCase): if vhost.addrs == set(v_addr1): self.assertEqual( vhost.names, - set([str(self.chall1.nonce + CONFIG.INVALID_EXT)])) + set([str(self.challs[0].nonce + CONFIG.INVALID_EXT)])) else: self.assertEqual(vhost.addrs, set(v_addr2)) self.assertEqual( vhost.names, - set([str(self.chall2.nonce + CONFIG.INVALID_EXT)])) + set([str(self.challs[1].nonce + CONFIG.INVALID_EXT)])) diff --git a/letsencrypt/client/tests/apache_obj_test.py b/letsencrypt/client/tests/apache/obj_test.py similarity index 67% rename from letsencrypt/client/tests/apache_obj_test.py rename to letsencrypt/client/tests/apache/obj_test.py index 46e76d4bd..151880ac4 100644 --- a/letsencrypt/client/tests/apache_obj_test.py +++ b/letsencrypt/client/tests/apache/obj_test.py @@ -1,14 +1,13 @@ +"""Test the helper objects in apache.obj.py.""" import unittest -from letsencrypt.client.apache import obj - - class AddrTest(unittest.TestCase): """Test the Addr class.""" def setUp(self): - self.addr1 = obj.Addr.fromstring("192.168.1.1") - self.addr2 = obj.Addr.fromstring("192.168.1.1:*") - self.addr3 = obj.Addr.fromstring("192.168.1.1:80") + from letsencrypt.client.apache.obj import Addr + self.addr1 = Addr.fromstring("192.168.1.1") + self.addr2 = Addr.fromstring("192.168.1.1:*") + self.addr3 = Addr.fromstring("192.168.1.1:80") def test_fromstring(self): self.assertEqual(self.addr1.get_addr(), "192.168.1.1") @@ -36,9 +35,10 @@ class AddrTest(unittest.TestCase): self.assertNotEqual(self.addr1, 3333) def test_set_inclusion(self): + from letsencrypt.client.apache.obj import Addr set_a = set([self.addr1, self.addr2]) - addr1b = obj.Addr.fromstring("192.168.1.1") - addr2b = obj.Addr.fromstring("192.168.1.1:*") + addr1b = Addr.fromstring("192.168.1.1") + addr2b = Addr.fromstring("192.168.1.1:*") set_b = set([addr1b, addr2b]) self.assertEqual(set_a, set_b) @@ -47,14 +47,18 @@ class AddrTest(unittest.TestCase): class VirtualHostTest(unittest.TestCase): """Test the VirtualHost class.""" def setUp(self): - self.vhost1 = obj.VirtualHost( + from letsencrypt.client.apache.obj import VirtualHost + from letsencrypt.client.apache.obj import Addr + self.vhost1 = VirtualHost( "filep", "vh_path", - set([obj.Addr.fromstring("localhost")]), False, False) + set([Addr.fromstring("localhost")]), False, False) def test_eq(self): - vhost1b = obj.VirtualHost( + from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.apache.obj import VirtualHost + vhost1b = VirtualHost( "filep", "vh_path", - set([obj.Addr.fromstring("localhost")]), False, False) + set([Addr.fromstring("localhost")]), False, False) self.assertEqual(vhost1b, self.vhost1) self.assertEqual(str(vhost1b), str(self.vhost1)) diff --git a/letsencrypt/client/tests/apache_parser_test.py b/letsencrypt/client/tests/apache/parser_test.py similarity index 98% rename from letsencrypt/client/tests/apache_parser_test.py rename to letsencrypt/client/tests/apache/parser_test.py index 340cdd324..b7f1f6aa2 100644 --- a/letsencrypt/client/tests/apache_parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -10,7 +10,7 @@ import zope.component from letsencrypt.client import display from letsencrypt.client import errors from letsencrypt.client.apache import parser -from letsencrypt.client.tests import config_util +from letsencrypt.client.tests.apache import config_util class ApacheParserTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 240f60cf7..945c2b0b9 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -57,7 +57,7 @@ class RecoveryTokenTest(unittest.TestCase): self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) response = self.rec_token.perform(RecTokenChall("example6.com")) - self.assertEqual(response, None) + self.assertIs(response, None) if __name__ == '__main__': diff --git a/tox.ini b/tox.ini index dc6b05e31..f636b5567 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ commands = [testenv:cover] commands = python setup.py dev - python setup.py nosetests --with-coverage --cover-min-percentage=60 + python setup.py nosetests --with-coverage --cover-min-percentage=59 [testenv:lint] commands = From 8f062ddc54456bd0a84de47ee3a3697bd0028da9 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 9 Jan 2015 22:29:46 -0800 Subject: [PATCH 53/66] update documentation for recovery_token --- docs/api/client/recovery_token.rst | 5 +++++ docs/api/client/recovery_token_challenge.rst | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 docs/api/client/recovery_token.rst delete mode 100644 docs/api/client/recovery_token_challenge.rst diff --git a/docs/api/client/recovery_token.rst b/docs/api/client/recovery_token.rst new file mode 100644 index 000000000..cc37e036d --- /dev/null +++ b/docs/api/client/recovery_token.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.recovery_token` +-------------------------------------------------- + +.. automodule:: letsencrypt.client.recovery_token + :members: diff --git a/docs/api/client/recovery_token_challenge.rst b/docs/api/client/recovery_token_challenge.rst deleted file mode 100644 index 68fbdc6e1..000000000 --- a/docs/api/client/recovery_token_challenge.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.recovery_token_challenge` --------------------------------------------------- - -.. automodule:: letsencrypt.client.recovery_token_challenge - :members: From be5ae7ae9a9bb2a8181fcf1255b5bc5938a6468d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sat, 10 Jan 2015 05:19:22 -0800 Subject: [PATCH 54/66] Created auth_handler and client_authenticator. Use dicts for all messages and keep client clean. --- docs/api/client/auth_handler.rst | 5 + docs/api/client/client_authenticator.rst | 5 + letsencrypt/client/CONFIG.py | 2 +- letsencrypt/client/auth_handler.py | 418 +++++++++++++++++++++ letsencrypt/client/challenge.py | 118 ------ letsencrypt/client/challenge_util.py | 3 + letsencrypt/client/client.py | 235 ++---------- letsencrypt/client/client_authenticator.py | 44 +++ letsencrypt/client/errors.py | 10 +- 9 files changed, 506 insertions(+), 334 deletions(-) create mode 100644 docs/api/client/auth_handler.rst create mode 100644 docs/api/client/client_authenticator.rst create mode 100644 letsencrypt/client/auth_handler.py delete mode 100644 letsencrypt/client/challenge.py create mode 100644 letsencrypt/client/client_authenticator.py diff --git a/docs/api/client/auth_handler.rst b/docs/api/client/auth_handler.rst new file mode 100644 index 000000000..e84745d1e --- /dev/null +++ b/docs/api/client/auth_handler.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.auth_handler` +-------------------------------- + +.. automodule:: letsencrypt.client.auth_handler + :members: diff --git a/docs/api/client/client_authenticator.rst b/docs/api/client/client_authenticator.rst new file mode 100644 index 000000000..a9050de50 --- /dev/null +++ b/docs/api/client/client_authenticator.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.client_authenticator` +-------------------------------- + +.. automodule:: letsencrypt.client.client_authenticator + :members: diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 9a850778c..2ce39a73b 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -60,7 +60,7 @@ INVALID_EXT = ".acme.invalid" EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] """Mutually Exclusive Challenges - only solve 1""" -AUTH_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) +DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) """These are challenges that must be solved by an Authenticator object""" CLIENT_CHALLENGES = frozenset( diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py new file mode 100644 index 000000000..7ac0f7429 --- /dev/null +++ b/letsencrypt/client/auth_handler.py @@ -0,0 +1,418 @@ +"""ACME AuthHandler.""" +import logging +import sys + +import zope.component + +from letsencrypt.client import acme +from letsencrypt.client import CONFIG +from letsencrypt.client import challenge_util +from letsencrypt.client import errors + + +class AuthHandler(object): + """ACME Authorization Handler for a client. + + :ivar dv_auth: Authenticator capable of solving CONFIG.DV_CHALLENGES + :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + :ivar client_auth: Authenticator capable of solving CONFIG.CLIENT_CHALLENGES + :type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + :ivar network: Network object for sending and receiving authorization + messages + :type network: :class:`letsencrypt.client.network.Network` + + :ivar list domains: list of str domains to get authorization + :ivar dict authkey: Authorized Keys for each domain. + values are of type :class:`letsencrypt.client.client.Client.Key` + :ivar dict responses: keys: domain, values: list of dict responses + :ivar dict msgs: ACME Challenge messages with domain as a key + :ivar dict paths: optimal path for authorization. eg. paths[domain] + :ivar dict dv_c: Keys - domain, Values are DV challenges in the form of + :class:`letsencrypt.client.challenge_util.IndexedChall` + :ivar dict client_c: Keys - domain, Values are Client challenges in the form + of :class:`letsencrypt.client.challenge_util.IndexedChall` + + """ + def __init__(self, dv_auth, client_auth, network): + self.dv_auth = dv_auth + self.client_auth = client_auth + self.network = network + + self.domains = [] + self.authkey = dict() + self.responses = dict() + self.msgs = dict() + self.paths = dict() + + self.dv_c = dict() + self.client_c = dict() + + def add_chall_msg(self, domain, msg, authkey): + """Add a challenge message to the AuthHandler. + + :param str domain: domain for authorization + :param dict msg: ACME challenge message + + :param authkey: authorized key for the challenge + :type authkey: :class:`letsencrypt.client.client.Client.Key` + + """ + if domain in self.domains: + raise errors.LetsEncryptAuthHandlerError( + "Multiple Challenges for the same domain is not supported.") + self.domains.append(domain) + self.responses[domain] = ["null"] * len(msg["challenges"]) + self.msgs[domain] = msg + self.authkey[domain] = authkey + + def get_authorizations(self): + """Retreive all authorizations for challenges. + + :raises LetsEncryptAuthHandlerError: If unable to retrieve all + authorizations + + """ + progress = True + while self.msgs and progress: + progress = False + self._satisfy_challenges() + + delete_list = [] + + for dom in self.domains: + if self._path_satisfied(dom): + self.acme_authorization(dom) + delete_list.append(dom) + + # This avoids modifying while iterating over the list + if delete_list: + self._cleanup_state(delete_list) + progress = True + + if not progress: + raise errors.LetsEncryptAuthHandlerError( + "Unable to solve challenges for requested names.") + + def acme_authorization(self, domain): + """Handle ACME "authorization" phase. + + :param str domain: domain that is requesting authorization + + :returns: ACME "authorization" message. + :rtype: dict + + """ + try: + return self.network.send_and_receive_expected( + acme.authorization_request( + self.msgs[domain]["sessionID"], + domain, + self.msgs[domain]["nonce"], + self.responses[domain], + self.authkey[domain].pem), + "authorization") + except errors.LetsEncryptClientError as err: + logging.fatal(str(err)) + logging.fatal( + "Failed Authorization procedure - cleaning up challenges") + sys.exit(1) + finally: + self._cleanup_challenges(domain) + + def _path_satisfied(self, dom): + """Returns whether a path has been completely satisfied.""" + return all( + None != self.responses[dom][i] and "null" != self.responses[dom][i] + for i in self.paths[dom]) + + def _satisfy_challenges(self): + """Attempt to satisfy all saved challenge messages.""" + logging.info("Performing the following challenges:") + for dom in self.domains: + self.paths[dom] = gen_challenge_path( + self.msgs[dom]["challenges"], + self._get_chall_pref(dom), + self.msgs[dom].get("combinations", None)) + + self.dv_c[dom], self.client_c[dom] = self._challenge_factory( + dom, self.paths[dom]) + + # Flatten challs for authenticator functions and remove index + # Order is important here as we will not expose the outside + # Authenticator to our own indices. + flat_client = [] + flat_auth = [] + for dom in self.domains: + flat_client.extend(ichall.chall for ichall in self.client_c[dom]) + flat_auth.extend(ichall.chall for ichall in self.dv_c[dom]) + + client_resp = self.client_auth.perform(flat_client) + dv_resp = self.dv_auth.perform(flat_auth) + + # Assemble Responses + self._assign_responses(client_resp, self.client_c) + self._assign_responses(dv_resp, self.dv_c) + + def _assign_responses(self, flat_list, ichall_dict): + """Assign responses from flat_list back to the IndexedChall dicts.""" + flat_index = 0 + for dom in self.domains: + for ichall in ichall_dict[dom]: + self.responses[dom][ichall.index] = flat_list[flat_index] + flat_index += 1 + + def _get_chall_pref(self, domain): + """Return list of challenge preferences.""" + chall_prefs = self.client_auth.get_chall_pref(domain) + chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) + return chall_prefs + + def _cleanup_challenges(self, domain): + """Cleanup configuration challenges + + :param str domain: domain for which to clean up challenges + + """ + logging.info("Cleaning up challenges...") + self.dv_auth.cleanup(self.dv_c[domain]) + self.client_auth.cleanup(self.client_c[domain]) + + def _cleanup_state(self, delete_list): + """Cleanup state after an authorization is received. + + :param list delete_list: list of domains in str form + + """ + for domain in delete_list: + del self.msgs[domain] + del self.responses[domain] + del self.paths[domain] + + del self.authkey[domain] + + del self.client_c[domain] + del self.dv_c[domain] + + self.domains.remove(domain) + + def _challenge_factory(self, domain, path): + """Construct Namedtuple Challenges + + :param str domain: domain of the enrollee + + :param list path: List of indices from `challenges`. + + :returns: dv_chall, list of + :class:`letsencrypt.client.challenge_util.IndexedChall` + client_chall, list of + :class:`letsencrypt.client.challenge_util.IndexedChall` + :rtype: tuple + + :raises errors.LetsEncryptClientError: If Challenge type is not + recognized + + """ + challenges = self.msgs[domain]["challenges"] + + dv_chall = [] + client_chall = [] + + for index in self.paths[domain]: + chall = challenges[index] + + # Authenticator Challenges + if chall["type"] in CONFIG.DV_CHALLENGES: + dv_chall.append(challenge_util.IndexedChall( + self._construct_dv_chall(chall, domain), index)) + + # Client Challenges + elif chall["type"] in CONFIG.CLIENT_CHALLENGES: + client_chall.append(challenge_util.IndexedChall( + self._construct_client_chall(chall, domain), index)) + + else: + raise errors.LetsEncryptClientError( + "Received unrecognized challenge of type: " + "%s" % chall["type"]) + + return dv_chall, client_chall + + def _construct_dv_chall(self, chall, domain): + """Construct Auth Type Challenges. + + :param dict chall: Single challenge + :param str domain: challenge's domain + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "dvsni": + logging.info(" DVSNI challenge for name %s.", domain) + return challenge_util.DvsniChall( + domain, str(chall["r"]), str(chall["nonce"]), + self.authkey[domain]) + + elif chall["type"] == "simpleHttps": + logging.info(" SimpleHTTPS challenge for name %s.", domain) + return challenge_util.SimpleHttpsChall( + domain, str(chall["token"]), self.authkey[domain]) + + elif chall["type"] == "dns": + logging.info(" DNS challenge for name %s.", domain) + return challenge_util.DnsChall( + domain, str(chall["token"]), self.authkey[domain]) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Auth Challenge: %s" % chall["type"]) + + def _construct_client_chall(self, chall, domain): + """Construct Client Type Challenges. + + :param dict chall: Single challenge + :param str domain: challenge's domain + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "recoveryToken": + logging.info(" Recovery Token Challenge for name: %s.", domain) + return challenge_util.RecTokenChall(domain) + + elif chall["type"] == "recoveryContact": + logging.info(" Recovery Contact Challenge for name: %s.", domain) + return challenge_util.RecContactChall( + domain, + chall.get("activationURL", None), + chall.get("successURL", None), + chall.get("contact", None)) + + elif chall["type"] == "proofOfPossession": + logging.info(" Proof-of-Possession Challenge for name: " + "%s", domain) + return challenge_util.PopChall( + domain, chall["alg"], chall["nonce"], chall["hints"]) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Client Challenge: %s" % chall["type"]) + +def gen_challenge_path(challenges, preferences, 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 list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :param list preferences: List of challenge preferences for domain + + :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, preferences, combos) + else: + return _find_dumb_path(challenges, preferences) + + +def _find_smart_path(challenges, preferences, combos): + """Find challenge path with server hints. + + Can be called if combinations is included. Function uses a simple + ranking system to choose the combo with the lowest cost. + + :param list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :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(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: + logging.fatal("Client does not support any combination of " + "challenges to satisfy ACME server") + sys.exit(22) + + return best_combo + +def _find_dumb_path(challenges, preferences): + """Find challenge path without server hints. + + 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 list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :returns: List of indices from `challenges`. + :rtype: list + + """ + # Add logic for a crappy server + # Choose a DV + path = [] + for pref_c in 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): + """Return whether or not the challenge is preferred in 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 diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py deleted file mode 100644 index 5abb78684..000000000 --- a/letsencrypt/client/challenge.py +++ /dev/null @@ -1,118 +0,0 @@ -"""ACME challenge.""" -import logging -import sys - -from letsencrypt.client import CONFIG - - -def gen_challenge_path(challenges, preferences, 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 list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :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, preferences, combos) - else: - return _find_dumb_path(challenges, preferences) - - -def _find_smart_path(challenges, preferences, combos): - """Find challenge path with server hints. - - Can be called if combinations is included. Function uses a simple - ranking system to choose the combo with the lowest cost. - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :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(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: - logging.fatal("Client does not support any combination of " - "challenges to satisfy ACME server") - sys.exit(22) - - return best_combo - - -def _find_dumb_path(challenges, preferences): - """Find challenge path without server hints. - - 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 list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :returns: List of indices from `challenges`. - :rtype: list - - """ - # Add logic for a crappy server - # Choose a DV - path = [] - for pref_c in 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): - """Return whether or not the challenge is preferred in 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 diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index fb7b8d267..2341270cd 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -20,6 +20,9 @@ RecContactChall = collections.namedtuple( RecTokenChall = collections.namedtuple("RecTokenChall", "domain") PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints") +# Helper Challenge Wrapper - Can be used to maintain the proper position of +# the response within a larger challenge list +IndexedChall = collections.namedtuple("IndexedChall", "chall, index") # DVSNI Challenge functions def dvsni_gen_cert(filepath, name, r_b64, nonce, key): diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 92da4540d..a88440aa7 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -12,8 +12,9 @@ import M2Crypto import zope.component from letsencrypt.client import acme -from letsencrypt.client import challenge +from letsencrypt.client import auth_handler from letsencrypt.client import challenge_util +from letsencrypt.client import client_authenticator from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors @@ -40,9 +41,9 @@ class Client(object): :ivar authkey: Authorization Key :type authkey: :class:`letsencrypt.client.client.Client.Key` - :ivar auth: Object that supports the IAuthenticator interface. - `auth` is used specifically for CONFIG.AUTH_CHALLENGES - :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + :ivar auth_handler: Object that supports the IAuthenticator interface. + auth_handler contains both a dv_authenticator and a client_authenticator + :type auth_handler: :class:`letsencrypt.client.auth_handler.AuthHandler` :ivar installer: Object supporting the IInstaller interface. :type installer: :class:`letsencrypt.client.interfaces.IInstraller` @@ -53,18 +54,26 @@ class Client(object): Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") - def __init__(self, server, names, authkey, auth, installer): - """Initialize a client.""" + def __init__(self, server, names, authkey, dv_auth, installer): + """Initialize a client. + + :param str server: CA server to contact + :param dv_auth: IAuthenticator Interface that can solve the + CONFIG.DV_CHALLENGES + :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + """ self.network = network.Network(server) self.names = names self.authkey = authkey sanity_check_names([server] + names) - self.auth = auth self.installer = installer - self.rec_token = recovery_token.RecoveryToken(server) + client_auth = client_authenticator.ClientAuthenticator(server) + self.auth_handler = auth_handler.AuthHandler( + dv_auth, client_auth, self.network) def obtain_certificate(self, csr, cert_path=CONFIG.CERT_PATH, @@ -82,42 +91,13 @@ class Client(object): :rtype: `tuple` of `str` """ - challenge_msgs = [] # Request Challenges for name in self.names: - # Maintaining order of challenge_msgs to names is important - challenge_msgs.append(self.acme_challenge(name)) + self.auth_handler.add_chall_msg( + name, self.acme_challenge(name), self.authkey) - # Perform Challenges - # Make sure at least one challenge is solved every round - progress = True - # This outer loop handles cases where the Authenticator cannot solve - # all challenge_msgs at once - while challenge_msgs and progress: - responses, auth_c, client_c = self.verify_identities(challenge_msgs) - progress = False - - i = 0 - while i < len(responses): - # Get Authorization - if responses[i] is not None: - self.acme_authorization( - challenge_msgs[i], self.names[i], - auth_c[i], client_c[i], responses[i]) - # Received authorization, remove challenge from list - # We have also cleaned up challenges... keep index - # in sync - del challenge_msgs[i] - del auth_c[i] - del client_c[i] - del responses[i] - progress = True - else: - i += 1 - - if not progress: - raise errors.LetsEncryptClientError( - "Unable to solve challenges for requested names.") + # Perform Challenges/Get Authorizations + self.auth_handler.get_authorizations() # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) @@ -140,34 +120,6 @@ class Client(object): return self.network.send_and_receive_expected( acme.challenge_request(domain), "challenge") - def acme_authorization( - self, challenge_msg, domain, auth_c, client_c, responses): - """Handle ACME "authorization" phase. - - :param dict challenge_msg: ACME "challenge" message. - :param str domain: domain that is requesting authorization - :param list auth_c: auth challenges - :param list client_c: client challenges - :param list responses: Responses to all challenges in challenge_msg - - :returns: ACME "authorization" message. - :rtype: dict - - """ - try: - return self.network.send_and_receive_expected( - acme.authorization_request( - challenge_msg["sessionID"], domain, - challenge_msg["nonce"], responses, self.authkey.pem), - "authorization") - except errors.LetsEncryptClientError as err: - logging.fatal(str(err)) - logging.fatal( - "Failed Authorization procedure - cleaning up challenges") - sys.exit(1) - finally: - self.cleanup_challenges(auth_c, client_c) - def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -277,17 +229,6 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, auth_c, client_c): - """Cleanup configuration challenges - - :param dict challenges: challenges from a challenge message - - """ - logging.info("Cleaning up challenges...") - self.auth.cleanup(auth_c) - # should cleanup client_c - assert not client_c - def verify_identities(self, challenge_msgs): """Verify identities. @@ -386,10 +327,6 @@ class Client(object): responses[msg_num][idx] = flat_resp[flat_index] flat_index += 1 - def _path_satisfied(self, responses, path): - """Returns whether a path has been completely satisfied.""" - return all("null" != responses[i] for i in path) - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -467,136 +404,6 @@ class Client(object): vhost.add(host) return vhost - def challenge_factory(self, domain, challenges, path): - """ - - :param str domain: domain of the enrollee - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param list path: List of indices from `challenges`. - - :returns: auth_chall, list of `collections.namedtuples` - auth_satisfies, list of indices, each associated auth_chall - satisfieswithin the challenge_msg - client_chall, list of `collections.namedtuples` - client_satisfies, list of indices each associated client_chall - satisfies within the challenge_msg - :rtype: tuple - - :raises errors.LetsEncryptClientError: If Challenge type is not - recognized - - """ - auth_chall = [] - # Since a single invocation of SNI challenge can satisfy multiple - # challenges. We must keep track of all the challenges it satisfies - auth_satisfies = [] - - client_chall = [] - client_satisfies = [] - domain = str(domain) - - for index in path: - chall = challenges[index] - - # Authenticator Challenges - if chall["type"] in CONFIG.AUTH_CHALLENGES: - auth_chall.append(self._construct_auth_chall(chall, domain)) - auth_satisfies.append(index) - - # Client Challenges - elif chall["type"] in CONFIG.CLIENT_CHALLENGES: - client_chall.append(self._construct_client_chall(chall, domain)) - client_satisfies.append(index) - - else: - raise errors.LetsEncryptClientError( - "Received unrecognized challenge of type: " - "%s" % chall["type"]) - - return auth_chall, auth_satisfies, client_chall, client_satisfies - - def _construct_auth_chall(self, chall, domain): - """Construct Auth Type Challenges. - - :param dict chall: Single challenge - - :returns: challenge_util named tuple Chall object - :rtype: `collections.namedtuple` - - :raises errors.LetsEncryptClientError: If unimplemented challenge exists - - """ - if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", domain) - return challenge_util.DvsniChall( - domain, str(chall["r"]), str(chall["nonce"]), self.authkey) - - elif chall["type"] == "simpleHttps": - logging.info(" SimpleHTTPS challenge for name %s.", domain) - return challenge_util.SimpleHttpsChall( - domain, str(chall["token"]), self.authkey) - - elif chall["type"] == "dns": - logging.info(" DNS challenge for name %s.", domain) - return challenge_util.DnsChall( - domain, str(chall["token"]), self.authkey) - - else: - raise errors.LetsEncryptClientError( - "Unimplemented Auth Challenge: %s" % chall["type"]) - - def _construct_client_chall(self, chall, domain): - """Construct Client Type Challenges. - - :param dict chall: Single challenge - - :returns: challenge_util named tuple Chall object - :rtype: `collections.namedtuple` - - :raises errors.LetsEncryptClientError: If unimplemented challenge exists - - """ - if chall["type"] == "recoveryToken": - logging.info(" Recovery Token Challenge for name: %s.", domain) - return challenge_util.RecTokenChall(domain) - - elif chall["type"] == "recoveryContact": - logging.info(" Recovery Contact Challenge for name: %s.", domain) - return challenge_util.RecContactChall( - domain, - chall.get("activationURL", None), - chall.get("successURL", None), - chall.get("contact", None)) - - elif chall["type"] == "proofOfPossession": - logging.info(" Proof-of-Possession Challenge for name: " - "%s", domain) - return challenge_util.PopChall( - domain, chall["alg"], chall["nonce"], chall["hints"]) - - else: - raise errors.LetsEncryptClientError( - "Unimplemented Client Challenge: %s" % chall["type"]) - - # pylint: disable=unused-argument - def get_chall_pref(self, domain): - """Return list of challenge preferences.""" - return ["recoveryToken"] - - def perform(self, chall_list): - """Perform client specific challenges.""" - responses = [] - for chall in chall_list: - if isinstance(chall, challenge_util.RecTokenChall): - responses.append(self.rec_token.perform(chall)) - else: - raise errors.LetsEncryptClientError("Unexpected Challenge") - return responses - def validate_key_csr(privkey, csr): """Validate CSR and key files. diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py new file mode 100644 index 000000000..fe4c95d3b --- /dev/null +++ b/letsencrypt/client/client_authenticator.py @@ -0,0 +1,44 @@ +import zope.interface + +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import recovery_token + +class ClientAuthenticator(object): + """Authenticator for CONFIG.CLIENT_CHALLENGES. + + :ivar rec_token: Performs "recoveryToken" challenges + :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` + + """ + zope.interface.implements(interfaces.IAuthenticator) + + # This will have an installer soon for get_key/cert purposes + def __init__(self, server): + """Initialize Client Authenticator. + + :param str server: ACME CA Server + + """ + self.rec_token = recovery_token.RecoveryToken(server) + + def get_chall_pref(self, domain): # pylint: disable=no-member-use + """Return list of challenge preferences.""" + return ["recoveryToken"] + + def perform(self, chall_list): + """Perform client specific challenges.""" + responses = [] + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + responses.append(self.rec_token.perform(chall)) + else: + raise errors.LetsEncryptClientAuthError("Unexpected Challenge") + return responses + + def cleanup(self, chall_list): + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + self.rec_token.cleanup(chall) + else: + raise errors.LetsEncryptClientAuthError("Unexpected Challenge") diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index dddfc5e4e..ec046c0a5 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -5,8 +5,16 @@ class LetsEncryptClientError(Exception): """Generic Let's Encrypt client error.""" +class LetsEncryptAuthHandlerError(LetsEncryptClientError): + """Let's Encrypt Auth Handler error.""" + + +class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError): + """Let's Encrypt Client Authenticator Error.""" + + class LetsEncryptConfiguratorError(LetsEncryptClientError): - """Let's Encrypt configurator error.""" + """Let's Encrypt Configurator error.""" class LetsEncryptDvsniError(LetsEncryptConfiguratorError): From ae20f2fd7df5787ce0c50d5fe311e59ad1f808e6 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 12 Jan 2015 05:44:06 -0800 Subject: [PATCH 55/66] tests for new code --- letsencrypt/client/auth_handler.py | 25 +- letsencrypt/client/client.py | 98 ---- letsencrypt/client/client_authenticator.py | 4 +- letsencrypt/client/tests/acme_util.py | 6 +- letsencrypt/client/tests/auth_handler_test.py | 444 ++++++++++++++++++ .../client/tests/client_authenticator_test.py | 80 ++++ letsencrypt/client/tests/client_test.py | 282 ----------- 7 files changed, 547 insertions(+), 392 deletions(-) create mode 100644 letsencrypt/client/tests/auth_handler_test.py create mode 100644 letsencrypt/client/tests/client_authenticator_test.py delete mode 100644 letsencrypt/client/tests/client_test.py diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 7ac0f7429..9ecb868ce 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -61,7 +61,8 @@ class AuthHandler(object): """ if domain in self.domains: raise errors.LetsEncryptAuthHandlerError( - "Multiple Challenges for the same domain is not supported.") + "Multiple ACMEChallengeMessages for the same domain " + "is not supported.") self.domains.append(domain) self.responses[domain] = ["null"] * len(msg["challenges"]) self.msgs[domain] = msg @@ -121,12 +122,6 @@ class AuthHandler(object): finally: self._cleanup_challenges(domain) - def _path_satisfied(self, dom): - """Returns whether a path has been completely satisfied.""" - return all( - None != self.responses[dom][i] and "null" != self.responses[dom][i] - for i in self.paths[dom]) - def _satisfy_challenges(self): """Attempt to satisfy all saved challenge messages.""" logging.info("Performing the following challenges:") @@ -151,6 +146,8 @@ class AuthHandler(object): client_resp = self.client_auth.perform(flat_client) dv_resp = self.dv_auth.perform(flat_auth) + logging.info("Ready for verification...") + # Assemble Responses self._assign_responses(client_resp, self.client_c) self._assign_responses(dv_resp, self.dv_c) @@ -163,9 +160,16 @@ class AuthHandler(object): self.responses[dom][ichall.index] = flat_list[flat_index] flat_index += 1 + def _path_satisfied(self, dom): + """Returns whether a path has been completely satisfied.""" + return all( + None != self.responses[dom][i] and "null" != self.responses[dom][i] + for i in self.paths[dom]) + def _get_chall_pref(self, domain): """Return list of challenge preferences.""" - chall_prefs = self.client_auth.get_chall_pref(domain) + chall_prefs = [] + chall_prefs.extend(self.client_auth.get_chall_pref(domain)) chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) return chall_prefs @@ -389,6 +393,10 @@ def _find_dumb_path(challenges, preferences): server message to be fulfilled by the client in order to prove possession of the identifier. + :param list preferences: A list of preferences representing the + challenge type found within the ACME spec. Each challenge type + can only be listed once. + :returns: List of indices from `challenges`. :rtype: list @@ -396,6 +404,7 @@ def _find_dumb_path(challenges, preferences): # Add logic for a crappy server # Choose a DV path = [] + assert(len(preferences) == len(set(preferences))) for pref_c in preferences: for i, offered_challenge in enumerate(challenges): if (pref_c == offered_challenge["type"] and diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a88440aa7..bba729d35 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -229,104 +229,6 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def verify_identities(self, challenge_msgs): - """Verify identities. - - This is greatly complicated by the fact that the Authenticator can - oftentimes solve many challenges at once. The strategy is to give - the authenticator all of the appropriate challenges at once to - speed up the process. This creates indexing issues as the challenges - can come from many different messages and are not in an exact order - because of the optimal path decision. All of this complicated indexing - will be completely hidden from the authenticator and all the - authenticator must do is return a list of responses in the same order - the challenges were given. - - :param list challenge_msgs: List of ACME "challenge" messages. - - :returns: TODO - :rtype: TODO - - """ - # Every msg's responses are a list within this list - responses = [] - # Every msg's desired path - paths = [] - - auth_chall = [] - client_chall = [] - - auth_idx = [] - client_idx = [] - - # Client challenges and Authenticator challenges should be separate - # and really should not be conflicting along the same path. - # I have chosen to make client challenges preferred - # as the client challenges should be able to be completely handled - # by this module and does not require outside config changes. - # (which may be costly) - - for i, msg in enumerate(challenge_msgs): - prefs = self.get_chall_pref(self.names[i]) - prefs.extend(self.auth.get_chall_pref(self.names[i])) - - paths.append(challenge.gen_challenge_path( - msg["challenges"], - prefs, - msg.get("combinations", []))) - - logging.info("Performing the following challenges:") - - auth_c, auth_i, client_c, client_i = self.challenge_factory( - self.names[i], msg["challenges"], paths[-1]) - - auth_chall.append(auth_c) - auth_idx.append(auth_i) - client_chall.append(client_c) - client_idx.append(client_i) - - responses.append(["null"] * len(msg["challenges"])) - - # Flatten list for client authenticator functions - client_resp = self.perform( - [chall for sublist in client_chall for chall in sublist]) - self._assign_responses(client_resp, client_idx, responses) - - # Flatten list for auth authenticator - auth_resp = self.auth.perform( - [chall for sublist in auth_chall for chall in sublist]) - self._assign_responses(auth_resp, auth_idx, responses) - - for i in range(len(paths)): - # If challenges failed to complete... zero them out - if not self._path_satisfied(responses[i], paths[i]): - responses[i] = None - auth_chall[i] = None - client_chall[i] = None - - logging.info( - "Configured Apache for challenges; waiting for verification...") - - return responses, auth_chall, client_chall - - # pylint: disable=no-self-use - def _assign_responses(self, flat_resp, idx_list, responses): - """Assign chall_response to appropriate places in response list. - - :param resp: responses from a challenge - :type resp: list of dicts - - :param list idx_list: respective challenges flat_resp satisfies - :param list responses: master list of responses - - """ - flat_index = 0 - # Every authorization_request message - for msg_num in range(len(responses)): - for idx in idx_list[msg_num]: - responses[msg_num][idx] = flat_resp[flat_index] - flat_index += 1 - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py index fe4c95d3b..fcccb99dc 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/client_authenticator.py @@ -1,5 +1,6 @@ import zope.interface +from letsencrypt.client import challenge_util from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import recovery_token @@ -27,7 +28,7 @@ class ClientAuthenticator(object): return ["recoveryToken"] def perform(self, chall_list): - """Perform client specific challenges.""" + """Perform client specific challenges for IAuthenticator""" responses = [] for chall in chall_list: if isinstance(chall, challenge_util.RecTokenChall): @@ -37,6 +38,7 @@ class ClientAuthenticator(object): return responses def cleanup(self, chall_list): + """Cleanup call for IAuthenticator.""" for chall in chall_list: if isinstance(chall, challenge_util.RecTokenChall): self.rec_token.cleanup(chall) diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 086733bd8..504009f02 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -59,10 +59,10 @@ CHALLENGES = { } -def get_auth_challenges(): +def get_dv_challenges(): """Returns all auth challenges.""" return [chall for typ, chall in CHALLENGES.iteritems() - if typ in CONFIG.AUTH_CHALLENGES] + if typ in CONFIG.DV_CHALLENGES] def get_client_challenges(): @@ -83,7 +83,7 @@ def gen_combos(challs): combos = [] for i, chall in enumerate(challs): - if chall["type"] in CONFIG.AUTH_CHALLENGES: + if chall["type"] in CONFIG.DV_CHALLENGES: dv_chall.append(i) else: renewal_chall.append(i) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py new file mode 100644 index 000000000..1581f22e0 --- /dev/null +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -0,0 +1,444 @@ +"""Test auth_handler.py.""" +import unittest +import mock +import pkg_resources + +from letsencrypt.client.tests import acme_util + + +# pylint: disable=protected-access +class SatisfyChallengesTest(unittest.TestCase): + """verify_identities test.""" + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator') + + self.mock_dv_auth.get_chall_pref.return_value = ["dvsni"] + self.mock_client_auth.get_chall_pref.return_value = ["recoveryToken"] + + self.mock_client_auth.perform.side_effect = gen_auth_resp + self.mock_dv_auth.perform.side_effect = gen_auth_resp + + self.handler = AuthHandler( + self.mock_dv_auth, self.mock_client_auth, None) + + def test_name1_dvsni1(self): + dom = "0" + challenge = [acme_util.CHALLENGES["dvsni"]] + msg = acme_util.get_chall_msg(dom, "nonce0", challenge) + self.handler.add_chall_msg(dom, msg, "dummy_key") + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), 1) + + self.assertEqual("DvsniChall0", self.handler.responses[dom][0]) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + + def test_name5_dvsni5(self): + challenge = [acme_util.CHALLENGES["dvsni"]] + for i in range(5): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge), + "dummy_key") + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + # Each message contains 1 auth, 0 client + + for i in range(5): + dom = str(i) + self.assertEqual(len(self.handler.responses[dom]), 1) + self.assertEqual(self.handler.responses[dom][0], "DvsniChall%d" % i) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name1_auth(self, mock_chall_path): + dom = "0" + + challenges = acme_util.get_dv_challenges() + combos = acme_util.gen_combos(challenges) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg("0", "nonce0", challenges, combos), + "dummy_key") + + path = gen_path(["simpleHttps"], challenges) + mock_chall_path.return_value = path + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name1_all(self, mock_chall_path): + dom = "0" + + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg(dom, "nonce0", challenges, combos), + "dummy_key") + + path = gen_path(["simpleHttps", "recoveryToken"], challenges) + mock_chall_path.return_value = path + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 1) + + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.client_c[dom][0].chall).__name__, "RecTokenChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name5_all(self, mock_chall_path): + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + msgs = [] + for i in range(5): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg( + str(i), "nonce%d" % i, challenges, combos), + "dummy_key") + + path = gen_path(["dvsni", "recoveryContact"], challenges) + mock_chall_path.return_value = path + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + for i in range(5): + self.assertEqual( + len(self.handler.responses[str(i)]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + + for i in range(5): + dom = str(i) + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 1) + + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall") + self.assertEqual( + type(self.handler.client_c[dom][0].chall).__name__, + "RecContactChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name5_mix(self, mock_chall_path): + paths = [] + msgs = [] + chosen_chall = [["dns"], + ["dvsni"], + ["simpleHttps", "proofOfPossession"], + ["simpleHttps"], + ["dns", "recoveryToken"]] + challenge_list = [acme_util.get_dv_challenges(), + [acme_util.CHALLENGES["dvsni"]], + acme_util.get_challenges(), + acme_util.get_dv_challenges(), + acme_util.get_challenges()] + + # Combos doesn't matter since I am overriding the gen_path function + for i in range(5): + dom = str(i) + paths.append(gen_path(chosen_chall[i], challenge_list[i])) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg( + dom, "nonce%d" % i, challenge_list[i]), + "dummy_key") + + mock_chall_path.side_effect = paths + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + + for i in range(5): + dom = str(i) + resp = self._get_exp_response(i, paths[i], challenge_list[i]) + self.assertEqual(self.handler.responses[dom], resp) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) + + self.assertEqual( + type(self.handler.dv_c["0"][0].chall).__name__, "DnsChall") + self.assertEqual( + type(self.handler.dv_c["1"][0].chall).__name__, "DvsniChall") + self.assertEqual( + type(self.handler.dv_c["2"][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.dv_c["3"][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.dv_c["4"][0].chall).__name__, "DnsChall") + + self.assertEqual( + type(self.handler.client_c["2"][0].chall).__name__, "PopChall") + self.assertEqual( + type(self.handler.client_c["4"][0].chall).__name__, "RecTokenChall") + + def _get_exp_response(self, domain, path, challenges): + exp_resp = ["null"] * len(challenges) + for i in path: + exp_resp[i] = translate[challenges[i]["type"]] + str(domain) + + return exp_resp + + def printout_handler(self): + print "***** Test Printout *****" + for dom in self.handler.domains: + print "Domain:", dom + print "***Challenge Messages***" + print self.handler.msgs[dom] + print "**responses**" + print self.handler.responses[dom] + print "**path**" + print self.handler.paths[dom] + print "**dv_c**" + for item in self.handler.dv_c[dom]: + print item + print "**client_c**" + for item in self.handler.client_c[dom]: + print item + + +# pylint: diable=protected-access +class GetAuthorizationsTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator') + + self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges") + self.mock_acme_auth = mock.MagicMock(name="acme_authorization") + + self.iteration = 0 + + self.handler = AuthHandler( + self.mock_dv_auth, self.mock_client_auth, None) + + self.handler._satisfy_challenges = self.mock_sat_chall + self.handler.acme_authorization = self.mock_acme_auth + + def test_solved3_at_once(self): + # Set 3 DVSNI challenges + challenge = [acme_util.CHALLENGES["dvsni"]] + for i in range(3): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge), + "dummy_key") + + self.mock_sat_chall.side_effect = self._sat_solved_at_once + self.handler.get_authorizations() + + self.assertEqual(self.mock_sat_chall.call_count, 1) + self.assertEqual(self.mock_acme_auth.call_count, 3) + + exp_call_list = [mock.call("0"), mock.call("1"), mock.call("2")] + self.assertEqual( + self.mock_acme_auth.call_args_list, exp_call_list) + self._test_finished() + + def _sat_solved_at_once(self): + for i in range(3): + dom = str(i) + self.handler.responses[dom] = ["DvsniChall%d" % i] + self.handler.paths[dom] = [0] + # Assignment was > 80 char... + dv_c, c_c = self.handler._challenge_factory(dom, [0]) + + self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + + def test_progress_failure(self): + from letsencrypt.client.errors import LetsEncryptAuthHandlerError + challenges = acme_util.get_challenges() + self.handler.add_chall_msg( + "0", + acme_util.get_chall_msg("0", "nonce0", challenges), + "dummy_key") + + # Don't do anything to satisfy challenges + self.mock_sat_chall.side_effect = self._sat_failure + + self.assertRaises( + LetsEncryptAuthHandlerError, self.handler.get_authorizations) + + # Check to make sure program didn't loop + self.assertEqual(self.mock_sat_chall.call_count, 1) + + def _sat_failure(self): + dom = "0" + self.handler.paths[dom] = gen_path( + ["dns", "recoveryToken"], self.handler.msgs[dom]["challenges"]) + dv_c, c_c = self.handler._challenge_factory( + dom, self.handler.paths[dom]) + self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + + def test_incremental_progress(self): + challs = [] + challs.append(acme_util.get_challenges()) + challs.append(acme_util.get_dv_challenges()) + for i in range(2): + dom = str(i) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg(dom, "nonce%d" % i, challs[i]), + "dummy_key") + + self.mock_sat_chall.side_effect = self._sat_incremental + + self.handler.get_authorizations() + + self._test_finished() + self.assertEqual(self.mock_acme_auth.call_args_list, + [mock.call("1"), mock.call("0")]) + + def _sat_incremental(self): + from letsencrypt.client.errors import LetsEncryptAuthHandlerError + + # Exact responses don't matter, just path/response match + if self.iteration == 0: + # Only solve one of "0" required challs + self.handler.responses["0"][1] = "onecomplete" + self.handler.responses["0"][3] = None + self.handler.responses["1"] = ["null", "null", "goodresp"] + self.handler.paths["0"] = [1, 3] + self.handler.paths["1"] = [2] + # This is probably overkill... but set it anyway + dv_c, c_c = self.handler._challenge_factory("0", [1, 3]) + self.handler.dv_c["0"], self.handler.client_c["0"] = dv_c, c_c + dv_c, c_c = self.handler._challenge_factory("1", [2]) + self.handler.dv_c["1"], self.handler.client_c["1"] = dv_c, c_c + + self.iteration += 1 + + elif self.iteration == 1: + # Quick check to make sure it was actually completed. + self.assertEqual( + self.mock_acme_auth.call_args_list, [mock.call("1")]) + self.handler.responses["0"][1] = "now_finish" + self.handler.responses["0"][3] = "finally!" + + else: + raise LetsEncryptAuthHandlerError( + "Failed incremental test: too many invocations") + + def _test_finished(self): + self.assertFalse(self.handler.msgs) + self.assertFalse(self.handler.dv_c) + self.assertFalse(self.handler.responses) + self.assertFalse(self.handler.paths) + self.assertFalse(self.handler.domains) + +# pylint: disable=protected-access +class PathSatisfiedTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + self.handler = AuthHandler(None, None, None) + + def test_satisfied_true(self): + dom = ["0", "1", "2", "3", "4"] + self.handler.paths[dom[0]] = [1, 2] + self.handler.responses[dom[0]] = ["null", "sat", "sat2", "null"] + + self.handler.paths[dom[1]] = [0] + self.handler.responses[dom[1]] = ["sat", None, None, "null"] + + self.handler.paths[dom[2]] = [0] + self.handler.responses[dom[2]] = ["sat"] + + self.handler.paths[dom[3]] = [] + self.handler.responses[dom[3]] = [] + + self.handler.paths[dom[4]] = [] + self.handler.responses[dom[4]] = ["respond... sure"] + + for i in range(5): + self.assertTrue(self.handler._path_satisfied(dom[i])) + + def test_not_satisfied(self): + dom = ["0", "1", "2", "3", "4"] + self.handler.paths[dom[0]] = [1, 2] + self.handler.responses[dom[0]] = ["sat1", "null", "sat2", "null"] + + self.handler.paths[dom[1]] = [0] + self.handler.responses[dom[1]] = [None, "null", "null", "null"] + + self.handler.paths[dom[2]] = [0] + self.handler.responses[dom[2]] = [None] + + self.handler.paths[dom[3]] = [0] + self.handler.responses[dom[3]] = ["null"] + + for i in range(4): + self.assertFalse(self.handler._path_satisfied(dom[i])) + + +translate = {"dvsni": "DvsniChall", + "simpleHttps": "SimpleHttpsChall", + "dns": "DnsChall", + "recoveryToken": "RecTokenChall", + "recoveryContact": "RecContactChall", + "proofOfPossession": "PopChall"} + + +def gen_auth_resp(chall_list): + return ["%s%s" % (type(chall).__name__, chall.domain) + for chall in chall_list] + +def gen_path(str_list, challenges): + path = [] + for i, chall in enumerate(challenges): + for str_chall in str_list: + if chall["type"] == str_chall: + path.append(i) + continue + return path + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/client_authenticator_test.py new file mode 100644 index 000000000..6027e1dba --- /dev/null +++ b/letsencrypt/client/tests/client_authenticator_test.py @@ -0,0 +1,80 @@ +import unittest + +import mock + + +class PerformTest(unittest.TestCase): + """Test client perform function.""" + def setUp(self): + from letsencrypt.client.client_authenticator import ClientAuthenticator + + self.auth = ClientAuthenticator("demo_server.org") + self.auth.rec_token.perform = mock.MagicMock( + name="rec_token_perform", side_effect=gen_client_resp) + + def test_rec_token1(self): + from letsencrypt.client.challenge_util import RecTokenChall + token = RecTokenChall("0") + + responses = self.auth.perform([token]) + + self.assertEqual(responses, ["RecTokenChall0"]) + + def test_rec_token5(self): + from letsencrypt.client.challenge_util import RecTokenChall + tokens = [] + for i in range(5): + tokens.append(RecTokenChall(str(i))) + + responses = self.auth.perform(tokens) + + self.assertEqual(len(responses), 5) + for i in range(5): + self.assertEqual(responses[i], "RecTokenChall%d" % i) + + def test_unexpected(self): + from letsencrypt.client.challenge_util import DvsniChall + from letsencrypt.client.errors import LetsEncryptClientAuthError + + unexpected = DvsniChall("0", "rb64", "123", "invalid_key") + + self.assertRaises( + LetsEncryptClientAuthError, self.auth.perform, [unexpected]) + + +class CleanupTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.client_authenticator import ClientAuthenticator + + self.auth = ClientAuthenticator("demo_server.org") + self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup") + self.auth.rec_token.cleanup = self.mock_cleanup + + def test_rec_token2(self): + from letsencrypt.client.challenge_util import RecTokenChall + token1 = RecTokenChall("0") + token2 = RecTokenChall("1") + + self.auth.cleanup([token1, token2]) + + self.assertEqual(self.mock_cleanup.call_args_list, + [mock.call(token1), mock.call(token2)]) + + def test_unexpected(self): + from letsencrypt.client.challenge_util import DvsniChall + from letsencrypt.client.challenge_util import RecTokenChall + from letsencrypt.client.errors import LetsEncryptClientAuthError + + token = RecTokenChall("0") + unexpected = DvsniChall("0", "rb64", "123", "dummy_key") + + self.assertRaises( + LetsEncryptClientAuthError, self.auth.cleanup, [token, unexpected]) + + +def gen_client_resp(chall): + return "%s%s" % (type(chall).__name__, chall.domain) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py deleted file mode 100644 index e22a95c64..000000000 --- a/letsencrypt/client/tests/client_test.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Test client.py.""" -import unittest -import mock -import pkg_resources - -from letsencrypt.client.tests import acme_util - - -class VerifyIdentityTest(unittest.TestCase): - """verify_identities test.""" - def setUp(self): - from letsencrypt.client.client import Client - from letsencrypt.client import CONFIG - - rsa256_file = pkg_resources.resource_filename( - __name__, 'testdata/rsa256_key.pem') - rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') - - auth_key = Client.Key(rsa256_file, rsa256_pem) - - self.mock_auth = mock.MagicMock(name='ApacheConfigurator') - self.mock_auth.get_chall_pref.return_value = ["dvsni"] - self.mock_auth.perform.side_effect = gen_auth_resp - - self.client = Client( - CONFIG.ACME_SERVER, ["0", "1", "2", "3", "4"], - auth_key, self.mock_auth, None) - self.client.perform = mock.MagicMock( - name='perform', side_effect=gen_auth_resp) - - def test_name1_dvsni1(self): - self.client.names = ["0"] - challenge = [acme_util.CHALLENGES["dvsni"]] - msgs = [acme_util.get_chall_msg("0", "nonce0", challenge)] - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 1) - self.assertEqual(len(responses[0]), 1) - - self.assertEqual("DvsniChall0", responses[0][0]) - self.assertEqual(len(auth_c), 1) - self.assertEqual(len(client_c), 1) - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 0) - - def test_name5_dvsni5(self): - challenge = [acme_util.CHALLENGES["dvsni"]] - msgs = [] - for i in range(5): - msgs.append( - acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge)) - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 5) - self.assertEqual(len(auth_c), 5) - self.assertEqual(len(client_c), 5) - # Each message contains 1 auth, 0 client - for i in range(5): - self.assertEqual(len(responses[i]), 1) - self.assertEqual(responses[i][0], "DvsniChall%d" % i) - self.assertEqual(len(auth_c[i]), 1) - self.assertEqual(len(client_c[i]), 0) - self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name1_auth(self, mock_chall_path): - self.client.names = ["0"] - - challenges = acme_util.get_auth_challenges() - combos = acme_util.gen_combos(challenges) - msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] - - path = gen_path(["simpleHttps"], challenges) - mock_chall_path.return_value = path - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 1) - self.assertEqual(len(responses[0]), len(challenges)) - self.assertEqual(len(auth_c), 1) - self.assertEqual(len(client_c), 1) - - self.assertEqual( - responses[0], - self._get_exp_response("0", path, challenges)) - - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 0) - self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name1_all(self, mock_chall_path): - self.client.names = ["0"] - - challenges = acme_util.get_challenges() - combos = acme_util.gen_combos(challenges) - msgs = [acme_util.get_chall_msg("0", "nonce0", challenges, combos)] - - path = gen_path(["simpleHttps", "recoveryToken"], challenges) - mock_chall_path.return_value = path - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 1) - self.assertEqual(len(responses[0]), len(challenges)) - self.assertEqual(len(auth_c), 1) - self.assertEqual(len(client_c), 1) - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 1) - - self.assertEqual( - responses[0], - self._get_exp_response("0", path, challenges)) - self.assertEqual(type(auth_c[0][0]).__name__, "SimpleHttpsChall") - self.assertEqual(type(client_c[0][0]).__name__, "RecTokenChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name5_all(self, mock_chall_path): - challenges = acme_util.get_challenges() - combos = acme_util.gen_combos(challenges) - msgs = [] - for i in range(5): - msgs.append( - acme_util.get_chall_msg( - str(i), "nonce%d" % i, challenges, combos)) - - path = gen_path(["dvsni", "recoveryContact"], challenges) - mock_chall_path.return_value = path - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 5) - for i in range(5): - self.assertEqual(len(responses[i]), len(challenges)) - self.assertEqual(len(auth_c), 5) - self.assertEqual(len(client_c), 5) - - for i in range(5): - self.assertEqual( - responses[i], self._get_exp_response(i, path, challenges)) - self.assertEqual(len(auth_c[0]), 1) - self.assertEqual(len(client_c[0]), 1) - - self.assertEqual(type(auth_c[i][0]).__name__, "DvsniChall") - self.assertEqual(type(client_c[i][0]).__name__, "RecContactChall") - - @mock.patch("letsencrypt.client.client." - "challenge.gen_challenge_path") - def test_name5_mix(self, mock_chall_path): - paths = [] - msgs = [] - chosen_chall = [["dns"], - ["dvsni"], - ["simpleHttps", "proofOfPossession"], - ["simpleHttps"], - ["dns", "recoveryToken"]] - challenge_list = [acme_util.get_auth_challenges(), - [acme_util.CHALLENGES["dvsni"]], - acme_util.get_challenges(), - acme_util.get_auth_challenges(), - acme_util.get_challenges()] - - # Combos doesn't matter since I am overriding the gen_path function - for i in range(5): - paths.append(gen_path(chosen_chall[i], challenge_list[i])) - msgs.append( - acme_util.get_chall_msg( - str(i), "nonce%d" % i, challenge_list[i])) - - mock_chall_path.side_effect = paths - - responses, auth_c, client_c = self.client.verify_identities(msgs) - - self.assertEqual(len(responses), 5) - self.assertEqual(len(auth_c), 5) - self.assertEqual(len(client_c), 5) - - for i in range(5): - resp = self._get_exp_response(i, paths[i], challenge_list[i]) - self.assertEqual(responses[i], resp) - self.assertEqual(len(auth_c[i]), 1) - self.assertEqual(len(client_c[i]), len(chosen_chall[i]) - 1) - - self.assertEqual(type(auth_c[0][0]).__name__, "DnsChall") - self.assertEqual(type(auth_c[1][0]).__name__, "DvsniChall") - self.assertEqual(type(auth_c[2][0]).__name__, "SimpleHttpsChall") - self.assertEqual(type(auth_c[3][0]).__name__, "SimpleHttpsChall") - self.assertEqual(type(auth_c[4][0]).__name__, "DnsChall") - - self.assertEqual(type(client_c[2][0]).__name__, "PopChall") - self.assertEqual(type(client_c[4][0]).__name__, "RecTokenChall") - - def _get_exp_response(self, domain, path, challenges): - exp_resp = ["null"] * len(challenges) - for i in path: - exp_resp[i] = translate[challenges[i]["type"]] + str(domain) - - return exp_resp - - -class ClientPerformTest(unittest.TestCase): - """Test client perform function.""" - def setUp(self): - from letsencrypt.client.client import Client - from letsencrypt.client import CONFIG - - rsa256_file = pkg_resources.resource_filename( - __name__, 'testdata/rsa256_key.pem') - rsa256_pem = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') - - auth_key = Client.Key(rsa256_file, rsa256_pem) - - self.client = Client( - CONFIG.ACME_SERVER, ["example.com"], auth_key, None, None) - self.client.rec_token.perform = mock.MagicMock( - name="rec_token_perform", side_effect=gen_client_resp) - - def test_rec_token1(self): - from letsencrypt.client.challenge_util import RecTokenChall - token = RecTokenChall("0") - - responses = self.client.perform([token]) - - self.assertEqual(responses, ["RecTokenChall0"]) - - def test_rec_token5(self): - from letsencrypt.client.challenge_util import RecTokenChall - tokens = [] - for i in range(5): - tokens.append(RecTokenChall(str(i))) - - responses = self.client.perform(tokens) - - self.assertEqual(len(responses), 5) - for i in range(5): - self.assertEqual(responses[i], "RecTokenChall%d" % i) - - def test_unexpected(self): - from letsencrypt.client.challenge_util import DvsniChall - from letsencrypt.client.errors import LetsEncryptClientError - unexpected = DvsniChall("0", "rb64", "123", "invalid_key") - - self.assertRaises( - LetsEncryptClientError, self.client.perform, [unexpected]) - - -translate = {"dvsni": "DvsniChall", - "simpleHttps": "SimpleHttpsChall", - "dns": "DnsChall", - "recoveryToken": "RecTokenChall", - "recoveryContact": "RecContactChall", - "proofOfPossession": "PopChall"} - - -def gen_auth_resp(chall_list): - return ["%s%s" % (type(chall).__name__, chall.domain) - for chall in chall_list] - - -def gen_client_resp(chall): - return "%s%s" % (type(chall).__name__, chall.domain) - - -def gen_path(str_list, challenges): - path = [] - for i, chall in enumerate(challenges): - for str_chall in str_list: - if chall["type"] == str_chall: - path.append(i) - continue - return path - - -if __name__ == '__main__': - unittest.main() From 541e006ad065cd7c34bb8fb988ffa0c0037efde2 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 13 Jan 2015 01:51:03 +0100 Subject: [PATCH 56/66] fix typos --- letsencrypt/client/apache/configurator.py | 2 +- letsencrypt/client/client.py | 2 +- letsencrypt/client/tests/challenge_util_test.py | 2 +- .../client/tests/testdata/debian_apache_2_4/two_vhost_80/sites | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 8b32cf7a7..0b1f576dc 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -401,7 +401,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def make_server_sni_ready(self, vhost, default_addr="*:443"): """Checks to see if the server is ready for SNI challenges. - :param vhost: VirtualHostost to check SNI compatibility + :param vhost: VirtualHost to check SNI compatibility :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :param str default_addr: TODO - investigate function further diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 8c5f4525f..8750d9526 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -42,7 +42,7 @@ class Client(object): :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` :ivar installer: Object supporting the IInstaller interface. - :type installer: :class:`letsencrypt.client.interfaces.IInstraller` + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` """ Key = collections.namedtuple("Key", "file pem") diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index 8bad21341..e0b99122f 100644 --- a/letsencrypt/client/tests/challenge_util_test.py +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -13,7 +13,7 @@ from letsencrypt.client import CONFIG from letsencrypt.client import le_util # pylint: disable=too-few-public-methods -class DvnsiGenCertTest(unittest.TestCase): +class DvsniGenCertTest(unittest.TestCase): """Tests for letsencrypt.client.challenge_util.dvsni_gen_cert.""" def test_standard(self): diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites b/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites index 5a7ad7622..3e73390fd 100644 --- a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites +++ b/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites @@ -1,2 +1,2 @@ -sites-available/letsencrypt.conf, letencrypt.demo +sites-available/letsencrypt.conf, letsencrypt.demo sites-available/encryption-example.conf, encryption-example.demo From 0e2a4984b1b3afc0ae410a8c0fbfa9ceb2d6dc65 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 13 Jan 2015 02:22:24 +0100 Subject: [PATCH 57/66] remove superfluous assignment --- letsencrypt/client/augeas_configurator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 231faa99d..ec107d934 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -243,7 +243,6 @@ class AugeasConfigurator(object): le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) existing_filepaths = [] - op_fd = None filepaths_path = os.path.join(cp_dir, "FILEPATHS") # Open up FILEPATHS differently depending on if it already exists From 97a4f27af67b02c6748f9269ae5c161cec9ed012 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 13 Jan 2015 02:26:39 +0100 Subject: [PATCH 58/66] remove redundant parentheses --- letsencrypt/client/display.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index 29b646794..b25e432ee 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -109,7 +109,7 @@ class FileDisplay(object): self.outfile = outfile def generic_notification(self, message): - side_frame = '-' * (79) + side_frame = '-' * 79 wm = textwrap.fill(message, 80) text = "\n%s\n%s\n%s\n" % (side_frame, wm, side_frame) self.outfile.write(text) @@ -121,7 +121,7 @@ class FileDisplay(object): choices = ["%s - %s" % (c[0], c[1]) for c in choices] self.outfile.write("\n%s\n" % message) - side_frame = '-' * (79) + side_frame = '-' * 79 self.outfile.write("%s\n" % side_frame) for i, choice in enumerate(choices, 1): @@ -194,7 +194,7 @@ class FileDisplay(object): return code, selection def success_installation(self, domains): - s_f = '*' * (79) + s_f = '*' * 79 wm = textwrap.fill(("Congratulations! You have successfully " + "enabled %s!") % gen_https_names(domains)) msg = "%s\n%s\n%s\n" From 0dddcd1ffa64f5679d86d61a0df3288812c6dc6e Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 01:03:21 -0800 Subject: [PATCH 59/66] cleanup branch --- letsencrypt/client/auth_handler.py | 4 +- letsencrypt/client/challenge_util.py | 2 +- letsencrypt/client/client.py | 2 - letsencrypt/client/client_authenticator.py | 6 ++- letsencrypt/client/le_util.py | 4 +- .../client/tests/apache/parser_test.py | 3 +- letsencrypt/client/tests/auth_handler_test.py | 43 ++++++------------- 7 files changed, 22 insertions(+), 42 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 9ecb868ce..b5153842d 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -2,8 +2,6 @@ import logging import sys -import zope.component - from letsencrypt.client import acme from letsencrypt.client import CONFIG from letsencrypt.client import challenge_util @@ -404,7 +402,7 @@ def _find_dumb_path(challenges, preferences): # Add logic for a crappy server # Choose a DV path = [] - assert(len(preferences) == len(set(preferences))) + assert len(preferences) == len(set(preferences)) for pref_c in preferences: for i, offered_challenge in enumerate(challenges): if (pref_c == offered_challenge["type"] and diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 2341270cd..aef0d27e8 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -20,7 +20,7 @@ RecContactChall = collections.namedtuple( RecTokenChall = collections.namedtuple("RecTokenChall", "domain") PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints") -# Helper Challenge Wrapper - Can be used to maintain the proper position of +# Helper Challenge Wrapper - Can be used to maintain the proper position of # the response within a larger challenge list IndexedChall = collections.namedtuple("IndexedChall", "chall, index") diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index bba729d35..ac3745f2f 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -13,7 +13,6 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import auth_handler -from letsencrypt.client import challenge_util from letsencrypt.client import client_authenticator from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util @@ -21,7 +20,6 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network -from letsencrypt.client import recovery_token # it's weird to point to chocolate servers via raw IPv6 addresses, and diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py index fcccb99dc..ac4406b28 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/client_authenticator.py @@ -1,3 +1,4 @@ +"""Client Authenticator""" import zope.interface from letsencrypt.client import challenge_util @@ -6,7 +7,7 @@ from letsencrypt.client import interfaces from letsencrypt.client import recovery_token class ClientAuthenticator(object): - """Authenticator for CONFIG.CLIENT_CHALLENGES. + """IAuthenticator for CONFIG.CLIENT_CHALLENGES. :ivar rec_token: Performs "recoveryToken" challenges :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` @@ -23,7 +24,8 @@ class ClientAuthenticator(object): """ self.rec_token = recovery_token.RecoveryToken(server) - def get_chall_pref(self, domain): # pylint: disable=no-member-use + # pylint: disable=unused-argument,no-self-use + def get_chall_pref(self, domain): """Return list of challenge preferences.""" return ["recoveryToken"] diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index d96dc8c09..08b0f6114 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -62,9 +62,9 @@ def unique_file(default_name, mode=0o777): f_parsed = os.path.splitext(default_name) while 1: try: - fd = os.open( + file_d = os.open( default_name, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) - return os.fdopen(fd, 'w'), default_name + return os.fdopen(file_d, 'w'), default_name except OSError: pass default_name = f_parsed[0] + '_' + str(count) + f_parsed[1] diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/tests/apache/parser_test.py index b7f1f6aa2..2acf6533e 100644 --- a/letsencrypt/client/tests/apache/parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -1,3 +1,4 @@ +"""Tests the ApacheParser class.""" import os import shutil import sys @@ -14,7 +15,7 @@ from letsencrypt.client.tests.apache import config_util class ApacheParserTest(unittest.TestCase): - + """Apache Parser Test.""" def setUp(self): zope.component.provideUtility(display.FileDisplay(sys.stdout)) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 1581f22e0..ee0a79895 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -1,11 +1,18 @@ """Test auth_handler.py.""" import unittest import mock -import pkg_resources from letsencrypt.client.tests import acme_util +TRANSLATE = {"dvsni": "DvsniChall", + "simpleHttps": "SimpleHttpsChall", + "dns": "DnsChall", + "recoveryToken": "RecTokenChall", + "recoveryContact": "RecContactChall", + "proofOfPossession": "PopChall"} + + # pylint: disable=protected-access class SatisfyChallengesTest(unittest.TestCase): """verify_identities test.""" @@ -129,7 +136,6 @@ class SatisfyChallengesTest(unittest.TestCase): def test_name5_all(self, mock_chall_path): challenges = acme_util.get_challenges() combos = acme_util.gen_combos(challenges) - msgs = [] for i in range(5): self.handler.add_chall_msg( str(i), @@ -166,7 +172,6 @@ class SatisfyChallengesTest(unittest.TestCase): @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") def test_name5_mix(self, mock_chall_path): paths = [] - msgs = [] chosen_chall = [["dns"], ["dvsni"], ["simpleHttps", "proofOfPossession"], @@ -201,7 +206,8 @@ class SatisfyChallengesTest(unittest.TestCase): resp = self._get_exp_response(i, paths[i], challenge_list[i]) self.assertEqual(self.handler.responses[dom], resp) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) + self.assertEqual( + len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) self.assertEqual( type(self.handler.dv_c["0"][0].chall).__name__, "DnsChall") @@ -222,29 +228,12 @@ class SatisfyChallengesTest(unittest.TestCase): def _get_exp_response(self, domain, path, challenges): exp_resp = ["null"] * len(challenges) for i in path: - exp_resp[i] = translate[challenges[i]["type"]] + str(domain) + exp_resp[i] = TRANSLATE[challenges[i]["type"]] + str(domain) return exp_resp - def printout_handler(self): - print "***** Test Printout *****" - for dom in self.handler.domains: - print "Domain:", dom - print "***Challenge Messages***" - print self.handler.msgs[dom] - print "**responses**" - print self.handler.responses[dom] - print "**path**" - print self.handler.paths[dom] - print "**dv_c**" - for item in self.handler.dv_c[dom]: - print item - print "**client_c**" - for item in self.handler.client_c[dom]: - print item - -# pylint: diable=protected-access +# pylint: disable=protected-access class GetAuthorizationsTest(unittest.TestCase): def setUp(self): from letsencrypt.client.auth_handler import AuthHandler @@ -418,14 +407,6 @@ class PathSatisfiedTest(unittest.TestCase): self.assertFalse(self.handler._path_satisfied(dom[i])) -translate = {"dvsni": "DvsniChall", - "simpleHttps": "SimpleHttpsChall", - "dns": "DnsChall", - "recoveryToken": "RecTokenChall", - "recoveryContact": "RecContactChall", - "proofOfPossession": "PopChall"} - - def gen_auth_resp(chall_list): return ["%s%s" % (type(chall).__name__, chall.domain) for chall in chall_list] From ddbe8e7b29ad56ee09e968f943024611451afc29 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 01:22:24 -0800 Subject: [PATCH 60/66] Add more index specific documentation --- letsencrypt/client/apache/configurator.py | 13 +++++++++++-- letsencrypt/client/auth_handler.py | 15 +++++++++++++-- letsencrypt/client/recovery_token.py | 3 --- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 488730dbf..28e7537f3 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -942,8 +942,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param list chall_list: List of challenges to be fulfilled by configurator. - :returns: list of responses. A None response indicates the challenge - was not perfromed. + :returns: list of responses. All responses are returned in the same + order as received by the perform function. A None response + indicates the challenge was not perfromed. :rtype: list """ @@ -953,6 +954,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): for i, chall in enumerate(chall_list): if isinstance(chall, challenge_util.DvsniChall): + # Currently also have dvsni hold associated index + # of the challenge. This helps to put all of the responses back + # together when they are all complete. apache_dvsni.add_chall(chall, i) sni_response = apache_dvsni.perform() @@ -960,6 +964,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Handled here because we may be able to load up other challenge types self.restart() + # Go through all of the challenges and assign them to the proper place + # in the responses return value. All responses must be in the same order + # as the original challenges. for i, resp in enumerate(sni_response): responses[apache_dvsni.indices[i]] = resp @@ -968,6 +975,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def cleanup(self, chall_list): """Revert all challenges.""" self.chall_out -= len(chall_list) + + # If all of the challenges have been finished, clean up everything if self.chall_out <= 0: self.revert_challenge_config() self.restart() diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index b5153842d..afe85c71a 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -151,7 +151,14 @@ class AuthHandler(object): self._assign_responses(dv_resp, self.dv_c) def _assign_responses(self, flat_list, ichall_dict): - """Assign responses from flat_list back to the IndexedChall dicts.""" + """Assign responses from flat_list back to the IndexedChall dicts. + + :param list flat_list: flat_list of responses from an IAuthenticator + :param dict ichall_dict: Master dict mapping all domains to a list of + their associated 'client' and 'dv' IndexedChallenges, or their + :class:`letsencrypt.client.challenge_util.IndexedChall` list + + """ flat_index = 0 for dom in self.domains: for ichall in ichall_dict[dom]: @@ -165,7 +172,11 @@ class AuthHandler(object): for i in self.paths[dom]) def _get_chall_pref(self, domain): - """Return list of challenge preferences.""" + """Return list of challenge preferences. + + :param str domain: domain for which you are requesting preferences + + """ chall_prefs = [] chall_prefs.extend(self.client_auth.get_chall_pref(domain)) chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py index b6111aea1..2da8a9c3f 100644 --- a/letsencrypt/client/recovery_token.py +++ b/letsencrypt/client/recovery_token.py @@ -16,10 +16,7 @@ class RecoveryToken(object): Based on draft-barnes-acme, section 6.4. """ - # zope.interface.implements(interfaces.IChallenge) - def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR): - # super(RecoveryToken, self).__init__() self.token_dir = os.path.join(direc, server) def perform(self, chall): From d0c9e4fc07fe261551c90e833a925d6088efe429 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 01:41:56 -0800 Subject: [PATCH 61/66] Remove assertIs - not supported in 2.6 --- letsencrypt/client/tests/apache/dvsni_test.py | 2 +- letsencrypt/client/tests/recovery_token_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index 6beb07b37..d2622a925 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -59,7 +59,7 @@ class DvsniPerformTest(unittest.TestCase): def test_perform0(self): resp = self.sni.perform() - self.assertIs(resp, None) + self.assertTrue(resp is None) @mock.patch("letsencrypt.client.apache.configurator." "ApacheConfigurator.restart") diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 945c2b0b9..d3d82e8ad 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -57,7 +57,7 @@ class RecoveryTokenTest(unittest.TestCase): self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) response = self.rec_token.perform(RecTokenChall("example6.com")) - self.assertIs(response, None) + self.assertTrue(response is None) if __name__ == '__main__': From ce13ead0cdf012ad9b2a25ff024eb00611a24555 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 16:48:18 -0800 Subject: [PATCH 62/66] pep8 compliance --- letsencrypt/client/apache/configurator.py | 2 +- letsencrypt/client/auth_handler.py | 5 ++++- letsencrypt/client/challenge_util.py | 1 + letsencrypt/client/client_authenticator.py | 1 + letsencrypt/client/tests/apache/configurator_test.py | 2 -- letsencrypt/client/tests/apache/dvsni_test.py | 3 +-- letsencrypt/client/tests/apache/obj_test.py | 1 + letsencrypt/client/tests/auth_handler_test.py | 2 ++ 8 files changed, 11 insertions(+), 6 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index a826c6756..bbf50bcb1 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -943,7 +943,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): fulfilled by configurator. :returns: list of responses. All responses are returned in the same - order as received by the perform function. A None response + order as received by the perform function. A None response indicates the challenge was not perfromed. :rtype: list diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index afe85c71a..40fd664f7 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -175,7 +175,7 @@ class AuthHandler(object): """Return list of challenge preferences. :param str domain: domain for which you are requesting preferences - + """ chall_prefs = [] chall_prefs.extend(self.client_auth.get_chall_pref(domain)) @@ -318,6 +318,7 @@ class AuthHandler(object): raise errors.LetsEncryptClientError( "Unimplemented Client Challenge: %s" % chall["type"]) + def gen_challenge_path(challenges, preferences, combos=None): """Generate a plan to get authority over the identity. @@ -391,6 +392,7 @@ def _find_smart_path(challenges, preferences, combos): return best_combo + def _find_dumb_path(challenges, preferences): """Find challenge path without server hints. @@ -422,6 +424,7 @@ def _find_dumb_path(challenges, preferences): return [i for (i, _) in path] + def is_preferred(offered_challenge_type, path): """Return whether or not the challenge is preferred in path.""" for _, challenge_type in path: diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index aef0d27e8..365d77edb 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -24,6 +24,7 @@ PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints") # the response within a larger challenge list IndexedChall = collections.namedtuple("IndexedChall", "chall, index") + # DVSNI Challenge functions def dvsni_gen_cert(filepath, name, r_b64, nonce, key): """Generate a DVSNI cert and save it to filepath. diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py index ac4406b28..1847d3760 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/client_authenticator.py @@ -6,6 +6,7 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import recovery_token + class ClientAuthenticator(object): """IAuthenticator for CONFIG.CLIENT_CHALLENGES. diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index 00560c970..ce12a137e 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -23,8 +23,6 @@ class TwoVhost80Test(unittest.TestCase): """Test two standard well configured HTTP vhosts.""" def setUp(self): - #zope.component.provideUtility(display.NcursesDisplay()) - self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( "debian_apache_2_4/two_vhost_80") diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index d2622a925..b24d47b45 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -18,7 +18,6 @@ class DvsniPerformTest(unittest.TestCase): def setUp(self): from letsencrypt.client.apache import dvsni - #zope.component.provideUtility(display.NcursesDisplay()) self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( "debian_apache_2_4/two_vhost_80") @@ -105,7 +104,7 @@ class DvsniPerformTest(unittest.TestCase): for chall in self.challs: expected_call_list.append( (self.sni.get_cert_file(chall.nonce), chall.domain, - chall.r_b64, chall.nonce, chall.key)) + chall.r_b64, chall.nonce, chall.key)) for i in range(len(expected_call_list)): for j in range(len(expected_call_list[0])): diff --git a/letsencrypt/client/tests/apache/obj_test.py b/letsencrypt/client/tests/apache/obj_test.py index 151880ac4..f78e83bb4 100644 --- a/letsencrypt/client/tests/apache/obj_test.py +++ b/letsencrypt/client/tests/apache/obj_test.py @@ -1,6 +1,7 @@ """Test the helper objects in apache.obj.py.""" import unittest + class AddrTest(unittest.TestCase): """Test the Addr class.""" def setUp(self): diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index ee0a79895..2cb801efc 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -363,6 +363,7 @@ class GetAuthorizationsTest(unittest.TestCase): self.assertFalse(self.handler.paths) self.assertFalse(self.handler.domains) + # pylint: disable=protected-access class PathSatisfiedTest(unittest.TestCase): def setUp(self): @@ -411,6 +412,7 @@ def gen_auth_resp(chall_list): return ["%s%s" % (type(chall).__name__, chall.domain) for chall in chall_list] + def gen_path(str_list, challenges): path = [] for i, chall in enumerate(challenges): From 32f628d3d2ee4692b847748211b9253230c9db20 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 17:10:39 -0800 Subject: [PATCH 63/66] use path param --- letsencrypt/client/auth_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 40fd664f7..e09e5bf59 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -232,7 +232,7 @@ class AuthHandler(object): dv_chall = [] client_chall = [] - for index in self.paths[domain]: + for index in path: chall = challenges[index] # Authenticator Challenges From 2114f61164421662d560ef158cf2da9c761ef0b9 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 17:31:11 -0800 Subject: [PATCH 64/66] better authorization logging --- letsencrypt/client/auth_handler.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index e09e5bf59..453fc881e 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -104,7 +104,7 @@ class AuthHandler(object): """ try: - return self.network.send_and_receive_expected( + auth = self.network.send_and_receive_expected( acme.authorization_request( self.msgs[domain]["sessionID"], domain, @@ -112,6 +112,7 @@ class AuthHandler(object): self.responses[domain], self.authkey[domain].pem), "authorization") + logging.info("Received Authorization for %s", domain) except errors.LetsEncryptClientError as err: logging.fatal(str(err)) logging.fatal( @@ -188,7 +189,7 @@ class AuthHandler(object): :param str domain: domain for which to clean up challenges """ - logging.info("Cleaning up challenges...") + logging.info("Cleaning up challenges for %s", domain) self.dv_auth.cleanup(self.dv_c[domain]) self.client_auth.cleanup(self.client_c[domain]) From d22ce3128cc0f1e0b6e8434484dd4e649ce6b603 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 13 Jan 2015 17:34:19 -0800 Subject: [PATCH 65/66] Return acme_auth for better api --- letsencrypt/client/auth_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 453fc881e..f33aede1c 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -113,6 +113,7 @@ class AuthHandler(object): self.authkey[domain].pem), "authorization") logging.info("Received Authorization for %s", domain) + return auth except errors.LetsEncryptClientError as err: logging.fatal(str(err)) logging.fatal( From 31cfe7cfe6c3720527af800343b32e11cfa28f7a Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 15 Jan 2015 03:25:39 -0800 Subject: [PATCH 66/66] replaced options-ssl.conf move to avoid unnecesary problems, also placed the copy more appropriately within configurator --- letsencrypt/client/apache/configurator.py | 25 ++++++++++++++++++++--- letsencrypt/client/apache/dvsni.py | 12 ----------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index bbf50bcb1..e0e1852ed 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -1,7 +1,9 @@ """Apache Configuration based off of Augeas Configurator.""" import logging import os +import pkg_resources import re +import shutil import socket import subprocess import sys @@ -127,6 +129,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # on initialization self._prepare_server_https() + # Move temporary files before release to reduce developer + # problems. + temp_install(ssl_options) + def deploy_cert(self, vhost, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. @@ -206,9 +212,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ # Allows for domain names to be associated with a virtual host # Client isn't using create_dn_server_assoc(self, dn, vh) yet - for domain, vhost in self.assoc: - if domain == target_name: - return vhost + if target_name in self.assoc: + return self.assoc[target_name] # Check for servernames/aliases for ssl hosts for vhost in self.vhosts: if vhost.ssl and target_name in vhost.names: @@ -1088,3 +1093,17 @@ def get_file_path(vhost_path): continue break return avail_fp + + +def temp_install(options_ssl): + """Temporary install for convenience.""" + # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY + # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER + # AND TAKEN OUT BEFORE RELEASE, INSTEAD + # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM. + + # Check to make sure options-ssl.conf is installed + if not os.path.isfile(options_ssl): + dist_conf = pkg_resources.resource_filename( + __name__, os.path.basename(options_ssl)) + shutil.copyfile(dist_conf, options_ssl) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index a9d08da27..c0aa552ad 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -111,18 +111,6 @@ class ApacheDvsni(object): :class:`letsencrypt.client.apache.obj.Addr` to apply """ - # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY - # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER - # AND TAKEN OUT BEFORE RELEASE, INSTEAD - # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM - - # Check to make sure options-ssl.conf is installed - # pylint: disable=no-member - # if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): - # dist_conf = pkg_resources.resource_filename( - # __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - # shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) - # TODO: Use ip address of existing vhost instead of relying on FQDN config_text = "\n" for idx, lis in enumerate(ll_addrs):