Add tlsenum package and add version/cipher enumeration into the checker
tlsenum package probes a remote endpoint with one ClientHello per (version, cipher) pair via utls, so the checker can report the exact set the server accepts rather than only the suite Go's stdlib happens to negotiate. Probe accepts an Upgrader callback so STARTTLS dialects plug in without tlsenum learning about them; the checker bridges its existing dialect registry through upgraderFor.
This commit is contained in:
parent
8a7f9feaf7
commit
a9f37c79cf
18 changed files with 1569 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
{
|
||||
|
|
|
|||
68
checker/enumerate.go
Normal file
68
checker/enumerate.go
Normal file
|
|
@ -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, "<reason>") 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
|
||||
198
checker/enumerate_test.go
Normal file
198
checker/enumerate_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ func Rules() []sdk.CheckRule {
|
|||
&expiryRule{},
|
||||
&tlsVersionRule{},
|
||||
&cipherSuiteRule{},
|
||||
&versionEnumerationRule{},
|
||||
&weakCipherRule{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
197
checker/rules_enumeration.go
Normal file
197
checker/rules_enumeration.go
Normal file
|
|
@ -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_<KX>_WITH_<CIPHER>_<MAC>
|
||||
// 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
|
||||
}
|
||||
135
checker/rules_enumeration_test.go
Normal file
135
checker/rules_enumeration_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
|
||||
`<stream:features><starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/></stream:features>`)
|
||||
if _, err := br.Read(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(c, `<failure xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>`)
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected failure error")
|
||||
}
|
||||
if errors.Is(err, errStartTLSNotOffered) {
|
||||
t.Fatalf("<failure/> 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,
|
||||
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
|
||||
`<stream:error><host-unknown xmlns='urn:ietf:params:xml:ns:xmpp-streams'/></stream:error>`)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// (<stream:stream> opening + <stream:features> + <proceed/>) 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(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' to='%s'>`, 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 <stream:stream> opening and its <stream:features>.
|
||||
// A peer that opens with <stream:error/> (or anything other than features)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
91
checker/upgrader_for_test.go
Normal file
91
checker/upgrader_for_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
8
go.mod
8
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
|
||||
)
|
||||
|
|
|
|||
10
go.sum
10
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=
|
||||
|
|
|
|||
103
tlsenum/ciphers.go
Normal file
103
tlsenum/ciphers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
283
tlsenum/tlsenum.go
Normal file
283
tlsenum/tlsenum.go
Normal file
|
|
@ -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")
|
||||
223
tlsenum/tlsenum_test.go
Normal file
223
tlsenum/tlsenum_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue