diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index e9134fffd..239b3fcff 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -17,9 +17,9 @@ 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 renewer from letsencrypt.client import reverter from letsencrypt.client import revoker +from letsencrypt.client import storage from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements @@ -160,7 +160,7 @@ class Client(object): # of assuming that each plugin has a .name attribute self.config.namespace.authenticator = authenticator.name self.config.namespace.installer = installer.name - return renewer.RenewableCert.new_lineage(domains[0], cert_pem, + return storage.RenewableCert.new_lineage(domains[0], cert_pem, privkey, chain_pem, vars(self.config.namespace)) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index c2b761d59..c813f3bd1 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -231,3 +231,14 @@ def make_ss_cert(key_str, domains, not_before=None, assert cert.verify() # print check_purpose(,0 return cert.as_pem() + + +def get_sans_from_cert(pem): + """Extracts the DNS subjectAltName values from a cert in PEM format. + """ + x509 = M2Crypto.X509.load_cert_string(pem) + try: + ext=x509.get_ext("subjectAltName") + except LookupError: + return [] + return [x[4:] for x in ext.get_value().split(", ") if x.startswith("DNS:")] diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 41ec37834..5af3a1064 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -14,461 +14,81 @@ configuration.""" # TODO: when renewing or deploying, update config file to # memorialize the fact that it happened +import code #XXX: remove + import configobj import copy import datetime import os import OpenSSL -import parsedatetime import pkg_resources import pyrfc3339 import pytz import re import time +from letsencrypt.client import configuration +from letsencrypt.client import client +from letsencrypt.client import crypto_util from letsencrypt.client import le_util from letsencrypt.client import notify +from letsencrypt.client import storage +from letsencrypt.client.plugins import disco as plugins_disco DEFAULTS = configobj.ConfigObj("renewal.conf") DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" -ALL_FOUR = ("cert", "privkey", "chain", "fullchain") -def parse_time_interval(interval, textparser=parsedatetime.Calendar()): - """Parse the time specified time interval, which can be in the - English-language format understood by parsedatetime, e.g., '10 days', - '3 weeks', '6 months', '9 hours', or a sequence of such intervals - like '6 months 1 week' or '3 days 12 hours'. If an integer is found - with no associated unit, it is interpreted by default as a number of - days.""" - if interval.strip().isdigit(): - interval += " days" - return datetime.timedelta(0, time.mktime(textparser.parse( - interval, time.localtime(0))[0])) - -class RenewableCert(object): # pylint: disable=too-many-instance-attributes - """Represents a lineage of certificates that is under the management - of the Let's Encrypt client, indicated by the existence of an - associated renewal configuration file.""" - - def __init__(self, configfile, defaults=DEFAULTS): - # self.configuration should be used to read parameters that - # may have been chosen based on default values from the - # systemwide renewal configuration; self.configfile should be - # used to make and save changes. - self.configuration = copy.deepcopy(defaults) - self.configfile = configobj.ConfigObj(configfile) - self.configuration.merge(self.configfile) - - if not configfile.filename.endswith(".conf"): - raise ValueError("renewal config file name must end in .conf") - self.lineagename = os.path.basename(configfile.filename)[:-5] - self.configfilename = configfile.filename - - self.cert = self.configuration["cert"] - self.privkey = self.configuration["privkey"] - self.chain = self.configuration["chain"] - self.fullchain = self.configuration["fullchain"] - - def consistent(self): - """Is the structure of the archived files and links related to this - lineage correct and self-consistent?""" - # Each element must be referenced with an absolute path - if any(not os.path.isabs(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - return False - # Each element must exist and be a symbolic link - if any(not os.path.islink(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - return False - for kind in ALL_FOUR: - link = self.__getattribute__(kind) - where = os.path.dirname(link) - target = os.readlink(link) - if not os.path.isabs(target): - target = os.path.join(where, target) - # Each element's link must point within the cert lineage's - # directory within the official archive directory - desired_directory = os.path.join( - self.configuration["official_archive_dir"], self.lineagename) - if not os.path.samefile(os.path.dirname(target), - desired_directory): - return False - # The link must point to a file that exists - if not os.path.exists(target): - return False - # The link must point to a file that follows the archive - # naming convention - pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) - if not pattern.match(os.path.basename(target)): - return False - # It is NOT required that the link's target be a regular - # file (it may itself be a symlink). But we should probably - # do a recursive check that ultimately the target does - # exist? - # XXX: Additional possible consistency checks (e.g. - # cryptographic validation of the chain being a chain, - # the chain matching the cert, and the cert matching - # the subject key) - # XXX: All four of the targets are in the same directory - # (This check is redundant with the check that they - # are all in the desired directory!) - # len(set(os.path.basename(self.current_target(x) - # for x in ALL_FOUR))) == 1 - return True - - def fix(self): - """Attempt to fix some kinds of defects or inconsistencies - in the symlink structure, if possible.""" - # TODO: Figure out what kinds of fixes are possible. For - # example, checking if there is a valid version that - # we can update the symlinks to. (Maybe involve - # parsing keys and certs to see if they exist and - # if a key corresponds to the subject key of a cert?) - - # TODO: In general, the symlink-reading functions below are not - # cautious enough about the possibility that links or their - # targets may not exist. (This shouldn't happen, but might - # happen as a result of random tampering by a sysadmin, or - # filesystem errors, or crashes.) - - def current_target(self, kind): - """Returns the full path to which the link of the specified - kind currently points.""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - link = self.__getattribute__(kind) - if not os.path.exists(link): - return None - target = os.readlink(link) - if not os.path.isabs(target): - target = os.path.join(os.path.dirname(link), target) - return target - - def current_version(self, kind): - """Returns the numerical version of the object to which the link - of the specified kind currently points. For example, if kind - is "chain" and the current chain link points to a file named - "chain7.pem", returns the integer 7.""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) - target = self.current_target(kind) - if not target or not os.path.exists(target): - target = "" - matches = pattern.match(os.path.basename(target)) - if matches: - return int(matches.groups()[0]) - else: - return None - - def version(self, kind, version): - """Constructs the filename that would correspond to the - specified version of the specified kind of item in this - lineage. Warning: the specified version may not exist.""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - where = os.path.dirname(self.current_target(kind)) - return os.path.join(where, "{0}{1}.pem".format(kind, version)) - - def available_versions(self, kind): - """Which alternative versions of the specified kind of item - exist in the archive directory where the current version is - stored?""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - where = os.path.dirname(self.current_target(kind)) - files = os.listdir(where) - pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) - matches = [pattern.match(f) for f in files] - return sorted([int(m.groups()[0]) for m in matches if m]) - - def newest_available_version(self, kind): - """What is the newest available version of the specified - kind of item?""" - return max(self.available_versions(kind)) - - def latest_common_version(self): - """What is the largest version number for which versions - of cert, privkey, chain, and fullchain are all available?""" - # TODO: this can raise ValueError if there is no version overlap - # (it should probably return None instead) - # TODO: this can raise a spurious AttributeError if the current - # link for any kind is missing (it should probably return None) - versions = [self.available_versions(x) for x in ALL_FOUR] - return max(n for n in versions[0] if all(n in v for v in versions[1:])) - - def next_free_version(self): - """What is the smallest new version number that is larger than - any available version of any managed item?""" - # TODO: consider locking/mutual exclusion between updating processes - # This isn't self.latest_common_version() + 1 because we don't want - # collide with a version that might exist for one file type but not - # for the others. - return max(self.newest_available_version(x) for x in ALL_FOUR) + 1 - - def has_pending_deployment(self): - """Is there a later version of all of the managed items?""" - # TODO: consider whether to assume consistency or treat - # inconsistent/consistent versions differently - smallest_current = min(self.current_version(x) for x in ALL_FOUR) - return smallest_current < self.latest_common_version() - - def update_link_to(self, kind, version): - """Change the target of the link of the specified item to point - to the specified version. (Note that this method doesn't verify - that the specified version exists.)""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - link = self.__getattribute__(kind) - filename = "{0}{1}.pem".format(kind, version) - # Relative rather than absolute target directory - target_directory = os.path.dirname(os.readlink(link)) - # TODO: it could be safer to make the link first under a temporary - # filename, then unlink the old link, then rename the new link - # to the old link; this ensures that this process is able to - # create symlinks. - # TODO: we might also want to check consistency of related links - # for the other corresponding items - os.unlink(link) - os.symlink(os.path.join(target_directory, filename), link) - - def update_all_links_to(self, version): - """Change the target of the cert, privkey, chain, and fullchain links - to point to the specified version.""" - for kind in ALL_FOUR: - self.update_link_to(kind, version) - - def notbefore(self, version=None): - """When is the beginning validity time of the specified version of the - cert in this lineage? (If no version is specified, use the current - version.)""" - if version == None: - target = self.current_target("cert") - else: - target = self.version("cert", version) - pem = open(target).read() - x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, - pem) - i = x509.get_notBefore() - return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + - i[8:10] + ":" + i[10:12] +":" +i[12:]) - - def notafter(self, version=None): - """When is the ending validity time of the specified version of the - cert in this lineage? (If no version is specified, use the current - version.)""" - if version == None: - target = self.current_target("cert") - else: - target = self.version("cert", version) - pem = open(target).read() - x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, - pem) - i = x509.get_notAfter() - return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + - i[8:10] + ":" + i[10:12] +":" +i[12:]) - - def should_autodeploy(self): - """Should this certificate lineage be updated automatically to - point to an existing pending newer version? (Considers whether - autodeployment is enabled, whether a relevant newer version - exists, and whether the time interval for autodeployment has - been reached.)""" - if (not self.configuration.has_key("autodeploy") or - self.configuration.as_bool("autodeploy")): - if self.has_pending_deployment(): - interval = self.configuration.get("deploy_before_expiry", - "5 days") - autodeploy_interval = parse_time_interval(interval) - expiry = self.notafter() - now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) - remaining = expiry - now - if remaining < autodeploy_interval: - return True - return False - - def ocsp_revoked(self, version=None): - # pylint: disable=no-self-use,unused-argument - """Is the specified version of this certificate lineage revoked - according to OCSP or intended to be revoked according to Let's - Encrypt OCSP extensions? (If no version is specified, use the - current version.)""" - # XXX: This query and its associated network service aren't - # implemented yet, so we currently return False (indicating that the - # certificate is not revoked). - return False - - def should_autorenew(self): - """Should an attempt be made to automatically renew the most - recent certificate in this certificate lineage right now?""" - if (not self.configuration.has_key("autorenew") - or self.configuration.as_bool("autorenew")): - # Consider whether to attempt to autorenew this cert now - # XXX: both self.ocsp_revoked() and self.notafter() are bugs - # here because we should be looking at the latest version, not - # the current version! - # Renewals on the basis of revocation - if self.ocsp_revoked(): - return True - # Renewals on the basis of expiry time - interval = self.configuration.get("renew_before_expiry", "10 days") - autorenew_interval = parse_time_interval(interval) - expiry = self.notafter() - now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) - remaining = expiry - now - if remaining < autorenew_interval: - return True - return False - - @classmethod - def new_lineage(cls, lineagename, cert, privkey, chain, - renewalparams=None, config=DEFAULTS): - # pylint: disable=too-many-locals - """Create a new certificate lineage with the (suggested) lineage name - lineagename, and the associated cert, privkey, and chain (the - associated fullchain will be created automatically). Optional - configurator and renewalparams record the configuration that was - originally used to obtain this cert, so that it can be reused later - during automated renewal. - - Returns a new RenewableCert object referring to the created - lineage. (The actual lineage name, as well as all the relevant - file paths, will be available within this object.)""" - print config - configs_dir = config["renewal_configs_dir"] - archive_dir = config["official_archive_dir"] - live_dir = config["live_dir"] - for i in (configs_dir, archive_dir, live_dir): - if not os.path.exists(i): - os.makedirs(i, 0700) - config_file, config_filename = le_util.unique_lineage_name(configs_dir, - lineagename) - if not config_filename.endswith(".conf"): - raise ValueError("renewal config file name must end in .conf") - # lineagename will now potentially be modified based on what - # renewal configuration file could actually be created - lineagename = os.path.basename(config_filename)[:-5] - archive = os.path.join(archive_dir, lineagename) - live_dir = os.path.join(live_dir, lineagename) - if os.path.exists(archive): - raise ValueError("archive directory exists for " + lineagename) - if os.path.exists(live_dir): - raise ValueError("live directory exists for " + lineagename) - os.mkdir(archive) - os.mkdir(live_dir) - relative_archive = os.path.join("..", "..", "archive", lineagename) - cert_target = os.path.join(live_dir, "cert.pem") - privkey_target = os.path.join(live_dir, "privkey.pem") - chain_target = os.path.join(live_dir, "chain.pem") - fullchain_target = os.path.join(live_dir, "fullchain.pem") - os.symlink(os.path.join(relative_archive, "cert1.pem"), - cert_target) - os.symlink(os.path.join(relative_archive, "privkey1.pem"), - privkey_target) - os.symlink(os.path.join(relative_archive, "chain1.pem"), - chain_target) - os.symlink(os.path.join(relative_archive, "fullchain1.pem"), - fullchain_target) - with open(cert_target, "w") as f: - f.write(cert) - with open(privkey_target, "w") as f: - f.write(privkey) - # XXX: Let's make sure to get the file permissions right here - with open(chain_target, "w") as f: - f.write(chain) - with open(fullchain_target, "w") as f: - f.write(cert + chain) - config_file.close() - new_config = configobj.ConfigObj(config_filename, create_empty=True) - new_config["cert"] = cert_target - new_config["privkey"] = privkey_target - new_config["chain"] = chain_target - new_config["fullchain"] = fullchain_target - if renewalparams: - new_config["renewalparams"] = renewalparams - new_config.comments["renewalparams"] = ["", - "Options and defaults used" - " in the renewal process"] - # TODO: add human-readable comments explaining other available - # parameters - new_config.write() - return cls(new_config, config) - - def save_successor(self, prior_version, new_cert, new_chain): - """Save a new cert and chain as a successor of a specific prior - version in this lineage. Returns the new version number that was - created. Note: does NOT update links to deploy this version.""" - # XXX: no private key change: should be extended with a key=None - # default argument that allows changing the private key; also we - # should perhaps allow new_chain=None which makes a link to - # the prior chain's target - # XXX: assumes official archive location rather than examining links - # XXX: consider using os.open for availablity of os.O_EXCL - # XXX: ensure file permissions are correct; also create directories - # if needed (ensuring their permissions are correct) - target_version = self.next_free_version() - archive = self.configuration["official_archive_dir"] - prefix = os.path.join(archive, self.lineagename) - cert_target = os.path.join( - prefix, "cert{0}.pem".format(target_version)) - privkey_target = os.path.join( - prefix, "privkey{0}.pem".format(target_version)) - chain_target = os.path.join( - prefix, "chain{0}.pem".format(target_version)) - fullchain_target = os.path.join( - prefix, "fullchain{0}.pem".format(target_version)) - with open(cert_target, "w") as f: - f.write(new_cert) - # The behavior below always keeps the prior key by creating a new - # symlink to the old key or the target of the old key symlink. - old_privkey = os.path.join( - prefix, "privkey{0}.pem".format(prior_version)) - if os.path.islink(old_privkey): - old_privkey = os.readlink(old_privkey) - else: - old_privkey = "privkey{0}.pem".format(prior_version) - os.symlink(old_privkey, privkey_target) - with open(chain_target, "w") as f: - f.write(new_chain) - with open(fullchain_target, "w") as f: - f.write(new_cert + new_chain) - return target_version +class AttrDict(dict): + def __init__(self, *args, **kwargs): + super(AttrDict, self).__init__(*args, **kwargs) + self.__dict__ = self def renew(cert, old_version): """Perform automated renewal of the referenced cert, if possible.""" # TODO: handle partial success - # TODO: handle obligatory key rotation - # XXX: Deserialize config here - - for entrypoint in pkg_resources.iter_entry_points( - SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): - auth_cls = entrypoint.load() - # XXX: need to regenerate "config" from serialized authenticator - # config! - auth = auth_cls(config) - try: - zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) - except zope.interface.exceptions.BrokenImplementation: - pass - else: - if entrypoint.name == cert.configuration["authenticator"]: - break - else: - # TODO: Notify failure to instantiate the authenticator + # TODO: handle obligatory key rotation vs. optional key rotation vs. + # requested key rotation + if not cert.configfile.has_key("renewalparams"): + # TODO: notify user? + return False + renewalparams = cert.configfile["renewalparams"] + if not renewalparams.has_key("authenticator"): + # TODO: notify user? + return False + # Instantiate the appropriate authenticator + plugins = plugins_disco.PluginsRegistry.find_all() + try: + config = configuration.NamespaceConfig(AttrDict(renewalparams)) + # XXX: this loses type data (for example, the fact that key_size + # was an int, not a str) + config.rsa_key_size = int(config.rsa_key_size) + authenticator = plugins[renewalparams["authenticator"]] + authenticator = authenticator.init(config) + except KeyError: + # TODO: Notify user? (authenticator could not be found) return False - auth.prepare() - client = Client(config, None, auth, None) - new_cert, new_key, new_chain = client.obtain_certificate(domains) + + authenticator.prepare() + account = client.determine_account(config) + # TODO: are there other ways to get the right account object, e.g. + # based on the email parameter that might be present in + # renewalparams? + + our_client = client.Client(config, account, authenticator, None) + # XXX: find the domains + with open(cert.version("cert", old_version)) as f: + sans = crypto_util.get_sans_from_cert(f.read()) + new_cert, new_key, new_chain = our_client.obtain_certificate(sans) if new_cert and new_key and new_chain: # XXX: Assumes that there was no key change. We need logic # for figuring out whether there was or not. Probably # best is to have obtain_certificate return None for # new_key if the old key is to be used (since save_successor # already understands this distinction!) - self.save_successor(old_version, new_cert, new_chain) + cert.save_successor(old_version, new_cert, new_key, new_chain) # Notify results else: # Notify negative results @@ -481,7 +101,17 @@ def main(config=DEFAULTS): print "Processing", i if not i.endswith(".conf"): continue - cert = RenewableCert(i) + try: + cert = storage.RenewableCert( + os.path.join(config["renewal_configs_dir"], i)) + except ValueError: + # This indicates an invalid renewal configuration file, such + # as one missing a required parameter (in the future, perhaps + # also one that is internally inconsistent or is missing a + # required parameter). As a TODO, maybe we should warn the + # user about the existence of an invalid or corrupt renewal + # config rather than simply ignoring it. + continue if cert.should_autodeploy(): cert.update_all_links_to(cert.latest_common_version()) # TODO: restart web server diff --git a/letsencrypt/client/storage.py b/letsencrypt/client/storage.py new file mode 100644 index 000000000..5bd38afc2 --- /dev/null +++ b/letsencrypt/client/storage.py @@ -0,0 +1,432 @@ +import configobj +import copy +import datetime +import os +import OpenSSL +import parsedatetime +import pyrfc3339 +import pytz +import re +import time + +from letsencrypt.client import le_util + +DEFAULTS = configobj.ConfigObj("renewal.conf") +DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" +DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" +DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" +ALL_FOUR = ("cert", "privkey", "chain", "fullchain") + +def parse_time_interval(interval, textparser=parsedatetime.Calendar()): + """Parse the time specified time interval, which can be in the + English-language format understood by parsedatetime, e.g., '10 days', + '3 weeks', '6 months', '9 hours', or a sequence of such intervals + like '6 months 1 week' or '3 days 12 hours'. If an integer is found + with no associated unit, it is interpreted by default as a number of + days.""" + if interval.strip().isdigit(): + interval += " days" + return datetime.timedelta(0, time.mktime(textparser.parse( + interval, time.localtime(0))[0])) + +class RenewableCert(object): # pylint: disable=too-many-instance-attributes + """Represents a lineage of certificates that is under the management + of the Let's Encrypt client, indicated by the existence of an + associated renewal configuration file.""" + + def __init__(self, configfile, defaults=DEFAULTS): + if isinstance(configfile, str): + if not os.path.exists(configfile): + raise ValueError( + "renewal config file {0} doesn't exist".format(configfile)) + if not configfile.endswith(".conf"): + raise ValueError("renewal config file name must end in .conf") + self.lineagename = os.path.basename(configfile)[:-5] + self.configfilename = os.path.basename(configfile) + elif isinstance(configfile, configobj.ConfigObj): + self.lineagename = os.path.basename(configfile.filename)[:-5] + self.configfilename = os.path.basename(configfile.filename) + else: + raise TypeError("RenewableCert config must be file path " + "or ConfigObj object") + + # self.configuration should be used to read parameters that + # may have been chosen based on default values from the + # systemwide renewal configuration; self.configfile should be + # used to make and save changes. + self.configuration = copy.deepcopy(defaults) + self.configfile = configobj.ConfigObj(configfile) + self.configuration.merge(self.configfile) + + if not all(self.configuration.has_key(x) for x in ALL_FOUR): + raise ValueError("renewal config file {0} is missing a required " + "file reference".format(configfile)) + + self.cert = self.configuration["cert"] + self.privkey = self.configuration["privkey"] + self.chain = self.configuration["chain"] + self.fullchain = self.configuration["fullchain"] + + def consistent(self): + """Is the structure of the archived files and links related to this + lineage correct and self-consistent?""" + # Each element must be referenced with an absolute path + if any(not os.path.isabs(x) for x in + (self.cert, self.privkey, self.chain, self.fullchain)): + return False + # Each element must exist and be a symbolic link + if any(not os.path.islink(x) for x in + (self.cert, self.privkey, self.chain, self.fullchain)): + return False + for kind in ALL_FOUR: + link = self.__getattribute__(kind) + where = os.path.dirname(link) + target = os.readlink(link) + if not os.path.isabs(target): + target = os.path.join(where, target) + # Each element's link must point within the cert lineage's + # directory within the official archive directory + desired_directory = os.path.join( + self.configuration["official_archive_dir"], self.lineagename) + if not os.path.samefile(os.path.dirname(target), + desired_directory): + return False + # The link must point to a file that exists + if not os.path.exists(target): + return False + # The link must point to a file that follows the archive + # naming convention + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) + if not pattern.match(os.path.basename(target)): + return False + # It is NOT required that the link's target be a regular + # file (it may itself be a symlink). But we should probably + # do a recursive check that ultimately the target does + # exist? + # XXX: Additional possible consistency checks (e.g. + # cryptographic validation of the chain being a chain, + # the chain matching the cert, and the cert matching + # the subject key) + # XXX: All four of the targets are in the same directory + # (This check is redundant with the check that they + # are all in the desired directory!) + # len(set(os.path.basename(self.current_target(x) + # for x in ALL_FOUR))) == 1 + return True + + def fix(self): + """Attempt to fix some kinds of defects or inconsistencies + in the symlink structure, if possible.""" + # TODO: Figure out what kinds of fixes are possible. For + # example, checking if there is a valid version that + # we can update the symlinks to. (Maybe involve + # parsing keys and certs to see if they exist and + # if a key corresponds to the subject key of a cert?) + + # TODO: In general, the symlink-reading functions below are not + # cautious enough about the possibility that links or their + # targets may not exist. (This shouldn't happen, but might + # happen as a result of random tampering by a sysadmin, or + # filesystem errors, or crashes.) + + def current_target(self, kind): + """Returns the full path to which the link of the specified + kind currently points.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + link = self.__getattribute__(kind) + if not os.path.exists(link): + return None + target = os.readlink(link) + if not os.path.isabs(target): + target = os.path.join(os.path.dirname(link), target) + return target + + def current_version(self, kind): + """Returns the numerical version of the object to which the link + of the specified kind currently points. For example, if kind + is "chain" and the current chain link points to a file named + "chain7.pem", returns the integer 7.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) + target = self.current_target(kind) + if not target or not os.path.exists(target): + target = "" + matches = pattern.match(os.path.basename(target)) + if matches: + return int(matches.groups()[0]) + else: + return None + + def version(self, kind, version): + """Constructs the filename that would correspond to the + specified version of the specified kind of item in this + lineage. Warning: the specified version may not exist.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + where = os.path.dirname(self.current_target(kind)) + return os.path.join(where, "{0}{1}.pem".format(kind, version)) + + def available_versions(self, kind): + """Which alternative versions of the specified kind of item + exist in the archive directory where the current version is + stored?""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + where = os.path.dirname(self.current_target(kind)) + files = os.listdir(where) + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) + matches = [pattern.match(f) for f in files] + return sorted([int(m.groups()[0]) for m in matches if m]) + + def newest_available_version(self, kind): + """What is the newest available version of the specified + kind of item?""" + return max(self.available_versions(kind)) + + def latest_common_version(self): + """What is the largest version number for which versions + of cert, privkey, chain, and fullchain are all available?""" + # TODO: this can raise ValueError if there is no version overlap + # (it should probably return None instead) + # TODO: this can raise a spurious AttributeError if the current + # link for any kind is missing (it should probably return None) + versions = [self.available_versions(x) for x in ALL_FOUR] + return max(n for n in versions[0] if all(n in v for v in versions[1:])) + + def next_free_version(self): + """What is the smallest new version number that is larger than + any available version of any managed item?""" + # TODO: consider locking/mutual exclusion between updating processes + # This isn't self.latest_common_version() + 1 because we don't want + # collide with a version that might exist for one file type but not + # for the others. + return max(self.newest_available_version(x) for x in ALL_FOUR) + 1 + + def has_pending_deployment(self): + """Is there a later version of all of the managed items?""" + # TODO: consider whether to assume consistency or treat + # inconsistent/consistent versions differently + smallest_current = min(self.current_version(x) for x in ALL_FOUR) + return smallest_current < self.latest_common_version() + + def update_link_to(self, kind, version): + """Change the target of the link of the specified item to point + to the specified version. (Note that this method doesn't verify + that the specified version exists.)""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + link = self.__getattribute__(kind) + filename = "{0}{1}.pem".format(kind, version) + # Relative rather than absolute target directory + target_directory = os.path.dirname(os.readlink(link)) + # TODO: it could be safer to make the link first under a temporary + # filename, then unlink the old link, then rename the new link + # to the old link; this ensures that this process is able to + # create symlinks. + # TODO: we might also want to check consistency of related links + # for the other corresponding items + os.unlink(link) + os.symlink(os.path.join(target_directory, filename), link) + + def update_all_links_to(self, version): + """Change the target of the cert, privkey, chain, and fullchain links + to point to the specified version.""" + for kind in ALL_FOUR: + self.update_link_to(kind, version) + + def notbefore(self, version=None): + """When is the beginning validity time of the specified version of the + cert in this lineage? (If no version is specified, use the current + version.)""" + if version == None: + target = self.current_target("cert") + else: + target = self.version("cert", version) + pem = open(target).read() + x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem) + i = x509.get_notBefore() + return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + + i[8:10] + ":" + i[10:12] +":" +i[12:]) + + def notafter(self, version=None): + """When is the ending validity time of the specified version of the + cert in this lineage? (If no version is specified, use the current + version.)""" + if version == None: + target = self.current_target("cert") + else: + target = self.version("cert", version) + pem = open(target).read() + x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem) + i = x509.get_notAfter() + return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + + i[8:10] + ":" + i[10:12] +":" +i[12:]) + + def should_autodeploy(self): + """Should this certificate lineage be updated automatically to + point to an existing pending newer version? (Considers whether + autodeployment is enabled, whether a relevant newer version + exists, and whether the time interval for autodeployment has + been reached.)""" + if (not self.configuration.has_key("autodeploy") or + self.configuration.as_bool("autodeploy")): + if self.has_pending_deployment(): + interval = self.configuration.get("deploy_before_expiry", + "5 days") + autodeploy_interval = parse_time_interval(interval) + expiry = self.notafter() + now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) + remaining = expiry - now + if remaining < autodeploy_interval: + return True + return False + + def ocsp_revoked(self, version=None): + # pylint: disable=no-self-use,unused-argument + """Is the specified version of this certificate lineage revoked + according to OCSP or intended to be revoked according to Let's + Encrypt OCSP extensions? (If no version is specified, use the + current version.)""" + # XXX: This query and its associated network service aren't + # implemented yet, so we currently return False (indicating that the + # certificate is not revoked). + return False + + def should_autorenew(self): + """Should an attempt be made to automatically renew the most + recent certificate in this certificate lineage right now?""" + if (not self.configuration.has_key("autorenew") + or self.configuration.as_bool("autorenew")): + # Consider whether to attempt to autorenew this cert now + # XXX: both self.ocsp_revoked() and self.notafter() are bugs + # here because we should be looking at the latest version, not + # the current version! + # Renewals on the basis of revocation + if self.ocsp_revoked(): + return True + # Renewals on the basis of expiry time + interval = self.configuration.get("renew_before_expiry", "10 days") + autorenew_interval = parse_time_interval(interval) + expiry = self.notafter() + now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) + remaining = expiry - now + if remaining < autorenew_interval: + return True + return False + + @classmethod + def new_lineage(cls, lineagename, cert, privkey, chain, + renewalparams=None, config=DEFAULTS): + # pylint: disable=too-many-locals + """Create a new certificate lineage with the (suggested) lineage name + lineagename, and the associated cert, privkey, and chain (the + associated fullchain will be created automatically). Optional + configurator and renewalparams record the configuration that was + originally used to obtain this cert, so that it can be reused later + during automated renewal. + + Returns a new RenewableCert object referring to the created + lineage. (The actual lineage name, as well as all the relevant + file paths, will be available within this object.)""" + configs_dir = config["renewal_configs_dir"] + archive_dir = config["official_archive_dir"] + live_dir = config["live_dir"] + for i in (configs_dir, archive_dir, live_dir): + if not os.path.exists(i): + os.makedirs(i, 0700) + config_file, config_filename = le_util.unique_lineage_name(configs_dir, + lineagename) + if not config_filename.endswith(".conf"): + raise ValueError("renewal config file name must end in .conf") + # lineagename will now potentially be modified based on what + # renewal configuration file could actually be created + lineagename = os.path.basename(config_filename)[:-5] + archive = os.path.join(archive_dir, lineagename) + live_dir = os.path.join(live_dir, lineagename) + if os.path.exists(archive): + raise ValueError("archive directory exists for " + lineagename) + if os.path.exists(live_dir): + raise ValueError("live directory exists for " + lineagename) + os.mkdir(archive) + os.mkdir(live_dir) + relative_archive = os.path.join("..", "..", "archive", lineagename) + cert_target = os.path.join(live_dir, "cert.pem") + privkey_target = os.path.join(live_dir, "privkey.pem") + chain_target = os.path.join(live_dir, "chain.pem") + fullchain_target = os.path.join(live_dir, "fullchain.pem") + os.symlink(os.path.join(relative_archive, "cert1.pem"), + cert_target) + os.symlink(os.path.join(relative_archive, "privkey1.pem"), + privkey_target) + os.symlink(os.path.join(relative_archive, "chain1.pem"), + chain_target) + os.symlink(os.path.join(relative_archive, "fullchain1.pem"), + fullchain_target) + with open(cert_target, "w") as f: + f.write(cert) + with open(privkey_target, "w") as f: + f.write(privkey) + # XXX: Let's make sure to get the file permissions right here + with open(chain_target, "w") as f: + f.write(chain) + with open(fullchain_target, "w") as f: + f.write(cert + chain) + config_file.close() + new_config = configobj.ConfigObj(config_filename, create_empty=True) + new_config["cert"] = cert_target + new_config["privkey"] = privkey_target + new_config["chain"] = chain_target + new_config["fullchain"] = fullchain_target + if renewalparams: + new_config["renewalparams"] = renewalparams + new_config.comments["renewalparams"] = ["", + "Options and defaults used" + " in the renewal process"] + # TODO: add human-readable comments explaining other available + # parameters + new_config.write() + return cls(new_config, config) + + def save_successor(self, prior_version, new_cert, new_privkey, new_chain): + """Save a new cert and chain as a successor of a specific prior + version in this lineage. Returns the new version number that was + created. Note: does NOT update links to deploy this version.""" + # XXX: assumes official archive location rather than examining links + # XXX: consider using os.open for availablity of os.O_EXCL + # XXX: ensure file permissions are correct; also create directories + # if needed (ensuring their permissions are correct) + target_version = self.next_free_version() + archive = self.configuration["official_archive_dir"] + prefix = os.path.join(archive, self.lineagename) + cert_target = os.path.join( + prefix, "cert{0}.pem".format(target_version)) + privkey_target = os.path.join( + prefix, "privkey{0}.pem".format(target_version)) + chain_target = os.path.join( + prefix, "chain{0}.pem".format(target_version)) + fullchain_target = os.path.join( + prefix, "fullchain{0}.pem".format(target_version)) + with open(cert_target, "w") as f: + f.write(new_cert) + if new_privkey is None: + # The behavior below keeps the prior key by creating a new + # symlink to the old key or the target of the old key symlink. + old_privkey = os.path.join( + prefix, "privkey{0}.pem".format(prior_version)) + if os.path.islink(old_privkey): + old_privkey = os.readlink(old_privkey) + else: + old_privkey = "privkey{0}.pem".format(prior_version) + os.symlink(old_privkey, privkey_target) + else: + with open(privkey_target, "w") as f: + f.write(new_privkey) + with open(chain_target, "w") as f: + f.write(new_chain) + with open(fullchain_target, "w") as f: + f.write(new_cert + new_chain) + return target_version diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index c28ffd993..d6025177c 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -17,7 +17,7 @@ class RenewableCertTests(unittest.TestCase): """Tests for the RenewableCert class as well as other functions within renewer.py.""" def setUp(self): - from letsencrypt.client import renewer + from letsencrypt.client import storage self.tempdir = tempfile.mkdtemp() os.makedirs(os.path.join(self.tempdir, "live", "example.org")) os.makedirs(os.path.join(self.tempdir, "archive", "example.org")) @@ -38,7 +38,7 @@ class RenewableCertTests(unittest.TestCase): config.filename = os.path.join(self.tempdir, "configs", "example.org.conf") self.defaults = defaults # for main() test - self.test_rc = renewer.RenewableCert(config, defaults) + self.test_rc = storage.RenewableCert(config, defaults) def tearDown(self): shutil.rmtree(self.tempdir) @@ -65,6 +65,7 @@ class RenewableCertTests(unittest.TestCase): """Test that the RenewableCert constructor will complain if the renewal configuration file doesn't end in ".conf".""" from letsencrypt.client import renewer + from letsencrypt.client import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() config["cert"] = "/tmp/cert.pem" @@ -72,7 +73,7 @@ class RenewableCertTests(unittest.TestCase): config["chain"] = "/tmp/chain.pem" config["fullchain"] = "/tmp/fullchain.pem" config.filename = "/tmp/sillyfile" - self.assertRaises(ValueError, renewer.RenewableCert, config, defaults) + self.assertRaises(ValueError, storage.RenewableCert, config, defaults) def test_consistent(self): # pylint: disable=too-many-statements oldcert = self.test_rc.cert @@ -404,7 +405,7 @@ class RenewableCertTests(unittest.TestCase): self.assertTrue(self.test_rc.should_autodeploy()) @mock.patch("letsencrypt.client.renewer.datetime") - @mock.patch("letsencrypt.client.renewer.RenewableCert.ocsp_revoked") + @mock.patch("letsencrypt.client.storage.RenewableCert.ocsp_revoked") def test_should_autorenew(self, mock_ocsp, mock_datetime): # pylint: disable=too-many-statements # Autorenewal turned off @@ -483,7 +484,7 @@ class RenewableCertTests(unittest.TestCase): with open(where, "w") as f: f.write(kind) self.test_rc.update_all_links_to(3) - self.assertEqual(6, self.test_rc.save_successor(3, "new cert", + self.assertEqual(6, self.test_rc.save_successor(3, "new cert", "key", "new chain")) with open(self.test_rc.version("cert", 6)) as f: self.assertEqual(f.read(), "new cert") @@ -495,9 +496,9 @@ class RenewableCertTests(unittest.TestCase): self.assertFalse(os.path.islink(self.test_rc.version("privkey", 3))) self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) # Let's try two more updates - self.assertEqual(7, self.test_rc.save_successor(6, "again", + self.assertEqual(7, self.test_rc.save_successor(6, "again", "key", "newer chain")) - self.assertEqual(8, self.test_rc.save_successor(7, "hello", + self.assertEqual(8, self.test_rc.save_successor(7, "hello", "key", "other chain")) # All of the subsequent versions should link directly to the original # privkey. @@ -518,7 +519,8 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.current_version(kind), 3) # Test updating from latest version rather than old version self.test_rc.update_all_links_to(8) - self.assertEqual(9, self.test_rc.save_successor(8, "last", "attempt")) + self.assertEqual(9, self.test_rc.save_successor(8, "a", "last", + "attempt")) for kind in ALL_FOUR: self.assertEqual(self.test_rc.available_versions(kind), range(1, 10)) @@ -529,12 +531,13 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage(self): """Test for new_lineage() class method.""" from letsencrypt.client import renewer + from letsencrypt.client import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] live_dir = self.defaults["live_dir"] - result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert", + result = storage.RenewableCert.new_lineage("the-lineage.com", "cert", "privkey", "chain", None, - None, self.defaults) + self.defaults) # This consistency check tests most relevant properties about the # newly created cert lineage. self.assertTrue(result.consistent()) @@ -543,33 +546,34 @@ class RenewableCertTests(unittest.TestCase): with open(result.fullchain) as f: self.assertEqual(f.read(), "cert" + "chain") # Let's do it again and make sure it makes a different lineage - result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", + result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", "privkey2", "chain2", None, - None, self.defaults) + self.defaults) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com-0001.conf"))) # Now trigger the detection of already existing files os.mkdir(os.path.join(live_dir, "the-lineage.com-0002")) - self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "the-lineage.com", "cert3", "privkey3", "chain3", - None, None, self.defaults) + None, self.defaults) os.mkdir(os.path.join(archive_dir, "other-example.com")) - self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "other-example.com", "cert4", "privkey4", "chain4", - None, None, self.defaults) + None, self.defaults) def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" from letsencrypt.client import renewer + from letsencrypt.client import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] live_dir = self.defaults["live_dir"] shutil.rmtree(config_dir) shutil.rmtree(archive_dir) shutil.rmtree(live_dir) - result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", + result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", "privkey2", "chain2", - None, None, self.defaults) + None, self.defaults) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com.conf"))) self.assertTrue(os.path.exists( @@ -580,10 +584,11 @@ class RenewableCertTests(unittest.TestCase): @mock.patch("letsencrypt.client.renewer.le_util.unique_lineage_name") def test_invalid_config_filename(self, mock_uln): from letsencrypt.client import renewer + from letsencrypt.client import storage mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" - self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "example.com", "cert", "privkey", "chain", - None, None, self.defaults) + None, self.defaults) def test_bad_kind(self): self.assertRaises(ValueError, self.test_rc.current_target, "elephant") @@ -602,33 +607,33 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.ocsp_revoked(), False) def test_parse_time_interval(self): - from letsencrypt.client import renewer + from letsencrypt.client import storage # XXX: I'm not sure if intervals related to years and months # take account of the current date (if so, some of these # may fail in the future, like in leap years or even in # months of different lengths!) - self.assertEqual(renewer.parse_time_interval(""), + self.assertEqual(storage.parse_time_interval(""), datetime.timedelta(0)) - self.assertEqual(renewer.parse_time_interval("1 hour"), + self.assertEqual(storage.parse_time_interval("1 hour"), datetime.timedelta(0, 3600)) - self.assertEqual(renewer.parse_time_interval("17 days"), + self.assertEqual(storage.parse_time_interval("17 days"), datetime.timedelta(17)) # Days are assumed if no unit is specified. - self.assertEqual(renewer.parse_time_interval("23"), + self.assertEqual(storage.parse_time_interval("23"), datetime.timedelta(23)) - self.assertEqual(renewer.parse_time_interval("1 month"), + self.assertEqual(storage.parse_time_interval("1 month"), datetime.timedelta(31)) - self.assertEqual(renewer.parse_time_interval("7 weeks"), + self.assertEqual(storage.parse_time_interval("7 weeks"), datetime.timedelta(49)) - self.assertEqual(renewer.parse_time_interval("1 year 1 day"), + self.assertEqual(storage.parse_time_interval("1 year 1 day"), datetime.timedelta(366)) - self.assertEqual(renewer.parse_time_interval("1 year-1 day"), + self.assertEqual(storage.parse_time_interval("1 year-1 day"), datetime.timedelta(364)) - self.assertEqual(renewer.parse_time_interval("4 years"), + self.assertEqual(storage.parse_time_interval("4 years"), datetime.timedelta(1461)) @mock.patch("letsencrypt.client.renewer.notify") - @mock.patch("letsencrypt.client.renewer.RenewableCert") + @mock.patch("letsencrypt.client.storage.RenewableCert") @mock.patch("letsencrypt.client.renewer.renew") def test_main(self, mock_renew, mock_rc, mock_notify): """Test for main() function."""