Compare commits
No commits in common. "master" and "v0.2.0" have entirely different histories.
39 changed files with 316 additions and 3198 deletions
|
|
@ -1,22 +0,0 @@
|
||||||
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
187
.drone.yml
|
|
@ -1,187 +0,0 @@
|
||||||
---
|
|
||||||
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
|
|
||||||
|
|
@ -6,12 +6,9 @@ WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls .
|
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls .
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /checker-tls /checker-tls
|
COPY --from=builder /checker-tls /checker-tls
|
||||||
USER 65534:65534
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD ["/checker-tls", "-healthcheck"]
|
|
||||||
ENTRYPOINT ["/checker-tls"]
|
ENTRYPOINT ["/checker-tls"]
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||||
|
|
||||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||||
|
|
||||||
.PHONY: all plugin docker test clean
|
.PHONY: all plugin docker clean
|
||||||
|
|
||||||
all: $(CHECKER_NAME)
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
plugin: $(CHECKER_NAME).so
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
|
@ -21,8 +21,5 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||||
docker:
|
docker:
|
||||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
test:
|
|
||||||
go test -tags standalone ./...
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
|
|
|
||||||
66
README.md
66
README.md
|
|
@ -119,7 +119,7 @@ Observation data written under `tls_probes`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The map is keyed by `contract.Ref(ep)`, the same value the host exposes
|
The map is keyed by `contract.Ref(ep)` — the same value the host exposes
|
||||||
on the lineage side so that a consumer knows which probe corresponds to
|
on the lineage side so that a consumer knows which probe corresponds to
|
||||||
which entry it originally published.
|
which entry it originally published.
|
||||||
|
|
||||||
|
|
@ -129,32 +129,14 @@ existing downstream parsers.
|
||||||
|
|
||||||
## Issues reported
|
## Issues reported
|
||||||
|
|
||||||
- `tcp_unreachable`, dial failed.
|
- `tcp_unreachable` — dial failed.
|
||||||
- `handshake_failed`, TLS handshake or STARTTLS upgrade failed.
|
- `handshake_failed` — TLS handshake or STARTTLS upgrade failed.
|
||||||
- `starttls_not_offered`, server didn't advertise STARTTLS. Severity is
|
- `starttls_not_offered` — server didn't advertise STARTTLS. Severity is
|
||||||
`crit` when `TLSEndpoint.RequireSTARTTLS` is `true`, `warn` otherwise.
|
`crit` when `TLSEndpoint.RequireSTARTTLS` is `true`, `warn` otherwise.
|
||||||
- `chain_invalid`, leaf does not chain to a system-trusted root.
|
- `chain_invalid` — leaf does not chain to a system-trusted root.
|
||||||
- `hostname_mismatch`, cert SANs don't cover the SNI.
|
- `hostname_mismatch` — cert SANs don't cover the SNI.
|
||||||
- `expired` / `expiring_soon`, cert expiry posture.
|
- `expired` / `expiring_soon` — cert expiry posture.
|
||||||
- `weak_tls_version`, negotiated TLS < 1.2.
|
- `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
|
## Options
|
||||||
|
|
||||||
|
|
@ -162,38 +144,6 @@ existing downstream parsers.
|
||||||
| ---------------- | ------ | ------- | -------------------------------------------- |
|
| ---------------- | ------ | ------- | -------------------------------------------- |
|
||||||
| `probeTimeoutMs` | number | 10000 | Per-endpoint dial + handshake timeout in ms. |
|
| `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
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
|
||||||
timeoutMs = DefaultProbeTimeoutMs
|
timeoutMs = DefaultProbeTimeoutMs
|
||||||
}
|
}
|
||||||
timeout := time.Duration(timeoutMs) * time.Millisecond
|
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||||
enumerate := sdk.GetBoolOption(opts, OptionEnumerateCiphers, false)
|
|
||||||
|
|
||||||
entries, warnings := contract.ParseEntries(raw)
|
entries, warnings := contract.ParseEntries(raw)
|
||||||
for _, w := range warnings {
|
for _, w := range warnings {
|
||||||
|
|
@ -41,36 +40,15 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
sem := make(chan struct{}, MaxConcurrentProbes)
|
sem := make(chan struct{}, MaxConcurrentProbes)
|
||||||
dispatch:
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
select {
|
|
||||||
case sem <- struct{}{}:
|
|
||||||
case <-ctx.Done():
|
|
||||||
break dispatch
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
pr := probe(ctx, e.Endpoint, timeout)
|
pr := probe(ctx, e.Endpoint, timeout)
|
||||||
log.Printf("checker-tls: %s %s:%d → tls=%s handshake_ok=%t elapsed=%dms err=%q",
|
log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q",
|
||||||
pr.Type, pr.Host, pr.Port, pr.TLSVersion, pr.TLSHandshakeOK, pr.ElapsedMS, pr.Error)
|
pr.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), 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()
|
mu.Lock()
|
||||||
probes[e.Ref] = pr
|
probes[e.Ref] = pr
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,6 @@ func (p *tlsProvider) Definition() *sdk.CheckerDefinition {
|
||||||
Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.",
|
Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.",
|
||||||
Default: float64(DefaultProbeTimeoutMs),
|
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{
|
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||||
{
|
{
|
||||||
|
|
@ -47,7 +40,9 @@ func (p *tlsProvider) Definition() *sdk.CheckerDefinition {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Rules: Rules(),
|
Rules: []sdk.CheckRule{
|
||||||
|
Rule(),
|
||||||
|
},
|
||||||
Interval: &sdk.CheckIntervalSpec{
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
Min: 6 * time.Hour,
|
Min: 6 * time.Hour,
|
||||||
Max: 7 * 24 * time.Hour,
|
Max: 7 * 24 * time.Hour,
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
108
checker/fetch.go
108
checker/fetch.go
|
|
@ -1,108 +0,0 @@
|
||||||
// This file is part of the happyDomain (R) project.
|
|
||||||
// Copyright (c) 2020-2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FetchChain dials host:port, optionally upgrades the connection via STARTTLS,
|
|
||||||
// completes a TLS handshake (InsecureSkipVerify so callers receive the chain
|
|
||||||
// even when PKIX would reject it), and returns the peer certificates leaf
|
|
||||||
// first.
|
|
||||||
//
|
|
||||||
// starttls is the protocol name as registered (smtp, submission, imap, pop3,
|
|
||||||
// ldap, xmpp-client, ...); pass "" for direct TLS. AutoSTARTTLS provides
|
|
||||||
// well-known port defaults.
|
|
||||||
func FetchChain(ctx context.Context, host string, port uint16, starttls string, timeout time.Duration) ([]*x509.Certificate, error) {
|
|
||||||
host = strings.TrimSuffix(host, ".")
|
|
||||||
addr := net.JoinHostPort(host, strconv.Itoa(int(port)))
|
|
||||||
|
|
||||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("dial %s: %w", addr, err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
if dl, ok := dialCtx.Deadline(); ok {
|
|
||||||
_ = conn.SetDeadline(dl)
|
|
||||||
}
|
|
||||||
|
|
||||||
if starttls != "" {
|
|
||||||
up, ok := starttlsUpgraders[starttls]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unsupported starttls protocol %q", starttls)
|
|
||||||
}
|
|
||||||
if err := up(conn, host); err != nil {
|
|
||||||
return nil, fmt.Errorf("starttls-%s: %w", starttls, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsConn := tls.Client(conn, &tls.Config{
|
|
||||||
ServerName: host,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
state := tlsConn.ConnectionState()
|
|
||||||
if len(state.PeerCertificates) == 0 {
|
|
||||||
return nil, fmt.Errorf("server presented no certificate")
|
|
||||||
}
|
|
||||||
return state.PeerCertificates, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildChain produces a CertInfo per peer certificate (leaf first), with the
|
|
||||||
// four (selector, matching_type) DANE hash pairs precomputed. This is the
|
|
||||||
// same projection probe() applies internally; exported so HTTP handlers can
|
|
||||||
// reuse it without re-deriving the format.
|
|
||||||
func BuildChain(certs []*x509.Certificate) []CertInfo {
|
|
||||||
return buildChain(certs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoSTARTTLS maps a well-known port to the STARTTLS dialect FetchChain
|
|
||||||
// should drive. Returns "" when the port has no auto-mapping (caller should
|
|
||||||
// then use direct TLS or pass an explicit dialect).
|
|
||||||
func AutoSTARTTLS(port uint16) string {
|
|
||||||
switch port {
|
|
||||||
case 25, 587:
|
|
||||||
return "smtp"
|
|
||||||
case 143:
|
|
||||||
return "imap"
|
|
||||||
case 110:
|
|
||||||
return "pop3"
|
|
||||||
case 389:
|
|
||||||
return "ldap"
|
|
||||||
case 5222:
|
|
||||||
return "xmpp-client"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
//go:build standalone
|
|
||||||
|
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -26,7 +24,7 @@ func starttlsChoices() []string {
|
||||||
return protos
|
return protos
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderForm satisfies server.Interactive. The fields mirror the inputs
|
// RenderForm satisfies sdk.CheckerInteractive. The fields mirror the inputs
|
||||||
// a producer checker would put into a contract.TLSEndpoint; a human fills
|
// a producer checker would put into a contract.TLSEndpoint; a human fills
|
||||||
// them in directly when running the checker standalone.
|
// them in directly when running the checker standalone.
|
||||||
func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField {
|
func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
|
|
@ -77,7 +75,7 @@ func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseForm satisfies server.Interactive. It turns the human inputs into
|
// ParseForm satisfies sdk.CheckerInteractive. It turns the human inputs into
|
||||||
// a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns
|
// a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns
|
||||||
// CheckerOptions shaped as if a happyDomain host had auto-filled
|
// CheckerOptions shaped as if a happyDomain host had auto-filled
|
||||||
// OptionEndpoints via AutoFillDiscoveryEntries.
|
// OptionEndpoints via AutoFillDiscoveryEntries.
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,8 @@ package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/sha512"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -18,32 +15,6 @@ import (
|
||||||
"git.happydns.org/checker-tls/contract"
|
"git.happydns.org/checker-tls/contract"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildChain returns CertInfo for each cert presented by the server, in the
|
|
||||||
// order the server sent them (leaf first). SPKI is extracted from the parsed
|
|
||||||
// certificate's RawSubjectPublicKeyInfo so we hash exactly the DER bytes
|
|
||||||
// DANE selector 1 refers to (RFC 6698 §1.1.3).
|
|
||||||
func buildChain(certs []*x509.Certificate) []CertInfo {
|
|
||||||
out := make([]CertInfo, len(certs))
|
|
||||||
for i, c := range certs {
|
|
||||||
certSum256 := sha256.Sum256(c.Raw)
|
|
||||||
certSum512 := sha512.Sum512(c.Raw)
|
|
||||||
spkiSum256 := sha256.Sum256(c.RawSubjectPublicKeyInfo)
|
|
||||||
spkiSum512 := sha512.Sum512(c.RawSubjectPublicKeyInfo)
|
|
||||||
out[i] = CertInfo{
|
|
||||||
DERBase64: base64.StdEncoding.EncodeToString(c.Raw),
|
|
||||||
Subject: c.Subject.String(),
|
|
||||||
Issuer: c.Issuer.String(),
|
|
||||||
NotAfter: c.NotAfter,
|
|
||||||
CertSHA256: hex.EncodeToString(certSum256[:]),
|
|
||||||
CertSHA512: hex.EncodeToString(certSum512[:]),
|
|
||||||
SPKISHA256: hex.EncodeToString(spkiSum256[:]),
|
|
||||||
SPKISHA512: hex.EncodeToString(spkiSum512[:]),
|
|
||||||
SPKIDERBase64: base64.StdEncoding.EncodeToString(c.RawSubjectPublicKeyInfo),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeTypeString renders the TLSProbe.Type string from a TLSEndpoint.
|
// probeTypeString renders the TLSProbe.Type string from a TLSEndpoint.
|
||||||
// Observation consumers already parse this field in its "tls" /
|
// Observation consumers already parse this field in its "tls" /
|
||||||
// "starttls-<proto>" shape; the contract-level split of direct vs.
|
// "starttls-<proto>" shape; the contract-level split of direct vs.
|
||||||
|
|
@ -58,11 +29,8 @@ func probeTypeString(ep contract.TLSEndpoint) string {
|
||||||
|
|
||||||
// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the
|
// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the
|
||||||
// given endpoint and returns a populated TLSProbe. It never returns an error:
|
// given endpoint and returns a populated TLSProbe. It never returns an error:
|
||||||
// transport/handshake failures are recorded on the probe as raw fields so
|
// transport/handshake failures are recorded on the probe so the caller can
|
||||||
// rules can classify them.
|
// still surface them in the report.
|
||||||
//
|
|
||||||
// This function MUST NOT decide severity or pass/fail: it only gathers
|
|
||||||
// observation data. All judgement happens in CheckRules (see rules_*.go).
|
|
||||||
func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe {
|
func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
host := strings.TrimSuffix(ep.Host, ".")
|
host := strings.TrimSuffix(ep.Host, ".")
|
||||||
|
|
@ -78,8 +46,6 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
||||||
Endpoint: addr,
|
Endpoint: addr,
|
||||||
Type: probeTypeString(ep),
|
Type: probeTypeString(ep),
|
||||||
SNI: sni,
|
SNI: sni,
|
||||||
RequireSTARTTLS: ep.RequireSTARTTLS,
|
|
||||||
STARTTLSDialect: ep.STARTTLS,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
|
@ -88,8 +54,13 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
||||||
d := &net.Dialer{}
|
d := &net.Dialer{}
|
||||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.TCPError = err.Error()
|
|
||||||
p.Error = "dial: " + err.Error()
|
p.Error = "dial: " + err.Error()
|
||||||
|
p.Issues = append(p.Issues, Issue{
|
||||||
|
Code: "tcp_unreachable",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err),
|
||||||
|
Fix: "Check DNS, firewall, and that the service listens on this port.",
|
||||||
|
})
|
||||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
@ -101,28 +72,23 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
||||||
|
|
||||||
tlsConn, err := handshake(conn, ep, sni)
|
tlsConn, err := handshake(conn, ep, sni)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.HandshakeError = err.Error()
|
|
||||||
p.Error = err.Error()
|
p.Error = err.Error()
|
||||||
if ep.STARTTLS != "" && isStartTLSUnsupported(err) {
|
p.Issues = append(p.Issues, classifyHandshakeError(ep, err))
|
||||||
p.STARTTLSNotOffered = true
|
|
||||||
}
|
|
||||||
if errors.Is(err, errUnsupportedStartTLSProto) {
|
|
||||||
p.STARTTLSUnsupportedProto = true
|
|
||||||
}
|
|
||||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
defer tlsConn.Close()
|
defer tlsConn.Close()
|
||||||
|
|
||||||
p.TLSHandshakeOK = true
|
|
||||||
state := tlsConn.ConnectionState()
|
state := tlsConn.ConnectionState()
|
||||||
p.TLSVersionNum = state.Version
|
|
||||||
p.TLSVersion = tls.VersionName(state.Version)
|
p.TLSVersion = tls.VersionName(state.Version)
|
||||||
p.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
|
p.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
|
||||||
p.CipherSuiteID = state.CipherSuite
|
|
||||||
|
|
||||||
if len(state.PeerCertificates) == 0 {
|
if len(state.PeerCertificates) == 0 {
|
||||||
p.NoPeerCert = true
|
p.Issues = append(p.Issues, Issue{
|
||||||
|
Code: "no_peer_cert",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "Server presented no certificate.",
|
||||||
|
})
|
||||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
@ -135,16 +101,15 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
||||||
p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId))
|
p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId))
|
||||||
}
|
}
|
||||||
p.Subject = leaf.Subject.CommonName
|
p.Subject = leaf.Subject.CommonName
|
||||||
p.DNSNames = leaf.DNSNames
|
p.DNSNames = append(p.DNSNames, leaf.DNSNames...)
|
||||||
p.Chain = buildChain(state.PeerCertificates)
|
|
||||||
|
|
||||||
hostnameMatch := leaf.VerifyHostname(sni) == nil
|
hostnameMatch := leaf.VerifyHostname(sni) == nil
|
||||||
p.HostnameMatch = &hostnameMatch
|
p.HostnameMatch = &hostnameMatch
|
||||||
|
|
||||||
// Chain verification against system roots, using intermediates presented
|
// Chain verification against system roots, using intermediates presented
|
||||||
// by the server. Running it separately from tls.Config verification
|
// by the server. We run this independently from Go's tls.Config
|
||||||
// means we can record it as a raw observation rather than aborting the
|
// verification so we can report a dedicated "chain invalid" issue rather
|
||||||
// handshake, rules classify it afterwards.
|
// than failing the whole handshake.
|
||||||
intermediates := x509.NewCertPool()
|
intermediates := x509.NewCertPool()
|
||||||
for _, c := range state.PeerCertificates[1:] {
|
for _, c := range state.PeerCertificates[1:] {
|
||||||
intermediates.AddCert(c)
|
intermediates.AddCert(c)
|
||||||
|
|
@ -157,8 +122,48 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
||||||
})
|
})
|
||||||
chainValid := verifyErr == nil
|
chainValid := verifyErr == nil
|
||||||
p.ChainValid = &chainValid
|
p.ChainValid = &chainValid
|
||||||
|
if !chainValid {
|
||||||
|
msg := "Invalid certificate chain"
|
||||||
if verifyErr != nil {
|
if verifyErr != nil {
|
||||||
p.ChainVerifyErr = verifyErr.Error()
|
msg = "Invalid certificate chain: " + verifyErr.Error()
|
||||||
|
}
|
||||||
|
p.Issues = append(p.Issues, Issue{
|
||||||
|
Code: "chain_invalid",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: msg,
|
||||||
|
Fix: "Serve the full intermediate chain and ensure the root is trusted.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !hostnameMatch {
|
||||||
|
p.Issues = append(p.Issues, Issue{
|
||||||
|
Code: "hostname_mismatch",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")),
|
||||||
|
Fix: "Re-issue the certificate with a matching SAN.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if leaf.NotAfter.Before(now) {
|
||||||
|
p.Issues = append(p.Issues, Issue{
|
||||||
|
Code: "expired",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339),
|
||||||
|
Fix: "Renew the certificate.",
|
||||||
|
})
|
||||||
|
} else if leaf.NotAfter.Sub(now) < 14*24*time.Hour {
|
||||||
|
p.Issues = append(p.Issues, Issue{
|
||||||
|
Code: "expiring_soon",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")",
|
||||||
|
Fix: "Renew before expiry.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if state.Version < tls.VersionTLS12 {
|
||||||
|
p.Issues = append(p.Issues, Issue{
|
||||||
|
Code: "weak_tls_version",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.",
|
||||||
|
Fix: "Disable TLS 1.0/1.1 on the server.",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||||
|
|
@ -167,12 +172,12 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
||||||
|
|
||||||
// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and
|
// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and
|
||||||
// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify
|
// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify
|
||||||
// the chain separately in probe so an invalid chain becomes a raw
|
// the chain separately in probe so an invalid chain becomes a structured
|
||||||
// observation rather than aborting the handshake.
|
// Issue rather than aborting the handshake.
|
||||||
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
|
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
|
||||||
cfg := &tls.Config{
|
cfg := &tls.Config{
|
||||||
ServerName: sni,
|
ServerName: sni,
|
||||||
InsecureSkipVerify: true, // #nosec G402 -- intentional: chain verified separately in probe()
|
InsecureSkipVerify: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ep.STARTTLS == "" {
|
if ep.STARTTLS == "" {
|
||||||
|
|
@ -185,7 +190,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
|
||||||
|
|
||||||
up, ok := starttlsUpgraders[ep.STARTTLS]
|
up, ok := starttlsUpgraders[ep.STARTTLS]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("%w: %q", errUnsupportedStartTLSProto, ep.STARTTLS)
|
return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS)
|
||||||
}
|
}
|
||||||
if err := up(conn, sni); err != nil {
|
if err := up(conn, sni); err != nil {
|
||||||
return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err)
|
return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err)
|
||||||
|
|
@ -197,10 +202,34 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
|
||||||
return tlsConn, nil
|
return tlsConn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// classifyHandshakeError converts a dial/handshake error into a structured
|
||||||
errStartTLSNotOffered = errors.New("starttls not advertised by server")
|
// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic
|
||||||
errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol")
|
// for some endpoints) from hard failures.
|
||||||
)
|
func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue {
|
||||||
|
msg := err.Error()
|
||||||
|
|
||||||
|
if ep.STARTTLS != "" && isStartTLSUnsupported(err) {
|
||||||
|
sev := SeverityWarn
|
||||||
|
if ep.RequireSTARTTLS {
|
||||||
|
sev = SeverityCrit
|
||||||
|
}
|
||||||
|
return Issue{
|
||||||
|
Code: "starttls_not_offered",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg),
|
||||||
|
Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Issue{
|
||||||
|
Code: "handshake_failed",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg),
|
||||||
|
Fix: "Inspect the server's TLS configuration and certificate.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errStartTLSNotOffered = errors.New("starttls not advertised by server")
|
||||||
|
|
||||||
func isStartTLSUnsupported(err error) bool {
|
func isStartTLSUnsupported(err error) bool {
|
||||||
return errors.Is(err, errStartTLSNotOffered)
|
return errors.Is(err, errStartTLSNotOffered)
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,11 @@ func TestProbe_TCPUnreachable(t *testing.T) {
|
||||||
Port: uint16(addr.Port),
|
Port: uint16(addr.Port),
|
||||||
}, 1*time.Second)
|
}, 1*time.Second)
|
||||||
|
|
||||||
if probe.TCPError == "" {
|
if probe.Error == "" {
|
||||||
t.Errorf("expected a TCP error for unreachable port")
|
t.Errorf("expected an error for unreachable port")
|
||||||
|
}
|
||||||
|
if len(probe.Issues) == 0 || probe.Issues[0].Code != "tcp_unreachable" {
|
||||||
|
t.Errorf("expected tcp_unreachable issue, got %+v", probe.Issues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
169
checker/rule.go
169
checker/rule.go
|
|
@ -8,83 +8,140 @@ import (
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rules returns the full list of CheckRules exposed by the TLS checker.
|
// Rule returns the rule that aggregates per-endpoint TLS probe outcomes into
|
||||||
// Each rule covers a single concern (reachability, handshake, chain, hostname,
|
// a single status for this checker run.
|
||||||
// expiry, TLS version, STARTTLS advertisement, cipher suite, …) so the UI can
|
func Rule() sdk.CheckRule {
|
||||||
// surface a passing-list rather than a single aggregated code.
|
return &tlsRule{}
|
||||||
func Rules() []sdk.CheckRule {
|
|
||||||
return []sdk.CheckRule{
|
|
||||||
&endpointsDiscoveredRule{},
|
|
||||||
&reachabilityRule{},
|
|
||||||
&tlsHandshakeRule{},
|
|
||||||
&starttlsAdvertisedRule{},
|
|
||||||
&starttlsSupportedRule{},
|
|
||||||
&peerCertificateRule{},
|
|
||||||
&chainValidityRule{},
|
|
||||||
&hostnameMatchRule{},
|
|
||||||
&expiryRule{},
|
|
||||||
&tlsVersionRule{},
|
|
||||||
&cipherSuiteRule{},
|
|
||||||
&versionEnumerationRule{},
|
|
||||||
&weakCipherRule{},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadData fetches the TLS observation. On error, returns a single error
|
type tlsRule struct{}
|
||||||
// state the caller should emit.
|
|
||||||
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*TLSData, *sdk.CheckState) {
|
func (r *tlsRule) Name() string { return "tls_posture" }
|
||||||
|
|
||||||
|
func (r *tlsRule) Description() string {
|
||||||
|
return "Summarises TLS handshake, certificate validity, hostname match and expiry across all probed endpoints"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tlsRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
var data TLSData
|
var data TLSData
|
||||||
if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil {
|
if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil {
|
||||||
return nil, &sdk.CheckState{
|
return []sdk.CheckState{{
|
||||||
Status: sdk.StatusError,
|
Status: sdk.StatusError,
|
||||||
Message: fmt.Sprintf("failed to load tls_probes observation: %v", err),
|
Message: fmt.Sprintf("Failed to read tls_probes: %v", err),
|
||||||
Code: "tls.observation_error",
|
Code: "tls_observation_error",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady state when no producer has published entries for this target
|
||||||
|
// yet (or when the last producer run cleared them). Report Unknown so
|
||||||
|
// we don't flap red during the eventual-consistency window between a
|
||||||
|
// fresh enrollment and the first producer cycle.
|
||||||
|
if len(data.Probes) == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Message: "No TLS endpoints have been discovered for this target yet",
|
||||||
|
Code: "tls_no_endpoints",
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return &data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortedRefs returns the probe refs in deterministic order. Rules iterate
|
|
||||||
// this sorted list so CheckState output is stable.
|
|
||||||
func sortedRefs(data *TLSData) []string {
|
|
||||||
refs := make([]string, 0, len(data.Probes))
|
refs := make([]string, 0, len(data.Probes))
|
||||||
for ref := range data.Probes {
|
for ref := range data.Probes {
|
||||||
refs = append(refs, ref)
|
refs = append(refs, ref)
|
||||||
}
|
}
|
||||||
sort.Strings(refs)
|
sort.Strings(refs)
|
||||||
return refs
|
|
||||||
|
out := make([]sdk.CheckState, 0, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
p := data.Probes[ref]
|
||||||
|
out = append(out, evaluateProbe(p))
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// subjectOf formats the UI-facing subject for a single probe.
|
// evaluateProbe distills a single TLSProbe into a CheckState. Subject is the
|
||||||
func subjectOf(p TLSProbe) string {
|
// probed endpoint so the host can correlate states across runs and surface
|
||||||
return fmt.Sprintf("%s://%s", p.Type, p.Endpoint)
|
// them per-target in the UI. Message describes the finding only -- the UI
|
||||||
}
|
// renders Subject separately.
|
||||||
|
func evaluateProbe(p TLSProbe) sdk.CheckState {
|
||||||
// metaOf returns a compact meta map to attach to a CheckState.
|
subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint)
|
||||||
func metaOf(p TLSProbe) map[string]any {
|
meta := map[string]any{
|
||||||
m := map[string]any{
|
|
||||||
"type": p.Type,
|
"type": p.Type,
|
||||||
"host": p.Host,
|
"host": p.Host,
|
||||||
"port": p.Port,
|
"port": p.Port,
|
||||||
"sni": p.SNI,
|
"sni": p.SNI,
|
||||||
|
"issues": len(p.Issues),
|
||||||
}
|
}
|
||||||
if p.TLSVersion != "" {
|
if p.TLSVersion != "" {
|
||||||
m["tls_version"] = p.TLSVersion
|
meta["tls_version"] = p.TLSVersion
|
||||||
|
}
|
||||||
|
if !p.NotAfter.IsZero() {
|
||||||
|
meta["not_after"] = p.NotAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
worst, critMsg, warnMsg := summarize(p.Issues)
|
||||||
|
switch worst {
|
||||||
|
case SeverityCrit:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Message: critMsg,
|
||||||
|
Code: "tls_critical",
|
||||||
|
Subject: subject,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
case SeverityWarn:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Message: warnMsg,
|
||||||
|
Code: "tls_warning",
|
||||||
|
Subject: subject,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
msg := "TLS endpoint OK"
|
||||||
|
if p.TLSVersion != "" {
|
||||||
|
msg = fmt.Sprintf("TLS endpoint OK (%s)", p.TLSVersion)
|
||||||
|
}
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Message: msg,
|
||||||
|
Code: "tls_ok",
|
||||||
|
Subject: subject,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// passState / infoState / unknownState helpers.
|
// summarize walks the issues once and returns (worst severity, first
|
||||||
func passState(code, message string) sdk.CheckState {
|
// critical message, first warning message). Picking the messages during the
|
||||||
return sdk.CheckState{Status: sdk.StatusOK, Code: code, Message: message}
|
// same pass avoids a second iteration in the caller.
|
||||||
}
|
func summarize(issues []Issue) (worst, firstCrit, firstWarn string) {
|
||||||
func unknownState(code, message string) sdk.CheckState {
|
for _, is := range issues {
|
||||||
return sdk.CheckState{Status: sdk.StatusUnknown, Code: code, Message: message}
|
msg := is.Message
|
||||||
}
|
if msg == "" {
|
||||||
|
msg = is.Code
|
||||||
// emptyCaseState returns a single state describing "no probes to evaluate".
|
}
|
||||||
// Rules call this when len(data.Probes) == 0 to avoid returning an empty
|
switch is.Severity {
|
||||||
// slice (see CheckRule.Evaluate contract).
|
case SeverityCrit:
|
||||||
func emptyCaseState(code string) sdk.CheckState {
|
worst = SeverityCrit
|
||||||
return unknownState(code, "No TLS endpoints have been discovered for this target yet.")
|
if firstCrit == "" {
|
||||||
|
firstCrit = msg
|
||||||
|
}
|
||||||
|
case SeverityWarn:
|
||||||
|
if worst == "" || worst == SeverityInfo {
|
||||||
|
worst = SeverityWarn
|
||||||
|
}
|
||||||
|
if firstWarn == "" {
|
||||||
|
firstWarn = msg
|
||||||
|
}
|
||||||
|
case SeverityInfo:
|
||||||
|
if worst == "" {
|
||||||
|
worst = SeverityInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// peerCertificateRule flags successful handshakes in which the server sent
|
|
||||||
// no certificate. This is distinct from chain validity: if no cert was sent,
|
|
||||||
// hostname/chain/expiry cannot be evaluated.
|
|
||||||
type peerCertificateRule struct{}
|
|
||||||
|
|
||||||
func (r *peerCertificateRule) Name() string { return "tls.peer_certificate_present" }
|
|
||||||
func (r *peerCertificateRule) Description() string {
|
|
||||||
return "Verifies the server presented a certificate during the TLS handshake."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *peerCertificateRule) 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.peer_certificate_present.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
anyHandshake := false
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if !p.TLSHandshakeOK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
anyHandshake = true
|
|
||||||
if !p.NoPeerCert {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Code: "tls.peer_certificate_present.missing",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: fmt.Sprintf("Server on %s completed the handshake but presented no certificate.", p.Endpoint),
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !anyHandshake {
|
|
||||||
return []sdk.CheckState{unknownState(
|
|
||||||
"tls.peer_certificate_present.skipped",
|
|
||||||
"No endpoint completed a TLS handshake.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.peer_certificate_present.ok",
|
|
||||||
"Every endpoint presented a certificate.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// chainValidityRule flags invalid certificate chains.
|
|
||||||
type chainValidityRule struct{}
|
|
||||||
|
|
||||||
func (r *chainValidityRule) Name() string { return "tls.chain_validity" }
|
|
||||||
func (r *chainValidityRule) Description() string {
|
|
||||||
return "Verifies the presented certificate chain validates against the system trust store."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *chainValidityRule) 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.chain_validity.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
any := false
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if p.ChainValid == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
any = true
|
|
||||||
if *p.ChainValid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msg := "Invalid certificate chain"
|
|
||||||
if p.ChainVerifyErr != "" {
|
|
||||||
msg = "Invalid certificate chain: " + p.ChainVerifyErr
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Code: "tls.chain_validity.invalid",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: msg,
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !any {
|
|
||||||
return []sdk.CheckState{unknownState(
|
|
||||||
"tls.chain_validity.skipped",
|
|
||||||
"No endpoint yielded a certificate chain to verify.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.chain_validity.ok",
|
|
||||||
"Every presented chain validates against the system trust store.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostnameMatchRule flags endpoints whose leaf cert does not cover the SNI
|
|
||||||
// the probe used.
|
|
||||||
type hostnameMatchRule struct{}
|
|
||||||
|
|
||||||
func (r *hostnameMatchRule) Name() string { return "tls.hostname_match" }
|
|
||||||
func (r *hostnameMatchRule) Description() string {
|
|
||||||
return "Verifies the leaf certificate covers the probed hostname (SNI)."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *hostnameMatchRule) 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.hostname_match.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
any := false
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if p.HostnameMatch == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
any = true
|
|
||||||
if *p.HostnameMatch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Code: "tls.hostname_match.mismatch",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", p.SNI, strings.Join(p.DNSNames, ", ")),
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !any {
|
|
||||||
return []sdk.CheckState{unknownState(
|
|
||||||
"tls.hostname_match.skipped",
|
|
||||||
"No endpoint yielded a certificate to hostname-match.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.hostname_match.ok",
|
|
||||||
"Every certificate covers its probed SNI.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// expiryRule flags expired or near-expiry certificates.
|
|
||||||
type expiryRule struct{}
|
|
||||||
|
|
||||||
func (r *expiryRule) Name() string { return "tls.expiry" }
|
|
||||||
func (r *expiryRule) Description() string {
|
|
||||||
return "Flags expired or soon-to-expire leaf certificates."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expiryRule) 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.expiry.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
var out []sdk.CheckState
|
|
||||||
any := false
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if p.NotAfter.IsZero() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
any = true
|
|
||||||
meta := metaOf(p)
|
|
||||||
meta["not_after"] = p.NotAfter
|
|
||||||
if p.NotAfter.Before(now) {
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Code: "tls.expiry.expired",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: "Certificate expired on " + p.NotAfter.Format(time.RFC3339),
|
|
||||||
Meta: meta,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.NotAfter.Sub(now) < ExpiringSoonThreshold {
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusWarn,
|
|
||||||
Code: "tls.expiry.expiring_soon",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: "Certificate expires in less than 14 days (" + p.NotAfter.Format(time.RFC3339) + ")",
|
|
||||||
Meta: meta,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !any {
|
|
||||||
return []sdk.CheckState{unknownState(
|
|
||||||
"tls.expiry.skipped",
|
|
||||||
"No endpoint yielded a certificate with an expiry to check.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.expiry.ok",
|
|
||||||
"Every leaf certificate is valid for more than 14 days.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// endpointsDiscoveredRule surfaces the "no producer has published endpoints
|
|
||||||
// for this target yet" steady state. Kept as its own rule so it does not
|
|
||||||
// contaminate per-endpoint findings when discovery is in flight.
|
|
||||||
type endpointsDiscoveredRule struct{}
|
|
||||||
|
|
||||||
func (r *endpointsDiscoveredRule) Name() string { return "tls.endpoints_discovered" }
|
|
||||||
func (r *endpointsDiscoveredRule) Description() string {
|
|
||||||
return "Verifies that at least one TLS endpoint has been discovered for this target."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *endpointsDiscoveredRule) 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{unknownState(
|
|
||||||
"tls.endpoints_discovered.none",
|
|
||||||
"No TLS endpoints have been discovered for this target yet.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.endpoints_discovered.ok",
|
|
||||||
"TLS endpoints were discovered for this target.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tlsHandshakeRule flags reachable endpoints on which the TLS handshake
|
|
||||||
// failed. STARTTLS-specific shortfalls (server not advertising the upgrade)
|
|
||||||
// are surfaced by starttlsAdvertisedRule / starttlsSupportedRule instead,
|
|
||||||
// so this rule skips them.
|
|
||||||
type tlsHandshakeRule struct{}
|
|
||||||
|
|
||||||
func (r *tlsHandshakeRule) Name() string { return "tls.handshake" }
|
|
||||||
func (r *tlsHandshakeRule) Description() string {
|
|
||||||
return "Verifies the TLS handshake completes on every reachable endpoint."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tlsHandshakeRule) 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.handshake.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if p.TCPError != "" {
|
|
||||||
continue // reachability covers this.
|
|
||||||
}
|
|
||||||
if p.STARTTLSNotOffered || p.STARTTLSUnsupportedProto {
|
|
||||||
continue // starttls-specific rules cover these.
|
|
||||||
}
|
|
||||||
if p.TLSHandshakeOK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.HandshakeError == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Code: "tls.handshake.failed",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: fmt.Sprintf("TLS handshake failed on %s: %s", p.Endpoint, p.HandshakeError),
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.handshake.ok",
|
|
||||||
"TLS handshake succeeded on every reachable endpoint.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tlsVersionRule flags endpoints negotiating a protocol version below the
|
|
||||||
// recommended TLS 1.2 floor.
|
|
||||||
type tlsVersionRule struct{}
|
|
||||||
|
|
||||||
func (r *tlsVersionRule) Name() string { return "tls.version" }
|
|
||||||
func (r *tlsVersionRule) Description() string {
|
|
||||||
return "Flags endpoints negotiating a TLS version below the recommended TLS 1.2."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *tlsVersionRule) 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.version.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
any := false
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if p.TLSVersionNum == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
any = true
|
|
||||||
if p.TLSVersionNum >= tls.VersionTLS12 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusWarn,
|
|
||||||
Code: "tls.version.weak",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: fmt.Sprintf("Negotiated TLS version %s is below the recommended TLS 1.2.", p.TLSVersion),
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !any {
|
|
||||||
return []sdk.CheckState{unknownState(
|
|
||||||
"tls.version.skipped",
|
|
||||||
"No endpoint completed a TLS handshake.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.version.ok",
|
|
||||||
"Every endpoint negotiates TLS 1.2 or higher.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// cipherSuiteRule reports the negotiated cipher suite for visibility.
|
|
||||||
// It does not currently classify suites as weak/strong: go's crypto/tls
|
|
||||||
// refuses to negotiate the known-weak suites anyway. The rule exists so the
|
|
||||||
// UI can expose the suite in the passing-list rather than leaving it buried
|
|
||||||
// in the raw observation.
|
|
||||||
type cipherSuiteRule struct{}
|
|
||||||
|
|
||||||
func (r *cipherSuiteRule) Name() string { return "tls.cipher_suite" }
|
|
||||||
func (r *cipherSuiteRule) Description() string {
|
|
||||||
return "Reports the cipher suite negotiated on each endpoint."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *cipherSuiteRule) 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.cipher_suite.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
suites[p.CipherSuite]++
|
|
||||||
endpoints[p.CipherSuite] = append(endpoints[p.CipherSuite], p.Endpoint)
|
|
||||||
}
|
|
||||||
if len(suites) == 0 {
|
|
||||||
return []sdk.CheckState{unknownState(
|
|
||||||
"tls.cipher_suite.skipped",
|
|
||||||
"No endpoint completed a TLS handshake.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
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},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// reachabilityRule flags endpoints that did not accept a TCP connection.
|
|
||||||
type reachabilityRule struct{}
|
|
||||||
|
|
||||||
func (r *reachabilityRule) Name() string { return "tls.reachability" }
|
|
||||||
func (r *reachabilityRule) Description() string {
|
|
||||||
return "Verifies that every discovered TLS endpoint accepts a TCP connection."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *reachabilityRule) 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.reachability.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if p.TCPError == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusCrit,
|
|
||||||
Code: "tls.reachability.tcp_unreachable",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: fmt.Sprintf("Cannot open TCP connection to %s: %s", p.Endpoint, p.TCPError),
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.reachability.ok",
|
|
||||||
"All discovered endpoints accepted a TCP connection.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// starttlsAdvertisedRule flags STARTTLS endpoints whose server did not
|
|
||||||
// advertise the upgrade. Severity depends on RequireSTARTTLS: opportunistic
|
|
||||||
// STARTTLS degrades to a warning; mandatory STARTTLS is critical.
|
|
||||||
type starttlsAdvertisedRule struct{}
|
|
||||||
|
|
||||||
func (r *starttlsAdvertisedRule) Name() string { return "tls.starttls_advertised" }
|
|
||||||
func (r *starttlsAdvertisedRule) Description() string {
|
|
||||||
return "Verifies that STARTTLS endpoints advertise the upgrade capability."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *starttlsAdvertisedRule) 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.starttls_advertised.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
anySTARTTLS := false
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if p.STARTTLSDialect == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
anySTARTTLS = true
|
|
||||||
if !p.STARTTLSNotOffered {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status := sdk.StatusWarn
|
|
||||||
if p.RequireSTARTTLS {
|
|
||||||
status = sdk.StatusCrit
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: status,
|
|
||||||
Code: "tls.starttls_advertised.missing",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: fmt.Sprintf("Server on %s does not advertise STARTTLS.", p.Endpoint),
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !anySTARTTLS {
|
|
||||||
return []sdk.CheckState{unknownState(
|
|
||||||
"tls.starttls_advertised.not_applicable",
|
|
||||||
"No STARTTLS endpoint in the discovered set.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.starttls_advertised.ok",
|
|
||||||
"STARTTLS is advertised on every STARTTLS endpoint.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// starttlsSupportedRule flags endpoints whose STARTTLS dialect is not
|
|
||||||
// implemented by this checker. A misconfigured discovery entry (typo, new
|
|
||||||
// protocol) should be visible as its own concern rather than blending into
|
|
||||||
// generic handshake failures.
|
|
||||||
type starttlsSupportedRule struct{}
|
|
||||||
|
|
||||||
func (r *starttlsSupportedRule) Name() string { return "tls.starttls_dialect_supported" }
|
|
||||||
func (r *starttlsSupportedRule) Description() string {
|
|
||||||
return "Verifies that discovered STARTTLS dialects are implemented by the checker."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *starttlsSupportedRule) 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.starttls_dialect_supported.no_endpoints")}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []sdk.CheckState
|
|
||||||
for _, ref := range sortedRefs(data) {
|
|
||||||
p := data.Probes[ref]
|
|
||||||
if !p.STARTTLSUnsupportedProto {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, sdk.CheckState{
|
|
||||||
Status: sdk.StatusError,
|
|
||||||
Code: "tls.starttls_dialect_supported.unknown",
|
|
||||||
Subject: subjectOf(p),
|
|
||||||
Message: fmt.Sprintf("Unsupported STARTTLS dialect %q for %s.", p.STARTTLSDialect, p.Endpoint),
|
|
||||||
Meta: metaOf(p),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []sdk.CheckState{passState(
|
|
||||||
"tls.starttls_dialect_supported.ok",
|
|
||||||
"Every STARTTLS dialect encountered is implemented.",
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +1,6 @@
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import "net"
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxSTARTTLSLineBytes caps the length of a single line read from a STARTTLS
|
|
||||||
// peer. Real banners and CAPABILITY responses are well under 1 KiB; this
|
|
||||||
// bound prevents a malicious or buggy server from exhausting memory by
|
|
||||||
// withholding the line terminator.
|
|
||||||
const maxSTARTTLSLineBytes = 8 * 1024
|
|
||||||
|
|
||||||
// readLineLimited reads bytes from r up to and including the next '\n', or
|
|
||||||
// until maxSTARTTLSLineBytes have been read without one (in which case it
|
|
||||||
// returns an error). The returned string keeps the trailing '\n' so callers
|
|
||||||
// can use the same parsing logic as bufio.Reader.ReadString('\n').
|
|
||||||
func readLineLimited(r *bufio.Reader) (string, error) {
|
|
||||||
out := make([]byte, 0, 128)
|
|
||||||
for {
|
|
||||||
b, err := r.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF && len(out) > 0 {
|
|
||||||
return string(out), io.ErrUnexpectedEOF
|
|
||||||
}
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
out = append(out, b)
|
|
||||||
if b == '\n' {
|
|
||||||
return string(out), nil
|
|
||||||
}
|
|
||||||
if len(out) >= maxSTARTTLSLineBytes {
|
|
||||||
return string(out), fmt.Errorf("line exceeds %d bytes without terminator", maxSTARTTLSLineBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on
|
// starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on
|
||||||
// conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success
|
// conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success
|
||||||
|
|
@ -48,18 +13,3 @@ var starttlsUpgraders = map[string]starttlsUpgrader{}
|
||||||
func registerStartTLS(protocol string, upgrader starttlsUpgrader) {
|
func registerStartTLS(protocol string, upgrader starttlsUpgrader) {
|
||||||
starttlsUpgraders[protocol] = upgrader
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ func init() {
|
||||||
func starttlsIMAP(conn net.Conn, sni string) error {
|
func starttlsIMAP(conn net.Conn, sni string) error {
|
||||||
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||||
|
|
||||||
if _, err := readLineLimited(rw.Reader); err != nil {
|
if _, err := rw.ReadString('\n'); err != nil {
|
||||||
return fmt.Errorf("read greeting: %w", err)
|
return fmt.Errorf("read greeting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,12 +23,12 @@ func starttlsIMAP(conn net.Conn, sni string) error {
|
||||||
return fmt.Errorf("write CAPABILITY: %w", err)
|
return fmt.Errorf("write CAPABILITY: %w", err)
|
||||||
}
|
}
|
||||||
if err := rw.Flush(); err != nil {
|
if err := rw.Flush(); err != nil {
|
||||||
return fmt.Errorf("flush CAPABILITY: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
supportsSTARTTLS := false
|
supportsSTARTTLS := false
|
||||||
for {
|
for {
|
||||||
line, err := readLineLimited(rw.Reader)
|
line, err := rw.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read CAPABILITY: %w", err)
|
return fmt.Errorf("read CAPABILITY: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -36,10 +36,6 @@ func starttlsIMAP(conn net.Conn, sni string) error {
|
||||||
supportsSTARTTLS = true
|
supportsSTARTTLS = true
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "A001 ") {
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -48,13 +44,13 @@ func starttlsIMAP(conn net.Conn, sni string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil {
|
if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil {
|
||||||
return fmt.Errorf("write STARTTLS: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := rw.Flush(); err != nil {
|
if err := rw.Flush(); err != nil {
|
||||||
return fmt.Errorf("flush STARTTLS: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
line, err := readLineLimited(rw.Reader)
|
line, err := rw.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read STARTTLS response: %w", err)
|
return fmt.Errorf("read STARTTLS response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,7 @@ func starttlsLDAP(conn net.Conn, sni string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read response length: %w", err)
|
return fmt.Errorf("read response length: %w", err)
|
||||||
}
|
}
|
||||||
// 16 KiB comfortably accommodates an ExtendedResponse with a verbose
|
if length <= 0 || length > 4096 {
|
||||||
// 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)
|
return fmt.Errorf("unreasonable LDAP response length %d", length)
|
||||||
}
|
}
|
||||||
body := make([]byte, length)
|
body := make([]byte, length)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ func init() {
|
||||||
func starttlsPOP3(conn net.Conn, sni string) error {
|
func starttlsPOP3(conn net.Conn, sni string) error {
|
||||||
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||||
|
|
||||||
greeting, err := readLineLimited(rw.Reader)
|
greeting, err := rw.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read greeting: %w", err)
|
return fmt.Errorf("read greeting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -24,19 +24,19 @@ func starttlsPOP3(conn net.Conn, sni string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := rw.WriteString("CAPA\r\n"); err != nil {
|
if _, err := rw.WriteString("CAPA\r\n"); err != nil {
|
||||||
return fmt.Errorf("write CAPA: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := rw.Flush(); err != nil {
|
if err := rw.Flush(); err != nil {
|
||||||
return fmt.Errorf("flush CAPA: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
first, err := readLineLimited(rw.Reader)
|
first, err := rw.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read CAPA: %w", err)
|
return fmt.Errorf("read CAPA: %w", err)
|
||||||
}
|
}
|
||||||
supportsSTLS := false
|
supportsSTLS := false
|
||||||
if strings.HasPrefix(first, "+OK") {
|
if strings.HasPrefix(first, "+OK") {
|
||||||
for {
|
for {
|
||||||
line, err := readLineLimited(rw.Reader)
|
line, err := rw.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read CAPA body: %w", err)
|
return fmt.Errorf("read CAPA body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -54,12 +54,12 @@ func starttlsPOP3(conn net.Conn, sni string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := rw.WriteString("STLS\r\n"); err != nil {
|
if _, err := rw.WriteString("STLS\r\n"); err != nil {
|
||||||
return fmt.Errorf("write STLS: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := rw.Flush(); err != nil {
|
if err := rw.Flush(); err != nil {
|
||||||
return fmt.Errorf("flush STLS: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
resp, err := readLineLimited(rw.Reader)
|
resp, err := rw.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read STLS response: %w", err)
|
return fmt.Errorf("read STLS response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,6 @@ import (
|
||||||
"strings"
|
"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() {
|
func init() {
|
||||||
registerStartTLS("smtp", starttlsSMTP)
|
registerStartTLS("smtp", starttlsSMTP)
|
||||||
registerStartTLS("submission", starttlsSMTP)
|
registerStartTLS("submission", starttlsSMTP)
|
||||||
|
|
@ -25,7 +20,7 @@ func starttlsSMTP(conn net.Conn, sni string) error {
|
||||||
return fmt.Errorf("read greeting: %w", err)
|
return fmt.Errorf("read greeting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := fmt.Fprintf(rw, "EHLO %s\r\n", EHLOHostname); err != nil {
|
if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil {
|
||||||
return fmt.Errorf("write ehlo: %w", err)
|
return fmt.Errorf("write ehlo: %w", err)
|
||||||
}
|
}
|
||||||
if err := rw.Flush(); err != nil {
|
if err := rw.Flush(); err != nil {
|
||||||
|
|
@ -65,7 +60,7 @@ func readSMTPGreeting(r *bufio.Reader) error {
|
||||||
func readSMTPResponse(r *bufio.Reader) ([]string, error) {
|
func readSMTPResponse(r *bufio.Reader) ([]string, error) {
|
||||||
var out []string
|
var out []string
|
||||||
for {
|
for {
|
||||||
line, err := readLineLimited(r)
|
line, err := r.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
package checker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runStartTLS drives upgrader against a fake server. The server callback runs
|
|
||||||
// on the peer end of an in-memory pipe and may read/write the plaintext
|
|
||||||
// dialect transcript. The test deadline guards both ends from hanging.
|
|
||||||
func runStartTLS(t *testing.T, upgrader func(net.Conn, string) error, sni string, server func(net.Conn) error) error {
|
|
||||||
t.Helper()
|
|
||||||
clientConn, serverConn := net.Pipe()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
_ = clientConn.SetDeadline(deadline)
|
|
||||||
_ = serverConn.SetDeadline(deadline)
|
|
||||||
|
|
||||||
srvErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
defer serverConn.Close()
|
|
||||||
srvErr <- server(serverConn)
|
|
||||||
}()
|
|
||||||
|
|
||||||
clientErr := upgrader(clientConn, sni)
|
|
||||||
clientConn.Close()
|
|
||||||
|
|
||||||
if err := <-srvErr; err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
|
|
||||||
t.Logf("server side returned: %v", err)
|
|
||||||
}
|
|
||||||
return clientErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// readLineCRLF reads one CRLF-terminated line.
|
|
||||||
func readLineCRLF(r *bufio.Reader) (string, error) {
|
|
||||||
line, err := r.ReadString('\n')
|
|
||||||
return strings.TrimRight(line, "\r\n"), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTLS_SMTP_OK(t *testing.T) {
|
|
||||||
err := runStartTLS(t, starttlsSMTP, "mail.example.com", func(c net.Conn) error {
|
|
||||||
br := bufio.NewReader(c)
|
|
||||||
if _, err := io.WriteString(c, "220 mail.example.com ESMTP\r\n"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ehlo, err := readLineCRLF(br)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(ehlo, "EHLO ") {
|
|
||||||
return errors.New("expected EHLO")
|
|
||||||
}
|
|
||||||
if _, err := io.WriteString(c, "250-mail.example.com\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stls, err := readLineCRLF(br)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if stls != "STARTTLS" {
|
|
||||||
return errors.New("expected STARTTLS")
|
|
||||||
}
|
|
||||||
_, err = io.WriteString(c, "220 ready\r\n")
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected success, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTLS_SMTP_NotAdvertised(t *testing.T) {
|
|
||||||
err := runStartTLS(t, starttlsSMTP, "mail.example.com", func(c net.Conn) error {
|
|
||||||
br := bufio.NewReader(c)
|
|
||||||
_, _ = io.WriteString(c, "220 mail.example.com ESMTP\r\n")
|
|
||||||
if _, err := readLineCRLF(br); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := io.WriteString(c, "250-mail.example.com\r\n250 SIZE 10485760\r\n")
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if !errors.Is(err, errStartTLSNotOffered) {
|
|
||||||
t.Fatalf("expected errStartTLSNotOffered, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTLS_SMTP_Refused(t *testing.T) {
|
|
||||||
err := runStartTLS(t, starttlsSMTP, "mail.example.com", func(c net.Conn) error {
|
|
||||||
br := bufio.NewReader(c)
|
|
||||||
_, _ = io.WriteString(c, "220 mail.example.com ESMTP\r\n")
|
|
||||||
_, _ = readLineCRLF(br)
|
|
||||||
_, _ = io.WriteString(c, "250-mail.example.com\r\n250 STARTTLS\r\n")
|
|
||||||
_, _ = readLineCRLF(br)
|
|
||||||
_, err := io.WriteString(c, "454 TLS not available\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_OK(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")
|
|
||||||
cap1, err := readLineCRLF(br)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(cap1, "CAPABILITY") {
|
|
||||||
return errors.New("expected CAPABILITY")
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(c, "* CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED\r\nA001 OK CAPABILITY completed\r\n")
|
|
||||||
stls, err := readLineCRLF(br)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(stls, "STARTTLS") {
|
|
||||||
return errors.New("expected STARTTLS")
|
|
||||||
}
|
|
||||||
_, err = io.WriteString(c, "A002 OK Begin TLS\r\n")
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected success, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
_, _ = io.WriteString(c, "* OK IMAP4rev1 ready\r\n")
|
|
||||||
_, _ = readLineCRLF(br)
|
|
||||||
_, err := io.WriteString(c, "* CAPABILITY IMAP4rev1 LOGINDISABLED\r\nA001 OK CAPABILITY completed\r\n")
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if !errors.Is(err, errStartTLSNotOffered) {
|
|
||||||
t.Fatalf("expected errStartTLSNotOffered, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTLS_POP3_OK(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")
|
|
||||||
capa, err := readLineCRLF(br)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if capa != "CAPA" {
|
|
||||||
return errors.New("expected CAPA")
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(c, "+OK capa list\r\nUSER\r\nSTLS\r\n.\r\n")
|
|
||||||
stls, err := readLineCRLF(br)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if stls != "STLS" {
|
|
||||||
return errors.New("expected STLS")
|
|
||||||
}
|
|
||||||
_, err = io.WriteString(c, "+OK begin TLS\r\n")
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected success, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTLS_POP3_NotAdvertised(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)
|
|
||||||
_, err := io.WriteString(c, "+OK capa list\r\nUSER\r\n.\r\n")
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if !errors.Is(err, errStartTLSNotOffered) {
|
|
||||||
t.Fatalf("expected errStartTLSNotOffered, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
// Read the client's stream header (one line is enough for our writer).
|
|
||||||
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'><required/></starttls></stream:features>`)
|
|
||||||
// Read the <starttls/> request from the client.
|
|
||||||
if _, err := br.Read(buf); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := io.WriteString(c, `<proceed xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>`)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected success, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartTLS_XMPP_NotAdvertised(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:features><mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'><mechanism>PLAIN</mechanism></mechanisms></stream:features>`)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if !errors.Is(err, errStartTLSNotOffered) {
|
|
||||||
t.Fatalf("expected errStartTLSNotOffered, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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).
|
|
||||||
req := make([]byte, 31)
|
|
||||||
if _, err := io.ReadFull(c, req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Build a minimal ExtendedResponse with resultCode=0.
|
|
||||||
// LDAPMessage SEQUENCE { messageID INTEGER 1, [APPLICATION 24] SEQUENCE { resultCode ENUMERATED 0, matchedDN "", diagnosticMessage "" } }
|
|
||||||
resp := []byte{
|
|
||||||
0x30, 0x0c, // SEQUENCE, length 12
|
|
||||||
0x02, 0x01, 0x01, // messageID = 1
|
|
||||||
0x78, 0x07, // [APPLICATION 24], length 7
|
|
||||||
0x0a, 0x01, 0x00, // resultCode ENUMERATED 0
|
|
||||||
0x04, 0x00, // matchedDN ""
|
|
||||||
0x04, 0x00, // diagnosticMessage ""
|
|
||||||
}
|
|
||||||
_, err := c.Write(resp)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected success, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
if _, err := io.ReadFull(c, req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// resultCode = 53 (unwillingToPerform) -> classified as not-offered.
|
|
||||||
resp := []byte{
|
|
||||||
0x30, 0x0c,
|
|
||||||
0x02, 0x01, 0x01,
|
|
||||||
0x78, 0x07,
|
|
||||||
0x0a, 0x01, 0x35,
|
|
||||||
0x04, 0x00,
|
|
||||||
0x04, 0x00,
|
|
||||||
}
|
|
||||||
_, err := c.Write(resp)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if !errors.Is(err, errStartTLSNotOffered) {
|
|
||||||
t.Fatalf("expected errStartTLSNotOffered for resultCode 53, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,41 +22,24 @@ func starttlsXMPPServer(conn net.Conn, sni string) error {
|
||||||
return starttlsXMPP(conn, sni, "jabber:server")
|
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 {
|
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)
|
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 {
|
if _, err := io.WriteString(conn, header); err != nil {
|
||||||
return fmt.Errorf("write stream header: %w", err)
|
return fmt.Errorf("write stream header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dec := xml.NewDecoder(&io.LimitedReader{R: conn, N: xmppPreTLSReadLimit})
|
dec := xml.NewDecoder(conn)
|
||||||
|
|
||||||
// Read the inbound <stream:stream> opening and its <stream:features>.
|
// 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
|
hasStartTLS := false
|
||||||
outer:
|
|
||||||
for {
|
for {
|
||||||
tok, err := dec.Token()
|
tok, err := dec.Token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read stream features: %w", err)
|
return fmt.Errorf("read stream features: %w", err)
|
||||||
}
|
}
|
||||||
se, ok := tok.(xml.StartElement)
|
if se, ok := tok.(xml.StartElement); ok {
|
||||||
if !ok {
|
if se.Name.Local == "features" {
|
||||||
continue
|
// Scan features children.
|
||||||
}
|
|
||||||
switch se.Name.Local {
|
|
||||||
case "stream":
|
|
||||||
// Outer <stream:stream> opening. Continue reading children.
|
|
||||||
continue
|
|
||||||
case "features":
|
|
||||||
for {
|
for {
|
||||||
t2, err := dec.Token()
|
t2, err := dec.Token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -67,21 +50,17 @@ outer:
|
||||||
if ee.Name.Local == "starttls" {
|
if ee.Name.Local == "starttls" {
|
||||||
hasStartTLS = true
|
hasStartTLS = true
|
||||||
}
|
}
|
||||||
if err := dec.Skip(); err != nil {
|
_ = dec.Skip()
|
||||||
return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err)
|
|
||||||
}
|
|
||||||
case xml.EndElement:
|
case xml.EndElement:
|
||||||
if ee.Name.Local == "features" {
|
if ee.Name.Local == "features" {
|
||||||
break outer
|
goto doneFeatures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
doneFeatures:
|
||||||
if !hasStartTLS {
|
if !hasStartTLS {
|
||||||
return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered)
|
return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
checker/types.go
138
checker/types.go
|
|
@ -11,7 +11,6 @@ const ObservationKeyTLSProbes = "tls_probes"
|
||||||
const (
|
const (
|
||||||
OptionEndpoints = "endpoints"
|
OptionEndpoints = "endpoints"
|
||||||
OptionProbeTimeoutMs = "probeTimeoutMs"
|
OptionProbeTimeoutMs = "probeTimeoutMs"
|
||||||
OptionEnumerateCiphers = "enumerateCiphers"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defaults shared between the definition's Default field and the runtime
|
// Defaults shared between the definition's Default field and the runtime
|
||||||
|
|
@ -23,65 +22,31 @@ const (
|
||||||
MaxConcurrentProbes = 32
|
MaxConcurrentProbes = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Severity values used in Issue.Severity (lowercase, ascii).
|
||||||
|
const (
|
||||||
|
SeverityCrit = "crit"
|
||||||
|
SeverityWarn = "warn"
|
||||||
|
SeverityInfo = "info"
|
||||||
|
)
|
||||||
|
|
||||||
// TLSData is the full collected payload written under ObservationKeyTLSProbes.
|
// TLSData is the full collected payload written under ObservationKeyTLSProbes.
|
||||||
type TLSData struct {
|
type TLSData struct {
|
||||||
Probes map[string]TLSProbe `json:"probes"`
|
Probes map[string]TLSProbe `json:"probes"`
|
||||||
CollectedAt time.Time `json:"collected_at"`
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLSProbe captures the outcome of probing a single endpoint.
|
// TLSProbe captures the outcome of probing a single endpoint. Field names
|
||||||
//
|
// mirror what consumers already parse (checker-xmpp's tlsProbeView).
|
||||||
// Only raw observation fields live here. Judgement (severity, pass/fail,
|
|
||||||
// human-facing messages) is derived from these fields by CheckRules.
|
|
||||||
type TLSProbe struct {
|
type TLSProbe struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port uint16 `json:"port"`
|
Port uint16 `json:"port"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SNI string `json:"sni,omitempty"`
|
SNI string `json:"sni,omitempty"`
|
||||||
|
|
||||||
// RequireSTARTTLS is copied from the discovery entry so rules can tell
|
|
||||||
// whether a missing STARTTLS advertisement is a hard or soft failure.
|
|
||||||
RequireSTARTTLS bool `json:"require_starttls,omitempty"`
|
|
||||||
|
|
||||||
// STARTTLSDialect mirrors contract.TLSEndpoint.STARTTLS verbatim. An
|
|
||||||
// empty value means direct TLS.
|
|
||||||
STARTTLSDialect string `json:"starttls_dialect,omitempty"`
|
|
||||||
|
|
||||||
// Raw error strings. Exactly one of TCPError or HandshakeError is set
|
|
||||||
// when the probe failed before gathering handshake data.
|
|
||||||
TCPError string `json:"tcp_error,omitempty"`
|
|
||||||
HandshakeError string `json:"handshake_error,omitempty"`
|
|
||||||
|
|
||||||
// STARTTLSNotOffered is true when HandshakeError was produced because
|
|
||||||
// the server did not advertise STARTTLS (errStartTLSNotOffered).
|
|
||||||
STARTTLSNotOffered bool `json:"starttls_not_offered,omitempty"`
|
|
||||||
|
|
||||||
// STARTTLSUnsupportedProto is true when the STARTTLS dialect is not
|
|
||||||
// implemented by this checker.
|
|
||||||
STARTTLSUnsupportedProto bool `json:"starttls_unsupported_proto,omitempty"`
|
|
||||||
|
|
||||||
// TLSHandshakeOK is true when a TLS handshake completed. It is
|
|
||||||
// independent from chain validity.
|
|
||||||
TLSHandshakeOK bool `json:"tls_handshake_ok,omitempty"`
|
|
||||||
|
|
||||||
// TLSVersionNum is the numeric TLS version negotiated (uint16 from
|
|
||||||
// crypto/tls). Zero means no handshake occurred. Kept as an unsigned
|
|
||||||
// integer so rules can compare against tls.VersionTLS12 without
|
|
||||||
// re-parsing a string.
|
|
||||||
TLSVersionNum uint16 `json:"tls_version_num,omitempty"`
|
|
||||||
|
|
||||||
TLSVersion string `json:"tls_version,omitempty"`
|
TLSVersion string `json:"tls_version,omitempty"`
|
||||||
CipherSuite string `json:"cipher_suite,omitempty"`
|
CipherSuite string `json:"cipher_suite,omitempty"`
|
||||||
CipherSuiteID uint16 `json:"cipher_suite_id,omitempty"`
|
|
||||||
|
|
||||||
// NoPeerCert is true when the handshake succeeded but the server sent
|
|
||||||
// no certificate.
|
|
||||||
NoPeerCert bool `json:"no_peer_cert,omitempty"`
|
|
||||||
|
|
||||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||||
ChainVerifyErr string `json:"chain_verify_err,omitempty"`
|
|
||||||
NotAfter time.Time `json:"not_after,omitempty"`
|
NotAfter time.Time `json:"not_after,omitempty"`
|
||||||
Issuer string `json:"issuer,omitempty"`
|
Issuer string `json:"issuer,omitempty"`
|
||||||
// IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for
|
// IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for
|
||||||
|
|
@ -94,86 +59,15 @@ type TLSProbe struct {
|
||||||
IssuerAKI string `json:"issuer_aki,omitempty"`
|
IssuerAKI string `json:"issuer_aki,omitempty"`
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
DNSNames []string `json:"dns_names,omitempty"`
|
DNSNames []string `json:"dns_names,omitempty"`
|
||||||
// Chain carries one entry per certificate presented by the server
|
|
||||||
// (leaf first, then intermediates in order). Each entry precomputes
|
|
||||||
// the four TLSA selector×matching_type hashes plus the raw DER so
|
|
||||||
// DANE consumers can match without re-handshaking or re-parsing.
|
|
||||||
Chain []CertInfo `json:"chain,omitempty"`
|
|
||||||
ElapsedMS int64 `json:"elapsed_ms,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.
|
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Issues []Issue `json:"issues,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CertInfo describes one certificate in the presented chain together with
|
// Issue is a single TLS finding surfaced to the consumer.
|
||||||
// pre-hashed forms suitable for DANE/TLSA matching (RFC 6698 §2.1).
|
type Issue struct {
|
||||||
//
|
Code string `json:"code"`
|
||||||
// Hex fields are lowercase, matching the representation emitted by
|
Severity string `json:"severity"`
|
||||||
// miekg/dns for TLSA RR Certificate fields.
|
Message string `json:"message,omitempty"`
|
||||||
type CertInfo struct {
|
Fix string `json:"fix,omitempty"`
|
||||||
// DERBase64 is the standard base64 encoding of the certificate's DER
|
|
||||||
// form. Carried so consumers can do matching-type 0 (Full) without
|
|
||||||
// requiring a precomputed "full" hash and for fallback inspection.
|
|
||||||
DERBase64 string `json:"der_base64,omitempty"`
|
|
||||||
|
|
||||||
// Subject / Issuer are short human-readable DNs for the HTML report.
|
|
||||||
Subject string `json:"subject,omitempty"`
|
|
||||||
Issuer string `json:"issuer,omitempty"`
|
|
||||||
|
|
||||||
// NotAfter is the certificate's expiry. Carried so editors can show
|
|
||||||
// "expires on …" without re-parsing the DER.
|
|
||||||
NotAfter time.Time `json:"not_after,omitempty"`
|
|
||||||
|
|
||||||
// Selector 0 = full certificate.
|
|
||||||
CertSHA256 string `json:"cert_sha256,omitempty"`
|
|
||||||
CertSHA512 string `json:"cert_sha512,omitempty"`
|
|
||||||
|
|
||||||
// Selector 1 = SubjectPublicKeyInfo.
|
|
||||||
SPKISHA256 string `json:"spki_sha256,omitempty"`
|
|
||||||
SPKISHA512 string `json:"spki_sha512,omitempty"`
|
|
||||||
|
|
||||||
// SPKIDERBase64 lets consumers handle (selector=1, matching=0) without
|
|
||||||
// re-parsing the certificate.
|
|
||||||
SPKIDERBase64 string `json:"spki_der_base64,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiry thresholds shared by rules.
|
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,7 +16,6 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
@ -59,27 +58,10 @@ type TLSEndpoint struct {
|
||||||
RequireSTARTTLS bool `json:"require,omitempty"`
|
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
|
// NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref
|
||||||
// derived from ep, and a marshaled Payload. The returned entry can be
|
// derived from ep, and a marshaled Payload. The returned entry can be
|
||||||
// returned as-is from a DiscoveryPublisher implementation.
|
// returned as-is from a DiscoveryPublisher implementation.
|
||||||
func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) {
|
func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) {
|
||||||
if err := ep.Validate(); err != nil {
|
|
||||||
return sdk.DiscoveryEntry{}, err
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(ep)
|
payload, err := json.Marshal(ep)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err)
|
return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err)
|
||||||
|
|
@ -113,7 +95,7 @@ func Ref(ep TLSEndpoint) string {
|
||||||
req = "1"
|
req = "1"
|
||||||
}
|
}
|
||||||
canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req)
|
canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req)
|
||||||
sum := sha1.Sum([]byte(canonical)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above
|
sum := sha1.Sum([]byte(canonical))
|
||||||
return hex.EncodeToString(sum[:8])
|
return hex.EncodeToString(sum[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,9 +109,6 @@ func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) {
|
||||||
if err := json.Unmarshal(e.Payload, &ep); err != nil {
|
if err := json.Unmarshal(e.Payload, &ep); err != nil {
|
||||||
return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err)
|
return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err)
|
||||||
}
|
}
|
||||||
if err := ep.Validate(); err != nil {
|
|
||||||
return TLSEndpoint{}, err
|
|
||||||
}
|
|
||||||
return ep, nil
|
return ep, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +123,7 @@ type Entry struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseEntries filters entries to those of Type and decodes each payload.
|
// ParseEntries filters entries to those of Type and decodes each payload.
|
||||||
// Entries of other types are ignored silently, they belong to other
|
// Entries of other types are ignored silently — they belong to other
|
||||||
// contracts. Entries of this type whose Payload fails to unmarshal are
|
// contracts. Entries of this type whose Payload fails to unmarshal are
|
||||||
// skipped and returned as warnings so a single malformed payload cannot
|
// skipped and returned as warnings so a single malformed payload cannot
|
||||||
// starve the checker of the rest of its workload.
|
// starve the checker of the rest of its workload.
|
||||||
|
|
|
||||||
10
go.mod
10
go.mod
|
|
@ -2,12 +2,4 @@ module git.happydns.org/checker-tls
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require git.happydns.org/checker-sdk-go v1.5.0
|
require git.happydns.org/checker-sdk-go v1.2.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
14
go.sum
|
|
@ -1,12 +1,2 @@
|
||||||
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
|
||||||
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
git.happydns.org/checker-sdk-go v1.2.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=
|
|
||||||
|
|
|
||||||
14
main.go
14
main.go
|
|
@ -4,28 +4,20 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"git.happydns.org/checker-sdk-go/checker/server"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
tls "git.happydns.org/checker-tls/checker"
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "custom-build"
|
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")
|
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
tls.Version = Version
|
tls.Version = Version
|
||||||
if EHLOHostname != "" {
|
|
||||||
tls.EHLOHostname = EHLOHostname
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := server.New(tls.Provider())
|
server := sdk.NewServer(tls.Provider())
|
||||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||||
log.Fatalf("server error: %v", err)
|
log.Fatalf("server error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,5 @@ var Version = "custom-build"
|
||||||
|
|
||||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
tls.Version = Version
|
tls.Version = Version
|
||||||
prvd := tls.Provider()
|
return tls.Definition(), tls.Provider(), nil
|
||||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
// 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")
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
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