Skip to content

Commit 6070ce2

Browse files
committed
Initial commit
0 parents  commit 6070ce2

15 files changed

+1359
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mailsec-check

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright © 2019 Max Mazurov <[email protected]>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
mailsec-check
2+
===============
3+
4+
Another utility to analyze state of deployment of security-related email
5+
protocols.
6+
7+
Compilation
8+
--------------
9+
10+
Needs [Go](https://golang.org) toolchain.
11+
12+
```
13+
go get github.com/foxcpp/mailsec-check
14+
```
15+
16+
Usage
17+
-------
18+
19+
```
20+
mailsec-check example.org
21+
```
22+
23+
Example
24+
---------
25+
26+
```
27+
$ mailsec-check protonmail.com
28+
-- Source forgery protection
29+
[+] DKIM: _domainkey subdomain present; DNSSEC-signed;
30+
[+] SPF: present; strict; DNSSEC-signed;
31+
[+] DMARC: present; strict; DNSSEC-signed;
32+
33+
-- TLS enforcement
34+
[+] MTA-STS: enforced; all MXs match policy;
35+
[+] DANE: present for all MXs; DNSSEC-signed; no validity check done;
36+
37+
-- DNS consistency
38+
[+] FCrDNS: all MXs have forward-confirmed rDNS
39+
[+] DNSSEC: A/AAAA and MX records are signed;
40+
41+
$ mailsec-check disroot.org
42+
-- Source forgery protection
43+
[+] DKIM: _domainkey subdomain present; DNSSEC-signed;
44+
[+] SPF: present; strict; DNSSEC-signed;
45+
[ ] DMARC: present; no-op; DNSSEC-signed;
46+
47+
-- TLS enforcement
48+
[ ] MTA-STS: not enforced; all MXs match policy;
49+
[+] DANE: present for all MXs; DNSSEC-signed; no validity check done;
50+
51+
-- DNS consistency
52+
[ ] FCrDNS: no MXs with forward-confirmed rDNS
53+
[+] DNSSEC: A/AAAA and MX records are signed;
54+
```

checks.go

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
"sync"
8+
9+
"github.com/emersion/go-msgauth/dmarc"
10+
"github.com/foxcpp/mailsec-check/dns"
11+
)
12+
13+
var extR *dns.ExtResolver
14+
15+
type Level int
16+
17+
const (
18+
LevelUnknown Level = iota
19+
LevelInvalid
20+
LevelMissing
21+
LevelInsecure
22+
LevelSecure
23+
)
24+
25+
type Results struct {
26+
dkim Level
27+
dkimDesc string
28+
29+
spf Level
30+
spfDesc string
31+
32+
dmarc Level
33+
dmarcDesc string
34+
35+
mtasts Level
36+
mtastsDesc string
37+
38+
dane Level
39+
daneDesc string
40+
41+
dnssecMX Level
42+
dnssecMXDesc string
43+
44+
fcrdns Level
45+
fcrdnsDesc string
46+
}
47+
48+
func evaluateAll(domain string) (Results, error) {
49+
res := Results{}
50+
51+
wg := sync.WaitGroup{}
52+
53+
wg.Add(7)
54+
go func() { evaluateDKIM(domain, &res); wg.Done() }()
55+
go func() { evaluateSPF(domain, &res); wg.Done() }()
56+
go func() { evaluateDMARC(domain, &res); wg.Done() }()
57+
go func() { evaluateMTASTS(domain, &res); wg.Done() }()
58+
go func() { evaluateDANE(domain, &res); wg.Done() }()
59+
go func() { evaluateDNSSEC(domain, &res); wg.Done() }()
60+
go func() { evaluateFCRDNS(domain, &res); wg.Done() }()
61+
62+
wg.Wait()
63+
64+
return res, nil
65+
}
66+
67+
func evaluateDNSSEC(domain string, res *Results) error {
68+
ad, addrs, err := extR.AuthLookupHost(context.Background(), domain)
69+
if err != nil {
70+
return err
71+
}
72+
if len(addrs) == 0 {
73+
return errors.New("domain does not resolve to an IP addr")
74+
}
75+
if !ad {
76+
res.dnssecMX = LevelInsecure
77+
res.dnssecMXDesc = "A/AAAA records are not signed;"
78+
return nil
79+
}
80+
81+
ad, mxs, err := extR.AuthLookupMX(context.Background(), domain)
82+
if err != nil {
83+
return err
84+
}
85+
if len(mxs) == 0 {
86+
return errors.New("domain does not have any MX records")
87+
}
88+
if !ad {
89+
res.dnssecMX = LevelInsecure
90+
res.dnssecMXDesc = "MX records are not signed;"
91+
return nil
92+
}
93+
94+
res.dnssecMX = LevelSecure
95+
res.dnssecMXDesc = "A/AAAA and MX records are signed;"
96+
return nil
97+
}
98+
99+
func evaluateDKIM(domain string, res *Results) error {
100+
ad, _, err := extR.AuthLookupTXT(context.Background(), "_domainkey."+domain)
101+
if err != nil {
102+
// Probably NXDOMAIN.
103+
// TODO: check for NXDOMAIN.
104+
res.dkim = LevelMissing
105+
res.dkimDesc = "no _domainkey subdomain"
106+
return nil
107+
}
108+
109+
res.dkim = LevelSecure
110+
res.dkimDesc += "_domainkey subdomain present; "
111+
112+
if !ad {
113+
res.dkim = LevelInsecure
114+
res.dkimDesc += "no DNSSEC; "
115+
} else {
116+
res.dkimDesc += "DNSSEC-signed; "
117+
}
118+
119+
return nil
120+
}
121+
122+
func evaluateDMARC(domain string, res *Results) error {
123+
res.dmarc = LevelSecure
124+
125+
ad, txts, err := extR.AuthLookupTXT(context.Background(), "_dmarc."+domain)
126+
if err != nil {
127+
// Probably NXDOMAIN.
128+
// TODO: check for NXDOMAIN.
129+
res.dmarc = LevelMissing
130+
res.dmarcDesc = "no policy;"
131+
return nil
132+
}
133+
134+
txt := strings.Join(txts, "")
135+
rec, err := dmarc.Parse(txt)
136+
if err != nil {
137+
res.dmarc = LevelInvalid
138+
res.dmarcDesc = "policy parse error: " + err.Error()
139+
return nil
140+
}
141+
142+
res.dmarcDesc += "present; "
143+
144+
if rec.Policy == dmarc.PolicyNone {
145+
res.dmarc = LevelMissing
146+
res.dmarcDesc += "no-op; "
147+
} else if rec.Percent != nil && *rec.Percent != 100 {
148+
res.dmarc = LevelMissing
149+
res.dmarcDesc += "applied partially; "
150+
} else {
151+
res.dmarcDesc += "strict; "
152+
}
153+
if !ad {
154+
res.dmarc = LevelInsecure
155+
res.dmarcDesc += "no DNSSEC; "
156+
} else {
157+
res.dmarcDesc += "DNSSEC-signed; "
158+
}
159+
160+
return nil
161+
}

