Initial commit

This commit is contained in:
nemunaire 2026-04-24 10:33:26 +07:00
commit a6dbcef0f9
26 changed files with 2993 additions and 0 deletions

417
checker/match_test.go Normal file
View file

@ -0,0 +1,417 @@
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")
}
}