diff --git a/README.md b/README.md index 288a6ea10..27563a899 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,47 @@ # STARTTLS Everywhere -NOTE: this is a pre-alpha codebase. Do not run it on non-experimental systems -yet! + +## Example usage + +**WARNING: this is a pre-alpha codebase. Do not run it on production +mailservers!!!** + + +If you have a Postfix server you're willing to endanger deliverability on, you +can try obtain a certificate with the [Let's Encrypt Python Client](https://github.com/letsencrypt/letsencrypt), note the directory it lives in below `/etc/letsencrypt/live` and then do: + +``` +git clone https://github.com/EFForg/starttls-everywhere +cd starttls-everywhere +# Promise you don't care if deliverability breaks on this mail server +letsencrypt-postfix/PostfixConfigGenerator.py examples/starttls-everywhere.json /etc/postfix /etc/letsencrypt/live/YOUR.DOMAIN.EXAMPLE.COM +``` + +This will: +* Ensure your mail server initiates STARTTLS encryption +* Install the Let's Encrypt cert in Postfix +* Enforce mandatory TLS to some major email domains +* Enforce minimum TLS versions to some major email domains + +## Project status + +STARTTLS Everywhere development is re-starting after a hiatus. Initial +objectives: + +* Postfix configuration generation: working pre-alpha, not yet safe +* Email security database: working pre-alpha, definitely not yet safe +* Fully integrated Let's Encrypt client postfix plugin: in progress, not yet ready +* DANE support: none yet +* SMTP-STS integration: none yet +* Direct mechanisms for mail domains to request inclusion: none yet +* Failure reporting mechanisms: early progress, not yet ready +* Mechanisms for secure multi-organization signature on the policy database: + none yet +* Support for mail servers other than Postfix: none yet ## Authors -Jacob Hoffman-Andrews , Peter Eckersley , Daniel Wilcox +Jacob Hoffman-Andrews , Peter Eckersley , Daniel Wilcox , Aaron Zauner ## Mailing List diff --git a/letsencrypt-postfix/PostfixConfigGenerator.py b/letsencrypt-postfix/PostfixConfigGenerator.py index 9863d05d2..1ac9d9fc6 100755 --- a/letsencrypt-postfix/PostfixConfigGenerator.py +++ b/letsencrypt-postfix/PostfixConfigGenerator.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import sys import string +import subprocess import os, os.path @@ -28,6 +29,7 @@ class PostfixConfigGenerator: self.postfix_dir = postfix_dir self.policy_config = policy_config self.policy_file = os.path.join(postfix_dir, "starttls_everywhere_policy") + self.ca_file = os.path.join(postfix_dir, "starttls_everywhere_CAfile") self.additions = [] self.deletions = [] @@ -56,7 +58,6 @@ class PostfixConfigGenerator: values = map(parse_line, l) if len(set(values)) > 1: if self.fixup: - #print "Scheduling deletions:" + `values` conflicting_lines = [num for num,_var,val in values] self.deletions.extend(conflicting_lines) self.additions.append(var + " = " + ideal) @@ -64,7 +65,6 @@ class PostfixConfigGenerator: raise ExistingConfigError, "Conflicting existing config values " + `l` val = values[0][2] if val not in acceptable: - #print "Scheduling deletions:" + `values` if self.fixup: self.deletions.append(values[0][0]) self.additions.append(var + " = " + ideal) @@ -86,7 +86,15 @@ class PostfixConfigGenerator: policy_cf_entry = "texthash:" + self.policy_file self.ensure_cf_var("smtp_tls_policy_maps", policy_cf_entry, []) + self.ensure_cf_var("smtp_tls_CAfile", self.ca_file, []) + # Disable SSLv2 and SSLv3. Syntax for `smtp_tls_protocols` changed + # between Postfix version 2.5 and 2.6, since we only support => 2.11 + # we don't use nor support legacy Postfix syntax. + # - Server: + self.ensure_cf_var("smtp_tls_protocols", "!SSLv2, !SSLv3", []) + # - Client: + self.ensure_cf_var("smtp_tls_mandatory_protocols", "!SSLv2, !SSLv3", []) def maybe_add_config_lines(self): if not self.additions: @@ -107,10 +115,11 @@ class PostfixConfigGenerator: self.new_cf += line self.new_cf += sep + new_cf_lines - #print self.new_cf - f = open(self.fn, "w") - f.write(self.new_cf) - f.close() + if not os.access(self.postfix_cf_file, os.W_OK): + raise Exception("Can't write to %s, please re-run as root." + % self.postfix_cf_file) + with open(self.fn, "w") as f: + f.write(self.new_cf) def set_domainwise_tls_policies(self): all_acceptable_mxs = self.policy_config.acceptable_mxs @@ -124,11 +133,11 @@ class PostfixConfigGenerator: mx_policy = self.policy_config.get_tls_policy(mx_domain) entry = address_domain + " encrypt" if mx_policy.min_tls_version.lower() == "tlsv1": - entry += " protocols=!SSLv2,!SSLv3" + entry += " protocols=!SSLv2:!SSLv3" elif mx_policy.min_tls_version.lower() == "tlsv1.1": - entry += " protocols=!SSLv2,!SSLv3,!TLSv1" + entry += " protocols=!SSLv2:!SSLv3:!TLSv1" elif mx_policy.min_tls_version.lower() == "tlsv1.2": - entry += " protocols=!SSLv2,!SSLv3,!TLSv1,!TLSv1.1" + entry += " protocols=!SSLv2:!SSLv3:!TLSv1:!TLSv1.1" else: print mx_policy.min_tls_version self.policy_lines.append(entry) @@ -138,6 +147,7 @@ class PostfixConfigGenerator: f.close() ### Let's Encrypt client IPlugin ### + # https://github.com/letsencrypt/letsencrypt/blob/master/letsencrypt/plugins/common.py#L35 def prepare(self): """Prepare the plugin. @@ -150,12 +160,65 @@ class PostfixConfigGenerator: :raises .NoInstallationError: when the necessary programs/files cannot be located. Plugin will NOT be displayed on a list of available plugins. - :raises .NotSupportedError: - when the installation is recognized, but the version is not - currently supported. - """ + :raises .NotSupportedError: + when the installation is recognized, but the version is not + currently supported. + :rtype tuple: + """ # XXX ensure we raise the right kinds of exceptions + # Parse Postfix version number (feature support, syntax changes etc.) + mail_version = subprocess.Popen(['/usr/sbin/postconf', '-d', 'mail_version'], \ + stdout=subprocess.PIPE) \ + .communicate()[0].split()[2] + maj, min, rev = mail_version.split('.') + self.postfix_version = mail_version + + # Postfix has changed support for TLS features, supported protocol versions + # KEX methods, ciphers et cetera over the years. We sort out version dependend + # differences here and pass them onto other configuration functions. + # see: + # http://www.postfix.org/TLS_README.html + # http://www.postfix.org/FORWARD_SECRECY_README.html + + # Postfix == 2.2: + # - TLS support introduced via 3rd party patch, see: + # http://www.postfix.org/TLS_LEGACY_README.html + + # Postfix => 2.2: + # - built-in TLS support added + # - Support for PFS introduced + # - Support for (E)DHE params >= 1024bit (need to be generated), default 1k + + # Postfix => 2.5: + # - Syntax to specify mandatory protocol version changes: + # * < 2.5: `smtpd_tls_mandatory_protocols = TLSv1` + # * => 2.5: `smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3` + # - Certificate fingerprint verification added + + # Postfix => 2.6: + # - Support for ECDHE NIST P-256 curve (enable `smtpd_tls_eecdh_grade = strong`) + # - Support for configurable cipher-suites and protocol versions added, pre-2.6 + # releases always set EXPORT, options: `smtp_tls_ciphers` and `smtp_tls_protocols` + # - `smtp_tls_eccert_file` and `smtp_tls_eckey_file` config. options added + + # Postfix => 2.8: + # - Override Client suite preference w. `tls_preempt_cipherlist = yes` + # - Elliptic curve crypto. support enabled by default + + # Postfix => 2.9: + # - Public key fingerprint support added + # - `permit_tls_clientcerts`, `permit_tls_all_clientcerts` and + # `check_ccert_access` config. options added + + # Postfix <= 2.9.5: + # - BUG: Public key fingerprint is computed incorrectly + + # Postfix => 3.1: + # - Built-in support for TLS management and DANE added, see: + # http://www.postfix.org/postfix-tls.1.html + + return maj, min, rev def more_info(self): """Human-readable string to help the user. @@ -166,6 +229,7 @@ class PostfixConfigGenerator: ### Let's Encrypt client IInstaller ### + # https://github.com/letsencrypt/letsencrypt/blob/master/letsencrypt/interfaces.py#L232 def get_all_names(self): """Returns all names that may be authenticated. @@ -195,6 +259,7 @@ class PostfixConfigGenerator: self.ensure_cf_var("smtpd_tls_cert_file", fullchain_path, []) self.ensure_cf_var("smtpd_tls_key_file", key_path, []) self.set_domainwise_tls_policies() + self.update_CAfile() def enhance(self, domain, enhancement, options=None): """Perform a configuration enhancement. @@ -268,17 +333,22 @@ class PostfixConfigGenerator: """Restart or refresh the server content. :raises .PluginError: when server cannot be restarted """ + print "Reloading postfix config..." if os.geteuid() != 0: os.system("sudo service postfix reload") else: os.system("service postfix reload") + def update_CAfile(self): + os.system("cat /usr/share/ca-certificates/mozilla/*.crt > " + self.ca_file) + def usage(): print ("Usage: %s starttls-everywhere.json /etc/postfix /etc/letsencrypt/live/example.com/" % sys.argv[0]) sys.exit(1) + if __name__ == "__main__": import Config as config if len(sys.argv) != 4: diff --git a/letsencrypt-postfix/PostfixLogSummary.py b/letsencrypt-postfix/PostfixLogSummary.py index 956a069eb..a641a9f31 100755 --- a/letsencrypt-postfix/PostfixLogSummary.py +++ b/letsencrypt-postfix/PostfixLogSummary.py @@ -1,10 +1,15 @@ #!/usr/bin/env python +import argparse +import collections +import os import re import sys -import collections +import time import Config +TIME_FORMAT = "%b %d %H:%M:%S" + # TODO: There's more to be learned from postfix logs! Here's one sample # observed during failures from the sender vagrant vm: @@ -21,7 +26,7 @@ import Config # # Also: # Oct 10 19:12:13 sender postfix/smtp[1711]: 62D3F481249: to=, relay=valid-example-recipient.com[192.168.33.7]:25, delay=0.07, delays=0.03/0.01/0.03/0, dsn=4.7.4, status=deferred (TLS is required, but was not offered by host valid-example-recipient.com[192.168.33.7]) -def get_counts(input, config): +def get_counts(input, config, earliest_timestamp): seen_trusted = False counts = collections.defaultdict(lambda: collections.defaultdict(int)) @@ -31,13 +36,17 @@ def get_counts(input, config): # indicate a problem that should be alerted on. # ([^[]*) <--- any group of characters that is not "[" # Log lines for when a message is deferred for a TLS-related reason. These - deferred_re = re.compile("relay=([^[]*).* status=deferred.*TLS") + deferred_re = re.compile("relay=([^[ ]*).* status=deferred.*TLS") # Log lines for when a TLS connection was successfully established. These can # indicate the difference between Untrusted, Trusted, and Verified certs. connected_re = re.compile("([A-Za-z]+) TLS connection established to ([^[]*)") mx_to_domain_mapping = config.get_mx_to_domain_policy_map() + timestamp = 0 for line in sys.stdin: + timestamp = time.strptime(line[0:15], TIME_FORMAT) + if timestamp < earliest_timestamp: + continue deferred = deferred_re.search(line) connected = connected_re.search(line) if connected: @@ -54,11 +63,7 @@ def get_counts(input, config): elif deferred: mx_hostname = deferred.group(1).lower() tls_deferred[mx_hostname] += 1 - if not seen_trusted: - # Postfix will only emit 'Trusted' if the certificate validates according to - # the set of trust roots (CA certs) configured in smtp_tls_CAfile. - print "Didn't see any trusted connections. Need to install some trust roots?" - return (counts, tls_deferred) + return (counts, tls_deferred, seen_trusted, timestamp) def print_summary(counts): for mx_hostname, validations in counts.items(): @@ -68,11 +73,32 @@ def print_summary(counts): print mx_hostname, validation, validation_count / validations["all"], "of", validations["all"] if __name__ == "__main__": - if len(sys.argv) != 2: - print "Usage: %s starttls-everywhere.json" % sys.argv[0] - sys.exit(1) + arg_parser = argparse.ArgumentParser(description='Detect delivery problems' + ' in Postfix log files that may be caused by security policies') + arg_parser.add_argument('-c', action="store_true", dest="cron", default=False) + arg_parser.add_argument("policy_file", nargs='?', + default=os.path.join("examples", "starttls-everywhere.json"), + help="STARTTLS Everywhere policy file") + + args = arg_parser.parse_args() config = Config.Config() - config.load_from_json_file(sys.argv[1]) - (counts, tls_deferred) = get_counts(sys.stdin, config) - print_summary(counts) - print tls_deferred + config.load_from_json_file(args.policy_file) + + last_timestamp_processed = 0 + timestamp_file = '/tmp/starttls-everywhere-last-timestamp-processed.txt' + if os.path.isfile(timestamp_file): + last_timestamp_processed = time.strptime(open(timestamp_file).read(), TIME_FORMAT) + (counts, tls_deferred, seen_trusted, latest_timestamp) = get_counts(sys.stdin, config, last_timestamp_processed) + with open(timestamp_file, "w") as f: + f.write(time.strftime(TIME_FORMAT, latest_timestamp)) + + # If not running in cron, print an overall summary of log lines seen from known hosts. + if not args.cron: + print_summary(counts) + if not seen_trusted: + print 'No Trusted connections seen! Probably need to install a CAfile.' + + if len(tls_deferred) > 0: + print "Some mail was deferred due to TLS problems:" + for (k, v) in tls_deferred.iteritems(): + print "%s: %s" % (k, v)