mirror of
https://github.com/certbot/certbot.git
synced 2026-08-02 03:11:55 +02:00
Rewrite acccounts and registration.
Save accounts to:
/etc/letsencrypt/accounts/www.letsencrypt-dmeo.org/acme/new-reg/ \
kuba.le.wtf@2015-07-04T14:04:10Z/ \
{regr.json,meta.json,private_key.json}
Account now represents a combination of private key, Registration
Resource and client account metadata. `Account.id` based on the
account metadata (creation host and datetime). UI interface
(`cli._determine_account`) based on the `id`, and not on email as
previously.
Add `AccountStorage` interface and `AccountFileStorage`,
`AccountMemoryStorage` implementations (latter, in-memory, useful for
testing).
Create Account only after Registration Resource is received
(`register()` returns `Account`).
Allow `client.Client(..., acme=acme, ...)`: API client might reuse
acme.client.Client as returned by `register()`.
Move report_new_account to letsencrypt.account, client.Client.register
into client.register.
Use Registration.from_data acme API.
achallenges.AChallenge.key is now the `acme.jose.JWK`, not
`le_util.Key`. Plugins have to export PEM/DER as necessary
(c.f. `letsencrypt.plugins.common.Dvsni.get_key_path`)
Add --agree-tos, save --agree-eula to "args.eula". Prompt for EULA as
soon as client is launched, add prompt for TOS.
Remove unnecessary letsencrypt.network. Remove, now irrelevant,
`IConfig.account_keys_dir`.
Based on the draft from
https://github.com/letsencrypt/letsencrypt/pull/362#issuecomment-97946817.
This commit is contained in:
+164
-197
@@ -1,20 +1,21 @@
|
||||
"""Creates ACME accounts for server."""
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
|
||||
import configobj
|
||||
import pyrfc3339
|
||||
import pytz
|
||||
import zope.component
|
||||
|
||||
from acme import fields as acme_fields
|
||||
from acme import jose
|
||||
from acme import messages
|
||||
|
||||
from letsencrypt import crypto_util
|
||||
from letsencrypt import errors
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt import le_util
|
||||
|
||||
from letsencrypt.display import util as display_util
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,212 +23,178 @@ logger = logging.getLogger(__name__)
|
||||
class Account(object):
|
||||
"""ACME protocol registration.
|
||||
|
||||
:ivar config: Client configuration object
|
||||
:type config: :class:`~letsencrypt.interfaces.IConfig`
|
||||
:ivar key: Account/Authorized Key
|
||||
:type key: :class:`~letsencrypt.le_util.Key`
|
||||
:ivar .RegistrationResource regr: Registration Resource
|
||||
:ivar .JWK key: Authorized Account Key
|
||||
:ivar .Meta: Account metadata
|
||||
|
||||
:ivar str email: Client's email address
|
||||
:ivar str phone: Client's phone number
|
||||
|
||||
:ivar regr: Registration Resource
|
||||
:type regr: :class:`~acme.messages.RegistrationResource`
|
||||
.. note:: ``creation_dt`` and ``creation_host`` are useful in
|
||||
cross-machine migration scenarios.
|
||||
|
||||
"""
|
||||
|
||||
# Just make sure we don't get pwned
|
||||
# Make sure that it also doesn't start with a period or have two consecutive
|
||||
# periods <- this needs to be done in addition to the regex
|
||||
EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$")
|
||||
class Meta(jose.JSONObjectWithFields):
|
||||
"""Account metadata
|
||||
|
||||
def __init__(self, config, key, email=None, phone=None, regr=None):
|
||||
le_util.make_or_verify_dir(
|
||||
config.accounts_dir, 0o700, os.geteuid())
|
||||
self.key = key
|
||||
self.config = config
|
||||
if email is not None and self.safe_email(email):
|
||||
self.email = email
|
||||
else:
|
||||
self.email = None
|
||||
self.phone = phone
|
||||
|
||||
self.regr = regr
|
||||
|
||||
@property
|
||||
def uri(self):
|
||||
"""URI link for new registrations."""
|
||||
if self.regr is not None:
|
||||
return self.regr.uri
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def new_authzr_uri(self): # pylint: disable=missing-docstring
|
||||
if self.regr is not None:
|
||||
return self.regr.new_authzr_uri
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def terms_of_service(self): # pylint: disable=missing-docstring
|
||||
if self.regr is not None:
|
||||
return self.regr.terms_of_service
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def recovery_token(self): # pylint: disable=missing-docstring
|
||||
if self.regr is not None and self.regr.body is not None:
|
||||
return self.regr.body.recovery_token
|
||||
else:
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""Save account to disk."""
|
||||
le_util.make_or_verify_dir(
|
||||
self.config.accounts_dir, 0o700, os.geteuid())
|
||||
|
||||
acc_config = configobj.ConfigObj()
|
||||
acc_config.filename = os.path.join(
|
||||
self.config.accounts_dir, self._get_config_filename(self.email))
|
||||
|
||||
acc_config.initial_comment = [
|
||||
"DO NOT EDIT THIS FILE",
|
||||
"Account information for %s under %s" % (
|
||||
self._get_config_filename(self.email), self.config.server),
|
||||
]
|
||||
|
||||
acc_config["key"] = self.key.file
|
||||
acc_config["phone"] = self.phone
|
||||
|
||||
if self.regr is not None:
|
||||
acc_config["RegistrationResource"] = {}
|
||||
acc_config["RegistrationResource"]["uri"] = self.uri
|
||||
acc_config["RegistrationResource"]["new_authzr_uri"] = (
|
||||
self.new_authzr_uri)
|
||||
acc_config["RegistrationResource"]["terms_of_service"] = (
|
||||
self.terms_of_service)
|
||||
|
||||
regr_dict = self.regr.body.to_json()
|
||||
acc_config["RegistrationResource"]["body"] = regr_dict
|
||||
|
||||
acc_config.write()
|
||||
|
||||
@classmethod
|
||||
def _get_config_filename(cls, email):
|
||||
return email if email is not None and email else "default"
|
||||
|
||||
@classmethod
|
||||
def from_existing_account(cls, config, email=None):
|
||||
"""Populate an account from an existing email."""
|
||||
config_fp = os.path.join(
|
||||
config.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.Error(
|
||||
"Account for %s does not exist" % os.path.basename(config_fp))
|
||||
|
||||
if os.path.basename(config_fp) != "default":
|
||||
email = os.path.basename(config_fp)
|
||||
else:
|
||||
email = None
|
||||
phone = acc_config["phone"] if acc_config["phone"] != "None" else None
|
||||
|
||||
with open(acc_config["key"]) as key_file:
|
||||
key = le_util.Key(acc_config["key"], key_file.read())
|
||||
|
||||
if "RegistrationResource" in acc_config:
|
||||
acc_config_rr = acc_config["RegistrationResource"]
|
||||
regr = messages.RegistrationResource(
|
||||
uri=acc_config_rr["uri"],
|
||||
new_authzr_uri=acc_config_rr["new_authzr_uri"],
|
||||
terms_of_service=acc_config_rr["terms_of_service"],
|
||||
body=messages.Registration.from_json(acc_config_rr["body"]))
|
||||
else:
|
||||
regr = None
|
||||
|
||||
return cls(config, key, email, phone, regr)
|
||||
|
||||
@classmethod
|
||||
def get_accounts(cls, config):
|
||||
"""Return all current accounts.
|
||||
|
||||
:param config: Configuration
|
||||
:type config: :class:`letsencrypt.interfaces.IConfig`
|
||||
:ivar datetime.datetime creation_dt: Creation date and time (UTC).
|
||||
:ivar str creation_host: FQDN of host, where account has been created.
|
||||
|
||||
"""
|
||||
creation_dt = acme_fields.RFC3339Field("creation_dt")
|
||||
creation_host = jose.Field("creation_host")
|
||||
|
||||
def __init__(self, regr, key, meta=None):
|
||||
self.key = key
|
||||
self.regr = regr
|
||||
self.meta = self.Meta(
|
||||
# pyrfc3339 drops microseconds, make sure __eq__ is sane
|
||||
creation_dt=datetime.datetime.now(
|
||||
tz=pytz.UTC).replace(microsecond=0),
|
||||
creation_host=socket.getfqdn()) if meta is None else meta
|
||||
|
||||
@property
|
||||
def id(self): # pylint: disable=invalid-name
|
||||
"""Globally unique account identifier.
|
||||
|
||||
Implementation note: Email? Multiple accounts can have the same
|
||||
email address. Registration URI? Assigned by the server, not
|
||||
guaranteed to be stable over time, nor cannonical URI can be
|
||||
generated. One could use the account key (fingerprint), as ACME
|
||||
protocol doesn't allow it to be updated...
|
||||
|
||||
"""
|
||||
return self.slug
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
"""Short account identification string, useful for UI."""
|
||||
return "{1}@{0}".format(pyrfc3339.generate(
|
||||
self.meta.creation_dt), self.meta.creation_host)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{0}({1})>".format(self.__class__.__name__, self.id)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (isinstance(other, self.__class__) and
|
||||
self.key == other.key and self.regr == other.regr and
|
||||
self.meta == other.meta)
|
||||
|
||||
|
||||
def report_new_account(acc, config):
|
||||
"""Informs the user about their new Let's Encrypt account."""
|
||||
reporter = zope.component.queryUtility(interfaces.IReporter)
|
||||
if reporter is None:
|
||||
return
|
||||
reporter.add_message(
|
||||
"Your account credentials have been saved in your Let's Encrypt "
|
||||
"configuration directory at {0}. You should make a secure backup "
|
||||
"of this folder now. This configuration directory will also "
|
||||
"contain certificates and private keys obtained by Let's Encrypt "
|
||||
"so making regular backups of this folder is ideal.".format(
|
||||
config.config_dir),
|
||||
reporter.MEDIUM_PRIORITY, True)
|
||||
|
||||
assert acc.regr.body.recovery_token is not None
|
||||
recovery_msg = ("If you lose your account credentials, you can recover "
|
||||
"them using the token \"{0}\". You must write that down "
|
||||
"and put it in a safe place.".format(
|
||||
acc.regr.body.recovery_token))
|
||||
if acc.regr.body.email is not None:
|
||||
recovery_msg += (" Another recovery method will be e-mails sent to "
|
||||
"{0}.".format(acc.regr.body.email))
|
||||
reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True)
|
||||
|
||||
|
||||
class AccountMemoryStorage(interfaces.AccountStorage):
|
||||
"""In-memory account strage."""
|
||||
|
||||
def __init__(self, initial_accounts=None):
|
||||
self.accounts = initial_accounts if initial_accounts is not None else {}
|
||||
|
||||
def find_all(self):
|
||||
return self.accounts.values()
|
||||
|
||||
def save(self, account):
|
||||
if account.id in self.accounts:
|
||||
logger.debug("Overwriting account: %s", account.id)
|
||||
self.accounts[account.id] = account
|
||||
|
||||
def load(self, account_id):
|
||||
try:
|
||||
filenames = os.listdir(config.accounts_dir)
|
||||
return self.accounts[account_id]
|
||||
except KeyError:
|
||||
raise errors.AccountNotFound(account_id)
|
||||
|
||||
|
||||
class AccountFileStorage(interfaces.AccountStorage):
|
||||
"""Accounts file storage.
|
||||
|
||||
:ivar .IConfig config: Client configuration
|
||||
|
||||
"""
|
||||
def __init__(self, config):
|
||||
le_util.make_or_verify_dir(config.accounts_dir, 0o700, os.geteuid())
|
||||
self.config = config
|
||||
|
||||
def _account_dir_path(self, account_id):
|
||||
return os.path.join(self.config.accounts_dir, account_id)
|
||||
|
||||
@classmethod
|
||||
def _regr_path(cls, account_dir_path):
|
||||
return os.path.join(account_dir_path, "regr.json")
|
||||
|
||||
@classmethod
|
||||
def _key_path(cls, account_dir_path):
|
||||
return os.path.join(account_dir_path, "private_key.json")
|
||||
|
||||
@classmethod
|
||||
def _metadata_path(cls, account_dir_path):
|
||||
return os.path.join(account_dir_path, "meta.json")
|
||||
|
||||
def find_all(self):
|
||||
try:
|
||||
candidates = os.listdir(self.config.accounts_dir)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
accounts = []
|
||||
for name in filenames:
|
||||
# Not some directory ie. keys
|
||||
config_fp = os.path.join(config.accounts_dir, name)
|
||||
if os.path.isfile(config_fp):
|
||||
accounts.append(cls._from_config_fp(config, config_fp))
|
||||
|
||||
for account_id in candidates:
|
||||
try:
|
||||
accounts.append(self.load(account_id))
|
||||
except errors.AccountStorageError:
|
||||
logger.debug("Account loading problem", exc_info=True)
|
||||
return accounts
|
||||
|
||||
@classmethod
|
||||
def from_prompts(cls, config):
|
||||
"""Generate an account from prompted user input.
|
||||
def load(self, account_id):
|
||||
account_dir_path = self._account_dir_path(account_id)
|
||||
if not os.path.isdir(account_dir_path):
|
||||
raise errors.AccountNotFound(
|
||||
"Account at %s does not exist" % account_dir_path)
|
||||
|
||||
:param config: Configuration
|
||||
:type config: :class:`letsencrypt.interfaces.IConfig`
|
||||
try:
|
||||
with open(self._regr_path(account_dir_path)) as regr_file:
|
||||
regr = messages.RegistrationResource.json_loads(regr_file.read())
|
||||
with open(self._key_path(account_dir_path)) as key_file:
|
||||
key = jose.JWK.json_loads(key_file.read())
|
||||
with open(self._metadata_path(account_dir_path)) as metadata_file:
|
||||
meta = Account.Meta.json_loads(metadata_file.read())
|
||||
except IOError as error:
|
||||
raise errors.AccountStorageError(error)
|
||||
|
||||
:returns: Account or None
|
||||
:rtype: :class:`letsencrypt.account.Account`
|
||||
acc = Account(regr, key, meta)
|
||||
assert acc.id == account_id, (
|
||||
"Account ids mismatch (expected: {0}, found: {1}".format(
|
||||
account_id, acc.id))
|
||||
return acc
|
||||
|
||||
"""
|
||||
while True:
|
||||
code, email = zope.component.getUtility(interfaces.IDisplay).input(
|
||||
"Enter email address")
|
||||
|
||||
if code == display_util.OK:
|
||||
try:
|
||||
return cls.from_email(config, email)
|
||||
except errors.Error:
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_email(cls, config, email):
|
||||
"""Generate a new account from an email address.
|
||||
|
||||
:param config: Configuration
|
||||
:type config: :class:`letsencrypt.interfaces.IConfig`
|
||||
|
||||
:param str email: Email address
|
||||
|
||||
:raises .errors.Error: If invalid email address is given.
|
||||
|
||||
"""
|
||||
if not email or cls.safe_email(email):
|
||||
email = email if email else None
|
||||
|
||||
le_util.make_or_verify_dir(
|
||||
config.account_keys_dir, 0o700, os.geteuid())
|
||||
key = crypto_util.init_save_key(
|
||||
config.rsa_key_size, config.account_keys_dir,
|
||||
cls._get_config_filename(email))
|
||||
return cls(config, key, email)
|
||||
|
||||
raise errors.Error("Invalid email address.")
|
||||
|
||||
@classmethod
|
||||
def safe_email(cls, email):
|
||||
"""Scrub email address before using it."""
|
||||
if cls.EMAIL_REGEX.match(email):
|
||||
return not email.startswith(".") and ".." not in email
|
||||
else:
|
||||
logger.warn("Invalid email address: %s.", email)
|
||||
return False
|
||||
def save(self, account):
|
||||
account_dir_path = self._account_dir_path(account.id)
|
||||
le_util.make_or_verify_dir(account_dir_path, 0o700, os.geteuid())
|
||||
try:
|
||||
with open(self._regr_path(account_dir_path), "w") as regr_file:
|
||||
regr_file.write(account.regr.json_dumps())
|
||||
with open(self._key_path(account_dir_path), "w") as key_file:
|
||||
key_file.write(account.key.json_dumps())
|
||||
with open(self._metadata_path(account_dir_path), "w") as metadata_file:
|
||||
metadata_file.write(account.meta.json_dumps())
|
||||
except IOError as error:
|
||||
raise errors.AccountStorageError(error)
|
||||
|
||||
Reference in New Issue
Block a user