Skip to content
This repository was archived by the owner on Dec 30, 2022. It is now read-only.

Commit 8f521b3

Browse files
committed
Add preliminary DANE support
TODO: * Do not fail when a TLS connection to a SMTP server fails. Otherwise we can't check "3 x 1" certificates that may not have been signed by a trusted CA. * Support "2 x 1" type certificates. This requires a certificate chain, which is not supported by Python.
1 parent a1833c6 commit 8f521b3

File tree

8 files changed

+267
-65
lines changed

8 files changed

+267
-65
lines changed

check.py

Lines changed: 191 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,28 @@
99
import smtplib
1010
import ssl
1111
import socket
12+
import re
13+
import hashlib
14+
1215
import flask # Debian: python3-flask
1316
from flask_limiter import Limiter # pip3: Flask-Limiter
1417
from flask_limiter.util import get_remote_address
15-
import re
18+
19+
from cryptography.hazmat.primitives import serialization # Debian: python3-cryptography
20+
from cryptography.x509 import load_der_x509_certificate
21+
from cryptography.hazmat.backends import default_backend
1622

1723
# See e.g. this page how to deploy:
1824
# http://flask.pocoo.org/docs/0.12/deploying/uwsgi/
1925

2026
domainPattern = re.compile( '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$')
2127
mxDomainPattern = re.compile('^\.?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$')
2228

29+
# Set up DNS resolver that requests validation
30+
resolver = dns.resolver.Resolver()
31+
resolver.edns = 0
32+
resolver.ednsflags = dns.flags.DO
33+
2334
# Ratings:
2435
# 1: error
2536
# 2: warning
@@ -28,10 +39,10 @@
2839
# 5: disabled (but OK)
2940
VERDICT_MAP = {
3041
None: None,
31-
1: 'fail',
32-
2: 'warn',
33-
4: 'ok',
34-
5: 'off',
42+
1: 'fail',
43+
2: 'warn',
44+
4: 'ok',
45+
5: 'off',
3546
}
3647

3748
class Result:
@@ -46,7 +57,7 @@ def __init__(self):
4657

4758
def error(self, message, value=None):
4859
''' Error for this specific result '''
49-
if self.errorName is not None:
60+
if self.errorName is not None and (self.errorName != message or self.errorValue != value):
5061
raise ValueError('Result.error: error has already been set')
5162
self.errorName = message
5263
self.errorValue = value
@@ -72,6 +83,12 @@ def __init__(self, name, preference, policyNames):
7283
self.preference = preference
7384
self.policyNames = policyNames
7485
self.certNames = None
86+
self.dnssec_a = None
87+
self.dnssec_tlsa = None
88+
self.tlsa_records = []
89+
self.error = None
90+
self.dane_hash_cert = None
91+
self.dane_hash_spki = None
7592

7693
@property
7794
def valid(self):
@@ -83,6 +100,83 @@ def valid(self):
83100
def verdict(self):
84101
return {True: 'ok', False: 'fail'}[self.valid]
85102

