417 lines
14 KiB
Go
417 lines
14 KiB
Go
package checker
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/sha512"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"strings"
|
|
"testing"
|
|
|
|
tls "git.happydns.org/checker-tls/checker"
|
|
)
|
|
|
|
// fakeCert builds a CertInfo whose hashes are precomputed from given
|
|
// pseudo-DER and pseudo-SPKI byte slices. Real DER is unnecessary: the
|
|
// matching logic only operates on bytes/hex.
|
|
func fakeCert(der, spki []byte) tls.CertInfo {
|
|
cs256 := sha256.Sum256(der)
|
|
cs512 := sha512.Sum512(der)
|
|
ss256 := sha256.Sum256(spki)
|
|
ss512 := sha512.Sum512(spki)
|
|
return tls.CertInfo{
|
|
DERBase64: base64.StdEncoding.EncodeToString(der),
|
|
SPKIDERBase64: base64.StdEncoding.EncodeToString(spki),
|
|
CertSHA256: hex.EncodeToString(cs256[:]),
|
|
CertSHA512: hex.EncodeToString(cs512[:]),
|
|
SPKISHA256: hex.EncodeToString(ss256[:]),
|
|
SPKISHA512: hex.EncodeToString(ss512[:]),
|
|
}
|
|
}
|
|
|
|
func TestTLSAOwnerRegex(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
in string
|
|
ok bool
|
|
port, proto, bs string
|
|
}{
|
|
{"_443._tcp.example.com", true, "443", "tcp", "example.com"},
|
|
{"_25._tcp.mail.example.com", true, "25", "tcp", "mail.example.com"},
|
|
{"_853._udp", true, "853", "udp", ""},
|
|
{"_443._sctp.example.com", false, "", "", ""},
|
|
{"443._tcp.example.com", false, "", "", ""},
|
|
{"_abc._tcp.example.com", false, "", "", ""},
|
|
{"_443.tcp.example.com", false, "", "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
m := tlsaOwner.FindStringSubmatch(tc.in)
|
|
if (m != nil) != tc.ok {
|
|
t.Errorf("%q: match=%v want=%v", tc.in, m != nil, tc.ok)
|
|
continue
|
|
}
|
|
if !tc.ok {
|
|
continue
|
|
}
|
|
if m[1] != tc.port || m[2] != tc.proto || m[3] != tc.bs {
|
|
t.Errorf("%q: got (%q,%q,%q) want (%q,%q,%q)", tc.in, m[1], m[2], m[3], tc.port, tc.proto, tc.bs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTLSAOwnerName(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
port uint16
|
|
proto string
|
|
base string
|
|
want string
|
|
}{
|
|
{443, "tcp", "example.com", "_443._tcp.example.com"},
|
|
{25, "tcp", "mail.example.com", "_25._tcp.mail.example.com"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := tlsaOwnerName(tc.port, tc.proto, tc.base)
|
|
if got != tc.want {
|
|
t.Errorf("tlsaOwnerName(%d,%q,%q)=%q want %q", tc.port, tc.proto, tc.base, got, tc.want)
|
|
}
|
|
}
|
|
|
|
// Empty base: trailing label is omitted so the result is still a
|
|
// syntactically valid relative name rather than "_443._tcp.".
|
|
if got := tlsaOwnerName(443, "tcp", ""); got != "_443._tcp" {
|
|
t.Errorf("empty base: got %q want %q", got, "_443._tcp")
|
|
}
|
|
if got := tlsaOwnerName(443, "tcp", "example.com."); got != "_443._tcp.example.com" {
|
|
t.Errorf("trailing dot stripped: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStarttlsKey(t *testing.T) {
|
|
t.Parallel()
|
|
if got := starttlsKey(25, "tcp"); got != "25/tcp" {
|
|
t.Errorf("got %q want 25/tcp", got)
|
|
}
|
|
}
|
|
|
|
func TestJoinName(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
base, sub, apex string
|
|
want string
|
|
}{
|
|
{"empty base, no sub", "", "", "example.com", "example.com"},
|
|
{"empty base with sub", "", "mail", "example.com", "mail.example.com"},
|
|
{"absolute base equal apex", "example.com", "", "example.com", "example.com"},
|
|
{"absolute base ending in apex", "mail.example.com", "", "example.com", "mail.example.com"},
|
|
{"absolute base ending in apex with sub", "host.sub.example.com", "sub", "example.com", "host.sub.example.com"},
|
|
{"relative base with sub", "host", "sub", "example.com", "host.sub.example.com"},
|
|
{"relative base no sub", "host", "", "example.com", "host.example.com"},
|
|
{"trailing dot", "host.", "", "example.com", "host.example.com"},
|
|
{"empty everything", "", "", "", ""},
|
|
// Brittle short-apex case (the "com" apex). Pinned to current
|
|
// behaviour: HasSuffix(".com") makes "example.com" already
|
|
// fully-qualified, so it is returned unchanged.
|
|
{"short apex collision", "example.com", "", "com", "example.com"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := joinName(tc.base, tc.sub, tc.apex)
|
|
if got != tc.want {
|
|
t.Errorf("got %q want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecordCandidate_Selectors(t *testing.T) {
|
|
t.Parallel()
|
|
der := []byte("der-bytes")
|
|
spki := []byte("spki-bytes")
|
|
c := fakeCert(der, spki)
|
|
|
|
derHex := hex.EncodeToString(der)
|
|
spkiHex := hex.EncodeToString(spki)
|
|
|
|
cases := []struct {
|
|
name string
|
|
rec TLSARecord
|
|
want string
|
|
}{
|
|
{"cert/full", TLSARecord{Selector: SelectorCert, MatchingType: MatchingFull}, derHex},
|
|
{"cert/sha256", TLSARecord{Selector: SelectorCert, MatchingType: MatchingSHA256}, c.CertSHA256},
|
|
{"cert/sha512", TLSARecord{Selector: SelectorCert, MatchingType: MatchingSHA512}, c.CertSHA512},
|
|
{"spki/full", TLSARecord{Selector: SelectorSPKI, MatchingType: MatchingFull}, spkiHex},
|
|
{"spki/sha256", TLSARecord{Selector: SelectorSPKI, MatchingType: MatchingSHA256}, c.SPKISHA256},
|
|
{"spki/sha512", TLSARecord{Selector: SelectorSPKI, MatchingType: MatchingSHA512}, c.SPKISHA512},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := recordCandidate(tc.rec, c)
|
|
if err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("got %q want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecordCandidate_Errors(t *testing.T) {
|
|
t.Parallel()
|
|
c := fakeCert([]byte("d"), []byte("s"))
|
|
if _, err := recordCandidate(TLSARecord{Selector: 9, MatchingType: MatchingSHA256}, c); err == nil {
|
|
t.Error("expected error on unknown selector")
|
|
}
|
|
if _, err := recordCandidate(TLSARecord{Selector: SelectorCert, MatchingType: 9}, c); err == nil {
|
|
t.Error("expected error on unknown matching type for cert")
|
|
}
|
|
if _, err := recordCandidate(TLSARecord{Selector: SelectorSPKI, MatchingType: 9}, c); err == nil {
|
|
t.Error("expected error on unknown matching type for spki")
|
|
}
|
|
bad := tls.CertInfo{DERBase64: "!!!not-base64!!!"}
|
|
if _, err := recordCandidate(TLSARecord{Selector: SelectorCert, MatchingType: MatchingFull}, bad); err == nil {
|
|
t.Error("expected base64 decode error")
|
|
}
|
|
}
|
|
|
|
func TestDecodeFullDER_SizeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
huge := strings.Repeat("A", maxFullDERBytes+10) // base64; decoded is ~3/4 of len
|
|
if _, err := decodeFullDER(huge, "test"); err == nil {
|
|
t.Error("expected size-limit error")
|
|
}
|
|
small := base64.StdEncoding.EncodeToString([]byte("hello"))
|
|
got, err := decodeFullDER(small, "test")
|
|
if err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if string(got) != "hello" {
|
|
t.Errorf("got %q want hello", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchRecord_LeafSelectsByUsage(t *testing.T) {
|
|
t.Parallel()
|
|
leaf := fakeCert([]byte("leaf-der"), []byte("leaf-spki"))
|
|
mid := fakeCert([]byte("mid-der"), []byte("mid-spki"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf, mid}}
|
|
|
|
// usage 3 (DANE-EE) matches leaf SHA-256 SPKI
|
|
rec := TLSARecord{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256}
|
|
if ok, why := matchRecord(rec, probe); !ok {
|
|
t.Errorf("DANE-EE leaf SPKI sha256: ok=false reason=%q", why)
|
|
}
|
|
// usage 3 with intermediate hash should NOT match (wrong slot)
|
|
rec.Certificate = mid.SPKISHA256
|
|
if ok, _ := matchRecord(rec, probe); ok {
|
|
t.Error("DANE-EE matching against intermediate SPKI should fail")
|
|
}
|
|
|
|
// usage 2 (DANE-TA) matches intermediate
|
|
rec = TLSARecord{Usage: UsageDANETA, Selector: SelectorCert, MatchingType: MatchingSHA256, Certificate: mid.CertSHA256}
|
|
if ok, why := matchRecord(rec, probe); !ok {
|
|
t.Errorf("DANE-TA intermediate cert sha256: ok=false reason=%q", why)
|
|
}
|
|
|
|
// usage 1 (PKIX-EE) matches leaf cert hash
|
|
rec = TLSARecord{Usage: UsagePKIXEE, Selector: SelectorCert, MatchingType: MatchingSHA256, Certificate: leaf.CertSHA256}
|
|
if ok, why := matchRecord(rec, probe); !ok {
|
|
t.Errorf("PKIX-EE leaf cert sha256: ok=false reason=%q", why)
|
|
}
|
|
|
|
// usage 0 (PKIX-TA) matches intermediate
|
|
rec = TLSARecord{Usage: UsagePKIXTA, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: mid.SPKISHA256}
|
|
if ok, why := matchRecord(rec, probe); !ok {
|
|
t.Errorf("PKIX-TA intermediate spki sha256: ok=false reason=%q", why)
|
|
}
|
|
}
|
|
|
|
func TestMatchRecord_NoChain(t *testing.T) {
|
|
t.Parallel()
|
|
if ok, why := matchRecord(TLSARecord{Usage: UsageDANEEE}, &tls.TLSProbe{}); ok || why == "" {
|
|
t.Errorf("empty chain: ok=%v reason=%q", ok, why)
|
|
}
|
|
}
|
|
|
|
func TestMatchRecord_TASelfSignedFallback(t *testing.T) {
|
|
t.Parallel()
|
|
// When the chain has only a leaf, usage 0/2 falls back to matching the
|
|
// leaf as a degenerate TA so the user gets feedback.
|
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
|
rec := TLSARecord{Usage: UsageDANETA, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256}
|
|
if ok, why := matchRecord(rec, probe); !ok {
|
|
t.Errorf("self-signed TA fallback: ok=false reason=%q", why)
|
|
}
|
|
}
|
|
|
|
func TestMatchRecord_UnsupportedUsage(t *testing.T) {
|
|
t.Parallel()
|
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
|
if ok, why := matchRecord(TLSARecord{Usage: 9}, probe); ok || !strings.Contains(why, "unsupported") {
|
|
t.Errorf("usage 9: ok=%v reason=%q", ok, why)
|
|
}
|
|
}
|
|
|
|
func TestMatchRecord_FullDER(t *testing.T) {
|
|
t.Parallel()
|
|
der := []byte("the-actual-cert-der")
|
|
leaf := fakeCert(der, []byte("ignored"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
|
rec := TLSARecord{
|
|
Usage: UsageDANEEE,
|
|
Selector: SelectorCert,
|
|
MatchingType: MatchingFull,
|
|
Certificate: hex.EncodeToString(der),
|
|
}
|
|
if ok, why := matchRecord(rec, probe); !ok {
|
|
t.Errorf("Full DER match failed: %q", why)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeMatches(t *testing.T) {
|
|
t.Parallel()
|
|
leaf := fakeCert([]byte("leaf"), []byte("ls"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
|
t1 := TargetResult{Records: []TLSARecord{
|
|
{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256}, // ok
|
|
{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: "deadbeef"}, // miss
|
|
{Usage: UsageDANEEE, Selector: SelectorCert, MatchingType: MatchingSHA256, Certificate: leaf.CertSHA256}, // ok
|
|
}}
|
|
s := summarizeMatches(t1, probe)
|
|
if s.matched != 2 || s.unmatched != 1 || s.firstUnmatchedIdx != 1 {
|
|
t.Errorf("got matched=%d unmatched=%d firstIdx=%d", s.matched, s.unmatched, s.firstUnmatchedIdx)
|
|
}
|
|
|
|
if got := summarizeMatches(t1, nil); got.matched != 0 || got.firstUnmatchedIdx != -1 {
|
|
t.Errorf("nil probe: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeMatches_BadFirstSlotDoesNotAbort(t *testing.T) {
|
|
t.Parallel()
|
|
// An undecodable Full record at slot 0 shouldn't prevent later valid
|
|
// records from matching: regression test for the per-slot continue.
|
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
|
bad := TargetResult{Records: []TLSARecord{
|
|
{Usage: UsageDANEEE, Selector: SelectorCert, MatchingType: MatchingFull, Certificate: "00"}, // hex won't match decoded DER
|
|
{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256},
|
|
}}
|
|
s := summarizeMatches(bad, probe)
|
|
if s.matched != 1 {
|
|
t.Errorf("expected 1 match (the second record), got %d (unmatched=%d)", s.matched, s.unmatched)
|
|
}
|
|
}
|
|
|
|
func TestHasPKIXUsage(t *testing.T) {
|
|
t.Parallel()
|
|
if hasPKIXUsage(TargetResult{Records: []TLSARecord{{Usage: UsageDANEEE}}}) {
|
|
t.Error("DANE-EE only: expected false")
|
|
}
|
|
if !hasPKIXUsage(TargetResult{Records: []TLSARecord{{Usage: UsagePKIXEE}}}) {
|
|
t.Error("PKIX-EE: expected true")
|
|
}
|
|
if !hasPKIXUsage(TargetResult{Records: []TLSARecord{{Usage: UsageDANETA}, {Usage: UsagePKIXTA}}}) {
|
|
t.Error("contains PKIX-TA: expected true")
|
|
}
|
|
if hasPKIXUsage(TargetResult{}) {
|
|
t.Error("empty: expected false")
|
|
}
|
|
}
|
|
|
|
func TestSuspiciousUsage(t *testing.T) {
|
|
t.Parallel()
|
|
leaf := fakeCert([]byte("leaf"), []byte("ls"))
|
|
mid := fakeCert([]byte("mid"), []byte("ms"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf, mid}}
|
|
|
|
// Record declared as EE but hash matches intermediate => suspicious.
|
|
tgt := TargetResult{Records: []TLSARecord{{
|
|
Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256,
|
|
Certificate: mid.SPKISHA256,
|
|
}}}
|
|
if got := suspiciousUsage(tgt, probe); got == "" {
|
|
t.Error("expected suspicious-usage warning")
|
|
}
|
|
|
|
// Record declared as EE matching the leaf is fine.
|
|
tgt.Records[0].Certificate = leaf.SPKISHA256
|
|
if got := suspiciousUsage(tgt, probe); got != "" {
|
|
t.Errorf("unexpected warning: %q", got)
|
|
}
|
|
|
|
// Single-cert chain: rule is silent.
|
|
if got := suspiciousUsage(tgt, &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}); got != "" {
|
|
t.Errorf("single-cert chain should be silent, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestProposedTLSA(t *testing.T) {
|
|
t.Parallel()
|
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
|
|
|
// No record published: defaults to 3 1 1.
|
|
t1 := TargetResult{Owner: "_443._tcp.example.com", Records: nil}
|
|
got := proposedTLSA(t1, probe)
|
|
if !strings.Contains(got, "TLSA 3 1 1 ") || !strings.Contains(got, leaf.SPKISHA256) {
|
|
t.Errorf("default proposal: %q", got)
|
|
}
|
|
|
|
// Existing record uses Full → suggestion downgrades to SHA-256.
|
|
t2 := TargetResult{Owner: "_443._tcp.example.com", Records: []TLSARecord{{Usage: UsageDANEEE, Selector: SelectorCert, MatchingType: MatchingFull}}}
|
|
got = proposedTLSA(t2, probe)
|
|
if !strings.Contains(got, "TLSA 3 0 1 ") {
|
|
t.Errorf("Full→SHA256 collapse: %q", got)
|
|
}
|
|
|
|
// No probe: empty.
|
|
if got := proposedTLSA(t1, nil); got != "" {
|
|
t.Errorf("no probe: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHandshakeFix(t *testing.T) {
|
|
t.Parallel()
|
|
got := handshakeFix(TargetResult{Host: "mail.example.com", Port: 25, STARTTLS: "smtp"})
|
|
if !strings.Contains(got, "-starttls smtp") || !strings.Contains(got, "-connect mail.example.com:25") {
|
|
t.Errorf("smtp fix: %q", got)
|
|
}
|
|
got = handshakeFix(TargetResult{Host: "example.com", Port: 443})
|
|
if strings.Contains(got, "-starttls") || !strings.Contains(got, "-connect example.com:443") {
|
|
t.Errorf("direct fix: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTruncHex(t *testing.T) {
|
|
t.Parallel()
|
|
if truncHex("abc") != "abc" {
|
|
t.Error("short")
|
|
}
|
|
long := strings.Repeat("a", 20)
|
|
if got := truncHex(long); got != "aaaaaaaaaaaa…" {
|
|
t.Errorf("long: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestProbeUsable(t *testing.T) {
|
|
t.Parallel()
|
|
leaf := fakeCert([]byte("l"), []byte("s"))
|
|
if probeUsable(nil) {
|
|
t.Error("nil")
|
|
}
|
|
if probeUsable(&tls.TLSProbe{}) {
|
|
t.Error("empty chain")
|
|
}
|
|
if probeUsable(&tls.TLSProbe{Chain: []tls.CertInfo{leaf}, Error: "boom"}) {
|
|
t.Error("error set")
|
|
}
|
|
if !probeUsable(&tls.TLSProbe{Chain: []tls.CertInfo{leaf}}) {
|
|
t.Error("good probe")
|
|
}
|
|
}
|