dane.go

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/emersion/go-smtp"
9+
"github.com/miekg/dns"
10+
)
11+
12+
func evaluateDANE(domain string, res *Results) error {
13+
res.dane = LevelSecure
14+
15+
_, mxs, err := extR.AuthLookupMX(context.Background(), domain)
16+
if err != nil {
17+
return err
18+
}
19+
if len(mxs) == 0 {
20+
return errors.New("domain does not have any MX records")
21+
}
22+
23+
levelDown := func(to Level) {
24+
if res.dane > to {
25+
res.dane = to
26+
}
27+
}
28+
29+
allAD := true
30+
allValid := true
31+
allPresent := true
32+
for _, mx := range mxs {
33+
ad, recs, err := extR.AuthLookupTLSA(context.Background(), "_25._tcp."+mx.Host)
34+
if err != nil {
35+
allPresent = false
36+
levelDown(LevelMissing)
37+
res.daneDesc += fmt.Sprintf("no record for %s; ", mx.Host)
38+
continue
39+
}
40+
if !ad {
41+
allAD = false
42+
}
43+
if len(recs) == 0 {
44+
allPresent = false
45+
levelDown(LevelMissing)
46+
res.daneDesc += fmt.Sprintf("no record for %s; ", mx.Host)
47+
continue
48+
}
49+
50+
if !(*active) {
51+
continue
52+
}
53+
54+
for _, mx := range mxs {
55+
if ok := checkTLSA(mx.Host, recs, res); !ok {
56+
allValid = false
57+
}
58+
}
59+
}
60+
61+
if allPresent {
62+
res.daneDesc += "present for all MXs; "
63+
}
64+
65+
if !allAD {
66+
levelDown(LevelInvalid)
67+
res.daneDesc += "no DNSSEC; "
68+
} else {
69+
res.daneDesc += "DNSSEC-signed; "
70+
}
71+
72+
if !(*active) {
73+
res.daneDesc += "no validity check done; "
74+
return nil
75+
}
76+
77+
if allValid {
78+
res.daneDesc += "valid for all MXs; "
79+
}
80+
81+
return nil
82+
}
83+
84+
func checkTLSA(mx string, recs []dns.TLSA, res *Results) bool {
85+
levelDown := func(to Level) {
86+
if res.dane > to {
87+
res.dane = to
88+
}
89+
}
90+
91+
cl, err := smtp.Dial(mx + ":25")
92+
if err != nil {
93+
levelDown(LevelUnknown)
94+
res.daneDesc += fmt.Sprintf("can't connect to %s: %v; ", mx, err)
95+
return false
96+
}
97+
defer cl.Close()
98+
99+
if ok, _ := cl.Extension("STARTTLS"); !ok {
100+
levelDown(LevelInvalid)
101+
res.daneDesc += fmt.Sprintf("%s doesn't support STARTTLS; ", mx)
102+
return false
103+
}
104+
105+
if err := cl.StartTLS(nil); err != nil {
106+
levelDown(LevelInvalid)
107+
res.daneDesc += err.Error()
108+
return false
109+
}
110+
111+
state, ok := cl.TLSConnectionState()
112+
if !ok {
113+
panic("No TLS state returned after STARTTLS")
114+
}
115+
116+
cert := state.PeerCertificates[0]
117+
match := false
118+
for _, rec := range recs {
119+
if rec.Verify(cert) == nil {
120+
match = true
121+
}
122+
}
123+
124+
if !match {
125+
levelDown(LevelInvalid)
126+
res.daneDesc += fmt.Sprintf("%v uses wrong cert; ", mx)
127+
}
128+
129+
return true
130+
}

0 commit comments

Comments
 (0)