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") } }