Compare commits

..

7 commits

Author SHA1 Message Date
7c2f4bfbb5 Add CI/CD pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-10 19:32:13 +08:00
e4d1bc36a7 Update rules section 2026-04-30 08:36:38 +07:00
a9f37c79cf 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.
2026-04-29 13:35:29 +07:00
8a7f9feaf7 Document usage of FetchChain, BuildChain, AutoSTARTTLS, and CertInfo 2026-04-29 13:33:27 +07:00
fa212f0fae Harden contract validation, STARTTLS edge cases, and rule output 2026-04-26 19:55:44 +07:00
a925e4f162 docker: add HEALTHCHECK probing /health
The binary doubles as its own healthcheck client via the SDK's
-healthcheck flag, so the probe works in the scratch image
(no shell, no curl, no wget).
2026-04-26 16:37:20 +07:00
ad32e1c757 Run container as non-root user
Add USER 65534:65534 to the scratch runtime image so the checker
process does not run as root.
2026-04-26 16:37:18 +07:00
29 changed files with 1938 additions and 47 deletions

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

187
.drone.yml Normal file
View file

@ -0,0 +1,187 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

View file

@ -10,5 +10,8 @@ RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_
FROM scratch
COPY --from=builder /checker-tls /checker-tls
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-tls", "-healthcheck"]
ENTRYPOINT ["/checker-tls"]

View file

