diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py new file mode 100644 index 000000000..75f9acabf --- /dev/null +++ b/letsencrypt/client/account.py @@ -0,0 +1,115 @@ +import json +import os +import sys + +import configobj +import zope.component + +from letsencrypt.acme import messages2 + +from letsencrypt.client import crypto_util +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util + +from letsencrypt.client.display import ops as display_ops + + +class Account(object): + """ACME protocol registration. + + :ivar config: Client configuration object + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + :ivar key: Account/Authorized Key + :type key: :class:`~letsencrypt.client.le_util.Key` + + :ivar str email: Client's email address + :ivar str phone: Client's phone number + + :ivar bool save: Whether or not to save the account information + + :ivar regr: Registration Resource + :type regr: :class:`~letsencrypt.acme.messages2.RegistrationResource` + + """ + def __init__(self, config, key, email=None, phone=None, regr=None): + self.key = key + self.config = config + self.email = email + self.phone = phone + + self.regr = regr + + def save(self): + # account_dir = le_util.make_or_verify_dir( + # os.path.join(self.config.config_dir, "accounts")) + # account_key_dir = le_util.make_or_verify_dir( + # os.path.join(account_dir, "keys"), 0o700) + + acc_config = configobj.ConfigObj() + # acc_config.filename = os.path.join( + # account_dir, self._get_config_filename()) + acc_config.filename = sys.stdout + + acc_config.initial_comment = [ + "Account information for %s under %s" % ( + self._get_config_filename(self.email), self.config.server)] + acc_config["key"] = self.key.path + acc_config["phone"] = self.phone + + regr_json = self.regr.to_json() + regr_dict = json.loads(regr_json) + + acc_config["regr"] = regr_dict + acc_config.write() + + @classmethod + def _get_config_filename(self, email): + return email if email is not None else "default" + + @classmethod + def from_existing_account(cls, config, email=None): + accounts_dir = os.path.join( + config.config_dir, "accounts", config.server) + config_fp = os.path.join(accounts_dir, cls._get_config_filename(email)) + return cls._from_config_fp(config, config_fp) + + @classmethod + def _from_config_fp(cls, config, config_fp): + try: + acc_config = configobj.ConfigObj( + infile=config_fp, file_error=True, create_empty=False) + except IOError: + raise errors.LetsEncryptClientError( + "Account for %s does not exist" % os.path.basename(config_fp)) + json_regr = json.dumps(acc_config["regr"]) + return cls(config, acc_config["key"], acc_config["email"], + acc_config["phone"], + messages2.RegistrationResource.from_json(json_regr)) + + @classmethod + def choose_account(cls, config): + """Choose one of the available accounts.""" + accounts = [] + accounts_dir = os.path.join(config.config_dir, "accounts") + filenames = os.listdir(accounts_dir) + for name in filenames: + # Not some directory ie. keys + config_fp = os.path.join(accounts_dir, name) + if os.path.isfile(config_fp): + accounts.append(cls._from_config_fp(config, config_fp)) + + if len(accounts) == 1: + return accounts[0] + elif len(accounts) > 1: + return display_ops.choose_account(accounts) + else: + return None + + @classmethod + def from_prompts(cls, config): + email = zope.component.getUtility(interfaces.IDisplay).input( + "Enter email address") + key_dir = os.path.join(config.config_dir, "accounts", config.server, "keys") + key = crypto_util.init_save_key(2048, config.accounts_dir, email) + return cls(config, email, key) \ No newline at end of file diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 09c731cf0..72af44526 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -172,8 +172,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes raise errors.AuthorizationError( "Failed Authorization procedure for %s" % domain) - self._cleanup_challenges(comp_challs) - self._cleanup_challenges(failed_challs) + self._cleanup_challenges(comp_challs+failed_challs) dom_to_check -= comp_domains comp_domains.clear() @@ -191,6 +190,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # challenges will be determined here... for achall in achalls: status = self._get_chall_status(self.authzr[domain], achall) + print "Status:", status # This does nothing for challenges that have yet to be decided yet. if status == messages2.STATUS_VALID: completed.append(achall) @@ -209,6 +209,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes for authzr_chall in authzr: if type(authzr_chall) is type(chall): return chall.status + raise errors.AuthorizationError( + "Target challenge not found in authorization resource") def _get_chall_pref(self, domain): """Return list of challenge preferences. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 67d89cff9..a89397046 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -4,6 +4,7 @@ import os import sys import M2Crypto +import zope.component from letsencrypt.acme import jose from letsencrypt.acme.jose import jwk @@ -12,6 +13,7 @@ from letsencrypt.client import auth_handler from letsencrypt.client import continuity_auth from letsencrypt.client import crypto_util from letsencrypt.client import errors +from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network2 from letsencrypt.client import reverter @@ -31,8 +33,8 @@ class Client(object): :ivar authkey: Authorization Key :type authkey: :class:`letsencrypt.client.le_util.Key` - :ivar reg: Registration Resource - :type reg: :class:`letsencrypt.acme.messages2.RegistrationResource` + :ivar account: Account object used for registration + :type account: :class:`letsencrypt.client.registration.Registration` :ivar auth_handler: Object that supports the IAuthenticator interface. auth_handler contains both a dv_authenticator and a @@ -58,7 +60,7 @@ class Client(object): """ self.authkey = authkey - self.regr = None + self.account = None self.installer = installer # TODO: Allow for other alg types besides RS256 @@ -75,34 +77,19 @@ class Client(object): else: self.auth_handler = None - def register(self, email=None, phone=None): + def register(self, network, store=True): """New Registration with the ACME server. - :param str email: User's email address - :param str phone: User's phone number + :param bool store: Whether to store the registration information """ - # TODO: properly format/scrub phone number - details = ( - "mailto:" + email if email is not None else None, - "tel:" + phone if phone is not None else None - ) - contact_tuple = tuple(detail for detail in details if detail is not None) - - # TODO: Replace with real info once through testing. - if not contact_tuple: - contact_tuple = ("mailto:letsencrypt-client@letsencrypt.org", - "tel:+12025551212") - self.regr = self.network.register(contact=contact_tuple) - - # If terms of service exist... we need to sign it. - # TODO: Replace the `preview EULA` with this... - if self.regr.terms_of_service: - self.network.agree_to_tos(self.regr) - - def set_regr(self, regr): - """Set a preexisting registration resource.""" - self.regr = regr + self.account = self.network.register_from_account(self.account) + if self.account.regr.terms_of_service or self.config.tos: + agree = zope.component.getUtility(interfaces.IDisplay).yesno( + self.account.regr.terms_of_service, "Agree", "Cancel") + if agree: + self.account.regr = self.network.agree_to_tos(self.account.regr) + # TODO: Handle case where user doesn't agree def obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. @@ -141,7 +128,8 @@ class Client(object): # Create CSR from names if csr is None: - csr = init_csr(self.authkey, domains, self.config.cert_dir) + csr = crypto_util.init_save_csr( + self.authkey, domains, self.config.cert_dir) # Retrieve certificate certr = self.network.request_issuance( @@ -323,60 +311,6 @@ def validate_key_csr(privkey, csr=None): "The key and CSR do not match") -def init_key(key_size, key_dir): - """Initializes privkey. - - 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. - - :param str key_dir: Key save directory. - - """ - try: - key_pem = crypto_util.make_key(key_size) - except ValueError as err: - logging.fatal(str(err)) - sys.exit(1) - - # Save file - le_util.make_or_verify_dir(key_dir, 0o700) - key_f, key_filename = le_util.unique_file( - os.path.join(key_dir, "key-letsencrypt.pem"), 0o600) - key_f.write(key_pem) - key_f.close() - - logging.info("Generating key (%d bits): %s", key_size, key_filename) - - return le_util.Key(key_filename, key_pem) - - -def init_csr(privkey, names, cert_dir): - """Initialize a CSR with the given private key. - - :param privkey: Key to include in the CSR - :type privkey: :class:`letsencrypt.client.le_util.Key` - - :param set names: `str` names to include in the CSR - - :param str cert_dir: Certificate save directory. - - """ - csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names) - - # Save CSR - le_util.make_or_verify_dir(cert_dir, 0o755) - csr_f, csr_filename = le_util.unique_file( - os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644) - csr_f.write(csr_pem) - csr_f.close() - - logging.info("Creating CSR: %s", csr_filename) - - return le_util.CSR(csr_filename, csr_der, "der") - - def list_available_authenticators(avail_auths): """Return a pretty-printed list of authenticators. diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index 87502ed63..7c5aecbcc 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -48,6 +48,16 @@ class NamespaceConfig(object): self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR, self.namespace.server.partition(":")[0]) + @property + def accounts_dir(self): #pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, "accounts", self.namespace.server) + + @property + def account_keys_dir(self): #pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, "accounts", + self.namespace.server, "keys") + # TODO: This should probably include the server name @property def rec_token_dir(self): # pylint: disable=missing-docstring diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index e3d0d1c4d..e4b4311b5 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -4,6 +4,8 @@ is capable of handling the signatures. """ +import logging +import os import time import Crypto.Hash.SHA256 @@ -12,7 +14,69 @@ import Crypto.Signature.PKCS1_v1_5 import M2Crypto +from letsencrypt.client import le_util + +# High level functions +def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): + """Initializes and saves a privkey. + + Inits key and saves it in PEM format on the filesystem. + + .. note:: keyname is the attempted filename, it may be different if a file + already exists at the path. + + :param int key_size: RSA key size in bits + :param str key_dir: Key save directory. + :param str keyname: Filename of key + + :raises ValueError: If unable to generate the key given key_size. + + """ + try: + key_pem = make_key(key_size) + except ValueError as err: + logging.fatal(str(err)) + raise err + + # Save file + le_util.make_or_verify_dir(key_dir, 0o700) + key_f, key_path = le_util.unique_file( + os.path.join(key_dir, keyname), 0o600) + key_f.write(key_pem) + key_f.close() + + logging.info("Generating key (%d bits): %s", key_size, key_path) + + return le_util.Key(key_path, key_pem) + + +def init_save_csr(privkey, names, cert_dir): + """Initialize a CSR with the given private key. + + :param privkey: Key to include in the CSR + :type privkey: :class:`letsencrypt.client.le_util.Key` + + :param set names: `str` names to include in the CSR + + :param str cert_dir: Certificate save directory. + + """ + csr_pem, csr_der = make_csr(privkey.pem, names) + + # Save CSR + le_util.make_or_verify_dir(cert_dir, 0o755) + csr_f, csr_filename = le_util.unique_file( + os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644) + csr_f.write(csr_pem) + csr_f.close() + + logging.info("Creating CSR: %s", csr_filename) + + return le_util.CSR(csr_filename, csr_der, "der") + + +# Lower level functions def make_csr(key_str, domains): """Generate a CSR. diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 1cffe2846..db4b4a4e9 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -42,6 +42,26 @@ def choose_authenticator(auths, errs): else: return +def choose_account(accounts): + """Choose an account. + + :param list accounts: where each is of type + :class:`~letsencrypt.client.account.Account` + + """ + # Note this will get more complicated once we start recording authorizations + + labels = [ + "%s | %s" % (acc.email.ljust(display_util.WIDTH - 39), acc.phone) + for acc in accounts + ] + + code, index = util(interfaces.IDisplay).menu( + "Please choose an account", labels) + if code == display_util.OK: + return accounts[index] + else: + return None def choose_names(installer): """Display screen to select domains to validate. diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 7a50a40bf..011710dbe 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -186,6 +186,19 @@ class Network(object): return regr + def register_from_account(self, account): + # TODO: properly format/scrub phone number and email + details = ( + "mailto:" + self.email if self.email is not None else None, + "tel:" + self.phone if self.phone is not None else None + ) + + contact_tuple = tuple(det for det in details if det is not None) + + account.regr = self.register(contact=contact_tuple) + + return account + def update_registration(self, regr): """Update registration. diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index e6104a559..f3b952915 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -1006,15 +1006,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): apache_dvsni.add_chall(achall, i) 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() + if sni_response: + # Must restart in order to activate the challenges. + # 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 + # 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 return responses diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py new file mode 100644 index 000000000..5d812fdd8 --- /dev/null +++ b/letsencrypt/client/tests/account_test.py @@ -0,0 +1,10 @@ +import mock + +from letsencrypt.client import account +from letsencrypt.client import configuration + + +mock_config = mock.MagicMock(spec=configuration.NamespaceConfig) +acc = account.Account.from_prompts(mock_config) + +acc.save() \ No newline at end of file diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index e7a2674e9..225154cba 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -18,6 +18,7 @@ import letsencrypt from letsencrypt.client import configuration from letsencrypt.client import client +from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util @@ -93,7 +94,7 @@ def create_parser(): add("--no-confirm", dest="no_confirm", action="store_true", help="Turn off confirmation screens, currently used for --revoke") - add("-e", "--agree-tos", dest="eula", action="store_true", + add("-e", "--agree-tos", dest="tos", action="store_true", help="Skip the end user license agreement screen.") add("-t", "--text", dest="use_curses", action="store_false", help="Use the text output instead of the curses UI.") @@ -163,7 +164,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements client.rollback(args.rollback, config) sys.exit() - if not args.eula: + if not args.tos: display_eula() all_auths = init_auths(config) @@ -195,7 +196,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # Prepare for init of Client if args.authkey is None: - authkey = client.init_key(args.rsa_key_size, config.key_dir) + authkey = crypto_util.init_save_key(args.rsa_key_size, config.key_dir) else: authkey = le_util.Key(args.authkey[0], args.authkey[1]) diff --git a/setup.py b/setup.py index c399179e4..474c1c448 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst')) install_requires = [ 'argparse', 'ConfArgParse', + 'configobj', 'jsonschema', 'mock', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)