9
9
import smtplib
10
10
import ssl
11
11
import socket
12
+ import re
13
+ import hashlib
14
+
12
15
import flask # Debian: python3-flask
13
16
from flask_limiter import Limiter # pip3: Flask-Limiter
14
17
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
16
22
17
23
# See e.g. this page how to deploy:
18
24
# http://flask.pocoo.org/docs/0.12/deploying/uwsgi/
19
25
20
26
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])$' )
21
27
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])$' )
22
28
29
+ # Set up DNS resolver that requests validation
30
+ resolver = dns .resolver .Resolver ()
31
+ resolver .edns = 0
32
+ resolver .ednsflags = dns .flags .DO
33
+
23
34
# Ratings:
24
35
# 1: error
25
36
# 2: warning
28
39
# 5: disabled (but OK)
29
40
VERDICT_MAP = {
30
41
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' ,
35
46
}
36
47
37
48
class Result :
@@ -46,7 +57,7 @@ def __init__(self):
46
57
47
58
def error (self , message , value = None ):
48
59
''' 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 ) :
50
61
raise ValueError ('Result.error: error has already been set' )
51
62
self .errorName = message
52
63
self .errorValue = value
@@ -72,6 +83,12 @@ def __init__(self, name, preference, policyNames):
72
83
self .preference = preference
73
84
self .policyNames = policyNames
74
85
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
75
92
76
93
@property
77
94
def valid (self ):
@@ -83,6 +100,83 @@ def valid(self):
83
100
def verdict (self ):
84
101
return {True : 'ok' , False : 'fail' }[self .valid ]
85
102
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
+
86
180
class Report :
87
181
'''
88
182
Whole result, of all tests together.
@@ -131,16 +225,27 @@ def ratingTLSRPT(self):
131
225
rating = 5 # TLSRPT is not enabled
132
226
else :
133
227
rating = 4
134
- return min (rating , self .sts .rating , self . policy . rating )
228
+ return min (rating , self .sts .rating )
135
229
136
230
@property
137
231
def verdictTLSRPT (self ):
138
232
return VERDICT_MAP [self .ratingTLSRPT ]
139
233
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
+
140
245
def retrieveTXTRecord (result , domain , prefix , magic ):
141
246
fullDomain = prefix + '.' + domain
142
247
try :
143
- answers = dns . resolver .query (fullDomain , 'TXT' )
248
+ answers = resolver .query (fullDomain , 'TXT' )
144
249
except dns .resolver .NXDOMAIN :
145
250
return result .error ('no-domain' , fullDomain )
146
251
except dns .resolver .NoAnswer :
@@ -161,10 +266,7 @@ def retrieveTXTRecord(result, domain, prefix, magic):
161
266
# This is actually possible, though I don't know whether it is
162
267
# allowed.
163
268
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' )
168
270
if data .startswith (magic ):
169
271
dnsPolicies .append (data )
170
272
else :
@@ -394,10 +496,12 @@ def checkPolicyFile(result, domain):
394
496
395
497
def getMX (result , domain ):
396
498
try :
397
- answers = dns . resolver .query (domain , 'MX' )
499
+ answers = resolver .query (domain , 'MX' )
398
500
except (dns .resolver .NXDOMAIN , dns .resolver .NoAnswer , dns .resolver .Timeout ):
399
501
return result .error ('dns-error' , domain )
400
502
503
+ result .value ['dnssec' ] = bool (answers .response .flags & dns .flags .AD )
504
+
401
505
mxs = {}
402
506
for record in answers :
403
507
mxs [record .exchange .to_text (omit_final_dot = True )] = record .preference
@@ -412,33 +516,68 @@ def mxsortkey(mx):
412
516
413
517
def checkMailserver (result , mx , preference , policyNames ):
414
518
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
416
537
417
538
conn = None
418
- names = set ()
419
539
cert = None
420
540
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
+
421
547
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 ()
436
566
437
567
# TODO check for expired certificate?
438
568
569
+ names = set ()
439
570
for san in cert .get ('subjectAltName' , ()):
440
571
if san [0 ] == 'DNS' :
441
572
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'
442
581
except ssl .SSLError as e :
443
582
data .error = e .reason
444
583
except (TimeoutError , socket .timeout ):
@@ -452,16 +591,6 @@ def checkMailserver(result, mx, preference, policyNames):
452
591
if conn is not None :
453
592
conn .close ()
454
593
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
-
465
594
if not data .valid :
466
595
result .error ('mx-fail' )
467
596
return data
@@ -526,19 +655,39 @@ def makeReport(domain):
526
655
checkPolicyFile (report .policy , domain )
527
656
yield renderSubReport (report , 'policy' , report .policy .rating )
528
657
658
+ report .mx .value = {'servers' : []}
529
659
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 })
532
667
533
668
policyNames = report .policy .value .get ('info' , {}).get ('mx' , None )
534
669
yield renderSubReport (report , 'mx' , None )
535
670
for mx , preference in mailservers :
536
671
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
+
537
681
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 )
539
683
yield makeEventSource ({
540
684
'reportName' : 'mx' ,
541
685
'part' : html })
686
+
687
+ yield makeEventSource ({
688
+ 'reportName' : 'dane' ,
689
+ 'verdict' : report .verdictDANE })
690
+
542
691
yield makeEventSource ({
543
692
'reportName' : 'mx' ,
544
693
'verdict' : VERDICT_MAP [report .mx .rating ]})
0 commit comments