@ -138,12 +138,62 @@ existing downstream parsers.
- `expired` / `expiring_soon`, cert expiry posture.
- `weak_tls_version`, negotiated TLS < 1.2.
## Rules
| Code | Description | Severity |
|---------------------------------|---------------------------------------------------------------------------------------------------|---------------------|
| `tls.endpoints_discovered` | Verifies that at least one TLS endpoint has been discovered for this target. | INFO |
| `tls.reachability` | Verifies that every discovered TLS endpoint accepts a TCP connection. | CRITICAL |
| `tls.handshake` | Verifies the TLS handshake completes on every reachable endpoint. | CRITICAL |
| `tls.starttls_advertised` | Verifies that STARTTLS endpoints advertise the upgrade capability. | CRITICAL |
| `tls.starttls_dialect_supported`| Verifies that discovered STARTTLS dialects are implemented by the checker. | CRITICAL |
| `tls.peer_certificate_present` | Verifies the server presented a certificate during the TLS handshake. | CRITICAL |
| `tls.chain_validity` | Verifies the presented certificate chain validates against the system trust store. | CRITICAL |
| `tls.hostname_match` | Verifies the leaf certificate covers the probed hostname (SNI). | CRITICAL |
| `tls.expiry` | Flags expired or soon-to-expire leaf certificates. | CRITICAL |
| `tls.version` | Flags endpoints negotiating a TLS version below the recommended TLS 1.2. | WARNING |
| `tls.cipher_suite` | Reports the cipher suite negotiated on each endpoint. | INFO |
| `tls.enum.versions` | Flags endpoints that still accept TLS versions below TLS 1.2 (requires the enumerate option). | WARNING |
| `tls.enum.ciphers` | Flags endpoints that accept broken cipher suites (NULL, anonymous, EXPORT, RC4, 3DES). | WARNING |
## Options
| Id | Type | Default | Description |
| ---------------- | ------ | ------- | -------------------------------------------- |
| `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

View file

@ -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()

View file

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

View file

@ -68,7 +68,7 @@ func FetchChain(ctx context.Context, host string, port uint16, starttls string,
tlsConn := tls.Client(conn, &tls.Config{
ServerName: host,
InsecureSkipVerify: true,
InsecureSkipVerify: true, // #nosec G402 -- intentional: caller receives the chain even when PKIX rejects it
})
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
return nil, fmt.Errorf("tls handshake: %w", err)

View file

@ -172,7 +172,7 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
cfg := &tls.Config{
ServerName: sni,
InsecureSkipVerify: true,
InsecureSkipVerify: true, // #nosec G402 -- intentional: chain verified separately in probe()
}
if ep.STARTTLS == "" {
@ -198,7 +198,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
}
var (
errStartTLSNotOffered = errors.New("starttls not advertised by server")
errStartTLSNotOffered = errors.New("starttls not advertised by server")
errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol")
)

View file

@ -25,6 +25,8 @@ func Rules() []sdk.CheckRule {
&expiryRule{},
&tlsVersionRule{},
&cipherSuiteRule{},
&versionEnumerationRule{},
&weakCipherRule{},
}
}

View 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
}

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

View file

@ -4,6 +4,8 @@ import (
"context"
"crypto/tls"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
@ -81,25 +83,38 @@ func (r *cipherSuiteRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
return []sdk.CheckState{emptyCaseState("tls.cipher_suite.no_endpoints")}
}
var out []sdk.CheckState
// Collapse per-endpoint cipher suites into a single info state. One
// row per endpoint drowns out actionable rules in the UI on domains
// with many endpoints; an aggregated list is enough for visibility.
suites := map[string]int{}
endpoints := map[string][]string{}
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.CipherSuite == "" {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusInfo,
Code: "tls.cipher_suite.negotiated",
Subject: subjectOf(p),
Message: fmt.Sprintf("Cipher suite %s negotiated.", p.CipherSuite),
Meta: metaOf(p),
})
suites[p.CipherSuite]++
endpoints[p.CipherSuite] = append(endpoints[p.CipherSuite], p.Endpoint)
}
if len(out) == 0 {
if len(suites) == 0 {
return []sdk.CheckState{unknownState(
"tls.cipher_suite.skipped",
"No endpoint completed a TLS handshake.",
)}
}
return out
names := make([]string, 0, len(suites))
for s := range suites {
names = append(names, s)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, n := range names {
parts = append(parts, fmt.Sprintf("%s (%d)", n, suites[n]))
}
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Code: "tls.cipher_suite.negotiated",
Message: "Negotiated cipher suites: " + strings.Join(parts, ", "),
Meta: map[string]any{"suites": endpoints},
}}
}

View file

@ -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
}

View file

@ -36,6 +36,10 @@ func starttlsIMAP(conn net.Conn, sni string) error {
supportsSTARTTLS = true
}
if strings.HasPrefix(line, "A001 ") {
rest := strings.TrimSpace(line[len("A001 "):])
if !strings.HasPrefix(strings.ToUpper(rest), "OK") {
return fmt.Errorf("CAPABILITY rejected by server: %s", rest)
}
break
}
}

View file

@ -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)

View file

@ -7,6 +7,11 @@ import (
"strings"
)
// EHLOHostname is the hostname sent in the SMTP EHLO command during STARTTLS
// negotiation. Override it at startup (e.g. via -ldflags or programmatically)
// to match the identity of the host running the checker.
var EHLOHostname = "checker.localhost"
func init() {
registerStartTLS("smtp", starttlsSMTP)
registerStartTLS("submission", starttlsSMTP)
@ -20,7 +25,7 @@ func starttlsSMTP(conn net.Conn, sni string) error {
return fmt.Errorf("read greeting: %w", err)
}
if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil {
if _, err := fmt.Fprintf(rw, "EHLO %s\r\n", EHLOHostname); err != nil {
return fmt.Errorf("write ehlo: %w", err)
}
if err := rw.Flush(); err != nil {

View file

@ -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)

View file

@ -22,15 +22,25 @@ 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)
// is not going to advertise STARTTLS: surface that immediately rather
// than spinning on tokens until the deadline fires.
hasStartTLS := false
outer:
for {
@ -38,29 +48,38 @@ outer:
if err != nil {
return fmt.Errorf("read stream features: %w", err)
}
if se, ok := tok.(xml.StartElement); ok {
if se.Name.Local == "features" {
// Scan features children.
for {
t2, err := dec.Token()
if err != nil {
return fmt.Errorf("read features body: %w", err)
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch se.Name.Local {
case "stream":
// Outer <stream:stream> opening. Continue reading children.
continue
case "features":
for {
t2, err := dec.Token()
if err != nil {
return fmt.Errorf("read features body: %w", err)
}
switch ee := t2.(type) {
case xml.StartElement:
if ee.Name.Local == "starttls" {
hasStartTLS = true
}
switch ee := t2.(type) {
case xml.StartElement:
if ee.Name.Local == "starttls" {
hasStartTLS = true
}
if err := dec.Skip(); err != nil {
return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err)
}
case xml.EndElement:
if ee.Name.Local == "features" {
break outer
}
if err := dec.Skip(); err != nil {
return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err)
}
case xml.EndElement:
if ee.Name.Local == "features" {
break outer
}
}
}
case "error":
return fmt.Errorf("server returned <stream:error/> before features")
default:
return fmt.Errorf("%w: unexpected element %q before features", errStartTLSNotOffered, se.Name.Local)
}
}
if !hasStartTLS {

View file

@ -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
@ -78,11 +79,11 @@ type TLSProbe struct {
// no certificate.
NoPeerCert bool `json:"no_peer_cert,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
ChainVerifyErr string `json:"chain_verify_err,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
Issuer string `json:"issuer,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
ChainVerifyErr string `json:"chain_verify_err,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
Issuer string `json:"issuer,omitempty"`
// IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for
// matching the CCADB CAA Identifiers CSV "Subject" column when the AKI
// lookup misses.
@ -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"`
}

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