103+
@property
104+
def daneVerdict(self):
105+
if not self.dnssec or not len(self.tlsa_records):
106+
return 'fail'
107+
return {
108+
'ok': 'ok',
109+
'fail': 'fail',
110+
'unusable': 'fail',
111+
'unsupported': 'none',
112+
}[self.tlsa_state]
113+
114+
@property
115+
def dnssec(self):
116+
return self.dnssec_a and self.dnssec_tlsa
117+
118+
@property
119+
def tlsa_state(self):
120+
has_unsupported = False
121+
has_unusable = False # has record that is not "3 1 1" or "2 1 1"
122+
is_valid = False
123+
for record in self.tlsa_records:
124+
# From https://tools.ietf.org/html/rfc7672#section-3.1:
125+
#
126+
# In summary, we RECOMMEND the use of "DANE-EE(3) SPKI(1) SHA2-256(1)",
127+
# with "DANE-TA(2) Cert(0) SHA2-256(1)" TLSA records as a second
128+
# choice, depending on site needs. See Sections 3.1.1 and 3.1.2 for
129+
# more details. Other combinations of TLSA parameters either (1) are
130+
# explicitly unsupported or (2) offer little to recommend them over
131+
# these two.
132+
#
133+
# From https://tools.ietf.org/html/rfc7672#section-3.1.1:
134+
#
135+
# TLSA records published for SMTP servers SHOULD, in most cases, be
136+
# "DANE-EE(3) SPKI(1) SHA2-256(1)" records. Since all DANE
137+
# implementations are required to support SHA2-256, this record type
138+
# works for all clients and need not change across certificate renewals
139+
# with the same key.
140+
141+
# In other words, the types supported by DANE are 3 x 1 and 2 x 1.
142+
143+
if record.selector not in (0, 1):
144+
has_unusable = True
145+
continue
146+
147+
if record.mtype != 1:
148+
has_unusable = True
149+
continue
150+
151+
if record.usage not in (2, 3):
152+
has_invalid = True
153+
continue
154+
if record.usage == 2:
155+
has_unsupported = True
156+
# TODO: verify hostname and chain
157+
continue
158+
159+
# record type is "3 x 1"
160+
161+
if record.selector == 0 and record.cert.hex() == self.dane_hash_cert:
162+
is_valid = True
163+
elif record.selector == 1 and record.cert.hex() == self.dane_hash_spki:
164+
# TODO UNTESTED
165+
is_valid = True
166+
167+
if is_valid:
168+
# At least one record validates.
169+
return 'ok'
170+
elif has_unsupported:
171+
# Has a "2 1 1" TLSA record which hasn't been implemented.
172+
return 'unsupported'
173+
elif has_unusable:
174+
# Has at least one unusable TLSA record, and validation failed.
175+
return 'unusable'
176+
else:
177+
# Could not validate.
178+
return 'fail'
179+
86180
class Report:
87181
'''
88182
Whole result, of all tests together.
@@ -131,16 +225,27 @@ def ratingTLSRPT(self):
131225
rating = 5 # TLSRPT is not enabled
132226
else:
133227
rating = 4
134-
return min(rating, self.sts.rating, self.policy.rating)
228+
return min(rating, self.sts.rating)
135229

136230
@property
137231
def verdictTLSRPT(self):
138232
return VERDICT_MAP[self.ratingTLSRPT]
139233

234+
@property
235+
def verdictDANE(self):
236+
daneVerdicts = set()
237+
for server in self.mx.value['servers']:
238+
daneVerdicts.add(server.daneVerdict)
239+
if 'none' in daneVerdicts:
240+
return 'none'
241+
if 'ok' in daneVerdicts:
242+
return 'ok'
243+
return 'fail'
244+
140245
def retrieveTXTRecord(result, domain, prefix, magic):
141246
fullDomain = prefix + '.' + domain
142247
try:
143-
answers = dns.resolver.query(fullDomain, 'TXT')
248+
answers = resolver.query(fullDomain, 'TXT')
144249
except dns.resolver.NXDOMAIN:
145250
return result.error('no-domain', fullDomain)
146251
except dns.resolver.NoAnswer:
@@ -161,10 +266,7 @@ def retrieveTXTRecord(result, domain, prefix, magic):
161266
# This is actually possible, though I don't know whether it is
162267
# allowed.
163268
continue
164-
if isinstance(record.strings[0], bytes):
165-
data = b''.join(record.strings).decode('ascii')
166-
else:
167-
data = ''.join(record.strings)
269+
data = b''.join(record.strings).decode('ascii')
168270
if data.startswith(magic):
169271
dnsPolicies.append(data)
170272
else:
@@ -394,10 +496,12 @@ def checkPolicyFile(result, domain):
394496

395497
def getMX(result, domain):
396498
try:
397-
answers = dns.resolver.query(domain, 'MX')
499+
answers = resolver.query(domain, 'MX')
398500
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):
399501
return result.error('dns-error', domain)
400502

503+
result.value['dnssec'] = bool(answers.response.flags & dns.flags.AD)
504+
401505
mxs = {}
402506
for record in answers:
403507
mxs[record.exchange.to_text(omit_final_dot=True)] = record.preference
@@ -412,33 +516,68 @@ def mxsortkey(mx):
412516

413517
def checkMailserver(result, mx, preference, policyNames):
414518
data = MailserverResult(mx, preference, policyNames)
415-
result.value.append(data)
519+
520+
if not domainPattern.match(mx):
521+
data.error = '!invalid-mx'
522+
elif len(result.value['servers']) > 5:
523+
data.error = '!skip'
524+
if data.error:
525+
result.error('mx-fail')
526+
return data
527+
528+
try:
529+
tlsarrs = resolver.query('_25._tcp.' + mx, 'TLSA')
530+
data.dnssec_tlsa = bool(tlsarrs.response.flags & dns.flags.AD)
531+
for rr in tlsarrs:
532+
if not isinstance(rr, dns.rdtypes.ANY.TLSA.TLSA):
533+
continue
534+
data.tlsa_records.append(rr)
535+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):
536+
pass
416537

417538
conn = None
418-
names = set()
419539
cert = None
420540
try:
541+
542+
answers = resolver.query(mx, 'A')
543+
# TODO: IPv6
544+
# TODO: try all other IP addresses returned
545+
data.dnssec_a = bool(answers.response.flags & dns.flags.AD)
546+
421547
cert = {}
422-
if not domainPattern.match(mx):
423-
data.error = '!invalid-mx'
424-
elif len(result.value) > 5:
425-
data.error = '!skip'
426-
else:
427-
# TODO test MX label validity
428-
context = ssl.create_default_context()
429-
context.options |= ssl.OP_NO_TLSv1
430-
context.options |= ssl.OP_NO_TLSv1_1
431-
# we do our own hostname checking
432-
context.check_hostname = False
433-
conn = smtplib.SMTP(mx, port=25, timeout=30)
434-
conn.starttls(context=context)
435-
cert = conn.sock.getpeercert()
548+
# TODO test MX label validity
549+
context = ssl.create_default_context()
550+
context.options |= ssl.OP_NO_TLSv1
551+
context.options |= ssl.OP_NO_TLSv1_1
552+
# we do our own hostname checking
553+
context.check_hostname = False
554+
# TODO: send SNI while using an IP address?
555+
conn = smtplib.SMTP(mx, port=25, timeout=30)
556+
conn.starttls(context=context)
557+
558+
# TODO: ignore expiration date when using DANE, as per RFC7672 section
559+
# 3.1.1.
560+
cert = conn.sock.getpeercert()
561+
cert_der = conn.sock.getpeercert(True)
562+
cert_x509 = load_der_x509_certificate(cert_der, default_backend())
563+
cert_pk = cert_x509.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
564+
data.dane_hash_cert = hashlib.sha256(cert_der).hexdigest()
565+
data.dane_hash_spki = hashlib.sha256(cert_pk).hexdigest()
436566

437567
# TODO check for expired certificate?
438568

569+
names = set()
439570
for san in cert.get('subjectAltName', ()):
440571
if san[0] == 'DNS':
441572
names.add(san[1])
573+
574+
def domainsortkey(n):
575+
n = n.split('.')
576+
n.reverse()
577+
return n
578+
data.certNames = sorted(names, key=domainsortkey)
579+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):
580+
data.error = '!dns-error'
442581
except ssl.SSLError as e:
443582
data.error = e.reason
444583
except (TimeoutError, socket.timeout):
@@ -452,16 +591,6 @@ def checkMailserver(result, mx, preference, policyNames):
452591
if conn is not None:
453592
conn.close()
454593

455-
if cert is not None and not names and not data.error:
456-
# does this actually happen?
457-
data.error = '!unknown'
458-
459-
def domainsortkey(n):
460-
n = n.split('.')
461-
n.reverse()
462-
return n
463-
data.certNames = sorted(names, key=domainsortkey)
464-
465594
if not data.valid:
466595
result.error('mx-fail')
467596
return data
@@ -526,19 +655,39 @@ def makeReport(domain):
526655
checkPolicyFile(report.policy, domain)
527656
yield renderSubReport(report, 'policy', report.policy.rating)
528657

658+
report.mx.value = {'servers': []}
529659
mailservers = getMX(report.mx, domain)
530-
if mailservers is not None:
531-
report.mx.value = []
660+
if mailservers is not None: # DNS request was successful
661+
with app.app_context():
662+
html = flask.render_template('result-dane-mx.html',
663+
verdict='ok' if report.mx.value.get('dnssec') else 'fail')
664+
yield makeEventSource({
665+
'reportName': 'dane',
666+
'part': html})
532667

533668
policyNames = report.policy.value.get('info', {}).get('mx', None)
534669
yield renderSubReport(report, 'mx', None)
535670
for mx, preference in mailservers:
536671
serverResult = checkMailserver(report.mx, mx, preference, policyNames)
672+
report.mx.value['servers'].append(serverResult)
673+
674+
with app.app_context():
675+
html = flask.render_template('result-dane-server.html',
676+
mx=serverResult)
677+
yield makeEventSource({
678+
'reportName': 'dane',
679+
'part': html})
680+
537681
with app.app_context():
538-
html = flask.render_template('result-mx-server.html', mx=serverResult)
682+
html = flask.render_template('result-mx-server.html', server=serverResult)
539683
yield makeEventSource({
540684
'reportName': 'mx',
541685
'part': html})
686+
687+
yield makeEventSource({
688+
'reportName': 'dane',
689+
'verdict': report.verdictDANE})
690+
542691
yield makeEventSource({
543692
'reportName': 'mx',
544693
'verdict': VERDICT_MAP[report.mx.rating]})

index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ <h1>MTA-STS validator</h1>
3232
<h2>Background</h2>
3333
<p><a href="https://datatracker.ietf.org/doc/draft-ietf-uta-mta-sts/">MTA-STS</a> is a new standard (<a href="https://github.com/mrisher/smtp-sts">still in development</a>) that makes it possible to send downgrade-resistant email over SMTP. In that sense, it is like an alternative to DANE. It does this by piggybacking on the browser Certificate Authority model.</p>
3434

35-
<p>Note: this validator is based on the <a href="https://github.com/mrisher/smtp-sts/blob/master/mta-sts.md">current draft specification</a> as of march 23, 2018. It is very likely some parts of it will change before the final publication. To see which changes haven't been included in this validator yet, see <a href="https://github.com/mrisher/smtp-sts/compare/8dbc6972bce1572311c64d4f50beed22dee95262...master">this diff</a>. The currently implemented specification is <a href="https://tools.ietf.org/html/draft-ietf-uta-mta-sts-14">MTA-STS draft 14</a> and <a href="https://tools.ietf.org/html/draft-ietf-uta-smtp-tlsrpt-17">SMTP-TLSRPT draft 17</a>.</p>
35+
<p>Note: this validator is based on the <a href="https://github.com/mrisher/smtp-sts/blob/master/mta-sts.md">current draft specification</a> as of march 23, 2018. It is likely some parts of it will change before the final publication. To see which changes haven't been included in this validator yet, see <a href="https://github.com/mrisher/smtp-sts/compare/8dbc6972bce1572311c64d4f50beed22dee95262...master">this diff</a>. The currently implemented specification is <a href="https://tools.ietf.org/html/draft-ietf-uta-mta-sts-14">MTA-STS draft 14</a> and <a href="https://tools.ietf.org/html/draft-ietf-uta-smtp-tlsrpt-17">SMTP-TLSRPT draft 17</a>.</p>
3636

3737
<p class="mb-0">To enable Strict Transport Security on your mailserver configure the following things:</p>
3838

@@ -80,6 +80,12 @@ <h3 class="loading">
8080
</h3>
8181
<div class="contents"></div>
8282
</div>
83+
<div class="report-dane">
84+
<h3 class="loading">
85+
<div>DANE</div>
86+
</h3>
87+
<dl class="parts"></dl>
88+
</div>
8389
<div class="report-policy">
8490
<h3 class="loading">
8591
<div>Policy file</div>

0 commit comments

Comments
 (0)