Compare commits
2 commits
fa212f0fae
...
a9f37c79cf
| Author | SHA1 | Date | |
|---|---|---|---|
| a9f37c79cf | |||
| 8a7f9feaf7 |
19 changed files with 1601 additions and 5 deletions
32
README.md
32
README.md
|
|
@ -144,6 +144,38 @@ existing downstream parsers.
|
|||
| ---------------- | ------ | ------- | -------------------------------------------- |
|
||||
| `probeTimeoutMs` | number | 10000 | Per-endpoint dial + handshake timeout in ms. |
|
||||
|
||||
## For embedders: certificate-fetch helpers
|
||||
|
||||
The `checker` package also exports a small, stable surface for hosts that
|
||||
want to reuse the dial/STARTTLS/handshake plumbing outside of a
|
||||
`Collect` cycle — typically an HTTP handler that prefills a TLSA editor
|
||||
from a live endpoint.
|
||||
|
||||
```go
|
||||
import tls "git.happydns.org/checker-tls/checker"
|
||||
|
||||
starttls := req.STARTTLS
|
||||
if starttls == "" {
|
||||
starttls = tls.AutoSTARTTLS(req.Port) // well-known port → dialect
|
||||
}
|
||||
|
||||
certs, err := tls.FetchChain(ctx, host, req.Port, starttls, 10*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chain := tls.BuildChain(certs) // []tls.CertInfo, leaf first
|
||||
```
|
||||
|
||||
| Symbol | Role |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `FetchChain` | Dials, runs the STARTTLS upgrade if requested, and returns the peer `*x509.Certificate` chain (leaf first). Uses `InsecureSkipVerify` so the chain is returned even when PKIX would reject it — callers do their own validation. |
|
||||
| `BuildChain` | Projects an `[]*x509.Certificate` to `[]CertInfo`, with the four DANE/TLSA `(selector, matching_type)` hashes precomputed. Same projection `Collect` writes into observations. |
|
||||
| `AutoSTARTTLS` | Maps a well-known port (25, 110, 143, 389, 587, 5222) to the STARTTLS dialect `FetchChain` should drive. Returns `""` when no mapping applies. |
|
||||
| `CertInfo` | DANE-friendly per-certificate view: DN, expiry, DER, SPKI DER, and `(cert\|spki) × (sha256\|sha512)` hex digests. |
|
||||
|
||||
These three helpers are part of the package's public contract: signatures
|
||||
will not change without a bump of the importing module's `go.mod`.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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