View file

@ -16,6 +16,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
@ -58,10 +59,27 @@ type TLSEndpoint struct {
RequireSTARTTLS bool `json:"require,omitempty"`
}
// Validate rejects endpoints that cannot be probed: empty Host or zero Port.
// STARTTLS dialect is intentionally not checked here (the checker surfaces
// unsupported dialects at runtime via the tls.starttls_dialect_supported
// rule), and SNI defaults to Host downstream.
func (ep TLSEndpoint) Validate() error {
if strings.TrimSpace(strings.TrimSuffix(ep.Host, ".")) == "" {
return fmt.Errorf("contract: TLSEndpoint.Host is required")
}
if ep.Port == 0 {
return fmt.Errorf("contract: TLSEndpoint.Port must be 1-65535")
}
return nil
}
// NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref
// derived from ep, and a marshaled Payload. The returned entry can be
// returned as-is from a DiscoveryPublisher implementation.
func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) {
if err := ep.Validate(); err != nil {
return sdk.DiscoveryEntry{}, err
}
payload, err := json.Marshal(ep)
if err != nil {
return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err)
@ -95,7 +113,7 @@ func Ref(ep TLSEndpoint) string {
req = "1"
}
canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req)
sum := sha1.Sum([]byte(canonical))
sum := sha1.Sum([]byte(canonical)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above
return hex.EncodeToString(sum[:8])
}
@ -109,6 +127,9 @@ func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) {
if err := json.Unmarshal(e.Payload, &ep); err != nil {
return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err)
}
if err := ep.Validate(); err != nil {
return TLSEndpoint{}, err
}
return ep, nil
}

10
go.mod
View file

@ -2,4 +2,12 @@ module git.happydns.org/checker-tls
go 1.25.0
require git.happydns.org/checker-sdk-go v1.4.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
)

14
go.sum
View file

@ -1,2 +1,12 @@
git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs=
git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
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=

View file

@ -10,11 +10,19 @@ import (
var Version = "custom-build"
// EHLOHostname is set via -ldflags to identify this checker instance in SMTP
// EHLO greetings. Falls back to the package default ("checker.localhost") when
// left empty.
var EHLOHostname = ""
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
tls.Version = Version
if EHLOHostname != "" {
tls.EHLOHostname = EHLOHostname
}
srv := server.New(tls.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {

103
tlsenum/ciphers.go Normal file
View 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
View 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
View 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)
}
}
}