diff --git a/.gitignore b/.gitignore index ecb4aa7..497cfb6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ # ... as well as external IO plugin script /external.sh -# `sshfs user@host:$ROOT public_html/` and `--default_root=public_html` +# `sshfs user@host:$ROOT public_html/.well-known/acme-challenge` and +# `simp_le ... public_html/.well-known/acme-challenge` /public_html/ diff --git a/README.rst b/README.rst index ca9c9bf..4182621 100644 --- a/README.rst +++ b/README.rst @@ -7,10 +7,10 @@ Simple `Let’s Encrypt`_ client. .. code:: shell + ./examples/generate_csr.sh example.com simp_le --email you@example.com -f account_key.json \ - -f fullchain.pem -f key.pem \ - -d example.com -d www.example.com --default_root /var/www/html \ - -d example.net:/var/www/other_html + -f csr.pem -f fullchain.pem -f chain.pem -f cert.pem \ + /var/www/html/.well-known/acme-challenge For more info see ``simp_le --help``. @@ -22,9 +22,9 @@ Manifest 2. ``simp_le --valid_min ${seconds?} -f cert.pem`` implies that ``cert.pem`` is valid for at at least ``valid_min``. Register new ACME CA account if necessary. Issue new certificate if no previous - key/certificate/chain found. Renew only if necessary. + certificate/chain found. Renew only if necessary. -3. (Sophisticated) “manager” for +3. (Sophisticated) "manager" for ``${webroot?}/.well-known/acme-challenge`` only. No challenges other than ``http-01``. Existing web-server must be running already. @@ -40,16 +40,16 @@ Manifest should write their own wrapper scripts or use shell aliases if necessary. -8. Support multiple domains with multiple roots. Always create single +8. Support multiple domains (sharing + ``${webroot?}/.well-known/acme-challenge``). Always create single SAN certificate per ``simp_le`` run. -9. Flexible storage capabilities. Built-in - ``simp_le -f fullchain.pem -f key.pem``, - ``simp_le -f chain.pem -f cert.pem -f key.pem``, etc. Extensions - through ``simp_le -f external.sh``. +9. Flexible storage capabilities. Built-in ``simp_le -f + fullchain.pem``, ``simp_le -f chain.pem -f cert.pem``, + etc. Extensions through ``simp_le -f external.sh``. -10. Do not allow specifying output file paths. Users should symlink if - necessary! +10. Do not allow specifying input/output file paths. Users should + symlink if necessary! 11. No need to allow specifying an arbitrary command when renewal has happened, just check the exit code: diff --git a/examples/external.sh b/examples/external.sh index fad27a6..540b281 100755 --- a/examples/external.sh +++ b/examples/external.sh @@ -1,7 +1,7 @@ #!/bin/sh # # Dummy example external script that loads/saves -# account_key/key/cert/chain to /tmp/foo. Experiment e.g. by running +# account_key/csr/cert/chain to /tmp/foo. Experiment e.g. by running # `./external.sh persisted`, `echo foo | ./external.sh save; cat # /tmp/foo`, or `./external.sh load`; note the exit codes. The plugin # can be loaded by running `simp_le -f external.sh`. @@ -9,5 +9,5 @@ case $1 in save) cat - > /tmp/foo;; load) [ ! -f /tmp/foo ] || cat /tmp/foo;; - persisted) echo account_key key cert chain;; + persisted) echo account_key csr cert chain;; esac diff --git a/examples/generate_csr.sh b/examples/generate_csr.sh new file mode 100755 index 0000000..6530314 --- /dev/null +++ b/examples/generate_csr.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# +# This script generates a simple SAN CSR to be used with ACME CA. + +if [ "$#" -lt 1 ] +then + echo "Usage: $0 name [name...]" >&2 + exit 1 +fi + +OUTFORM=${OUTFORM:-pem} +OUT="csr.${OUTFORM}" +# 512 or 1024 too low for Boulder, 2048 is smallest for tests +BITS="${BITS:-4096}" +KEYOUT=key.pem + +names="DNS:$1" +shift +for x in "$@" +do + names="$names,DNS:$x" +done + +openssl_cnf=$(mktemp) +cat >"${openssl_cnf}" <>> pem = OpenSSL.crypto.dump_privatekey( - ... OpenSSL.crypto.FILETYPE_PEM, gen_pkey(1024)) - >>> k1 = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, pem) - >>> k2 = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, pem) - - Unfortunately, in pyOpenSSL, equality is not well defined: - - >>> k1 == k2 - False - - Using `ComparablePKey` you get the equality relation right: - - >>> ck1, ck2 = ComparablePKey(k1), ComparablePKey(k2) - >>> other_ckey = ComparablePKey(gen_pkey(1024)) - >>> ck1 == ck2 - True - >>> ck1 == k1 - False - >>> k1 == ck1 - False - >>> other_ckey == ck1 - False - - Non-equalty is also well defined: - - >>> ck1 != ck2 - False - >>> ck1 != k1 - True - >>> k1 != ck1 - True - >>> k1 != other_ckey - True - >>> other_ckey != ck1 - True - - Wrapepd key is available as well: - - >>> ck1.wrapped is k1 - True - - Internal implementation is not optimized for performance! - """ - def __init__(self, wrapped): - self.wrapped = wrapped - - def __ne__(self, other): - return not self == other # pylint: disable=unneeded-not - - def _dump(self): - return OpenSSL.crypto.dump_privatekey( - OpenSSL.crypto.FILETYPE_ASN1, self.wrapped) - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - # pylint: disable=protected-access - return self._dump() == other._dump() - - -class Vhost(collections.namedtuple('Vhost', 'name root')): - """Vhost: domain name and public html root.""" - _SEP = ':' - - @classmethod - def decode(cls, data): - """Decode vhost. - - >>> Vhost.decode('example.com') - Vhost(name='example.com', root=None) - >>> Vhost.decode('example.com:/var/www/html') - Vhost(name='example.com', root='/var/www/html') - >>> Vhost.decode(Vhost(name='example.com', root=None)) - Vhost(name='example.com', root=None) - """ - if isinstance(data, cls): - return data - parts = data.split(cls._SEP, 1) - parts.append(None) - return cls(name=parts[0], root=parts[1]) - - class IOPlugin(object): """Input/output plugin. - In case of any problems, `persisted`, `load` and `save` - methods should raise `Error`, for which message will be - displayed directly to the user through STDERR (in `main`). + In case of any problems, `persisted`, `load` and `save` methods + should raise `Error`, for which message will be displayed directly + to the user through STDERR (in `main`). """ __metaclass__ = abc.ABCMeta - Data = collections.namedtuple('IOPluginData', 'account_key key cert chain') + Data = collections.namedtuple('IOPluginData', 'account_key csr cert chain') """Plugin data. Unless otherwise stated, plugin data components are typically filled with the following data: - for `account_key`: private account key, an instance of `acme.jose.JWK` - - for `key`: private key, an instance of `OpenSSL.crypto.PKey` + - for `csr`: Certificate Signing Request, an instance of + `OpenSSL.crypto.X509` wrapped in `acme.jose.ComparableX509` - for `cert`: certificate, an instance of `OpenSSL.crypto.X509` - - for `chain`: certificate chain, a list of `OpenSSL.crypto.X509` instances + wrapped in `acme.jose.ComparableX509` + - for `chain`: certificate chain, a list of `OpenSSL.crypto.X509` + instances wrapped in `acme.jose.ComparableX509` """ - EMPTY_DATA = Data(account_key=None, key=None, cert=None, chain=None) + EMPTY_DATA = Data(account_key=None, csr=None, cert=None, chain=None) def __init__(self, path, **dummy_kwargs): self.path = path @@ -420,10 +336,10 @@ class AccountKey(FileIOPlugin, JWKIOPlugin): WRITE_MODE = 'w' def persisted(self): - return self.Data(account_key=True, key=False, cert=False, chain=False) + return self.Data(account_key=True, csr=False, cert=False, chain=False) def load_from_content(self, content): - return self.Data(account_key=self.load_jwk(content), key=None, + return self.Data(account_key=self.load_jwk(content), csr=None, cert=None, chain=None) def save(self, data): @@ -441,13 +357,16 @@ def __init__(self, typ=OpenSSL.crypto.FILETYPE_PEM, **kwargs): self.typ = typ super(OpenSSLIOPlugin, self).__init__(**kwargs) - def load_key(self, data): - """Load private key.""" - return ComparablePKey(OpenSSL.crypto.load_privatekey(self.typ, data)) + def load_csr(self, data): + """Load CSR.""" + return jose.ComparableX509(OpenSSL.crypto.load_certificate_request( + self.typ, data)) - def dump_key(self, data): - """Dump private key.""" - return OpenSSL.crypto.dump_privatekey(self.typ, data.wrapped).strip() + def dump_csr(self, data): + """Dump CSR.""" + # pylint: disable=protected-access + return OpenSSL.crypto.dump_certificate_request( + self.typ, data.wrapped).strip() def load_cert(self, data): """Load certificate.""" @@ -483,12 +402,12 @@ class ExternalIOPlugin(OpenSSLIOPlugin): - whenever the script is called with `persisted` as the first argument, it should send to STDOUT a single line consisting of a - subset of three keywords: `account_key`, `key`, `cart`, `chain` + subset of three keywords: `account_key`, `csr`, `cart`, `chain` (in any order, separated by whitespace); - whenever the script is called with `load` as the first argument it shall write to STDOUT all persisted data as PEM encoded strings in - the following order: account_key, key, certificate, certificates + the following order: account_key, csr, certificate, certificates in the chain (from leaf to root). If some data is not persisted, it must be skipped in the output; @@ -518,8 +437,8 @@ def get_output_or_fail(self, command): raise Error('External script exited with non-zero code: %d' % proc.returncode) - # Do NOT log `stdout` as it might contain secret material (in - # case key is persisted) + # invariant: STDOUT will not contain any secret material + logger.debug('STDOUT: %s', stdout) return stdout def persisted(self): @@ -527,7 +446,7 @@ def persisted(self): output = self.get_output_or_fail('persisted').split() return self.Data( account_key=(b'account_key' in output), - key=(b'key' in output), + csr=(b'csr' in output), cert=(b'cert' in output), chain=(b'chain' in output), ) @@ -541,11 +460,11 @@ def load(self): account_key = load_pem_jwk( pems.pop(0)) if persisted.account_key else None - key = self.load_key(pems.pop(0)) if persisted.key else None + csr = self.load_csr(pems.pop(0)) if persisted.csr else None cert = self.load_cert(pems.pop(0)) if persisted.cert else None chain = ([self.load_cert(cert_data) for cert_data in pems] if persisted.chain else None) - return self.Data(account_key=account_key, key=key, + return self.Data(account_key=account_key, csr=csr, cert=cert, chain=chain) def save(self, data): @@ -554,8 +473,8 @@ def save(self, data): output = [] if persisted.account_key: output.append(dump_pem_jwk(data.account_key)) - if persisted.key: - output.append(self.dump_key(data.key)) + if persisted.csr: + output.append(self.dump_csr(data.csr)) if persisted.cert: output.append(self.dump_cert(data.cert)) if persisted.chain: @@ -595,14 +514,13 @@ def __init__(self, *args, **kwargs): public_exponent=65537, key_size=1024, backend=default_backend(), )), - key=ComparablePKey(raw_key), + csr=jose.ComparableX509(gen_csr(raw_key, [b'example.com'])), cert=jose.ComparableX509(crypto_util.gen_ss_cert(raw_key, ['a'])), chain=[ jose.ComparableX509(crypto_util.gen_ss_cert(raw_key, ['b'])), jose.ComparableX509(crypto_util.gen_ss_cert(raw_key, ['c'])), ], ) - self.key_data = IOPlugin.EMPTY_DATA._replace(key=self.all_data.key) def setUp(self): # pylint: disable=invalid-name self.root = tempfile.mkdtemp() @@ -654,7 +572,7 @@ def test_load_nonzero_raises_error(self): def test_save_nonzero_raises_error(self): self.save_script('#!/bin/sh\nfalse') self.assert_raises_error( - '.*exited with non-zero code: 1', self.plugin.save, self.key_data) + '.*exited with non-zero code: 1', self.plugin.save, self.all_data) def one_file_script(self, persisted): path = os.path.join(self.root, 'pem') @@ -669,7 +587,7 @@ def one_file_script(self, persisted): return path def test_it(self): - path = self.one_file_script('cert chain key account_key') + path = self.one_file_script('cert chain csr account_key') # not yet persisted self.assertEqual(IOPlugin.EMPTY_DATA, self.plugin.load()) # save some data @@ -684,12 +602,12 @@ class ChainFile(FileIOPlugin, OpenSSLIOPlugin): """Certificate chain plugin.""" def persisted(self): - return self.Data(account_key=False, key=False, cert=False, chain=True) + return self.Data(account_key=False, csr=False, cert=False, chain=True) def load_from_content(self, output): chain = [self.load_cert(cert_data) for cert_data in split_pems(output)] - return self.Data(account_key=None, key=None, cert=None, chain=chain) + return self.Data(account_key=None, csr=None, cert=None, chain=chain) def save(self, data): return self.save_to_file(_PEMS_SEP.join( @@ -707,7 +625,7 @@ class FullChainFile(ChainFile): """Full chain file plugin.""" def persisted(self): - return self.Data(account_key=False, key=False, cert=True, chain=True) + return self.Data(account_key=False, csr=False, cert=True, chain=True) def load(self): data = super(FullChainFile, self).load() @@ -715,12 +633,12 @@ def load(self): cert, chain = None, None else: cert, chain = data.chain[0], data.chain[1:] - return self.Data(account_key=data.account_key, key=data.key, + return self.Data(account_key=data.account_key, csr=data.csr, cert=cert, chain=chain) def save(self, data): return super(FullChainFile, self).save(self.Data( - account_key=data.account_key, key=data.key, + account_key=data.account_key, csr=data.csr, cert=None, chain=([data.cert] + data.chain))) @@ -730,26 +648,27 @@ class FullChainFileTest(FileIOPluginTestMixin, UnitTestCase): PLUGIN_CLS = FullChainFile -@IOPlugin.register(path='key.der', typ=OpenSSL.crypto.FILETYPE_ASN1) -@IOPlugin.register(path='key.pem', typ=OpenSSL.crypto.FILETYPE_PEM) -class KeyFile(FileIOPlugin, OpenSSLIOPlugin): - """Private key file plugin.""" +@IOPlugin.register(path='csr.der', typ=OpenSSL.crypto.FILETYPE_ASN1) +@IOPlugin.register(path='csr.pem', typ=OpenSSL.crypto.FILETYPE_PEM) +class CSRFile(FileIOPlugin, OpenSSLIOPlugin): + """CSR file plugin.""" def persisted(self): - return self.Data(account_key=False, key=True, cert=False, chain=False) + return self.Data(account_key=False, csr=True, cert=False, chain=False) def load_from_content(self, output): - return self.Data(account_key=None, key=self.load_key(output), + return self.Data(account_key=None, csr=self.load_csr(output), cert=None, chain=None) def save(self, data): - return self.save_to_file(self.dump_key(data.key)) + # TODO: CSRs should be read-only, it's silly to overwrite existing file + return self.save_to_file(self.dump_csr(data.csr)) -class KeyFileTest(FileIOPluginTestMixin, UnitTestCase): - """Tests for KeyFile.""" +class CSRFileTest(FileIOPluginTestMixin, UnitTestCase): + """Tests for CSRFile.""" # this is a test suite | pylint: disable=missing-docstring - PLUGIN_CLS = KeyFile + PLUGIN_CLS = CSRFile @IOPlugin.register(path='cert.der', typ=OpenSSL.crypto.FILETYPE_ASN1) @@ -758,10 +677,10 @@ class CertFile(FileIOPlugin, OpenSSLIOPlugin): """Certificate file plugin.""" def persisted(self): - return self.Data(account_key=False, key=False, cert=True, chain=False) + return self.Data(account_key=False, csr=False, cert=True, chain=False) def load_from_content(self, output): - return self.Data(account_key=None, key=None, + return self.Data(account_key=None, csr=None, cert=self.load_cert(output), chain=None) def save(self, data): @@ -774,34 +693,6 @@ class CertFileTest(FileIOPluginTestMixin, UnitTestCase): PLUGIN_CLS = CertFile -@IOPlugin.register(path='full.pem', typ=OpenSSL.crypto.FILETYPE_PEM) -class FullFile(FileIOPlugin, OpenSSLIOPlugin): - """Private key, certificate and chain plugin.""" - - def persisted(self): - return self.Data(account_key=False, key=True, cert=True, chain=True) - - def load_from_content(self, content): - pems = split_pems(content) - return self.Data( - account_key=None, - key=self.load_key(next(pems)), - cert=self.load_cert(next(pems)), - chain=[self.load_cert(cert) for cert in pems], - ) - - def save(self, data): - pems = [self.dump_key(data.key), self.dump_cert(data.cert)] - pems.extend(self.dump_cert(cert) for cert in data.chain) - self.save_to_file(_PEMS_SEP.join(pems)) - - -class FullFileTest(FileIOPluginTestMixin, UnitTestCase): - """Tests for FullFile.""" - # this is a test suite | pylint: disable=missing-docstring - PLUGIN_CLS = FullFile - - def create_parser(): """Create argument parser.""" parser = argparse.ArgumentParser( @@ -838,30 +729,13 @@ def create_parser(): help='Run integration tests and exit.', ) - manager = parser.add_argument_group( - 'Webroot manager', description='This client is just a ' - 'sophisticated manager for $webroot/' + - challenges.HTTP01.URI_ROOT_PATH + '. You can (optionally) ' - 'specify `--default_root`, and override per-vhost with ' - '`-d example.com:/var/www/other_html` syntax.', - ) - manager.add_argument( - '-d', '--vhost', dest='vhosts', action='append', - help='Domain name that will be included in the certificate. ' - 'Must be specified at least once.', metavar='DOMAIN:PATH', - type=Vhost.decode, - ) - manager.add_argument( - '--default_root', help='Default webroot path.', metavar='PATH', - ) - io_group = parser.add_argument_group('Certificate data files') io_group.add_argument( '-f', dest='ioplugins', action='append', default=[], metavar='PLUGIN', choices=sorted(IOPlugin.registered), help='Input/output plugin of choice, can be specified multiple ' 'times and, in fact, it should be specified as many times as it ' - 'is necessary to cover all components: key, certificate, chain. ' + 'is necessary to cover all components: csr, certificate, chain. ' 'Allowed values: %s.' % ', '.join(sorted(IOPlugin.registered)), ) io_group.add_argument( @@ -915,6 +789,7 @@ def create_parser(): help='Directory URI for the CA ACME API endpoint.', ) + parser.add_argument('root', nargs='?', help='Path to webroot.') return parser @@ -936,34 +811,6 @@ def supported_challb(authorization): return None -def compute_roots(vhosts, default_root): - """Compute webroots. - - Args: - vhosts: collection of `Vhost` objects. - default_root: Default webroot path. - - Returns: - Dictionary mapping vhost name to its webroot path. Vhosts without - a root will be pre-populated with the `default_root`. - """ - roots = {} - for vhost in vhosts: - if vhost.root is not None: - root = vhost.root - else: - root = default_root - roots[vhost.name] = root - - empty_roots = dict((name, root) - for name, root in six.iteritems(roots) if root is None) - if empty_roots: - raise Error('Root for the following host(s) were not specified: %s. ' - 'Try --default_root or use -d example.com:/var/www/html ' - 'syntax' % ', '.join(empty_roots)) - return roots - - def save_validation(root, challb, validation): """Save validation to webroot. @@ -973,12 +820,13 @@ def save_validation(root, challb, validation): validation: `http-01` validation """ try: - os.makedirs(os.path.join(root, challb.URI_ROOT_PATH)) + os.makedirs(root) except OSError as error: if error.errno != errno.EEXIST: # directory doesn't already exist and we cannot create it raise - path = os.path.join(root, challb.path[1:]) + # TODO: this is a nasty hack + path = os.path.join(root, challb.path.split('/')[-1]) with open(path, 'w') as validation_file: logger.debug('Saving validation (%r) at %s', validation, path) validation_file.write(validation) @@ -1108,7 +956,7 @@ def integration_test(args): def check_plugins_persist_all(ioplugins): """Do plugins cover all components (key/cert/chain)?""" persisted = IOPlugin.Data( - account_key=False, key=False, cert=False, chain=False) + account_key=False, csr=False, cert=False, chain=False) for plugin_name in ioplugins: persisted = IOPlugin.Data(*componentwise_or( persisted, IOPlugin.registered[plugin_name].persisted())) @@ -1170,12 +1018,12 @@ def pyopenssl_cert_or_req_san(cert): return crypto_util._pyopenssl_cert_or_req_san(cert) -def valid_existing_cert(cert, vhosts, valid_min): +def valid_existing_cert(cert, names, valid_min): """Is the existing cert data valid for enough time? If provided certificate is `None`, then always return True: - >>> valid_existing_cert(cert=None, vhosts=[], valid_min=0) + >>> valid_existing_cert(cert=None, names=[], valid_min=0) False >>> cert = jose.ComparableX509(crypto_util.gen_ss_cert( @@ -1183,25 +1031,24 @@ def valid_existing_cert(cert, vhosts, valid_min): Return True iff `valid_min` is not bigger than certificate lifespan: - >>> valid_existing_cert(cert, [Vhost.decode('example.com')], 0) + >>> valid_existing_cert(cert, ['example.com'], 0) True - >>> valid_existing_cert(cert, [Vhost.decode('example.com')], 60 * 60 + 1) + >>> valid_existing_cert(cert, ['example.com'], 60 * 60 + 1) False If SANs mismatch return False no matter if expiring or not: - >>> valid_existing_cert(cert, [Vhost.decode('example.net')], 0) + >>> valid_existing_cert(cert, ['example.net'], 0) False - >>> valid_existing_cert(cert, [Vhost.decode('example.org')], 60 * 60 + 1) + >>> valid_existing_cert(cert, ['example.org'], 60 * 60 + 1) False """ if cert is None: return False # no existing certificate else: # renew existing? - new_sans = [vhost.name for vhost in vhosts] existing_sans = pyopenssl_cert_or_req_san(cert.wrapped) - logger.debug('Existing SANs: %r, new: %r', existing_sans, new_sans) - return (set(existing_sans) == set(new_sans) and + logger.debug('Existing SANs: %r, new: %r', existing_sans, names) + return (set(existing_sans) == set(names) and not renewal_necessary(cert, valid_min)) @@ -1246,7 +1093,7 @@ def get_certr(client, csr, authorizations): """Get Certificate Resource for specified CSR and authorizations.""" try: certr, _ = client.poll_and_request_issuance( - jose.ComparableX509(csr), authorizations.values(), + csr, authorizations.values(), # https://github.com/letsencrypt/letsencrypt/issues/1719 max_attempts=(10 * len(authorizations))) except acme_errors.PollError as error: @@ -1263,10 +1110,10 @@ def get_certr(client, csr, authorizations): logger.error('CA marked some of the authorizations as invalid, ' 'which likely means it could not access ' 'http://example.com/.well-known/acme-challenge/X. ' - 'Did you set correct path in -d example.com:path ' - 'or --default_root? Is there a warning log entry ' - 'about unsuccessful self-verification? Are all your ' - 'domains accessible from the internet? Failing ' + 'Did you set correct webroot path? Is there a ' + 'warning log entry about unsuccessful ' + 'self-verification? Are all your domains ' + 'accessible from the internet? Failing ' 'authorizations: %s', ', '.join(authzr.uri for authzr in invalid)) @@ -1274,17 +1121,15 @@ def get_certr(client, csr, authorizations): return certr -def persist_new_data(args, existing_data): +def persist_new_data(args, existing_data, names): """Issue and persist new key/cert/chain.""" - roots = compute_roots(args.vhosts, args.default_root) - logger.debug('Computed roots: %r', roots) - + assert names client = registered_client(args, existing_data.account_key) authorizations = dict( - (vhost.name, client.request_domain_challenges( - vhost.name, new_authzr_uri=client.directory.new_authz)) - for vhost in args.vhosts + (name, client.request_domain_challenges( + name, new_authzr_uri=client.directory.new_authz)) + for name in names ) if any(supported_challb(auth) is None for auth in six.itervalues(authorizations)): @@ -1294,7 +1139,7 @@ def persist_new_data(args, existing_data): for name, auth in six.iteritems(authorizations): challb = supported_challb(auth) response, validation = challb.response_and_validation(client.key) - save_validation(roots[name], challb, validation) + save_validation(args.root, challb, validation) verified = response.simple_verify( challb.chall, name, client.key.public_key()) @@ -1306,16 +1151,12 @@ def persist_new_data(args, existing_data): client.answer_challenge(challb, response) - if args.reuse_key and existing_data.key is not None: - logger.info('Reusing existing certificate private key') - key = existing_data.key - else: - logger.info('Generating new certificate private key') - key = ComparablePKey(gen_pkey(args.cert_key_size)) - csr = gen_csr(key.wrapped, [vhost.name.encode() for vhost in args.vhosts]) - certr = get_certr(client, csr, authorizations) + certr = get_certr(client, existing_data.csr, authorizations) + # pylint: disable=protected-access + assert set(names) == set(crypto_util._pyopenssl_cert_or_req_san( + certr.body.wrapped)) # pylint: disable=no-member persist_data(args, existing_data, new_data=IOPlugin.Data( - account_key=client.key, key=key, + account_key=client.key, csr=existing_data.csr, cert=certr.body, chain=client.fetch_chain(certr))) @@ -1373,17 +1214,22 @@ def main_with_exceptions(cli_args): if args.revoke: # --revoke return revoke(args) - if args.vhosts is None: - raise Error('You must set at least one -d/--vhost') + if args.root is None: + raise Error('Webroot argument is required') + check_plugins_persist_all(args.ioplugins) existing_data = load_existing_data(args.ioplugins) - if valid_existing_cert(existing_data.cert, args.vhosts, args.valid_min): + assert existing_data.csr is not None + # pylint: disable=protected-access + names = crypto_util._pyopenssl_cert_or_req_san( + existing_data.csr.wrapped) + if valid_existing_cert(existing_data.cert, names, args.valid_min): logger.info('Certificates already exist and renewal is not ' 'necessary, exiting with status code %d.', EXIT_NO_RENEWAL) return EXIT_NO_RENEWAL else: - persist_new_data(args, existing_data) + persist_new_data(args, existing_data, names) return EXIT_RENEWAL @@ -1430,23 +1276,18 @@ def test_error_exit_codes(self, dummy_stderr): test_args = [ '', # no args - no good '--bar', # unrecognized - '-f account_key.json -f key.pem -f fullchain.pem', # no vhosts - # no root - '-f account_key.json -f key.pem -f fullchain.pem -d example.com', - # no root with multiple domains - '-f account_key.json -f key.pem -f fullchain.pem ' - '-d example.com:public_html -d www.example.com', + '-f account_key.json -f csr.pem -f fullchain.pem', # no root ] # missing plugin coverage - test_args.extend(['-d example.com:public_html %s' % rest for rest in [ + test_args.extend([ '-f account_key.json', - '-f key.pem', - '-f account_key.json -f key.pem', - '-f key.pem -f cert.pem', - '-f key.pem -f chain.pem', + '-f csr.pem', + '-f account_key.json -f csr.pem', + '-f csr.pem -f cert.pem', + '-f csr.pem -f chain.pem', '-f fullchain.pem', '-f cert.pem -f fullchain.pem', - ]]) + ]) for args in test_args: self.assertEqual( @@ -1475,6 +1316,7 @@ class IntegrationTests(unittest.TestCase): # this is a test suite | pylint: disable=missing-docstring SERVER = 'http://localhost:4000/directory' + BOULDER_MIN_BITS = 2048 TOS_SHA256 = ('b16e15764b8bc06c5c3f9f19bc8b99fa' '48e7894aa5a6ccdad65da49bbf564793') PORT = 5002 @@ -1509,13 +1351,22 @@ def _single_path_stats(path): return stats return dict((path, _single_path_stats(path)) for path in paths) + @classmethod + def _save_csr(cls, *names): + return IOPlugin.registered['csr.pem'].save( + IOPlugin.EMPTY_DATA._replace(csr=jose.ComparableX509( + gen_csr(gen_pkey(cls.BOULDER_MIN_BITS), names)))) + def test_it(self): - webroot = os.path.join(os.getcwd(), 'public_html') + webroot = os.path.join( + os.getcwd(), 'public_html', '.well-known', 'acme-challenge') args = ('--server %s --tos_sha256 %s -f account_key.json ' - '-f key.pem -f full.pem -d le.wtf:%s' % ( + '-f csr.pem -f fullchain.pem %s' % ( self.SERVER, self.TOS_SHA256, webroot)) - files = ('account_key.json', 'key.pem', 'full.pem') + files = ('account_key.json', 'csr.pem', 'fullchain.pem') + with self._new_swd(): + self._save_csr(b'le.wtf') self.assertEqual(EXIT_RENEWAL, self._run(args)) initial_stats = self.get_stats(*files) unchangeable_stats = self.get_stats(files[0]) @@ -1526,14 +1377,13 @@ def test_it(self): self.assertEqual(initial_stats, self.get_stats(*files)) self.assertEqual(EXIT_REVOKE_OK, self._run( - '--server %s --revoke -f account_key.json -f full.pem' % + '--server %s --revoke -f account_key.json -f fullchain.pem' % self.SERVER)) # Revocation shouldn't touch any files self.assertEqual(initial_stats, self.get_stats(*files)) # Changing SANs should trigger "renewal" - self.assertEqual( - EXIT_RENEWAL, self._run('%s -d le2.wtf:%s' % (args, webroot))) + self._save_csr(b'le.wtf', b'le2.wtf') # but it shouldn't unnecessarily overwrite the account key (#67) self.assertEqual(unchangeable_stats, self.get_stats(files[0]))