towards accounts

This commit is contained in:
James Kasten 2015-04-15 16:53:39 -07:00
parent 31915c5a01
commit 214c0e9355
11 changed files with 266 additions and 95 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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()

View file

@ -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])

View file

@ -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)