diff --git a/checker/collect.go b/checker/collect.go index 8db0ddb..d576fb3 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -22,6 +22,7 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any timeoutMs = DefaultProbeTimeoutMs } timeout := time.Duration(timeoutMs) * time.Millisecond + enumerate := sdk.GetBoolOption(opts, OptionEnumerateCiphers, false) entries, warnings := contract.ParseEntries(raw) for _, w := range warnings { @@ -40,15 +41,36 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any var mu sync.Mutex var wg sync.WaitGroup sem := make(chan struct{}, MaxConcurrentProbes) +dispatch: for _, e := range entries { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + break dispatch + } wg.Add(1) - sem <- struct{}{} go func() { defer wg.Done() defer func() { <-sem }() pr := probe(ctx, e.Endpoint, timeout) log.Printf("checker-tls: %s %s:%d → tls=%s handshake_ok=%t elapsed=%dms err=%q", pr.Type, pr.Host, pr.Port, pr.TLSVersion, pr.TLSHandshakeOK, pr.ElapsedMS, pr.Error) + if enumerate && pr.TLSHandshakeOK { + enumRes, skipReason := enumerateEndpoint(ctx, e.Endpoint, enumerationBudget) + switch { + case enumRes != nil && enumRes.Skipped != "": + pr.Enum = enumRes + log.Printf("checker-tls: enum %s:%d → error: %s (duration=%dms)", + pr.Host, pr.Port, enumRes.Skipped, enumRes.DurationMS) + case enumRes != nil: + pr.Enum = enumRes + log.Printf("checker-tls: enum %s:%d → versions=%d duration=%dms", + pr.Host, pr.Port, len(enumRes.Versions), enumRes.DurationMS) + case skipReason != "": + log.Printf("checker-tls: enum %s:%d → skipped: %s", + pr.Host, pr.Port, skipReason) + } + } mu.Lock() probes[e.Ref] = pr mu.Unlock() diff --git a/checker/definition.go b/checker/definition.go index b948e5b..4fd166a 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -29,6 +29,13 @@ func (p *tlsProvider) Definition() *sdk.CheckerDefinition { Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.", Default: float64(DefaultProbeTimeoutMs), }, + { + Id: OptionEnumerateCiphers, + Type: "boolean", + Label: "Enumerate accepted TLS versions and cipher suites", + Description: "When enabled, each direct-TLS endpoint is swept with one ClientHello per (version, cipher) pair to discover the exact set the server accepts. Adds ~50 handshakes per endpoint.", + Default: false, + }, }, RunOpts: []sdk.CheckerOptionDocumentation{ { diff --git a/checker/enumerate.go b/checker/enumerate.go new file mode 100644 index 0000000..61e76ef --- /dev/null +++ b/checker/enumerate.go @@ -0,0 +1,68 @@ +package checker + +import ( + "context" + "net" + "strconv" + "strings" + "time" + + "git.happydns.org/checker-tls/contract" + "git.happydns.org/checker-tls/tlsenum" +) + +// enumerationProbeTimeout caps each individual sub-probe. It is intentionally +// shorter than the main probe timeout: a sweep does dozens of handshakes and +// most rejections come back in tens of ms, so 3s is enough to absorb a slow +// network without dragging the total cost. +const enumerationProbeTimeout = 3 * time.Second + +// enumerateEndpoint runs a (version × cipher) sweep against an endpoint — +// direct TLS or STARTTLS — and returns the result in the wire-format consumed +// by rules. It returns (nil, "") to signal the sweep was deliberately +// skipped. +func enumerateEndpoint(ctx context.Context, ep contract.TLSEndpoint, totalBudget time.Duration) (*TLSEnumeration, string) { + host := strings.TrimSuffix(ep.Host, ".") + addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port))) + sni := ep.SNI + if sni == "" { + sni = host + } + + upgrader, ok := upgraderFor(ep.STARTTLS, sni) + if !ok { + return nil, "unsupported starttls dialect: " + ep.STARTTLS + } + + sweepCtx := ctx + if totalBudget > 0 { + var cancel context.CancelFunc + sweepCtx, cancel = context.WithTimeout(ctx, totalBudget) + defer cancel() + } + + start := time.Now() + res, err := tlsenum.Enumerate(sweepCtx, addr, sni, tlsenum.EnumerateOptions{ + ProbeTimeout: enumerationProbeTimeout, + Upgrader: upgrader, + }) + elapsed := time.Since(start).Milliseconds() + if err != nil { + return &TLSEnumeration{Skipped: "enumeration error: " + err.Error(), DurationMS: elapsed}, "" + } + + out := &TLSEnumeration{DurationMS: elapsed} + for _, v := range res.SupportedVersions { + ev := EnumVersion{Version: v, Name: tlsenum.VersionName(v)} + for _, c := range res.CiphersByVersion[v] { + ev.Ciphers = append(ev.Ciphers, EnumCipher{ID: c.ID, Name: c.Name}) + } + out.Versions = append(out.Versions, ev) + } + return out, "" +} + +// enumerationBudget is the upper bound we give one endpoint's sweep. ~50 +// handshakes × enumerationProbeTimeout would be 2-3 minutes worst case; we +// cap at 60s so a black-holing target can't stall the whole collect run. +const enumerationBudget = 60 * time.Second diff --git a/checker/enumerate_test.go b/checker/enumerate_test.go new file mode 100644 index 0000000..02484bc --- /dev/null +++ b/checker/enumerate_test.go @@ -0,0 +1,198 @@ +package checker + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net" + "strconv" + "testing" + "time" + + "git.happydns.org/checker-tls/contract" +) + +// startEnumTestServer spins up a TCP listener that, for every accepted +// connection: (1) optionally drives a fake STARTTLS dialect handshake, then +// (2) lets the standard library terminate TLS with the provided cert. It +// keeps accepting until the test closes the listener. +// +// We use the stdlib tls.Server (not utls) on the server side: the point of +// these tests is to exercise the *checker* glue (upgraderFor + enumerate) +// against the real client-side code, not to replay tlsenum's internals. +func startEnumTestServer(t *testing.T, withSTARTTLS bool, cert tls.Certificate) net.Listener { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go handleEnumConn(c, withSTARTTLS, cert) + } + }() + return ln +} + +func handleEnumConn(c net.Conn, withSTARTTLS bool, cert tls.Certificate) { + defer c.Close() + if withSTARTTLS { + // Pretend to be SMTP: 220 banner, EHLO ack, STARTTLS ack. The + // implementation of starttlsSMTP only requires the server to + // advertise STARTTLS in its EHLO response and to reply with a 2xx + // to the STARTTLS verb — exact verbs come from RFC 3207. + if _, err := io.WriteString(c, "220 enum.test ESMTP\r\n"); err != nil { + return + } + buf := make([]byte, 1024) + // EHLO line + if _, err := c.Read(buf); err != nil { + return + } + if _, err := io.WriteString(c, "250-enum.test\r\n250 STARTTLS\r\n"); err != nil { + return + } + // STARTTLS line + if _, err := c.Read(buf); err != nil { + return + } + if _, err := io.WriteString(c, "220 ready\r\n"); err != nil { + return + } + } + tc := tls.Server(c, &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, // narrow surface so the sweep is fast + }) + defer tc.Close() + _ = tc.Handshake() +} + +// enumTestCert is a one-time self-signed ECDSA cert reused across tests. +func enumTestCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("genkey: %v", err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "enum.test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"enum.test"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("createcert: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + c, err := tls.X509KeyPair( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), + ) + if err != nil { + t.Fatalf("keypair: %v", err) + } + return c +} + +func portOf(t *testing.T, ln net.Listener) uint16 { + t.Helper() + _, p, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatalf("split addr: %v", err) + } + n, err := strconv.ParseUint(p, 10, 16) + if err != nil { + t.Fatalf("parse port: %v", err) + } + return uint16(n) +} + +// TestEnumerateEndpoint_DirectTLS asserts the sweep returns at least one +// supported version + cipher when the endpoint is plain TLS — proving the +// nil-upgrader path of upgraderFor wires correctly. +func TestEnumerateEndpoint_DirectTLS(t *testing.T) { + cert := enumTestCert(t) + ln := startEnumTestServer(t, false, cert) + defer ln.Close() + + res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: portOf(t, ln), + SNI: "enum.test", + }, 30*time.Second) + if skip != "" { + t.Fatalf("unexpected skip reason: %q", skip) + } + if res == nil || len(res.Versions) == 0 { + t.Fatalf("expected at least one supported version, got %+v", res) + } + gotTLS12 := false + for _, v := range res.Versions { + if v.Version == tls.VersionTLS12 && len(v.Ciphers) > 0 { + gotTLS12 = true + } + } + if !gotTLS12 { + t.Fatalf("expected TLS 1.2 with at least one cipher, got %+v", res.Versions) + } +} + +// TestEnumerateEndpoint_SMTP_STARTTLS asserts the sweep drives the SMTP +// dialect upgrade on every sub-probe and still discovers ciphers — proving +// the upgraderFor("smtp", sni) path is wired into Enumerate. +func TestEnumerateEndpoint_SMTP_STARTTLS(t *testing.T) { + cert := enumTestCert(t) + ln := startEnumTestServer(t, true, cert) + defer ln.Close() + + res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: portOf(t, ln), + SNI: "enum.test", + STARTTLS: "smtp", + }, 60*time.Second) + if skip != "" { + t.Fatalf("unexpected skip reason: %q", skip) + } + if res == nil || len(res.Versions) == 0 { + t.Fatalf("expected at least one supported version through STARTTLS, got %+v", res) + } +} + +// TestEnumerateEndpoint_UnknownDialect asserts an unsupported STARTTLS +// dialect is rejected with a non-empty skip reason and no result — the +// observation must record *why* enumeration didn't run, not silently report +// "no versions accepted". +func TestEnumerateEndpoint_UnknownDialect(t *testing.T) { + res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: 1, // unreachable on purpose; we never get past the dialect check + STARTTLS: "no-such-dialect", + }, time.Second) + if res != nil { + t.Fatalf("expected nil result for unknown dialect, got %+v", res) + } + if skip == "" { + t.Fatalf("expected non-empty skip reason for unknown dialect") + } +} diff --git a/checker/rule.go b/checker/rule.go index bcb1858..ad9d626 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -25,6 +25,8 @@ func Rules() []sdk.CheckRule { &expiryRule{}, &tlsVersionRule{}, &cipherSuiteRule{}, + &versionEnumerationRule{}, + &weakCipherRule{}, } } diff --git a/checker/rules_enumeration.go b/checker/rules_enumeration.go new file mode 100644 index 0000000..6757be0 --- /dev/null +++ b/checker/rules_enumeration.go @@ -0,0 +1,197 @@ +package checker + +import ( + "context" + "crypto/tls" + "fmt" + "sort" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// hasEnum returns true when at least one probe carries enumeration data. +// Rules use this to short-circuit to "skipped" when the user hasn't enabled +// the enumerate option (rather than falsely emitting a "passing" verdict). +func hasEnum(data *TLSData) bool { + for _, p := range data.Probes { + if p.Enum != nil && len(p.Enum.Versions) > 0 { + return true + } + } + return false +} + +// versionEnumerationRule reports the full set of protocol versions accepted +// by each endpoint, and flags any acceptance below the TLS 1.2 floor — the +// regular handshake rule only sees the *negotiated* version, so a server +// that still accepts TLS 1.0 alongside TLS 1.3 would otherwise look healthy. +type versionEnumerationRule struct{} + +func (r *versionEnumerationRule) Name() string { return "tls.enum.versions" } +func (r *versionEnumerationRule) Description() string { + return "Flags endpoints that still accept TLS versions below TLS 1.2 (requires the enumerate option)." +} + +func (r *versionEnumerationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.enum.versions.no_endpoints")} + } + if !hasEnum(data) { + return []sdk.CheckState{unknownState( + "tls.enum.versions.skipped", + "TLS version/cipher enumeration was not run for any endpoint (enable the enumerateCiphers option).", + )} + } + + var out []sdk.CheckState + anyEnum := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.Enum == nil || len(p.Enum.Versions) == 0 { + continue + } + anyEnum = true + + var legacy []string + for _, v := range p.Enum.Versions { + if v.Version < tls.VersionTLS12 { + legacy = append(legacy, v.Name) + } + } + if len(legacy) == 0 { + continue + } + sort.Strings(legacy) + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "tls.enum.versions.legacy_accepted", + Subject: subjectOf(p), + Message: fmt.Sprintf("Endpoint accepts legacy protocol version(s): %s.", strings.Join(legacy, ", ")), + Meta: metaOf(p), + }) + } + if !anyEnum { + return []sdk.CheckState{unknownState( + "tls.enum.versions.skipped", + "No endpoint produced enumeration data.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.enum.versions.ok", + "No endpoint accepts a protocol version below TLS 1.2.", + )} + } + return out +} + +// weakCipherRule flags endpoints that accept cipher suites widely considered +// broken or insecure: NULL, anonymous, EXPORT, RC4, 3DES, and any other CBC +// suite using SHA-1 in MAC-then-encrypt mode is *not* flagged here because +// real-world servers still need them for legacy clients; this rule limits +// itself to the set with no defensible use in 2026. +type weakCipherRule struct{} + +func (r *weakCipherRule) Name() string { return "tls.enum.ciphers" } +func (r *weakCipherRule) Description() string { + return "Flags endpoints that accept broken cipher suites (NULL, anonymous, EXPORT, RC4, 3DES)." +} + +// classifyCipher returns a non-empty category when the named cipher belongs +// to a class with no defensible modern use. The check is by substring on the +// IANA name because every entry follows the TLS__WITH__ +// convention. +func classifyCipher(name string) string { + upper := strings.ToUpper(name) + switch { + case strings.Contains(upper, "_NULL_"), strings.HasSuffix(upper, "_NULL"): + return "NULL" + case strings.Contains(upper, "_ANON_"): + return "anonymous" + case strings.Contains(upper, "_EXPORT_"): + return "EXPORT" + case strings.Contains(upper, "_RC4_"): + return "RC4" + case strings.Contains(upper, "_3DES_"), strings.Contains(upper, "_DES_"): + return "3DES/DES" + } + return "" +} + +func (r *weakCipherRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.enum.ciphers.no_endpoints")} + } + if !hasEnum(data) { + return []sdk.CheckState{unknownState( + "tls.enum.ciphers.skipped", + "TLS version/cipher enumeration was not run for any endpoint (enable the enumerateCiphers option).", + )} + } + + var out []sdk.CheckState + anyEnum := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.Enum == nil || len(p.Enum.Versions) == 0 { + continue + } + anyEnum = true + + // Aggregate by category so a server accepting six EXPORT suites + // produces one finding, not six. + byCategory := map[string][]string{} + for _, v := range p.Enum.Versions { + for _, c := range v.Ciphers { + cat := classifyCipher(c.Name) + if cat == "" { + continue + } + byCategory[cat] = append(byCategory[cat], c.Name) + } + } + if len(byCategory) == 0 { + continue + } + cats := make([]string, 0, len(byCategory)) + for c := range byCategory { + cats = append(cats, c) + } + sort.Strings(cats) + parts := make([]string, 0, len(cats)) + for _, c := range cats { + parts = append(parts, fmt.Sprintf("%s (%d)", c, len(byCategory[c]))) + } + meta := metaOf(p) + meta["weak_ciphers"] = byCategory + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "tls.enum.ciphers.weak_accepted", + Subject: subjectOf(p), + Message: "Endpoint accepts broken cipher suites: " + strings.Join(parts, ", ") + ".", + Meta: meta, + }) + } + if !anyEnum { + return []sdk.CheckState{unknownState( + "tls.enum.ciphers.skipped", + "No endpoint produced enumeration data.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.enum.ciphers.ok", + "No endpoint accepts a known-broken cipher suite (NULL/anonymous/EXPORT/RC4/3DES).", + )} + } + return out +} diff --git a/checker/rules_enumeration_test.go b/checker/rules_enumeration_test.go new file mode 100644 index 0000000..3097477 --- /dev/null +++ b/checker/rules_enumeration_test.go @@ -0,0 +1,135 @@ +package checker + +import ( + "context" + "crypto/tls" + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// stubObs is a minimal ObservationGetter that serves a pre-built TLSData +// payload and ignores related lookups. It is local to this file rather than +// promoted to a shared helper to keep the rule tests self-contained. +type stubObs struct{ data TLSData } + +func (s stubObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { + if key != ObservationKeyTLSProbes { + return nil + } + raw, _ := json.Marshal(s.data) + return json.Unmarshal(raw, dest) +} +func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return nil, nil +} + +func newProbeWithEnum(versions ...EnumVersion) TLSProbe { + return TLSProbe{ + Host: "example.test", Port: 443, Endpoint: "example.test:443", Type: "tls", + TLSHandshakeOK: true, TLSVersionNum: tls.VersionTLS13, + Enum: &TLSEnumeration{Versions: versions}, + } +} + +func TestVersionEnumerationRule_Skipped_NoEnum(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{"a": {Host: "x", Port: 443, Endpoint: "x:443", Type: "tls", TLSHandshakeOK: true}}, + CollectedAt: time.Now(), + }} + got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Code != "tls.enum.versions.skipped" { + t.Fatalf("want a single skipped state, got %+v", got) + } +} + +func TestVersionEnumerationRule_OK_OnlyModern(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"}, + EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3"}, + ), + }, + }} + got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.versions.ok" { + t.Fatalf("want a single OK state, got %+v", got) + } +} + +func TestVersionEnumerationRule_LegacyAccepted(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS10, Name: "TLS 1.0"}, + EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"}, + ), + }, + }} + got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.versions.legacy_accepted" { + t.Fatalf("want a single warn state, got %+v", got) + } + if !strings.Contains(got[0].Message, "TLS 1.0") { + t.Fatalf("warn message should mention the legacy version, got %q", got[0].Message) + } +} + +func TestClassifyCipher(t *testing.T) { + cases := map[string]string{ + "TLS_RSA_WITH_NULL_SHA": "NULL", + "TLS_DH_anon_WITH_AES_128_CBC_SHA": "anonymous", + "TLS_RSA_EXPORT_WITH_RC4_40_MD5": "EXPORT", + "TLS_ECDHE_RSA_WITH_RC4_128_SHA": "RC4", + "TLS_RSA_WITH_3DES_EDE_CBC_SHA": "3DES/DES", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": "", + "TLS_AES_256_GCM_SHA384": "", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": "", + } + for name, want := range cases { + if got := classifyCipher(name); got != want { + t.Errorf("classifyCipher(%q) = %q, want %q", name, got, want) + } + } +} + +func TestWeakCipherRule_Detects(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2", Ciphers: []EnumCipher{ + {ID: 0xC02F, Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}, + {ID: 0x000A, Name: "TLS_RSA_WITH_3DES_EDE_CBC_SHA"}, + {ID: 0x0005, Name: "TLS_RSA_WITH_RC4_128_SHA"}, + }}, + ), + }, + }} + got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.ciphers.weak_accepted" { + t.Fatalf("want a single weak warn state, got %+v", got) + } + if !strings.Contains(got[0].Message, "RC4") || !strings.Contains(got[0].Message, "3DES/DES") { + t.Fatalf("warn message should list the broken categories, got %q", got[0].Message) + } +} + +func TestWeakCipherRule_OK_OnlyModern(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3", Ciphers: []EnumCipher{ + {ID: 0x1301, Name: "TLS_AES_128_GCM_SHA256"}, + }}, + ), + }, + }} + got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.ciphers.ok" { + t.Fatalf("want a single OK state, got %+v", got) + } +} diff --git a/checker/starttls.go b/checker/starttls.go index 892e533..b06adf6 100644 --- a/checker/starttls.go +++ b/checker/starttls.go @@ -48,3 +48,18 @@ var starttlsUpgraders = map[string]starttlsUpgrader{} func registerStartTLS(protocol string, upgrader starttlsUpgrader) { starttlsUpgraders[protocol] = upgrader } + +// upgraderFor returns a tlsenum-compatible upgrader callback for a given +// STARTTLS dialect, plus an ok flag. An empty dialect means direct TLS and +// returns (nil, true) — tlsenum will skip the upgrade phase. An unknown +// dialect returns (nil, false) so the caller can record the skip reason. +func upgraderFor(dialect, sni string) (func(net.Conn) error, bool) { + if dialect == "" { + return nil, true + } + up, ok := starttlsUpgraders[dialect] + if !ok { + return nil, false + } + return func(c net.Conn) error { return up(c, sni) }, true +} diff --git a/checker/starttls_ldap.go b/checker/starttls_ldap.go index 30b47d3..40d1c5d 100644 --- a/checker/starttls_ldap.go +++ b/checker/starttls_ldap.go @@ -52,7 +52,10 @@ func starttlsLDAP(conn net.Conn, sni string) error { if err != nil { return fmt.Errorf("read response length: %w", err) } - if length <= 0 || length > 4096 { + // 16 KiB comfortably accommodates an ExtendedResponse with a verbose + // diagnosticMessage while still bounding memory against a hostile peer. + const maxLDAPResponseBytes = 16 * 1024 + if length <= 0 || length > maxLDAPResponseBytes { return fmt.Errorf("unreasonable LDAP response length %d", length) } body := make([]byte, length) diff --git a/checker/starttls_test.go b/checker/starttls_test.go index 9d05e8f..0f76ebd 100644 --- a/checker/starttls_test.go +++ b/checker/starttls_test.go @@ -132,6 +132,24 @@ func TestStartTLS_IMAP_OK(t *testing.T) { } } +func TestStartTLS_IMAP_Refused(t *testing.T) { + err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "* OK IMAP4rev1 ready\r\n") + _, _ = readLineCRLF(br) + _, _ = io.WriteString(c, "* CAPABILITY IMAP4rev1 STARTTLS\r\nA001 OK CAPABILITY completed\r\n") + _, _ = readLineCRLF(br) + _, err := io.WriteString(c, "A002 NO STARTTLS unavailable\r\n") + return err + }) + if err == nil { + t.Fatal("expected refusal error") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("refusal should not be classified as not-offered: %v", err) + } +} + func TestStartTLS_IMAP_NotAdvertised(t *testing.T) { err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error { br := bufio.NewReader(c) @@ -185,6 +203,24 @@ func TestStartTLS_POP3_NotAdvertised(t *testing.T) { } } +func TestStartTLS_POP3_Refused(t *testing.T) { + err := runStartTLS(t, starttlsPOP3, "pop.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "+OK POP3 ready\r\n") + _, _ = readLineCRLF(br) + _, _ = io.WriteString(c, "+OK capa list\r\nUSER\r\nSTLS\r\n.\r\n") + _, _ = readLineCRLF(br) + _, err := io.WriteString(c, "-ERR STLS unavailable\r\n") + return err + }) + if err == nil { + t.Fatal("expected refusal error") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("refusal should not be classified as not-offered: %v", err) + } +} + func TestStartTLS_XMPP_OK(t *testing.T) { err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { br := bufio.NewReader(c) @@ -225,6 +261,47 @@ func TestStartTLS_XMPP_NotAdvertised(t *testing.T) { } } +func TestStartTLS_XMPP_Refused(t *testing.T) { + err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + buf := make([]byte, 1024) + if _, err := br.Read(buf); err != nil { + return err + } + _, _ = io.WriteString(c, + ``+ + ``) + if _, err := br.Read(buf); err != nil { + return err + } + _, err := io.WriteString(c, ``) + return err + }) + if err == nil { + t.Fatal("expected failure error") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf(" should not be classified as not-offered: %v", err) + } +} + +func TestStartTLS_XMPP_StreamError(t *testing.T) { + err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + buf := make([]byte, 1024) + if _, err := br.Read(buf); err != nil { + return err + } + _, err := io.WriteString(c, + ``+ + ``) + return err + }) + if err == nil { + t.Fatal("expected stream:error to surface as error") + } +} + func TestStartTLS_LDAP_OK(t *testing.T) { err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { // Drain the StartTLS request (fixed 31 bytes: 0x30 0x1d + 29 bytes). @@ -250,6 +327,86 @@ func TestStartTLS_LDAP_OK(t *testing.T) { } } +func TestStartTLS_LDAP_WrongTag(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + _, err := c.Write([]byte{0x42, 0x00}) + return err + }) + if err == nil { + t.Fatal("expected error for wrong tag") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("malformed response should not be classified as not-offered: %v", err) + } +} + +func TestStartTLS_LDAP_OversizedLength(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // SEQUENCE with long-form length = 0x10000 (64 KiB) — beyond our 16 KiB cap. + _, err := c.Write([]byte{0x30, 0x83, 0x01, 0x00, 0x00}) + return err + }) + if err == nil { + t.Fatal("expected oversized-length error") + } +} + +func TestStartTLS_LDAP_TruncatedBody(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // Announce 12 bytes of body, only send 5 then close. + _, err := c.Write([]byte{0x30, 0x0c, 0x02, 0x01, 0x01, 0x78, 0x07}) + return err + }) + if err == nil { + t.Fatal("expected error on truncated body") + } +} + +func TestStartTLS_LDAP_DiagnosticMessageOver4KiB(t *testing.T) { + // A real-world response with a verbose diagnosticMessage can exceed the + // previous 4 KiB cap. Confirm the bumped 16 KiB cap accepts it. + const diagLen = 8000 + diag := make([]byte, diagLen) + for i := range diag { + diag[i] = 'x' + } + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // Body: messageID(3) + extResp tag(1) + extResp len(3) + resultCode(3) + matchedDN(2) + diag tag+long-len(4) + diag bytes + // extResp inner length = resultCode(3) + matchedDN(2) + diagTLV(4+diagLen) = 9 + diagLen + extInner := 9 + diagLen + // Outer SEQUENCE inner length = messageID(3) + extResp TLV(1+3+extInner) + outerInner := 3 + 4 + extInner + buf := []byte{0x30, 0x82, byte(outerInner >> 8), byte(outerInner & 0xff)} + buf = append(buf, 0x02, 0x01, 0x01) // messageID + buf = append(buf, 0x78, 0x82, byte(extInner>>8), byte(extInner&0xff)) + buf = append(buf, 0x0a, 0x01, 0x00) // resultCode = success + buf = append(buf, 0x04, 0x00) // matchedDN "" + buf = append(buf, 0x04, 0x82, byte(diagLen>>8), byte(diagLen&0xff)) + buf = append(buf, diag...) + _, err := c.Write(buf) + return err + }) + if err != nil { + t.Fatalf("expected success with verbose diagnosticMessage, got: %v", err) + } +} + func TestStartTLS_LDAP_Refused(t *testing.T) { err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { req := make([]byte, 31) diff --git a/checker/starttls_xmpp.go b/checker/starttls_xmpp.go index 22b421a..7b6e1ad 100644 --- a/checker/starttls_xmpp.go +++ b/checker/starttls_xmpp.go @@ -22,13 +22,20 @@ func starttlsXMPPServer(conn net.Conn, sni string) error { return starttlsXMPP(conn, sni, "jabber:server") } +// xmppPreTLSReadLimit caps the bytes the XML decoder may pull from an +// untrusted peer before the TLS handshake. The legitimate pre-TLS exchange +// ( opening + + ) is well under +// 1 KiB; 64 KiB is generous for non-malicious servers while bounding memory +// against a peer that streams unbounded XML to exhaust the prober. +const xmppPreTLSReadLimit = 64 * 1024 + func starttlsXMPP(conn net.Conn, sni, ns string) error { header := fmt.Sprintf(``, ns, sni) if _, err := io.WriteString(conn, header); err != nil { return fmt.Errorf("write stream header: %w", err) } - dec := xml.NewDecoder(conn) + dec := xml.NewDecoder(&io.LimitedReader{R: conn, N: xmppPreTLSReadLimit}) // Read the inbound opening and its . // A peer that opens with (or anything other than features) diff --git a/checker/types.go b/checker/types.go index 8cbf23d..a0d5389 100644 --- a/checker/types.go +++ b/checker/types.go @@ -9,8 +9,9 @@ const ObservationKeyTLSProbes = "tls_probes" // Option ids on CheckerOptions. const ( - OptionEndpoints = "endpoints" - OptionProbeTimeoutMs = "probeTimeoutMs" + OptionEndpoints = "endpoints" + OptionProbeTimeoutMs = "probeTimeoutMs" + OptionEnumerateCiphers = "enumerateCiphers" ) // Defaults shared between the definition's Default field and the runtime @@ -100,6 +101,12 @@ type TLSProbe struct { Chain []CertInfo `json:"chain,omitempty"` ElapsedMS int64 `json:"elapsed_ms,omitempty"` + // Enum carries the protocol-version and cipher-suite sweep. It is only + // populated when the user enables OptionEnumerateCiphers. Direct TLS and + // supported STARTTLS dialects are both swept; a STARTTLS endpoint with + // an unknown dialect is skipped with a reason recorded in Enum.Skipped. + Enum *TLSEnumeration `json:"enum,omitempty"` + // Error is a compatibility summary of whichever raw error applies. // Left for any external consumer still inspecting it; rules should // look at TCPError / HandshakeError instead. @@ -142,3 +149,31 @@ type CertInfo struct { const ( ExpiringSoonThreshold = 14 * 24 * time.Hour ) + +// TLSEnumeration is the result of sweeping a (version × cipher) matrix +// against an endpoint. The exact set the server accepts (rather than just the +// one combination it negotiated under default Go preferences) lets rules flag +// legacy versions and weak cipher suites that would otherwise stay invisible. +type TLSEnumeration struct { + // Versions lists every protocol version for which at least one cipher + // was accepted, with the matching cipher suites. + Versions []EnumVersion `json:"versions,omitempty"` + // Skipped is set when enumeration was not attempted (e.g. STARTTLS + // endpoint, prior handshake failure). Empty when enumeration ran. + Skipped string `json:"skipped,omitempty"` + // DurationMS is the wall-clock time spent enumerating, for ops visibility. + DurationMS int64 `json:"duration_ms,omitempty"` +} + +// EnumVersion is one accepted protocol version plus the ciphers it accepted. +type EnumVersion struct { + Version uint16 `json:"version"` + Name string `json:"name"` + Ciphers []EnumCipher `json:"ciphers,omitempty"` +} + +// EnumCipher is one accepted cipher suite. +type EnumCipher struct { + ID uint16 `json:"id"` + Name string `json:"name"` +} diff --git a/checker/upgrader_for_test.go b/checker/upgrader_for_test.go new file mode 100644 index 0000000..3803649 --- /dev/null +++ b/checker/upgrader_for_test.go @@ -0,0 +1,91 @@ +package checker + +import ( + "errors" + "net" + "testing" +) + +// TestUpgraderFor_DirectTLS verifies that an empty dialect returns a nil +// upgrader with ok=true: tlsenum's contract is that nil means "no upgrade +// phase", so direct-TLS endpoints must round-trip through this branch +// without producing a shim that would call into the registry. +func TestUpgraderFor_DirectTLS(t *testing.T) { + up, ok := upgraderFor("", "example.test") + if !ok { + t.Fatalf("expected ok=true for empty dialect") + } + if up != nil { + t.Fatalf("expected nil upgrader for empty dialect, got %T", up) + } +} + +func TestUpgraderFor_UnknownDialect(t *testing.T) { + up, ok := upgraderFor("totally-not-a-dialect", "example.test") + if ok { + t.Fatalf("expected ok=false for unknown dialect") + } + if up != nil { + t.Fatalf("expected nil upgrader for unknown dialect, got %T", up) + } +} + +// TestUpgraderFor_KnownDialect_ForwardsSNI registers a temporary fake dialect +// in the registry, asks upgraderFor for its callback, invokes the callback, +// and asserts the registered upgrader received the expected SNI. We can't +// reuse a real dialect for this because they all read/write protocol-specific +// banners on the connection — the point of this test is the SNI plumbing in +// the closure, not the dialect's own behavior. +func TestUpgraderFor_KnownDialect_ForwardsSNI(t *testing.T) { + const dialect = "test-fake" + const wantSNI = "host.example.test" + + var ( + gotSNI string + gotConn net.Conn + ) + wantErr := errors.New("sentinel from fake upgrader") + registerStartTLS(dialect, func(c net.Conn, sni string) error { + gotConn = c + gotSNI = sni + return wantErr + }) + defer delete(starttlsUpgraders, dialect) + + up, ok := upgraderFor(dialect, wantSNI) + if !ok || up == nil { + t.Fatalf("expected non-nil upgrader and ok=true, got nil=%v ok=%v", up == nil, ok) + } + + // Use a closed pipe end as a sentinel net.Conn — the registered upgrader + // captures it without doing I/O, so a real connection is unnecessary. + a, b := net.Pipe() + _ = a.Close() + _ = b.Close() + + if err := up(a); !errors.Is(err, wantErr) { + t.Fatalf("expected sentinel error to propagate, got %v", err) + } + if gotSNI != wantSNI { + t.Fatalf("registered upgrader received SNI %q, want %q", gotSNI, wantSNI) + } + if gotConn != a { + t.Fatalf("registered upgrader received a different conn than the one passed in") + } +} + +// TestUpgraderFor_RealDialects_AllRegistered guards against silently dropping +// a dialect from the registry: every protocol referenced by the contract's +// STARTTLS values must resolve to a non-nil upgrader. The list mirrors the +// dialects implemented in starttls_*.go. +func TestUpgraderFor_RealDialects_AllRegistered(t *testing.T) { + dialects := []string{"smtp", "submission", "imap", "pop3", "xmpp-client", "xmpp-server", "ldap"} + for _, d := range dialects { + t.Run(d, func(t *testing.T) { + up, ok := upgraderFor(d, "host.example") + if !ok || up == nil { + t.Fatalf("dialect %q is not registered", d) + } + }) + } +} diff --git a/go.mod b/go.mod index 9656a31..c23c67d 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,11 @@ module git.happydns.org/checker-tls go 1.25.0 require git.happydns.org/checker-sdk-go v1.5.0 + +require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sys v0.31.0 // indirect +) diff --git a/go.sum b/go.sum index c389c68..c811775 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,12 @@ git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/tlsenum/ciphers.go b/tlsenum/ciphers.go new file mode 100644 index 0000000..303bfdd --- /dev/null +++ b/tlsenum/ciphers.go @@ -0,0 +1,103 @@ +package tlsenum + +// CipherSuite pairs an IANA TLS cipher suite ID with its standard name. +// +// The catalog below intentionally covers the "real-world" set: modern AEAD +// suites used by TLS 1.2/1.3, plus a long tail of legacy CBC/RC4/3DES/EXPORT +// suites we want to *detect* on remote servers (so we can flag them), even +// though Go's stdlib refuses to negotiate them. utls lets us put any 16-bit +// value in the offered list, so the server's accept/reject decision is the +// source of truth. +type CipherSuite struct { + ID uint16 + Name string + // TLS13 is true for the five TLS 1.3 AEAD suites; those must only be + // offered with TLS 1.3 ClientHellos. + TLS13 bool +} + +// TLS13Ciphers are the AEAD suites defined for TLS 1.3 (RFC 8446 §B.4). +var TLS13Ciphers = []CipherSuite{ + {0x1301, "TLS_AES_128_GCM_SHA256", true}, + {0x1302, "TLS_AES_256_GCM_SHA384", true}, + {0x1303, "TLS_CHACHA20_POLY1305_SHA256", true}, + {0x1304, "TLS_AES_128_CCM_SHA256", true}, + {0x1305, "TLS_AES_128_CCM_8_SHA256", true}, +} + +// LegacyCiphers covers TLS 1.0/1.1/1.2 (and SSLv3) suites. Not exhaustive of +// the IANA registry, but it includes everything any modern audit cares about: +// ECDHE/DHE/RSA/PSK kex, AES-GCM/CCM/CBC, ChaCha20, 3DES, RC4, NULL, EXPORT, +// anonymous, and a handful of GOST/CAMELLIA/ARIA entries seen in the wild. +var LegacyCiphers = []CipherSuite{ + // ECDHE-ECDSA + {0xC02B, "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", false}, + {0xC02C, "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", false}, + {0xCCA9, "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", false}, + {0xC023, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", false}, + {0xC024, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", false}, + {0xC009, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", false}, + {0xC00A, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", false}, + {0xC008, "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", false}, + {0xC007, "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", false}, + {0xC006, "TLS_ECDHE_ECDSA_WITH_NULL_SHA", false}, + + // ECDHE-RSA + {0xC02F, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", false}, + {0xC030, "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", false}, + {0xCCA8, "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", false}, + {0xC027, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", false}, + {0xC028, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", false}, + {0xC013, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", false}, + {0xC014, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", false}, + {0xC012, "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", false}, + {0xC011, "TLS_ECDHE_RSA_WITH_RC4_128_SHA", false}, + {0xC010, "TLS_ECDHE_RSA_WITH_NULL_SHA", false}, + + // DHE-RSA + {0x009E, "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", false}, + {0x009F, "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", false}, + {0xCCAA, "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", false}, + {0x0067, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", false}, + {0x006B, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", false}, + {0x0033, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", false}, + {0x0039, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", false}, + {0x0016, "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", false}, + + // Plain RSA + {0x009C, "TLS_RSA_WITH_AES_128_GCM_SHA256", false}, + {0x009D, "TLS_RSA_WITH_AES_256_GCM_SHA384", false}, + {0x003C, "TLS_RSA_WITH_AES_128_CBC_SHA256", false}, + {0x003D, "TLS_RSA_WITH_AES_256_CBC_SHA256", false}, + {0x002F, "TLS_RSA_WITH_AES_128_CBC_SHA", false}, + {0x0035, "TLS_RSA_WITH_AES_256_CBC_SHA", false}, + {0x000A, "TLS_RSA_WITH_3DES_EDE_CBC_SHA", false}, + {0x0005, "TLS_RSA_WITH_RC4_128_SHA", false}, + {0x0004, "TLS_RSA_WITH_RC4_128_MD5", false}, + {0x003B, "TLS_RSA_WITH_NULL_SHA256", false}, + {0x0002, "TLS_RSA_WITH_NULL_SHA", false}, + {0x0001, "TLS_RSA_WITH_NULL_MD5", false}, + + // Anonymous (broken by design — flag if seen) + {0x006D, "TLS_DH_anon_WITH_AES_256_CBC_SHA256", false}, + {0x0034, "TLS_DH_anon_WITH_AES_128_CBC_SHA", false}, + {0x003A, "TLS_DH_anon_WITH_AES_256_CBC_SHA", false}, + {0xC018, "TLS_ECDH_anon_WITH_AES_128_CBC_SHA", false}, + {0xC019, "TLS_ECDH_anon_WITH_AES_256_CBC_SHA", false}, + + // EXPORT (40-bit, illegal since ~2000 — flag if seen) + {0x0008, "TLS_RSA_EXPORT_WITH_DES40_CBC_SHA", false}, + {0x0014, "TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", false}, + {0x0017, "TLS_DH_anon_EXPORT_WITH_RC4_40_MD5", false}, + {0x0019, "TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA", false}, + {0x0003, "TLS_RSA_EXPORT_WITH_RC4_40_MD5", false}, + {0x0006, "TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5", false}, +} + +// AllCiphers concatenates legacy and TLS 1.3 cipher suites. +func AllCiphers() []CipherSuite { + out := make([]CipherSuite, 0, len(LegacyCiphers)+len(TLS13Ciphers)) + out = append(out, LegacyCiphers...) + out = append(out, TLS13Ciphers...) + return out +} diff --git a/tlsenum/tlsenum.go b/tlsenum/tlsenum.go new file mode 100644 index 0000000..b2a7562 --- /dev/null +++ b/tlsenum/tlsenum.go @@ -0,0 +1,283 @@ +// Package tlsenum probes a remote endpoint to discover the exact set of +// SSL/TLS protocol versions and cipher suites it accepts. +// +// The Go stdlib's crypto/tls only negotiates a curated subset of modern +// suites and refuses to even offer legacy ones (RC4, 3DES, EXPORT, NULL, +// anonymous, …), so it cannot be used to *audit* what a server accepts. +// Instead we use github.com/refraction-networking/utls to craft a fully +// custom ClientHello carrying a single (version, cipher) pair and let the +// server tell us — by ServerHello or alert — whether it accepts it. +// +// Scope of the minimal version: +// - TLS 1.0, 1.1, 1.2, 1.3 (negotiated via the SupportedVersions extension). +// - Direct TLS only; STARTTLS upgrade is the caller's responsibility for +// now (the existing checker package owns those dialect handlers). +// - SSLv3 and SSLv2 are deliberately out of scope; SSLv2 has a different +// wire format and would require either raw byte crafting or a legacy +// OpenSSL sidecar. +package tlsenum + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "time" + + utls "github.com/refraction-networking/utls" +) + +// AllVersions is the set of protocol versions Probe knows how to offer. +var AllVersions = []uint16{ + utls.VersionTLS10, + utls.VersionTLS11, + utls.VersionTLS12, + utls.VersionTLS13, +} + +// VersionName returns a human-readable label for a TLS protocol version. +func VersionName(v uint16) string { + switch v { + case utls.VersionTLS10: + return "TLS 1.0" + case utls.VersionTLS11: + return "TLS 1.1" + case utls.VersionTLS12: + return "TLS 1.2" + case utls.VersionTLS13: + return "TLS 1.3" + default: + return "0x" + strconv.FormatUint(uint64(v), 16) + } +} + +// ProbeResult is the outcome of a single (version, cipher) attempt. +type ProbeResult struct { + OfferedVersion uint16 + OfferedCipher uint16 + + // Accepted is true when the server completed enough of the handshake to + // echo back a ServerHello with our offered version and cipher. We do not + // require a fully successful handshake (certificate verification can fail + // for unrelated reasons); ServerHello acceptance is what we measure. + Accepted bool + + // NegotiatedVersion / NegotiatedCipher are populated when Accepted is + // true. They should match the offered values; if they differ, the server + // is misbehaving (or downgrading). + NegotiatedVersion uint16 + NegotiatedCipher uint16 + + // Err is the underlying error from the dial or handshake. For a clean + // "server rejected this combination" outcome it will typically be a TLS + // alert (handshake_failure, protocol_version, insufficient_security…). + Err error +} + +// ProbeOptions controls a single Probe call. +type ProbeOptions struct { + // Timeout bounds dial + (optional) upgrade + handshake. A zero value + // means no deadline beyond the parent context's. + Timeout time.Duration + + // Upgrader, when non-nil, is invoked on the freshly-dialed connection + // before the TLS ClientHello is sent. It is the injection point for + // STARTTLS dialect handlers (SMTP, IMAP, POP3, …): the callback drives + // the plaintext exchange that requests the upgrade and returns nil once + // the connection is ready for tls.Client. tlsenum stays agnostic of the + // dialect; the caller owns that knowledge. + Upgrader func(net.Conn) error +} + +// Probe attempts a TLS handshake against addr offering exactly one protocol +// version and one cipher suite. It never panics; transport / handshake errors +// are reported on the returned ProbeResult. +// +// addr must be host:port. sni is the SNI to send (pass the host if unsure). +func Probe(ctx context.Context, addr, sni string, version, cipher uint16, opts ProbeOptions) ProbeResult { + res := ProbeResult{OfferedVersion: version, OfferedCipher: cipher} + + dialCtx := ctx + if opts.Timeout > 0 { + var cancel context.CancelFunc + dialCtx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } + + d := &net.Dialer{} + raw, err := d.DialContext(dialCtx, "tcp", addr) + if err != nil { + res.Err = fmt.Errorf("dial: %w", err) + return res + } + defer raw.Close() + if dl, ok := dialCtx.Deadline(); ok { + _ = raw.SetDeadline(dl) + } + + if opts.Upgrader != nil { + if err := opts.Upgrader(raw); err != nil { + res.Err = fmt.Errorf("upgrade: %w", err) + return res + } + } + + cfg := &utls.Config{ + ServerName: sni, + InsecureSkipVerify: true, // #nosec G402 -- enumeration; we only care about handshake outcome + } + uc := utls.UClient(raw, cfg, utls.HelloCustom) + spec := buildSpec(version, cipher, sni) + if err := uc.ApplyPreset(&spec); err != nil { + res.Err = fmt.Errorf("apply-preset: %w", err) + return res + } + + err = uc.Handshake() + state := uc.ConnectionState() + if err == nil { + res.Accepted = true + res.NegotiatedVersion = state.Version + res.NegotiatedCipher = state.CipherSuite + return res + } + + // Some servers complete ServerHello (so we know they accepted version + + // cipher) but fail later — for example, certificate-mismatch or the + // client failing to verify. If state has a non-zero Version/CipherSuite + // matching what we offered, we still count it as accepted. + if state.Version == version && state.CipherSuite == cipher && state.CipherSuite != 0 { + res.Accepted = true + res.NegotiatedVersion = state.Version + res.NegotiatedCipher = state.CipherSuite + } + res.Err = err + return res +} + +// EnumerateOptions controls Enumerate. +type EnumerateOptions struct { + // Timeout for each individual probe. Defaults to 5s when zero. + ProbeTimeout time.Duration + // Versions to try. Defaults to AllVersions when nil. + Versions []uint16 + // Ciphers to try. Defaults to AllCiphers() when nil. The TLS13 flag is + // honored: TLS 1.3 ciphers are only offered with TLS 1.3 probes, and + // vice-versa. + Ciphers []CipherSuite + // Upgrader, when non-nil, is forwarded to every sub-probe (see + // ProbeOptions.Upgrader). It is invoked on a freshly-dialed connection + // before each ClientHello, so STARTTLS dialect handlers run once per + // probe, not once for the whole sweep. + Upgrader func(net.Conn) error +} + +// EnumerationResult is the aggregate outcome of an enumeration sweep. +type EnumerationResult struct { + // SupportedVersions lists protocol versions for which at least one + // cipher was accepted. + SupportedVersions []uint16 + // CiphersByVersion lists, per accepted version, the cipher suites the + // server agreed to negotiate. + CiphersByVersion map[uint16][]CipherSuite +} + +// Enumerate sweeps a (version × cipher) matrix against addr and returns what +// the server actually accepts. Probes are performed sequentially; concurrency +// can be added later but tends to upset some middleboxes when probing too +// hard. +func Enumerate(ctx context.Context, addr, sni string, opts EnumerateOptions) (EnumerationResult, error) { + if opts.ProbeTimeout == 0 { + opts.ProbeTimeout = 5 * time.Second + } + versions := opts.Versions + if versions == nil { + versions = AllVersions + } + ciphers := opts.Ciphers + if ciphers == nil { + ciphers = AllCiphers() + } + + out := EnumerationResult{ + CiphersByVersion: make(map[uint16][]CipherSuite), + } + seenVersion := make(map[uint16]bool) + + for _, v := range versions { + isTLS13 := v == utls.VersionTLS13 + for _, c := range ciphers { + if c.TLS13 != isTLS13 { + continue + } + if err := ctx.Err(); err != nil { + return out, err + } + r := Probe(ctx, addr, sni, v, c.ID, ProbeOptions{ + Timeout: opts.ProbeTimeout, + Upgrader: opts.Upgrader, + }) + if !r.Accepted { + continue + } + out.CiphersByVersion[v] = append(out.CiphersByVersion[v], c) + if !seenVersion[v] { + seenVersion[v] = true + out.SupportedVersions = append(out.SupportedVersions, v) + } + } + } + return out, nil +} + +// buildSpec assembles a ClientHelloSpec offering exactly one cipher and one +// protocol version. For TLS 1.3 the legacy version field stays at TLS 1.2 and +// the real version is signalled through the SupportedVersions extension, per +// RFC 8446 §4.1.2 / §4.2.1. +func buildSpec(version, cipher uint16, sni string) utls.ClientHelloSpec { + tlsVersMin := version + tlsVersMax := version + if version == utls.VersionTLS13 { + // utls inspects TLSVersMax to decide whether to drive TLS 1.3 + // machinery; the on-the-wire legacy_version stays TLS 1.2. + tlsVersMin = utls.VersionTLS12 + } + + exts := []utls.TLSExtension{ + &utls.SNIExtension{ServerName: sni}, + &utls.SupportedCurvesExtension{Curves: []utls.CurveID{ + utls.X25519, utls.CurveP256, utls.CurveP384, utls.CurveP521, + }}, + &utls.SupportedPointsExtension{SupportedPoints: []byte{0}}, // uncompressed + &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []utls.SignatureScheme{ + utls.ECDSAWithP256AndSHA256, utls.ECDSAWithP384AndSHA384, utls.ECDSAWithP521AndSHA512, + utls.PSSWithSHA256, utls.PSSWithSHA384, utls.PSSWithSHA512, + utls.PKCS1WithSHA256, utls.PKCS1WithSHA384, utls.PKCS1WithSHA512, + utls.PKCS1WithSHA1, utls.ECDSAWithSHA1, + }}, + &utls.RenegotiationInfoExtension{Renegotiation: utls.RenegotiateOnceAsClient}, + } + + if version == utls.VersionTLS13 { + exts = append(exts, + &utls.SupportedVersionsExtension{Versions: []uint16{utls.VersionTLS13}}, + &utls.KeyShareExtension{KeyShares: []utls.KeyShare{ + {Group: utls.X25519}, + }}, + &utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}}, + ) + } + + return utls.ClientHelloSpec{ + TLSVersMin: tlsVersMin, + TLSVersMax: tlsVersMax, + CipherSuites: []uint16{cipher}, + CompressionMethods: []byte{0}, // null + Extensions: exts, + } +} + +// ErrNoVersions is returned when an enumeration request asks for an empty set +// of versions or ciphers. +var ErrNoVersions = errors.New("tlsenum: no versions or ciphers to probe") diff --git a/tlsenum/tlsenum_test.go b/tlsenum/tlsenum_test.go new file mode 100644 index 0000000..7d94670 --- /dev/null +++ b/tlsenum/tlsenum_test.go @@ -0,0 +1,223 @@ +package tlsenum + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + stdtls "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "os" + "testing" + "time" + + utls "github.com/refraction-networking/utls" +) + +// selfSignedCert returns a brand-new in-memory self-signed cert + key for +// "test.local", suitable for stdlib tls.Server. +func selfSignedCert() (stdtls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return stdtls.Certificate{}, err + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test.local"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"test.local"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + return stdtls.Certificate{}, err + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return stdtls.Certificate{}, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return stdtls.X509KeyPair(certPEM, keyPEM) +} + +// runFakeStartTLSServer accepts one connection, expects a "STARTTLS\r\n" +// line, replies "OK\r\n", then runs a TLS handshake. It returns once the +// handshake completes (or fails) and the connection is closed. +func runFakeStartTLSServer(ln net.Listener, cert stdtls.Certificate) error { + c, err := ln.Accept() + if err != nil { + return err + } + defer c.Close() + buf := make([]byte, len("STARTTLS\r\n")) + if _, err := io.ReadFull(c, buf); err != nil { + return err + } + if string(buf) != "STARTTLS\r\n" { + return fmt.Errorf("unexpected pre-tls line: %q", string(buf)) + } + if _, err := c.Write([]byte("OK\r\n")); err != nil { + return err + } + tc := stdtls.Server(c, &stdtls.Config{ + Certificates: []stdtls.Certificate{cert}, + MinVersion: stdtls.VersionTLS12, + }) + defer tc.Close() + return tc.Handshake() +} + +// liveTarget returns a host:port to enumerate against, or skips the test if +// the environment hasn't opted in. Network tests are gated behind +// TLSENUM_LIVE=1 so the unit-test suite stays hermetic. +func liveTarget(t *testing.T) (addr, sni string) { + t.Helper() + if os.Getenv("TLSENUM_LIVE") == "" { + t.Skip("set TLSENUM_LIVE=1 to run live enumeration tests") + } + host := os.Getenv("TLSENUM_HOST") + if host == "" { + host = "tls-v1-2.badssl.com" + } + port := os.Getenv("TLSENUM_PORT") + if port == "" { + port = "1012" + } + return net.JoinHostPort(host, port), host +} + +func TestProbe_TLS12_AESGCM(t *testing.T) { + addr, sni := liveTarget(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + r := Probe(ctx, addr, sni, utls.VersionTLS12, 0xC02F /* ECDHE-RSA-AES128-GCM-SHA256 */, ProbeOptions{Timeout: 5 * time.Second}) + if !r.Accepted { + t.Fatalf("expected ECDHE-RSA-AES128-GCM-SHA256 to be accepted on TLS 1.2 target; got err=%v", r.Err) + } + if r.NegotiatedVersion != utls.VersionTLS12 { + t.Fatalf("negotiated version = %x, want %x", r.NegotiatedVersion, utls.VersionTLS12) + } +} + +func TestEnumerate_BasicShape(t *testing.T) { + addr, sni := liveTarget(t) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + res, err := Enumerate(ctx, addr, sni, EnumerateOptions{ + ProbeTimeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("Enumerate: %v", err) + } + if len(res.SupportedVersions) == 0 { + t.Fatalf("no supported versions discovered") + } + for v, ciphers := range res.CiphersByVersion { + if len(ciphers) == 0 { + t.Errorf("version %s listed as supported but no ciphers recorded", VersionName(v)) + } + t.Logf("%s: %d cipher(s)", VersionName(v), len(ciphers)) + } +} + +// TestProbe_UpgraderInvoked uses a tiny in-memory STARTTLS-style server: a +// goroutine listens, reads one "STARTTLS\r\n" line, replies "OK\r\n", then +// performs a real Go-stdlib TLS handshake. We probe through the matching +// Upgrader and assert the handshake succeeds — proving the callback runs in +// the right place between dial and ClientHello. +func TestProbe_UpgraderInvoked(t *testing.T) { + cert, err := selfSignedCert() + if err != nil { + t.Fatalf("self-signed cert: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + srvDone := make(chan error, 1) + go func() { srvDone <- runFakeStartTLSServer(ln, cert) }() + + upgrader := func(c net.Conn) error { + if _, err := c.Write([]byte("STARTTLS\r\n")); err != nil { + return err + } + buf := make([]byte, 16) + n, err := c.Read(buf) + if err != nil { + return err + } + if got := string(buf[:n]); got != "OK\r\n" { + return fmt.Errorf("unexpected reply: %q", got) + } + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + r := Probe(ctx, ln.Addr().String(), "test.local", + utls.VersionTLS12, 0xC02B, /* ECDHE-ECDSA-AES128-GCM-SHA256 (matches the P-256 cert) */ + ProbeOptions{Timeout: 3 * time.Second, Upgrader: upgrader}) + if !r.Accepted { + t.Fatalf("expected handshake to succeed through upgrader; err=%v", r.Err) + } + if r.NegotiatedVersion != utls.VersionTLS12 { + t.Fatalf("negotiated %#x, want %#x", r.NegotiatedVersion, utls.VersionTLS12) + } + if err := <-srvDone; err != nil { + t.Logf("fake server done with: %v", err) // accept clean close from utls + } +} + +func TestProbe_UpgraderError(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + go func() { + c, _ := ln.Accept() + if c != nil { + c.Close() + } + }() + + wantErr := errors.New("plaintext refused starttls") + r := Probe(context.Background(), ln.Addr().String(), "x", + utls.VersionTLS12, 0xC02F, + ProbeOptions{Timeout: 2 * time.Second, Upgrader: func(net.Conn) error { return wantErr }}) + if r.Accepted { + t.Fatalf("expected probe to fail when upgrader returns error") + } + if r.Err == nil || !errors.Is(r.Err, wantErr) { + t.Fatalf("expected wrapped upgrader error, got %v", r.Err) + } +} + +func TestVersionName(t *testing.T) { + cases := map[uint16]string{ + utls.VersionTLS10: "TLS 1.0", + utls.VersionTLS11: "TLS 1.1", + utls.VersionTLS12: "TLS 1.2", + utls.VersionTLS13: "TLS 1.3", + 0x9999: "0x9999", + } + for v, want := range cases { + if got := VersionName(v); got != want { + t.Errorf("VersionName(%#x) = %q, want %q", v, got, want) + } + } +}