Initial commit
This commit is contained in:
commit
a6dbcef0f9
26 changed files with 2993 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
checker-dane
|
||||||
|
checker-dane.so
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
ARG CHECKER_VERSION=custom-build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-dane .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /checker-dane /checker-dane
|
||||||
|
USER 65534:65534
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/checker-dane"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 The happyDomain Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the “Software”), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
CHECKER_NAME := checker-dane
|
||||||
|
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||||
|
CHECKER_VERSION ?= custom-build
|
||||||
|
|
||||||
|
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||||
|
|
||||||
|
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||||
|
|
||||||
|
.PHONY: all plugin docker test clean
|
||||||
|
|
||||||
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
|
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||||
|
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -tags standalone ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
68
README.md
Normal file
68
README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# checker-dane
|
||||||
|
|
||||||
|
DANE / TLSA checker for [happyDomain](https://www.happydomain.org/).
|
||||||
|
|
||||||
|
Bound to the `svcs.TLSAs` service: groups the user's TLSA records by
|
||||||
|
`(port, proto, base)`, publishes one `tls.endpoint.v1` discovery entry
|
||||||
|
per endpoint so [`checker-tls`](https://git.happydns.org/checker-tls)
|
||||||
|
probes them, then matches each TLSA against the observed certificate
|
||||||
|
chain per RFC 6698.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Standalone HTTP server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run
|
||||||
|
make
|
||||||
|
./checker-dane -listen :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The server exposes:
|
||||||
|
|
||||||
|
- `GET /health`, health check
|
||||||
|
- `POST /collect`, collect DANE observations (happyDomain external checker protocol)
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker
|
||||||
|
docker run -p 8080:8080 happydomain/checker-dane
|
||||||
|
```
|
||||||
|
|
||||||
|
### happyDomain plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make plugin
|
||||||
|
# produces checker-dane.so, loadable by happyDomain as a Go plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin exposes a `NewCheckerPlugin` symbol returning the checker
|
||||||
|
definition and observation provider, which happyDomain registers in its
|
||||||
|
global registries at load time.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
The binary, plugin, and Docker image embed a version string overridable
|
||||||
|
at build time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make CHECKER_VERSION=1.2.3
|
||||||
|
make plugin CHECKER_VERSION=1.2.3
|
||||||
|
make docker CHECKER_VERSION=1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
### happyDomain remote endpoint
|
||||||
|
|
||||||
|
Set the `endpoint` admin option for the DANE checker to the URL of the
|
||||||
|
running checker-dane server (e.g., `http://checker-dane:8080`).
|
||||||
|
happyDomain will delegate observation collection to this endpoint.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- **Usage 0 (PKIX-TA) / 1 (PKIX-EE)**: TLSA match + publicly trusted PKIX chain required.
|
||||||
|
- **Usage 2 (DANE-TA) / 3 (DANE-EE)**: TLSA acts as the trust anchor; PKIX validity is informational.
|
||||||
|
- **Selector** 0 (Cert) / 1 (SPKI) and **MatchingType** 0/1/2 (Full / SHA-256 / SHA-512)
|
||||||
|
are matched against the chain slot implied by the usage.
|
||||||
|
- Common STARTTLS ports (25, 110, 143, 389, 587, 5222, 5269) are auto-mapped;
|
||||||
|
override via the `starttls` option keyed by `"<port>/<proto>"`.
|
||||||
282
checker/collect.go
Normal file
282
checker/collect.go
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
tlscontract "git.happydns.org/checker-tls/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tlsaOwner matches the "_<port>._<proto>.<base>" TLSA owner-name pattern.
|
||||||
|
// The base group is whatever the happyDomain analyzer bucketed the TLSAs
|
||||||
|
// under; when empty, the TLSAs live directly under the zone apex.
|
||||||
|
var tlsaOwner = regexp.MustCompile(`^_(\d+)\._(tcp|udp)(?:\.(.*))?$`)
|
||||||
|
|
||||||
|
// tlsaOwnerName builds the canonical "_<port>._<proto>.<base>" owner name.
|
||||||
|
// When base is empty (TLSA records sit directly at the zone apex of an
|
||||||
|
// otherwise-unspecified host), the trailing label is omitted so the result
|
||||||
|
// is still a syntactically valid relative name rather than "_443._tcp.".
|
||||||
|
func tlsaOwnerName(port uint16, proto, base string) string {
|
||||||
|
base = strings.TrimSuffix(base, ".")
|
||||||
|
if base == "" {
|
||||||
|
return fmt.Sprintf("_%d._%s", port, proto)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("_%d._%s.%s", port, proto, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// starttlsKey is the "<port>/<proto>" lookup key used in OptionSTARTTLS.
|
||||||
|
func starttlsKey(port uint16, proto string) string {
|
||||||
|
return fmt.Sprintf("%d/%s", port, proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceMessage mirrors the on-wire happydns.ServiceMessage shape, kept
|
||||||
|
// local so this module does not depend on happyDomain core. Same pattern
|
||||||
|
// as checker-caa/checker/collect.go.
|
||||||
|
type serviceMessage struct {
|
||||||
|
Type string `json:"_svctype"`
|
||||||
|
Domain string `json:"_domain"`
|
||||||
|
Service json.RawMessage `json:"Service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsasPayload mirrors the JSON shape of svcs.TLSAs (services/tlsa.go).
|
||||||
|
type tlsasPayload struct {
|
||||||
|
Records []tlsaRecord `json:"tlsa"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsaRecord decodes one dns.TLSA as serialized by miekg/dns. The Hdr.Name
|
||||||
|
// is how we learn which endpoint each record applies to; Certificate is
|
||||||
|
// already a lowercase-hex string as miekg/dns emits it.
|
||||||
|
type tlsaRecord struct {
|
||||||
|
Hdr struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
} `json:"Hdr"`
|
||||||
|
Usage uint8 `json:"Usage"`
|
||||||
|
Selector uint8 `json:"Selector"`
|
||||||
|
MatchingType uint8 `json:"MatchingType"`
|
||||||
|
Certificate string `json:"Certificate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSTARTTLS maps common ports to the STARTTLS service name checker-tls
|
||||||
|
// expects. Endpoints not covered default to direct TLS; the user can override
|
||||||
|
// explicitly via the OptionSTARTTLS map.
|
||||||
|
var defaultSTARTTLS = map[uint16]string{
|
||||||
|
25: "smtp",
|
||||||
|
110: "pop3",
|
||||||
|
143: "imap",
|
||||||
|
389: "ldap",
|
||||||
|
587: "submission",
|
||||||
|
5222: "xmpp-client",
|
||||||
|
5269: "xmpp-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect walks the bound TLSAs service, groups records by (port, proto,
|
||||||
|
// base), emits one tls.endpoint.v1 discovery entry per group so checker-tls
|
||||||
|
// probes each of them, and returns DANEData with the user's TLSA records.
|
||||||
|
// No TLSA matching happens here; that's the rule's job: it reads the TLS
|
||||||
|
// chain via obs.GetRelated on the next evaluation.
|
||||||
|
func (p *daneProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
svc, err := serviceFromOptions(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if svc.Type != serviceType {
|
||||||
|
return nil, fmt.Errorf("service is %q, expected %q", svc.Type, serviceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pl tlsasPayload
|
||||||
|
if err := json.Unmarshal(svc.Service, &pl); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode TLSAs service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apex, _ := sdk.GetOption[string](opts, OptionDomain)
|
||||||
|
apex = strings.TrimSuffix(apex, ".")
|
||||||
|
subdomain, _ := sdk.GetOption[string](opts, OptionSubdomain)
|
||||||
|
subdomain = strings.TrimSuffix(subdomain, ".")
|
||||||
|
|
||||||
|
// STARTTLS overrides: map of "port/proto" → service name.
|
||||||
|
var starttlsOverride map[string]string
|
||||||
|
if v, ok := opts[OptionSTARTTLS]; ok {
|
||||||
|
raw, _ := json.Marshal(v)
|
||||||
|
_ = json.Unmarshal(raw, &starttlsOverride)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group records by endpoint key.
|
||||||
|
type key struct {
|
||||||
|
Port uint16
|
||||||
|
Proto string
|
||||||
|
Base string // base host, fully-qualified without trailing dot
|
||||||
|
}
|
||||||
|
groups := map[key][]TLSARecord{}
|
||||||
|
var invalid []InvalidRecord
|
||||||
|
for _, r := range pl.Records {
|
||||||
|
owner := strings.TrimSuffix(r.Hdr.Name, ".")
|
||||||
|
m := tlsaOwner.FindStringSubmatch(owner)
|
||||||
|
if len(m) != 4 {
|
||||||
|
invalid = append(invalid, InvalidRecord{
|
||||||
|
Owner: owner,
|
||||||
|
Reason: "owner name does not match _<port>._<tcp|udp>[.<base>]",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
port64, err := strconv.ParseUint(m[1], 10, 16)
|
||||||
|
if err != nil || port64 == 0 {
|
||||||
|
invalid = append(invalid, InvalidRecord{
|
||||||
|
Owner: owner,
|
||||||
|
Reason: fmt.Sprintf("port %q out of range (1-65535)", m[1]),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := m[3]
|
||||||
|
// Resolve base relative to the apex: TLSA owners in the service
|
||||||
|
// are typically stored relative to the service's subdomain
|
||||||
|
// bucket. Fall back to the apex when unspecified.
|
||||||
|
base = joinName(base, subdomain, apex)
|
||||||
|
if base == "" {
|
||||||
|
invalid = append(invalid, InvalidRecord{
|
||||||
|
Owner: owner,
|
||||||
|
Reason: "could not resolve a host name (apex and subdomain both empty)",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
k := key{Port: uint16(port64), Proto: m[2], Base: base}
|
||||||
|
groups[k] = append(groups[k], TLSARecord{
|
||||||
|
Usage: r.Usage,
|
||||||
|
Selector: r.Selector,
|
||||||
|
MatchingType: r.MatchingType,
|
||||||
|
Certificate: strings.ToLower(strings.TrimSpace(r.Certificate)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic output ordering keeps diffs quiet across runs.
|
||||||
|
keys := make([]key, 0, len(groups))
|
||||||
|
for k := range groups {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].Base != keys[j].Base {
|
||||||
|
return keys[i].Base < keys[j].Base
|
||||||
|
}
|
||||||
|
if keys[i].Port != keys[j].Port {
|
||||||
|
return keys[i].Port < keys[j].Port
|
||||||
|
}
|
||||||
|
return keys[i].Proto < keys[j].Proto
|
||||||
|
})
|
||||||
|
|
||||||
|
targets := make([]TargetResult, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
starttls := defaultSTARTTLS[k.Port]
|
||||||
|
if v, ok := starttlsOverride[starttlsKey(k.Port, k.Proto)]; ok {
|
||||||
|
starttls = v
|
||||||
|
}
|
||||||
|
t := TargetResult{
|
||||||
|
Owner: tlsaOwnerName(k.Port, k.Proto, k.Base),
|
||||||
|
Host: k.Base,
|
||||||
|
Port: k.Port,
|
||||||
|
Proto: k.Proto,
|
||||||
|
STARTTLS: starttls,
|
||||||
|
Records: groups[k],
|
||||||
|
}
|
||||||
|
t.Ref = tlscontract.Ref(endpointFromTarget(t))
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &DANEData{
|
||||||
|
Targets: targets,
|
||||||
|
Invalid: invalid,
|
||||||
|
CollectedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if v, ok := opts[OptionDNSSECValidated]; ok {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
data.DNSSECValidated = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointFromTarget builds the TLSEndpoint for a collected target.
|
||||||
|
func endpointFromTarget(t TargetResult) tlscontract.TLSEndpoint {
|
||||||
|
return tlscontract.TLSEndpoint{
|
||||||
|
Host: t.Host,
|
||||||
|
Port: t.Port,
|
||||||
|
SNI: t.Host,
|
||||||
|
STARTTLS: t.STARTTLS,
|
||||||
|
// RFC 7672 §2.2: when a TLSA record exists for an SMTP service, the
|
||||||
|
// receiving MTA MUST use STARTTLS. The whole point of DANE on port 25
|
||||||
|
// is to defeat STARTTLS-stripping downgrade attacks, so the presence
|
||||||
|
// of TLSA records here flips the connection from opportunistic to
|
||||||
|
// mandatory.
|
||||||
|
RequireSTARTTLS: t.STARTTLS != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverEntries publishes one tls.endpoint.v1 entry per target so
|
||||||
|
// checker-tls probes them in its next cycle. Implements sdk.DiscoveryPublisher.
|
||||||
|
func (p *daneProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||||
|
d, ok := data.(*DANEData)
|
||||||
|
if !ok || d == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := make([]sdk.DiscoveryEntry, 0, len(d.Targets))
|
||||||
|
for _, t := range d.Targets {
|
||||||
|
entry, err := tlscontract.NewEntry(endpointFromTarget(t))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceFromOptions extracts and decodes the happyDomain service payload.
|
||||||
|
func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
|
||||||
|
v, ok := opts[OptionService]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("service option missing")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal service option: %w", err)
|
||||||
|
}
|
||||||
|
var svc serviceMessage
|
||||||
|
if err := json.Unmarshal(raw, &svc); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode service option: %w", err)
|
||||||
|
}
|
||||||
|
return &svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinName resolves a possibly-relative TLSA base name against the service's
|
||||||
|
// subdomain bucket and the zone apex, returning a fully-qualified host name
|
||||||
|
// without trailing dot. An empty base means "the subdomain/apex itself".
|
||||||
|
func joinName(base, subdomain, apex string) string {
|
||||||
|
base = strings.TrimSuffix(base, ".")
|
||||||
|
// Absolute match to apex: return apex; otherwise treat as relative.
|
||||||
|
if base == "" {
|
||||||
|
if subdomain != "" {
|
||||||
|
return strings.TrimSuffix(subdomain+"."+apex, ".")
|
||||||
|
}
|
||||||
|
return apex
|
||||||
|
}
|
||||||
|
// If base already ends with apex (fully qualified), keep as-is.
|
||||||
|
if apex != "" && (base == apex || strings.HasSuffix(base, "."+apex)) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
// Otherwise, base is relative to the subdomain bucket (or apex).
|
||||||
|
if subdomain != "" {
|
||||||
|
return strings.TrimSuffix(base+"."+subdomain+"."+apex, ".")
|
||||||
|
}
|
||||||
|
if apex != "" {
|
||||||
|
return base + "." + apex
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
226
checker/collect_test.go
Normal file
226
checker/collect_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeOpts(t *testing.T, apex, subdomain string, records []map[string]any, starttls map[string]string) sdk.CheckerOptions {
|
||||||
|
t.Helper()
|
||||||
|
svc := map[string]any{
|
||||||
|
"_svctype": serviceType,
|
||||||
|
"_domain": apex,
|
||||||
|
"Service": map[string]any{"tlsa": records},
|
||||||
|
}
|
||||||
|
opts := sdk.CheckerOptions{
|
||||||
|
OptionDomain: apex,
|
||||||
|
OptionService: svc,
|
||||||
|
}
|
||||||
|
if subdomain != "" {
|
||||||
|
opts[OptionSubdomain] = subdomain
|
||||||
|
}
|
||||||
|
if starttls != nil {
|
||||||
|
opts[OptionSTARTTLS] = starttls
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func tlsaRR(owner string, usage, selector, mtype int, cert string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"Hdr": map[string]any{"Name": owner},
|
||||||
|
"Usage": usage,
|
||||||
|
"Selector": selector,
|
||||||
|
"MatchingType": mtype,
|
||||||
|
"Certificate": cert,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_GroupsByEndpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := makeOpts(t, "example.com.", "", []map[string]any{
|
||||||
|
tlsaRR("_443._tcp.example.com.", 3, 1, 1, "AABB"),
|
||||||
|
tlsaRR("_443._tcp.example.com.", 3, 1, 1, "CCDD"),
|
||||||
|
tlsaRR("_25._tcp.mail.example.com.", 3, 1, 1, "EEFF"),
|
||||||
|
}, nil)
|
||||||
|
p := &daneProvider{}
|
||||||
|
out, err := p.Collect(context.Background(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
d := out.(*DANEData)
|
||||||
|
if len(d.Targets) != 2 {
|
||||||
|
t.Fatalf("targets=%d want 2", len(d.Targets))
|
||||||
|
}
|
||||||
|
// Sorted by base alphabetically: example.com < mail.example.com.
|
||||||
|
if d.Targets[0].Host != "example.com" || d.Targets[0].Port != 443 {
|
||||||
|
t.Errorf("sort[0]: %+v", d.Targets[0])
|
||||||
|
}
|
||||||
|
if d.Targets[1].Host != "mail.example.com" || d.Targets[1].Port != 25 {
|
||||||
|
t.Errorf("sort[1]: %+v", d.Targets[1])
|
||||||
|
}
|
||||||
|
// Two records on the 443 endpoint
|
||||||
|
if len(d.Targets[0].Records) != 2 {
|
||||||
|
t.Errorf("443 records=%d want 2", len(d.Targets[0].Records))
|
||||||
|
}
|
||||||
|
// Certificate hex was lowercased
|
||||||
|
if d.Targets[0].Records[0].Certificate != "aabb" {
|
||||||
|
t.Errorf("expected lowercased cert, got %q", d.Targets[0].Records[0].Certificate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_DefaultSTARTTLS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := makeOpts(t, "example.com", "", []map[string]any{
|
||||||
|
tlsaRR("_25._tcp.mail.example.com", 3, 1, 1, "00"),
|
||||||
|
tlsaRR("_443._tcp.example.com", 3, 1, 1, "00"),
|
||||||
|
tlsaRR("_587._tcp.mail.example.com", 3, 1, 1, "00"),
|
||||||
|
}, nil)
|
||||||
|
out, err := (&daneProvider{}).Collect(context.Background(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := out.(*DANEData)
|
||||||
|
got := map[uint16]string{}
|
||||||
|
for _, t := range d.Targets {
|
||||||
|
got[t.Port] = t.STARTTLS
|
||||||
|
}
|
||||||
|
if got[25] != "smtp" {
|
||||||
|
t.Errorf("port 25 starttls=%q want smtp", got[25])
|
||||||
|
}
|
||||||
|
if got[443] != "" {
|
||||||
|
t.Errorf("port 443 starttls=%q want empty (direct TLS)", got[443])
|
||||||
|
}
|
||||||
|
if got[587] != "submission" {
|
||||||
|
t.Errorf("port 587 starttls=%q want submission", got[587])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_STARTTLSOverride(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := makeOpts(t, "example.com", "", []map[string]any{
|
||||||
|
tlsaRR("_25._tcp.mail.example.com", 3, 1, 1, "00"),
|
||||||
|
}, map[string]string{"25/tcp": "lmtp"})
|
||||||
|
out, err := (&daneProvider{}).Collect(context.Background(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := out.(*DANEData)
|
||||||
|
if d.Targets[0].STARTTLS != "lmtp" {
|
||||||
|
t.Errorf("override: starttls=%q want lmtp", d.Targets[0].STARTTLS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_MalformedOwnerSurfaced(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := makeOpts(t, "example.com", "", []map[string]any{
|
||||||
|
tlsaRR("totally-invalid", 3, 1, 1, "00"),
|
||||||
|
tlsaRR("_99999._tcp.example.com", 3, 1, 1, "00"), // port > 65535
|
||||||
|
tlsaRR("_443._tcp.example.com", 3, 1, 1, "AA"),
|
||||||
|
}, nil)
|
||||||
|
out, err := (&daneProvider{}).Collect(context.Background(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := out.(*DANEData)
|
||||||
|
if len(d.Targets) != 1 {
|
||||||
|
t.Errorf("expected one well-formed target, got %d", len(d.Targets))
|
||||||
|
}
|
||||||
|
if len(d.Invalid) != 2 {
|
||||||
|
t.Errorf("expected 2 invalid entries, got %d (%+v)", len(d.Invalid), d.Invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_BaseRelativeToSubdomain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := makeOpts(t, "example.com", "mail", []map[string]any{
|
||||||
|
// Owner has no base, so the records live on the subdomain itself.
|
||||||
|
tlsaRR("_25._tcp", 3, 1, 1, "AA"),
|
||||||
|
}, nil)
|
||||||
|
out, err := (&daneProvider{}).Collect(context.Background(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := out.(*DANEData)
|
||||||
|
if len(d.Targets) != 1 {
|
||||||
|
t.Fatalf("targets=%d", len(d.Targets))
|
||||||
|
}
|
||||||
|
if d.Targets[0].Host != "mail.example.com" {
|
||||||
|
t.Errorf("host=%q want mail.example.com", d.Targets[0].Host)
|
||||||
|
}
|
||||||
|
if d.Targets[0].Owner != "_25._tcp.mail.example.com" {
|
||||||
|
t.Errorf("owner=%q", d.Targets[0].Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_WrongServiceType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc := map[string]any{
|
||||||
|
"_svctype": "svcs.NotTLSAs",
|
||||||
|
"Service": map[string]any{"tlsa": []any{}},
|
||||||
|
}
|
||||||
|
opts := sdk.CheckerOptions{OptionDomain: "example.com", OptionService: svc}
|
||||||
|
if _, err := (&daneProvider{}).Collect(context.Background(), opts); err == nil {
|
||||||
|
t.Error("expected error on wrong service type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_MissingService(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := sdk.CheckerOptions{OptionDomain: "example.com"}
|
||||||
|
if _, err := (&daneProvider{}).Collect(context.Background(), opts); err == nil {
|
||||||
|
t.Error("expected error on missing service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_DiscoverEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := makeOpts(t, "example.com", "", []map[string]any{
|
||||||
|
tlsaRR("_443._tcp.example.com", 3, 1, 1, "AA"),
|
||||||
|
tlsaRR("_25._tcp.mail.example.com", 3, 1, 1, "BB"),
|
||||||
|
}, nil)
|
||||||
|
p := &daneProvider{}
|
||||||
|
data, err := p.Collect(context.Background(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := p.DiscoverEntries(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Errorf("entries=%d want 2", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nil/wrong type returns nil, nil (defensive).
|
||||||
|
if got, err := p.DiscoverEntries(nil); err != nil || got != nil {
|
||||||
|
t.Errorf("nil: got=%v err=%v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := p.DiscoverEntries("not a *DANEData"); err != nil || got != nil {
|
||||||
|
t.Errorf("wrong type: got=%v err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollect_DeterministicOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts := makeOpts(t, "example.com", "", []map[string]any{
|
||||||
|
tlsaRR("_25._tcp.b.example.com", 3, 1, 1, "AA"),
|
||||||
|
tlsaRR("_25._tcp.a.example.com", 3, 1, 1, "BB"),
|
||||||
|
tlsaRR("_443._tcp.a.example.com", 3, 1, 1, "CC"),
|
||||||
|
}, nil)
|
||||||
|
var prev []byte
|
||||||
|
for i := range 3 {
|
||||||
|
out, err := (&daneProvider{}).Collect(context.Background(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Compare only Targets: CollectedAt is a wall-clock timestamp.
|
||||||
|
b, _ := json.Marshal(out.(*DANEData).Targets)
|
||||||
|
if i > 0 && string(b) != string(prev) {
|
||||||
|
t.Errorf("non-deterministic targets:\n%s\nvs\n%s", prev, b)
|
||||||
|
}
|
||||||
|
prev = b
|
||||||
|
}
|
||||||
|
}
|
||||||
68
checker/definition.go
Normal file
68
checker/definition.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version defaults to "built-in"; standalone and plugin builds override it
|
||||||
|
// via -ldflags "-X .../checker.Version=...".
|
||||||
|
var Version = "built-in"
|
||||||
|
|
||||||
|
// serviceType is the happyDomain service type this checker binds to.
|
||||||
|
const serviceType = "svcs.TLSAs"
|
||||||
|
|
||||||
|
// Definition satisfies sdk.CheckerDefinitionProvider.
|
||||||
|
func (p *daneProvider) Definition() *sdk.CheckerDefinition {
|
||||||
|
return &sdk.CheckerDefinition{
|
||||||
|
ID: "dane",
|
||||||
|
Name: "DANE / TLSA",
|
||||||
|
Version: Version,
|
||||||
|
Availability: sdk.CheckerAvailability{
|
||||||
|
ApplyToService: true,
|
||||||
|
LimitToServices: []string{serviceType},
|
||||||
|
},
|
||||||
|
ObservationKeys: []sdk.ObservationKey{ObservationKeyDANE},
|
||||||
|
HasHTMLReport: true,
|
||||||
|
Options: sdk.CheckerOptionsDocumentation{
|
||||||
|
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: OptionProbeTimeoutMs,
|
||||||
|
Type: "number",
|
||||||
|
Label: "Probe timeout (ms)",
|
||||||
|
Description: "Forwarded to checker-tls for each DANE endpoint.",
|
||||||
|
Default: float64(tls.DefaultProbeTimeoutMs),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: OptionDomain,
|
||||||
|
Type: "string",
|
||||||
|
Label: "Domain",
|
||||||
|
AutoFill: sdk.AutoFillDomainName,
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: OptionSubdomain,
|
||||||
|
Type: "string",
|
||||||
|
Label: "Subdomain",
|
||||||
|
AutoFill: sdk.AutoFillSubdomain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: OptionService,
|
||||||
|
Label: "TLSAs service",
|
||||||
|
AutoFill: sdk.AutoFillService,
|
||||||
|
Hide: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Rules: Rules(),
|
||||||
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
|
Min: 6 * time.Hour,
|
||||||
|
Max: 7 * 24 * time.Hour,
|
||||||
|
Default: 24 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
211
checker/interactive.go
Normal file
211
checker/interactive.go
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
//go:build standalone
|
||||||
|
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolverEnvVar names the environment variable that points at the
|
||||||
|
// DNSSEC-validating resolver this checker queries. The operator MUST point
|
||||||
|
// this at a trusted, validating resolver (typically 127.0.0.1:53 backed by
|
||||||
|
// Unbound, BIND, or Knot Resolver). DANE without DNSSEC validation is a
|
||||||
|
// downgrade primitive: an on-path attacker can forge TLSA responses. To
|
||||||
|
// fail loudly rather than silently insecure, lookupTLSA returns an error
|
||||||
|
// when no validating resolver is configured.
|
||||||
|
const resolverEnvVar = "DANE_CHECKER_RESOLVER"
|
||||||
|
|
||||||
|
// dnsClientTimeout bounds each TLSA exchange so a black-holing resolver
|
||||||
|
// cannot tie up server goroutines indefinitely on the public listener.
|
||||||
|
const dnsClientTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// tlsaLookup fetches TLSA records for owner via the system resolver and
|
||||||
|
// reports whether the resolver cryptographically validated the answer
|
||||||
|
// (AD bit set). It is a package variable so tests can swap it for a
|
||||||
|
// fixture. The context bounds the underlying DNS exchange so a slow or
|
||||||
|
// hung resolver cannot outlive the originating HTTP request on the
|
||||||
|
// public listener.
|
||||||
|
var tlsaLookup = lookupTLSA
|
||||||
|
|
||||||
|
// RenderForm lets a human run this checker standalone. The form only
|
||||||
|
// collects the endpoint coordinates; the expected TLSA records are read
|
||||||
|
// from DNS by ParseForm and the live certificate is fetched in-process by
|
||||||
|
// the SDK running checker-tls as a sibling (see RelatedProviders).
|
||||||
|
func (p *daneProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
|
return []sdk.CheckerOptionField{
|
||||||
|
{Id: OptionDomain, Type: "string", Label: "Domain", Placeholder: "example.com", Required: true},
|
||||||
|
{Id: "port", Type: "uint", Label: "Port", Default: float64(443), Required: true},
|
||||||
|
{Id: "proto", Type: "string", Label: "Protocol", Choices: []string{"tcp", "udp"}, Default: "tcp"},
|
||||||
|
{
|
||||||
|
Id: "starttls",
|
||||||
|
Type: "string",
|
||||||
|
Label: "STARTTLS override",
|
||||||
|
Description: "Leave empty to auto-derive from port (25→smtp, 587→submission, 143→imap, …).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: OptionProbeTimeoutMs,
|
||||||
|
Type: "uint",
|
||||||
|
Label: "Probe timeout (ms)",
|
||||||
|
Default: float64(tls.DefaultProbeTimeoutMs),
|
||||||
|
Description: "Forwarded to checker-tls for the live probe.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForm turns the submitted endpoint into the same CheckerOptions
|
||||||
|
// shape happyDomain would feed Collect. The TLSA RRset expected by
|
||||||
|
// Collect is resolved live from DNS at _<port>._<proto>.<domain>; if
|
||||||
|
// nothing is published there, no validation is possible and the form is
|
||||||
|
// re-rendered with the error.
|
||||||
|
func (p *daneProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||||
|
domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue(OptionDomain)), ".")
|
||||||
|
if domain == "" {
|
||||||
|
return nil, errors.New("domain is required")
|
||||||
|
}
|
||||||
|
portStr := strings.TrimSpace(r.FormValue("port"))
|
||||||
|
if portStr == "" {
|
||||||
|
return nil, errors.New("port is required")
|
||||||
|
}
|
||||||
|
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||||
|
if err != nil || port64 == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid port %q: must be 1-65535", portStr)
|
||||||
|
}
|
||||||
|
port := uint16(port64)
|
||||||
|
proto := strings.TrimSpace(r.FormValue("proto"))
|
||||||
|
if proto == "" {
|
||||||
|
proto = "tcp"
|
||||||
|
}
|
||||||
|
if proto != "tcp" && proto != "udp" {
|
||||||
|
return nil, fmt.Errorf("invalid protocol %q: must be tcp or udp", proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := tlsaOwnerName(port, proto, domain)
|
||||||
|
records, validated, err := tlsaLookup(r.Context(), owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("TLSA lookup for %s: %w", owner, err)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, fmt.Errorf("no TLSA records found at %s", owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsaEntries := make([]map[string]any, 0, len(records))
|
||||||
|
for _, t := range records {
|
||||||
|
tlsaEntries = append(tlsaEntries, map[string]any{
|
||||||
|
"Hdr": map[string]any{"Name": owner},
|
||||||
|
"Usage": t.Usage,
|
||||||
|
"Selector": t.Selector,
|
||||||
|
"MatchingType": t.MatchingType,
|
||||||
|
"Certificate": strings.ToLower(t.Certificate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]any{"tlsa": tlsaEntries})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal TLSAs service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := sdk.CheckerOptions{
|
||||||
|
OptionDomain: domain,
|
||||||
|
OptionService: serviceMessage{
|
||||||
|
Type: serviceType,
|
||||||
|
Domain: domain,
|
||||||
|
Service: body,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := strings.TrimSpace(r.FormValue("starttls")); s != "" {
|
||||||
|
opts[OptionSTARTTLS] = map[string]string{
|
||||||
|
starttlsKey(port, proto): s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
opts[OptionProbeTimeoutMs] = float64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts[OptionDNSSECValidated] = validated
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelatedProviders declares checker-tls as the sibling the SDK should run
|
||||||
|
// in-process during the interactive flow. The SDK harvests the discovery
|
||||||
|
// entries this checker publishes via DiscoverEntries and auto-fills
|
||||||
|
// checker-tls's OptionEndpoints (the option tagged
|
||||||
|
// sdk.AutoFillDiscoveryEntries in its definition), so the probe map the
|
||||||
|
// rule reads via GetRelated is populated with live data.
|
||||||
|
func (p *daneProvider) RelatedProviders() []sdk.ObservationProvider {
|
||||||
|
return []sdk.ObservationProvider{tls.Provider()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupTLSA queries the configured DNSSEC-validating resolver for TLSA
|
||||||
|
// records at owner. The second return reports whether the resolver
|
||||||
|
// cryptographically validated the response (AD bit set). Callers must
|
||||||
|
// treat unvalidated answers as untrusted: a DANE "match" against
|
||||||
|
// records that lack DNSSEC protection is meaningless because an on-path
|
||||||
|
// attacker could have injected them. The records are still returned so
|
||||||
|
// the absence of validation surfaces as a check rule failure rather
|
||||||
|
// than a hard error that aborts the whole evaluation.
|
||||||
|
func lookupTLSA(ctx context.Context, owner string) ([]*dns.TLSA, bool, error) {
|
||||||
|
resolver, err := interactiveResolver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion(dns.Fqdn(owner), dns.TypeTLSA)
|
||||||
|
msg.RecursionDesired = true
|
||||||
|
// AuthenticDataRequired = true asks the resolver to set AD on validated
|
||||||
|
// answers; SetEdns0 with do=true requests DNSSEC RRs.
|
||||||
|
msg.AuthenticatedData = true
|
||||||
|
msg.SetEdns0(4096, true)
|
||||||
|
|
||||||
|
c := &dns.Client{Timeout: dnsClientTimeout}
|
||||||
|
in, _, err := c.ExchangeContext(ctx, msg, resolver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError {
|
||||||
|
return nil, false, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
|
||||||
|
}
|
||||||
|
var out []*dns.TLSA
|
||||||
|
for _, rr := range in.Answer {
|
||||||
|
if t, ok := rr.(*dns.TLSA); ok {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, in.AuthenticatedData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interactiveResolver returns the address of the trusted, DNSSEC-validating
|
||||||
|
// resolver this checker should use. It refuses to silently fall back to a
|
||||||
|
// public plaintext resolver: that path is a downgrade vector and would make
|
||||||
|
// every "validation" trivially spoofable on a hostile network. The operator
|
||||||
|
// must opt in by setting DANE_CHECKER_RESOLVER (e.g. "127.0.0.1:53") or
|
||||||
|
// providing an /etc/resolv.conf entry that explicitly points at a local
|
||||||
|
// validating resolver.
|
||||||
|
func interactiveResolver() (string, error) {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(resolverEnvVar)); v != "" {
|
||||||
|
// Accept either "host" (port defaults to 53) or "host:port".
|
||||||
|
if _, _, err := net.SplitHostPort(v); err != nil {
|
||||||
|
v = net.JoinHostPort(v, "53")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||||
|
if err != nil || len(cfg.Servers) == 0 {
|
||||||
|
return "", fmt.Errorf("no DNSSEC-validating resolver configured: set %s to a trusted validator (e.g. 127.0.0.1:53)", resolverEnvVar)
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil
|
||||||
|
}
|
||||||
150
checker/interactive_test.go
Normal file
150
checker/interactive_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
//go:build standalone
|
||||||
|
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubTLSA returns a synthetic TLSA RR with the given fields, avoiding the
|
||||||
|
// textual-parse boilerplate of dns.NewRR.
|
||||||
|
func stubTLSA(owner string, usage, selector, matching uint8, cert string) *dns.TLSA {
|
||||||
|
return &dns.TLSA{
|
||||||
|
Hdr: dns.RR_Header{Name: dns.Fqdn(owner), Rrtype: dns.TypeTLSA, Class: dns.ClassINET, Ttl: 3600},
|
||||||
|
Usage: usage,
|
||||||
|
Selector: selector,
|
||||||
|
MatchingType: matching,
|
||||||
|
Certificate: cert,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withStubLookup(t *testing.T, records []*dns.TLSA, err error) {
|
||||||
|
t.Helper()
|
||||||
|
withStubLookupValidated(t, records, true, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withStubLookupValidated(t *testing.T, records []*dns.TLSA, validated bool, err error) {
|
||||||
|
t.Helper()
|
||||||
|
prev := tlsaLookup
|
||||||
|
tlsaLookup = func(_ context.Context, _ string) ([]*dns.TLSA, bool, error) {
|
||||||
|
return records, validated, err
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { tlsaLookup = prev })
|
||||||
|
}
|
||||||
|
|
||||||
|
func postForm(values url.Values) *http.Request {
|
||||||
|
req := httptest.NewRequest("POST", "/check", strings.NewReader(values.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.ParseForm()
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseForm_PopulatesServiceFromDNS(t *testing.T) {
|
||||||
|
withStubLookup(t, []*dns.TLSA{
|
||||||
|
stubTLSA("_443._tcp.example.com", 3, 1, 1, "DEADBEEF"),
|
||||||
|
stubTLSA("_443._tcp.example.com", 2, 0, 1, "cafebabe"),
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
p := &daneProvider{}
|
||||||
|
opts, err := p.ParseForm(postForm(url.Values{
|
||||||
|
"domain_name": {"example.com"},
|
||||||
|
"port": {"443"},
|
||||||
|
"proto": {"tcp"},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseForm: %v", err)
|
||||||
|
}
|
||||||
|
svc, ok := opts[OptionService].(serviceMessage)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("service option has wrong type: %#v", opts[OptionService])
|
||||||
|
}
|
||||||
|
if svc.Type != serviceType {
|
||||||
|
t.Errorf("service type = %q, want %q", svc.Type, serviceType)
|
||||||
|
}
|
||||||
|
if svc.Domain != "example.com" {
|
||||||
|
t.Errorf("service domain = %q, want example.com", svc.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
TLSA []struct {
|
||||||
|
Hdr struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
Usage uint8
|
||||||
|
Selector uint8
|
||||||
|
MatchingType uint8
|
||||||
|
Certificate string
|
||||||
|
} `json:"tlsa"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(svc.Service, &body); err != nil {
|
||||||
|
t.Fatalf("decode service body: %v", err)
|
||||||
|
}
|
||||||
|
if len(body.TLSA) != 2 {
|
||||||
|
t.Fatalf("got %d TLSA entries, want 2", len(body.TLSA))
|
||||||
|
}
|
||||||
|
if body.TLSA[0].Certificate != "deadbeef" {
|
||||||
|
t.Errorf("expected lowercased cert, got %q", body.TLSA[0].Certificate)
|
||||||
|
}
|
||||||
|
if body.TLSA[0].Hdr.Name != "_443._tcp.example.com" {
|
||||||
|
t.Errorf("owner = %q, want _443._tcp.example.com", body.TLSA[0].Hdr.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseForm_NoRecordsIsError(t *testing.T) {
|
||||||
|
withStubLookup(t, nil, nil)
|
||||||
|
|
||||||
|
p := &daneProvider{}
|
||||||
|
_, err := p.ParseForm(postForm(url.Values{
|
||||||
|
"domain_name": {"example.com"},
|
||||||
|
"port": {"443"},
|
||||||
|
"proto": {"tcp"},
|
||||||
|
}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no TLSA records found, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no TLSA records") {
|
||||||
|
t.Errorf("unexpected error %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseForm_StartTLSOverride(t *testing.T) {
|
||||||
|
withStubLookup(t, []*dns.TLSA{stubTLSA("_25._tcp.mail.example.com", 3, 1, 1, "aa")}, nil)
|
||||||
|
|
||||||
|
p := &daneProvider{}
|
||||||
|
opts, err := p.ParseForm(postForm(url.Values{
|
||||||
|
"domain_name": {"mail.example.com"},
|
||||||
|
"port": {"25"},
|
||||||
|
"proto": {"tcp"},
|
||||||
|
"starttls": {"smtp"},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseForm: %v", err)
|
||||||
|
}
|
||||||
|
override, ok := opts[OptionSTARTTLS].(map[string]string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("starttls option type = %T", opts[OptionSTARTTLS])
|
||||||
|
}
|
||||||
|
if override["25/tcp"] != "smtp" {
|
||||||
|
t.Errorf("override[25/tcp] = %q, want smtp", override["25/tcp"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseForm_InvalidPort(t *testing.T) {
|
||||||
|
p := &daneProvider{}
|
||||||
|
_, err := p.ParseForm(postForm(url.Values{
|
||||||
|
"domain_name": {"example.com"},
|
||||||
|
"port": {"0"},
|
||||||
|
"proto": {"tcp"},
|
||||||
|
}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for port 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
417
checker/match_test.go
Normal file
417
checker/match_test.go
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeCert builds a CertInfo whose hashes are precomputed from given
|
||||||
|
// pseudo-DER and pseudo-SPKI byte slices. Real DER is unnecessary: the
|
||||||
|
// matching logic only operates on bytes/hex.
|
||||||
|
func fakeCert(der, spki []byte) tls.CertInfo {
|
||||||
|
cs256 := sha256.Sum256(der)
|
||||||
|
cs512 := sha512.Sum512(der)
|
||||||
|
ss256 := sha256.Sum256(spki)
|
||||||
|
ss512 := sha512.Sum512(spki)
|
||||||
|
return tls.CertInfo{
|
||||||
|
DERBase64: base64.StdEncoding.EncodeToString(der),
|
||||||
|
SPKIDERBase64: base64.StdEncoding.EncodeToString(spki),
|
||||||
|
CertSHA256: hex.EncodeToString(cs256[:]),
|
||||||
|
CertSHA512: hex.EncodeToString(cs512[:]),
|
||||||
|
SPKISHA256: hex.EncodeToString(ss256[:]),
|
||||||
|
SPKISHA512: hex.EncodeToString(ss512[:]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSAOwnerRegex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
ok bool
|
||||||
|
port, proto, bs string
|
||||||
|
}{
|
||||||
|
{"_443._tcp.example.com", true, "443", "tcp", "example.com"},
|
||||||
|
{"_25._tcp.mail.example.com", true, "25", "tcp", "mail.example.com"},
|
||||||
|
{"_853._udp", true, "853", "udp", ""},
|
||||||
|
{"_443._sctp.example.com", false, "", "", ""},
|
||||||
|
{"443._tcp.example.com", false, "", "", ""},
|
||||||
|
{"_abc._tcp.example.com", false, "", "", ""},
|
||||||
|
{"_443.tcp.example.com", false, "", "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
m := tlsaOwner.FindStringSubmatch(tc.in)
|
||||||
|
if (m != nil) != tc.ok {
|
||||||
|
t.Errorf("%q: match=%v want=%v", tc.in, m != nil, tc.ok)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !tc.ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m[1] != tc.port || m[2] != tc.proto || m[3] != tc.bs {
|
||||||
|
t.Errorf("%q: got (%q,%q,%q) want (%q,%q,%q)", tc.in, m[1], m[2], m[3], tc.port, tc.proto, tc.bs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSAOwnerName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
port uint16
|
||||||
|
proto string
|
||||||
|
base string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{443, "tcp", "example.com", "_443._tcp.example.com"},
|
||||||
|
{25, "tcp", "mail.example.com", "_25._tcp.mail.example.com"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := tlsaOwnerName(tc.port, tc.proto, tc.base)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("tlsaOwnerName(%d,%q,%q)=%q want %q", tc.port, tc.proto, tc.base, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty base: trailing label is omitted so the result is still a
|
||||||
|
// syntactically valid relative name rather than "_443._tcp.".
|
||||||
|
if got := tlsaOwnerName(443, "tcp", ""); got != "_443._tcp" {
|
||||||
|
t.Errorf("empty base: got %q want %q", got, "_443._tcp")
|
||||||
|
}
|
||||||
|
if got := tlsaOwnerName(443, "tcp", "example.com."); got != "_443._tcp.example.com" {
|
||||||
|
t.Errorf("trailing dot stripped: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarttlsKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := starttlsKey(25, "tcp"); got != "25/tcp" {
|
||||||
|
t.Errorf("got %q want 25/tcp", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
base, sub, apex string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty base, no sub", "", "", "example.com", "example.com"},
|
||||||
|
{"empty base with sub", "", "mail", "example.com", "mail.example.com"},
|
||||||
|
{"absolute base equal apex", "example.com", "", "example.com", "example.com"},
|
||||||
|
{"absolute base ending in apex", "mail.example.com", "", "example.com", "mail.example.com"},
|
||||||
|
{"absolute base ending in apex with sub", "host.sub.example.com", "sub", "example.com", "host.sub.example.com"},
|
||||||
|
{"relative base with sub", "host", "sub", "example.com", "host.sub.example.com"},
|
||||||
|
{"relative base no sub", "host", "", "example.com", "host.example.com"},
|
||||||
|
{"trailing dot", "host.", "", "example.com", "host.example.com"},
|
||||||
|
{"empty everything", "", "", "", ""},
|
||||||
|
// Brittle short-apex case (the "com" apex). Pinned to current
|
||||||
|
// behaviour: HasSuffix(".com") makes "example.com" already
|
||||||
|
// fully-qualified, so it is returned unchanged.
|
||||||
|
{"short apex collision", "example.com", "", "com", "example.com"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := joinName(tc.base, tc.sub, tc.apex)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("got %q want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordCandidate_Selectors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
der := []byte("der-bytes")
|
||||||
|
spki := []byte("spki-bytes")
|
||||||
|
c := fakeCert(der, spki)
|
||||||
|
|
||||||
|
derHex := hex.EncodeToString(der)
|
||||||
|
spkiHex := hex.EncodeToString(spki)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rec TLSARecord
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"cert/full", TLSARecord{Selector: SelectorCert, MatchingType: MatchingFull}, derHex},
|
||||||
|
{"cert/sha256", TLSARecord{Selector: SelectorCert, MatchingType: MatchingSHA256}, c.CertSHA256},
|
||||||
|
{"cert/sha512", TLSARecord{Selector: SelectorCert, MatchingType: MatchingSHA512}, c.CertSHA512},
|
||||||
|
{"spki/full", TLSARecord{Selector: SelectorSPKI, MatchingType: MatchingFull}, spkiHex},
|
||||||
|
{"spki/sha256", TLSARecord{Selector: SelectorSPKI, MatchingType: MatchingSHA256}, c.SPKISHA256},
|
||||||
|
{"spki/sha512", TLSARecord{Selector: SelectorSPKI, MatchingType: MatchingSHA512}, c.SPKISHA512},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := recordCandidate(tc.rec, c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("got %q want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordCandidate_Errors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := fakeCert([]byte("d"), []byte("s"))
|
||||||
|
if _, err := recordCandidate(TLSARecord{Selector: 9, MatchingType: MatchingSHA256}, c); err == nil {
|
||||||
|
t.Error("expected error on unknown selector")
|
||||||
|
}
|
||||||
|
if _, err := recordCandidate(TLSARecord{Selector: SelectorCert, MatchingType: 9}, c); err == nil {
|
||||||
|
t.Error("expected error on unknown matching type for cert")
|
||||||
|
}
|
||||||
|
if _, err := recordCandidate(TLSARecord{Selector: SelectorSPKI, MatchingType: 9}, c); err == nil {
|
||||||
|
t.Error("expected error on unknown matching type for spki")
|
||||||
|
}
|
||||||
|
bad := tls.CertInfo{DERBase64: "!!!not-base64!!!"}
|
||||||
|
if _, err := recordCandidate(TLSARecord{Selector: SelectorCert, MatchingType: MatchingFull}, bad); err == nil {
|
||||||
|
t.Error("expected base64 decode error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeFullDER_SizeLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
huge := strings.Repeat("A", maxFullDERBytes+10) // base64; decoded is ~3/4 of len
|
||||||
|
if _, err := decodeFullDER(huge, "test"); err == nil {
|
||||||
|
t.Error("expected size-limit error")
|
||||||
|
}
|
||||||
|
small := base64.StdEncoding.EncodeToString([]byte("hello"))
|
||||||
|
got, err := decodeFullDER(small, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "hello" {
|
||||||
|
t.Errorf("got %q want hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRecord_LeafSelectsByUsage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
leaf := fakeCert([]byte("leaf-der"), []byte("leaf-spki"))
|
||||||
|
mid := fakeCert([]byte("mid-der"), []byte("mid-spki"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf, mid}}
|
||||||
|
|
||||||
|
// usage 3 (DANE-EE) matches leaf SHA-256 SPKI
|
||||||
|
rec := TLSARecord{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256}
|
||||||
|
if ok, why := matchRecord(rec, probe); !ok {
|
||||||
|
t.Errorf("DANE-EE leaf SPKI sha256: ok=false reason=%q", why)
|
||||||
|
}
|
||||||
|
// usage 3 with intermediate hash should NOT match (wrong slot)
|
||||||
|
rec.Certificate = mid.SPKISHA256
|
||||||
|
if ok, _ := matchRecord(rec, probe); ok {
|
||||||
|
t.Error("DANE-EE matching against intermediate SPKI should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage 2 (DANE-TA) matches intermediate
|
||||||
|
rec = TLSARecord{Usage: UsageDANETA, Selector: SelectorCert, MatchingType: MatchingSHA256, Certificate: mid.CertSHA256}
|
||||||
|
if ok, why := matchRecord(rec, probe); !ok {
|
||||||
|
t.Errorf("DANE-TA intermediate cert sha256: ok=false reason=%q", why)
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage 1 (PKIX-EE) matches leaf cert hash
|
||||||
|
rec = TLSARecord{Usage: UsagePKIXEE, Selector: SelectorCert, MatchingType: MatchingSHA256, Certificate: leaf.CertSHA256}
|
||||||
|
if ok, why := matchRecord(rec, probe); !ok {
|
||||||
|
t.Errorf("PKIX-EE leaf cert sha256: ok=false reason=%q", why)
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage 0 (PKIX-TA) matches intermediate
|
||||||
|
rec = TLSARecord{Usage: UsagePKIXTA, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: mid.SPKISHA256}
|
||||||
|
if ok, why := matchRecord(rec, probe); !ok {
|
||||||
|
t.Errorf("PKIX-TA intermediate spki sha256: ok=false reason=%q", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRecord_NoChain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if ok, why := matchRecord(TLSARecord{Usage: UsageDANEEE}, &tls.TLSProbe{}); ok || why == "" {
|
||||||
|
t.Errorf("empty chain: ok=%v reason=%q", ok, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRecord_TASelfSignedFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// When the chain has only a leaf, usage 0/2 falls back to matching the
|
||||||
|
// leaf as a degenerate TA so the user gets feedback.
|
||||||
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
||||||
|
rec := TLSARecord{Usage: UsageDANETA, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256}
|
||||||
|
if ok, why := matchRecord(rec, probe); !ok {
|
||||||
|
t.Errorf("self-signed TA fallback: ok=false reason=%q", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRecord_UnsupportedUsage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
||||||
|
if ok, why := matchRecord(TLSARecord{Usage: 9}, probe); ok || !strings.Contains(why, "unsupported") {
|
||||||
|
t.Errorf("usage 9: ok=%v reason=%q", ok, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRecord_FullDER(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
der := []byte("the-actual-cert-der")
|
||||||
|
leaf := fakeCert(der, []byte("ignored"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
||||||
|
rec := TLSARecord{
|
||||||
|
Usage: UsageDANEEE,
|
||||||
|
Selector: SelectorCert,
|
||||||
|
MatchingType: MatchingFull,
|
||||||
|
Certificate: hex.EncodeToString(der),
|
||||||
|
}
|
||||||
|
if ok, why := matchRecord(rec, probe); !ok {
|
||||||
|
t.Errorf("Full DER match failed: %q", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeMatches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
leaf := fakeCert([]byte("leaf"), []byte("ls"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
||||||
|
t1 := TargetResult{Records: []TLSARecord{
|
||||||
|
{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256}, // ok
|
||||||
|
{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: "deadbeef"}, // miss
|
||||||
|
{Usage: UsageDANEEE, Selector: SelectorCert, MatchingType: MatchingSHA256, Certificate: leaf.CertSHA256}, // ok
|
||||||
|
}}
|
||||||
|
s := summarizeMatches(t1, probe)
|
||||||
|
if s.matched != 2 || s.unmatched != 1 || s.firstUnmatchedIdx != 1 {
|
||||||
|
t.Errorf("got matched=%d unmatched=%d firstIdx=%d", s.matched, s.unmatched, s.firstUnmatchedIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := summarizeMatches(t1, nil); got.matched != 0 || got.firstUnmatchedIdx != -1 {
|
||||||
|
t.Errorf("nil probe: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeMatches_BadFirstSlotDoesNotAbort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// An undecodable Full record at slot 0 shouldn't prevent later valid
|
||||||
|
// records from matching: regression test for the per-slot continue.
|
||||||
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
||||||
|
bad := TargetResult{Records: []TLSARecord{
|
||||||
|
{Usage: UsageDANEEE, Selector: SelectorCert, MatchingType: MatchingFull, Certificate: "00"}, // hex won't match decoded DER
|
||||||
|
{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256},
|
||||||
|
}}
|
||||||
|
s := summarizeMatches(bad, probe)
|
||||||
|
if s.matched != 1 {
|
||||||
|
t.Errorf("expected 1 match (the second record), got %d (unmatched=%d)", s.matched, s.unmatched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPKIXUsage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if hasPKIXUsage(TargetResult{Records: []TLSARecord{{Usage: UsageDANEEE}}}) {
|
||||||
|
t.Error("DANE-EE only: expected false")
|
||||||
|
}
|
||||||
|
if !hasPKIXUsage(TargetResult{Records: []TLSARecord{{Usage: UsagePKIXEE}}}) {
|
||||||
|
t.Error("PKIX-EE: expected true")
|
||||||
|
}
|
||||||
|
if !hasPKIXUsage(TargetResult{Records: []TLSARecord{{Usage: UsageDANETA}, {Usage: UsagePKIXTA}}}) {
|
||||||
|
t.Error("contains PKIX-TA: expected true")
|
||||||
|
}
|
||||||
|
if hasPKIXUsage(TargetResult{}) {
|
||||||
|
t.Error("empty: expected false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuspiciousUsage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
leaf := fakeCert([]byte("leaf"), []byte("ls"))
|
||||||
|
mid := fakeCert([]byte("mid"), []byte("ms"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf, mid}}
|
||||||
|
|
||||||
|
// Record declared as EE but hash matches intermediate => suspicious.
|
||||||
|
tgt := TargetResult{Records: []TLSARecord{{
|
||||||
|
Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256,
|
||||||
|
Certificate: mid.SPKISHA256,
|
||||||
|
}}}
|
||||||
|
if got := suspiciousUsage(tgt, probe); got == "" {
|
||||||
|
t.Error("expected suspicious-usage warning")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record declared as EE matching the leaf is fine.
|
||||||
|
tgt.Records[0].Certificate = leaf.SPKISHA256
|
||||||
|
if got := suspiciousUsage(tgt, probe); got != "" {
|
||||||
|
t.Errorf("unexpected warning: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-cert chain: rule is silent.
|
||||||
|
if got := suspiciousUsage(tgt, &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}); got != "" {
|
||||||
|
t.Errorf("single-cert chain should be silent, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposedTLSA(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
leaf := fakeCert([]byte("leaf"), []byte("spki"))
|
||||||
|
probe := &tls.TLSProbe{Chain: []tls.CertInfo{leaf}}
|
||||||
|
|
||||||
|
// No record published: defaults to 3 1 1.
|
||||||
|
t1 := TargetResult{Owner: "_443._tcp.example.com", Records: nil}
|
||||||
|
got := proposedTLSA(t1, probe)
|
||||||
|
if !strings.Contains(got, "TLSA 3 1 1 ") || !strings.Contains(got, leaf.SPKISHA256) {
|
||||||
|
t.Errorf("default proposal: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing record uses Full → suggestion downgrades to SHA-256.
|
||||||
|
t2 := TargetResult{Owner: "_443._tcp.example.com", Records: []TLSARecord{{Usage: UsageDANEEE, Selector: SelectorCert, MatchingType: MatchingFull}}}
|
||||||
|
got = proposedTLSA(t2, probe)
|
||||||
|
if !strings.Contains(got, "TLSA 3 0 1 ") {
|
||||||
|
t.Errorf("Full→SHA256 collapse: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No probe: empty.
|
||||||
|
if got := proposedTLSA(t1, nil); got != "" {
|
||||||
|
t.Errorf("no probe: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandshakeFix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := handshakeFix(TargetResult{Host: "mail.example.com", Port: 25, STARTTLS: "smtp"})
|
||||||
|
if !strings.Contains(got, "-starttls smtp") || !strings.Contains(got, "-connect mail.example.com:25") {
|
||||||
|
t.Errorf("smtp fix: %q", got)
|
||||||
|
}
|
||||||
|
got = handshakeFix(TargetResult{Host: "example.com", Port: 443})
|
||||||
|
if strings.Contains(got, "-starttls") || !strings.Contains(got, "-connect example.com:443") {
|
||||||
|
t.Errorf("direct fix: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncHex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if truncHex("abc") != "abc" {
|
||||||
|
t.Error("short")
|
||||||
|
}
|
||||||
|
long := strings.Repeat("a", 20)
|
||||||
|
if got := truncHex(long); got != "aaaaaaaaaaaa…" {
|
||||||
|
t.Errorf("long: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeUsable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
leaf := fakeCert([]byte("l"), []byte("s"))
|
||||||
|
if probeUsable(nil) {
|
||||||
|
t.Error("nil")
|
||||||
|
}
|
||||||
|
if probeUsable(&tls.TLSProbe{}) {
|
||||||
|
t.Error("empty chain")
|
||||||
|
}
|
||||||
|
if probeUsable(&tls.TLSProbe{Chain: []tls.CertInfo{leaf}, Error: "boom"}) {
|
||||||
|
t.Error("error set")
|
||||||
|
}
|
||||||
|
if !probeUsable(&tls.TLSProbe{Chain: []tls.CertInfo{leaf}}) {
|
||||||
|
t.Error("good probe")
|
||||||
|
}
|
||||||
|
}
|
||||||
14
checker/provider.go
Normal file
14
checker/provider.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
|
||||||
|
// Provider returns a new DANE observation provider.
|
||||||
|
func Provider() sdk.ObservationProvider {
|
||||||
|
return &daneProvider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type daneProvider struct{}
|
||||||
|
|
||||||
|
func (p *daneProvider) Key() sdk.ObservationKey {
|
||||||
|
return ObservationKeyDANE
|
||||||
|
}
|
||||||
292
checker/report.go
Normal file
292
checker/report.go
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHTMLReport implements sdk.CheckerHTMLReporter. The report opens with a
|
||||||
|
// diagnosis-first section that lists the most common DANE failure modes
|
||||||
|
// actually detected on the user's targets, each with a one-shot remediation
|
||||||
|
// snippet; a per-target table follows for reference.
|
||||||
|
func (p *daneProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||||
|
var data DANEData
|
||||||
|
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
|
||||||
|
return "", fmt.Errorf("decode DANE data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
probes := indexProbes(ctx.Related(tls.ObservationKeyTLSProbes))
|
||||||
|
|
||||||
|
rows := make([]reportRow, 0, len(data.Targets))
|
||||||
|
for _, t := range data.Targets {
|
||||||
|
probe := probes[t.Ref]
|
||||||
|
status, cls := targetStatus(t, probe)
|
||||||
|
leaf := "—"
|
||||||
|
if probe != nil && len(probe.Chain) > 0 {
|
||||||
|
leaf = probe.Chain[0].Subject
|
||||||
|
} else if probe != nil && probe.Error != "" {
|
||||||
|
leaf = "handshake error"
|
||||||
|
}
|
||||||
|
rows = append(rows, reportRow{
|
||||||
|
Owner: t.Owner,
|
||||||
|
Host: t.Host,
|
||||||
|
Port: t.Port,
|
||||||
|
Proto: t.Proto,
|
||||||
|
STARTTLS: t.STARTTLS,
|
||||||
|
RecordCount: len(t.Records),
|
||||||
|
StatusLabel: status,
|
||||||
|
StatusClass: cls,
|
||||||
|
Leaf: leaf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
view := reportView{
|
||||||
|
CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"),
|
||||||
|
TargetCount: len(data.Targets),
|
||||||
|
Diagnoses: diagnose(data, probes),
|
||||||
|
Rows: rows,
|
||||||
|
CSS: template.CSS(reportCSS),
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := reportTemplate.Execute(&b, view); err != nil {
|
||||||
|
return "", fmt.Errorf("render DANE report: %w", err)
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportView is the rendering payload passed to reportTemplate. Pre-computing
|
||||||
|
// the per-row status label/class and leaf string keeps the template free of
|
||||||
|
// branching beyond simple range/if.
|
||||||
|
type reportView struct {
|
||||||
|
CollectedAt string
|
||||||
|
TargetCount int
|
||||||
|
Diagnoses []diagnosis
|
||||||
|
Rows []reportRow
|
||||||
|
CSS template.CSS
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportRow struct {
|
||||||
|
Owner string
|
||||||
|
Host string
|
||||||
|
Port uint16
|
||||||
|
Proto string
|
||||||
|
STARTTLS string
|
||||||
|
RecordCount int
|
||||||
|
StatusLabel string
|
||||||
|
StatusClass string
|
||||||
|
Leaf string
|
||||||
|
}
|
||||||
|
|
||||||
|
// diagnosis is a single actionable hint surfaced at the top of the report.
|
||||||
|
type diagnosis struct {
|
||||||
|
Severity string // crit | warn | info
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
Fix string // ready-to-apply snippet (shell or zone fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// diagnose scans every target and produces the minimum set of high-signal
|
||||||
|
// cards users need to act on. Priority ordering (most-common first):
|
||||||
|
//
|
||||||
|
// 1. no_match: TLSA records do not cover the live cert (post-rotation miss).
|
||||||
|
// 2. handshake_failed: endpoint unreachable or TLS broken, DANE can't be
|
||||||
|
// validated at all.
|
||||||
|
// 3. pkix_chain_invalid: usage 0/1 published but public chain is broken.
|
||||||
|
// 4. usage_3_matches_issuer: DANE-EE selector matches an intermediate
|
||||||
|
// the record is probably miscategorized (usage 2 was intended).
|
||||||
|
// 5. no_probe_yet: quiet informational to avoid false alarms on first run.
|
||||||
|
func diagnose(data DANEData, probes map[string]*tls.TLSProbe) []diagnosis {
|
||||||
|
var out []diagnosis
|
||||||
|
|
||||||
|
for _, t := range data.Targets {
|
||||||
|
probe := probes[t.Ref]
|
||||||
|
switch {
|
||||||
|
case probe == nil:
|
||||||
|
out = append(out, diagnosis{
|
||||||
|
Severity: SeverityInfo,
|
||||||
|
Title: fmt.Sprintf("Waiting for first TLS probe on %s:%d", t.Host, t.Port),
|
||||||
|
Detail: "checker-tls has not yet probed this endpoint. This is normal immediately after publishing a new TLSA record; status will clear on the next cycle.",
|
||||||
|
})
|
||||||
|
case !probeUsable(probe):
|
||||||
|
out = append(out, diagnosis{
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Title: fmt.Sprintf("Cannot reach %s:%d to validate DANE", t.Host, t.Port),
|
||||||
|
Detail: "TLS handshake failed, DANE publishes hashes for a certificate nobody can see. Either the service is down, the port is blocked, or STARTTLS negotiation is broken.",
|
||||||
|
Fix: handshakeFix(t),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
if summarizeMatches(t, probe).matched == 0 && len(t.Records) > 0 {
|
||||||
|
out = append(out, diagnosis{
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Title: fmt.Sprintf("No TLSA record matches the live certificate on %s:%d", t.Host, t.Port),
|
||||||
|
Detail: "This is the most common DANE outage cause: the certificate was rotated without rolling over the TLSA RRset, and validating resolvers are now rejecting the connection. Publish a TLSA record for the new certificate before removing the old one.",
|
||||||
|
Fix: proposedTLSA(t, probe),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if hasPKIXUsage(t) && (probe.ChainValid == nil || !*probe.ChainValid) {
|
||||||
|
out = append(out, diagnosis{
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Title: fmt.Sprintf("Usage 0/1 needs a publicly-trusted chain on %s:%d", t.Host, t.Port),
|
||||||
|
Detail: "TLSA usages 0 (PKIX-TA) and 1 (PKIX-EE) require the certificate chain to validate against system roots. Either re-issue through a publicly-trusted CA or switch to usage 2 / 3, which skip PKIX.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if warn := suspiciousUsage(t, probe); warn != "" {
|
||||||
|
out = append(out, diagnosis{
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Title: fmt.Sprintf("Suspicious TLSA usage on %s:%d", t.Host, t.Port),
|
||||||
|
Detail: warn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable: crit first, then warn, then info; preserving encounter order
|
||||||
|
// within each group keeps the table and the cards aligned.
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
return sevRank(out[i].Severity) < sevRank(out[j].Severity)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sevRank(s string) int {
|
||||||
|
switch s {
|
||||||
|
case SeverityCrit:
|
||||||
|
return 0
|
||||||
|
case SeverityWarn:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPKIXUsage reports whether any TLSA record at this target demands PKIX
|
||||||
|
// validation (usage 0 or 1).
|
||||||
|
func hasPKIXUsage(t TargetResult) bool {
|
||||||
|
for _, r := range t.Records {
|
||||||
|
if r.Usage == UsagePKIXTA || r.Usage == UsagePKIXEE {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// proposedTLSA renders a ready-to-paste replacement RR computed from the
|
||||||
|
// live chain. The (usage, selector, matching) triplet is taken from the
|
||||||
|
// user's first existing record so the suggestion stays consistent with
|
||||||
|
// their published profile (e.g. a deployment standardised on usage 2 keeps
|
||||||
|
// usage 2). When no record is published yet, fall back to the DANE-EE +
|
||||||
|
// SPKI + SHA-256 triplet most Let's Encrypt deployers settle on.
|
||||||
|
func proposedTLSA(t TargetResult, p *tls.TLSProbe) string {
|
||||||
|
if p == nil || len(p.Chain) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tmpl := TLSARecord{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256}
|
||||||
|
if len(t.Records) > 0 {
|
||||||
|
r := t.Records[0]
|
||||||
|
tmpl.Usage = r.Usage
|
||||||
|
tmpl.Selector = r.Selector
|
||||||
|
tmpl.MatchingType = r.MatchingType
|
||||||
|
// Suggesting Full (matching type 0) inline as a zone fragment is
|
||||||
|
// not useful: collapse to SHA-256 of the same selector, which is
|
||||||
|
// what operators publish in practice.
|
||||||
|
if tmpl.MatchingType == MatchingFull {
|
||||||
|
tmpl.MatchingType = MatchingSHA256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slot := p.Chain[0]
|
||||||
|
if (tmpl.Usage == UsagePKIXTA || tmpl.Usage == UsageDANETA) && len(p.Chain) > 1 {
|
||||||
|
slot = p.Chain[1]
|
||||||
|
}
|
||||||
|
hex, err := recordCandidate(tmpl, slot)
|
||||||
|
if err != nil || hex == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s IN TLSA %d %d %d %s", t.Owner, tmpl.Usage, tmpl.Selector, tmpl.MatchingType, hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handshakeFix proposes a STARTTLS-aware first step when the probe failed.
|
||||||
|
func handshakeFix(t TargetResult) string {
|
||||||
|
if t.STARTTLS != "" {
|
||||||
|
return fmt.Sprintf("openssl s_client -connect %s:%d -starttls %s -servername %s", t.Host, t.Port, t.STARTTLS, t.Host)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("openssl s_client -connect %s:%d -servername %s", t.Host, t.Port, t.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetStatus(t TargetResult, p *tls.TLSProbe) (label, class string) {
|
||||||
|
if p == nil {
|
||||||
|
return "Waiting for probe", "unknown"
|
||||||
|
}
|
||||||
|
if !probeUsable(p) {
|
||||||
|
return "Handshake failed", "crit"
|
||||||
|
}
|
||||||
|
if len(t.Records) == 0 {
|
||||||
|
return "No records", "info"
|
||||||
|
}
|
||||||
|
matched := summarizeMatches(t, p).matched
|
||||||
|
if matched == 0 {
|
||||||
|
return "No match", "crit"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d/%d match", matched, len(t.Records)), "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
var reportTemplate = template.Must(template.New("dane").Parse(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>DANE report</title>
|
||||||
|
<style>{{.CSS}}</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<h1>DANE / TLSA</h1>
|
||||||
|
<p class="meta">Collected {{.CollectedAt}} · {{.TargetCount}} endpoint(s).</p>
|
||||||
|
{{with .Diagnoses}}<section class="diagnosis">
|
||||||
|
<h2>Action required</h2>
|
||||||
|
{{range .}}<article class="finding sev-{{.Severity}}">
|
||||||
|
<h3>{{.Title}}</h3>
|
||||||
|
<p>{{.Detail}}</p>
|
||||||
|
{{with .Fix}}<pre class="fix">{{.}}</pre>{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}</section>
|
||||||
|
{{end}}<section class="targets">
|
||||||
|
<h2>Endpoints</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Endpoint</th><th>Status</th><th>Records</th><th>Observed leaf</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Rows}}<tr class="status-{{.StatusClass}}">
|
||||||
|
<td><code>{{.Owner}}</code><br><small>{{.Proto}} → {{.Host}}:{{.Port}}{{with .STARTTLS}} · STARTTLS {{.}}{{end}}</small></td>
|
||||||
|
<td>{{.StatusLabel}}</td>
|
||||||
|
<td>{{.RecordCount}}</td>
|
||||||
|
<td>{{.Leaf}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main></body></html>`))
|
||||||
|
|
||||||
|
const reportCSS = `body{font-family:system-ui,sans-serif;margin:0;background:#fafbfc;color:#1b1f23;}
|
||||||
|
main{max-width:980px;margin:0 auto;padding:1.5rem;}
|
||||||
|
h1{margin:0 0 .25rem 0;}
|
||||||
|
.meta{color:#586069;margin:0 0 1.5rem 0;}
|
||||||
|
section{margin-bottom:2rem;}
|
||||||
|
h2{border-bottom:1px solid #e1e4e8;padding-bottom:.25rem;}
|
||||||
|
.finding{border-left:4px solid;padding:.75rem 1rem;margin:.75rem 0;background:#fff;border-radius:4px;}
|
||||||
|
.finding h3{margin:0 0 .25rem 0;font-size:1rem;}
|
||||||
|
.finding.sev-crit{border-color:#d73a49;}
|
||||||
|
.finding.sev-warn{border-color:#dbab09;}
|
||||||
|
.finding.sev-info{border-color:#0366d6;}
|
||||||
|
.fix{background:#1b1f23;color:#fafbfc;padding:.5rem .75rem;border-radius:4px;overflow-x:auto;font-size:.85rem;}
|
||||||
|
table{width:100%;border-collapse:collapse;background:#fff;}
|
||||||
|
th,td{padding:.5rem .75rem;border-bottom:1px solid #e1e4e8;text-align:left;vertical-align:top;}
|
||||||
|
tr.status-crit td:nth-child(2){color:#d73a49;font-weight:600;}
|
||||||
|
tr.status-ok td:nth-child(2){color:#22863a;font-weight:600;}
|
||||||
|
tr.status-unknown td:nth-child(2){color:#586069;}
|
||||||
|
code{font-size:.85rem;}
|
||||||
|
small{color:#586069;}`
|
||||||
277
checker/rule.go
Normal file
277
checker/rule.go
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rules returns the full list of CheckRules exposed by the DANE checker.
|
||||||
|
// Each rule covers exactly one concern so the UI can show per-concern
|
||||||
|
// status rather than a single monolithic rule that multiplexes many codes.
|
||||||
|
func Rules() []sdk.CheckRule {
|
||||||
|
return []sdk.CheckRule{
|
||||||
|
&hasRecordsRule{},
|
||||||
|
&dnssecValidatedRule{},
|
||||||
|
&probeAvailableRule{},
|
||||||
|
&handshakeOKRule{},
|
||||||
|
&recordsMatchChainRule{},
|
||||||
|
&pkixChainValidRule{},
|
||||||
|
&usageCoherentRule{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleContext bundles the data rules typically need: the checker's own
|
||||||
|
// observation plus the map of related TLS probes keyed by endpoint Ref.
|
||||||
|
type ruleContext struct {
|
||||||
|
data DANEData
|
||||||
|
probes map[string]*tls.TLSProbe
|
||||||
|
// relatedErr is a non-fatal error encountered while loading related
|
||||||
|
// probes (e.g. the cross-checker lineage was unreachable). Rules
|
||||||
|
// surface it as an error state so operators can spot misconfiguration.
|
||||||
|
relatedErr error
|
||||||
|
// err is a fatal error loading the checker's own observation.
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRuleContext fetches the DANE observation and the related TLS probes.
|
||||||
|
// Rules call this once and then filter on the fields they care about.
|
||||||
|
func loadRuleContext(ctx context.Context, obs sdk.ObservationGetter) *ruleContext {
|
||||||
|
rc := &ruleContext{}
|
||||||
|
if err := obs.Get(ctx, ObservationKeyDANE, &rc.data); err != nil {
|
||||||
|
rc.err = err
|
||||||
|
return rc
|
||||||
|
}
|
||||||
|
rc.probes, rc.relatedErr = relatedTLSProbes(ctx, obs)
|
||||||
|
return rc
|
||||||
|
}
|
||||||
|
|
||||||
|
// observationErrorState is the canonical short-circuit state emitted when a
|
||||||
|
// rule cannot load the DANE observation at all.
|
||||||
|
func observationErrorState(err error) sdk.CheckState {
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Message: fmt.Sprintf("Failed to read %s: %v", ObservationKeyDANE, err),
|
||||||
|
Code: "dane_observation_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetMeta builds the common Meta map for per-endpoint states.
|
||||||
|
func targetMeta(t TargetResult) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"host": t.Host,
|
||||||
|
"port": t.Port,
|
||||||
|
"proto": t.Proto,
|
||||||
|
"owner": t.Owner,
|
||||||
|
"starttls": t.STARTTLS,
|
||||||
|
"records": len(t.Records),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetSubject is the human-readable subject tag used on per-endpoint states.
|
||||||
|
func targetSubject(t TargetResult) string {
|
||||||
|
return fmt.Sprintf("%s:%d (%s)", t.Host, t.Port, t.Proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeUsable reports whether p carries a successfully-observed certificate
|
||||||
|
// chain. Rules that need to compare against the chain skip endpoints where
|
||||||
|
// this is false; the missing/failed cases are surfaced by probeAvailableRule
|
||||||
|
// and handshakeOKRule respectively, so other rules stay focused.
|
||||||
|
func probeUsable(p *tls.TLSProbe) bool {
|
||||||
|
return p != nil && p.Error == "" && len(p.Chain) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchSummary aggregates per-target match outcomes so callers don't redo the
|
||||||
|
// per-record loop. firstUnmatchedIdx is -1 when every record matched.
|
||||||
|
type matchSummary struct {
|
||||||
|
matched, unmatched int
|
||||||
|
firstUnmatchedIdx int
|
||||||
|
firstUnmatchedReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeMatches walks t.Records once and reports how many matched p's
|
||||||
|
// chain, plus the first unmatched index and reason for messaging.
|
||||||
|
func summarizeMatches(t TargetResult, p *tls.TLSProbe) matchSummary {
|
||||||
|
s := matchSummary{firstUnmatchedIdx: -1}
|
||||||
|
if p == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
for i, rec := range t.Records {
|
||||||
|
ok, reason := matchRecord(rec, p)
|
||||||
|
if ok {
|
||||||
|
s.matched++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.unmatched++
|
||||||
|
if s.firstUnmatchedIdx < 0 {
|
||||||
|
s.firstUnmatchedIdx = i
|
||||||
|
s.firstUnmatchedReason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchRecord returns true when rec matches some certificate at the chain
|
||||||
|
// slot implied by rec.Usage. reason explains the miss on a false return.
|
||||||
|
//
|
||||||
|
// Slot selection:
|
||||||
|
//
|
||||||
|
// - Usage 1 (PKIX-EE) and 3 (DANE-EE): leaf only.
|
||||||
|
// - Usage 0 (PKIX-TA) and 2 (DANE-TA): intermediates + the root the
|
||||||
|
// server presented (if any). We match against every non-leaf cert the
|
||||||
|
// server sent, because some deployments publish the root and some the
|
||||||
|
// intermediate; either is a valid TA reference for the connection's
|
||||||
|
// path.
|
||||||
|
func matchRecord(rec TLSARecord, p *tls.TLSProbe) (bool, string) {
|
||||||
|
if len(p.Chain) == 0 {
|
||||||
|
return false, "no certificates observed on the endpoint"
|
||||||
|
}
|
||||||
|
var slots []tls.CertInfo
|
||||||
|
switch rec.Usage {
|
||||||
|
case UsagePKIXEE, UsageDANEEE:
|
||||||
|
slots = p.Chain[:1]
|
||||||
|
case UsagePKIXTA, UsageDANETA:
|
||||||
|
if len(p.Chain) > 1 {
|
||||||
|
slots = p.Chain[1:]
|
||||||
|
} else {
|
||||||
|
// Self-signed / bundle with only a leaf: allow matching against
|
||||||
|
// the leaf as a degenerate TA so the user gets a hash comparison
|
||||||
|
// rather than a silent "no slot".
|
||||||
|
slots = p.Chain[:1]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false, fmt.Sprintf("unsupported TLSA usage %d", rec.Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr string
|
||||||
|
for _, c := range slots {
|
||||||
|
got, err := recordCandidate(rec, c)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err.Error()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(got, rec.Certificate) {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != "" {
|
||||||
|
return false, lastErr
|
||||||
|
}
|
||||||
|
return false, fmt.Sprintf("expected %s, got none matching in chain", truncHex(rec.Certificate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxFullDERBytes caps the size of a "Full" (MatchingType 0) DER payload
|
||||||
|
// that this checker is willing to base64-decode and hex-encode. Real X.509
|
||||||
|
// certificates rarely exceed 8 KiB; 64 KiB leaves comfortable headroom for
|
||||||
|
// pathological-but-legitimate chains while preventing a hostile probe
|
||||||
|
// payload from forcing arbitrary heap allocations during evaluation.
|
||||||
|
const maxFullDERBytes = 64 * 1024
|
||||||
|
|
||||||
|
// decodeFullDER base64-decodes b after rejecting payloads whose decoded size
|
||||||
|
// would exceed maxFullDERBytes, so an attacker-controlled probe cannot make
|
||||||
|
// the rule allocate unbounded memory before the hex comparison.
|
||||||
|
func decodeFullDER(b string, what string) ([]byte, error) {
|
||||||
|
// base64 decoded length is at most ceil(len(b)/4)*3; bail out cheaply
|
||||||
|
// before allocating the destination buffer.
|
||||||
|
if len(b)/4*3 > maxFullDERBytes {
|
||||||
|
return nil, fmt.Errorf("%s exceeds %d bytes", what, maxFullDERBytes)
|
||||||
|
}
|
||||||
|
der, err := base64.StdEncoding.DecodeString(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s: %w", what, err)
|
||||||
|
}
|
||||||
|
if len(der) > maxFullDERBytes {
|
||||||
|
return nil, fmt.Errorf("%s exceeds %d bytes", what, maxFullDERBytes)
|
||||||
|
}
|
||||||
|
return der, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordCandidate returns the hex value the TLSA record should match for
|
||||||
|
// the (selector, matching_type) pair against this certificate slot. For
|
||||||
|
// matching_type 0 (Full), both sides are compared as hex-encoded DER.
|
||||||
|
func recordCandidate(rec TLSARecord, c tls.CertInfo) (string, error) {
|
||||||
|
var source string
|
||||||
|
switch rec.Selector {
|
||||||
|
case SelectorCert:
|
||||||
|
switch rec.MatchingType {
|
||||||
|
case MatchingFull:
|
||||||
|
der, err := decodeFullDER(c.DERBase64, "cert DER")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
source = hex.EncodeToString(der)
|
||||||
|
case MatchingSHA256:
|
||||||
|
source = c.CertSHA256
|
||||||
|
case MatchingSHA512:
|
||||||
|
source = c.CertSHA512
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported matching type %d", rec.MatchingType)
|
||||||
|
}
|
||||||
|
case SelectorSPKI:
|
||||||
|
switch rec.MatchingType {
|
||||||
|
case MatchingFull:
|
||||||
|
spki, err := decodeFullDER(c.SPKIDERBase64, "SPKI DER")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
source = hex.EncodeToString(spki)
|
||||||
|
case MatchingSHA256:
|
||||||
|
source = c.SPKISHA256
|
||||||
|
case MatchingSHA512:
|
||||||
|
source = c.SPKISHA512
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported matching type %d", rec.MatchingType)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported selector %d", rec.Selector)
|
||||||
|
}
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTLSProbeMap decodes one related-observation payload into its constituent
|
||||||
|
// probes, keyed by endpoint Ref. Returns nil on decode error (caller skips).
|
||||||
|
func parseTLSProbeMap(data []byte) map[string]tls.TLSProbe {
|
||||||
|
var payload struct {
|
||||||
|
Probes map[string]tls.TLSProbe `json:"probes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return payload.Probes
|
||||||
|
}
|
||||||
|
|
||||||
|
// relatedTLSProbes indexes TLS probes fetched via GetRelated by endpoint Ref.
|
||||||
|
func relatedTLSProbes(ctx context.Context, obs sdk.ObservationGetter) (map[string]*tls.TLSProbe, error) {
|
||||||
|
related, err := obs.GetRelated(ctx, tls.ObservationKeyTLSProbes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("related TLS probes unavailable: %w", err)
|
||||||
|
}
|
||||||
|
return indexProbes(related), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexProbes flattens a slice of related TLS-probe observations into a probe
|
||||||
|
// map keyed by endpoint Ref. Shared by the rule path (relatedTLSProbes) and
|
||||||
|
// the report path (GetHTMLReport), which receive the same RelatedObservation
|
||||||
|
// type from different SDK entry points.
|
||||||
|
func indexProbes(related []sdk.RelatedObservation) map[string]*tls.TLSProbe {
|
||||||
|
out := map[string]*tls.TLSProbe{}
|
||||||
|
for _, ro := range related {
|
||||||
|
for k, v := range parseTLSProbeMap(ro.Data) {
|
||||||
|
out[k] = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncHex(s string) string {
|
||||||
|
if len(s) > 12 {
|
||||||
|
return s[:12] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
57
checker/rules_handshake.go
Normal file
57
checker/rules_handshake.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handshakeOKRule reports whether the TLS handshake succeeded on every
|
||||||
|
// endpoint that has been probed. A failing handshake means DANE cannot be
|
||||||
|
// validated regardless of what TLSA records are published.
|
||||||
|
type handshakeOKRule struct{}
|
||||||
|
|
||||||
|
func (r *handshakeOKRule) Name() string { return "dane.handshake_ok" }
|
||||||
|
func (r *handshakeOKRule) Description() string {
|
||||||
|
return "Verifies the TLS handshake succeeds on every DANE endpoint so the presented chain can be compared to TLSA records."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *handshakeOKRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
rc := loadRuleContext(ctx, obs)
|
||||||
|
if rc.err != nil {
|
||||||
|
return []sdk.CheckState{observationErrorState(rc.err)}
|
||||||
|
}
|
||||||
|
var out []sdk.CheckState
|
||||||
|
tested := 0
|
||||||
|
for _, t := range rc.data.Targets {
|
||||||
|
probe := rc.probes[t.Ref]
|
||||||
|
if probe == nil {
|
||||||
|
continue // covered by probeAvailableRule
|
||||||
|
}
|
||||||
|
tested++
|
||||||
|
if !probeUsable(probe) {
|
||||||
|
out = append(out, sdk.CheckState{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Code: "dane_handshake_failed",
|
||||||
|
Subject: targetSubject(t),
|
||||||
|
Message: "TLS handshake failed, cannot validate DANE: " + probe.Error,
|
||||||
|
Meta: targetMeta(t),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
if tested == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Code: "dane_handshake_ok_skipped",
|
||||||
|
Message: "No probed endpoint to evaluate (waiting for checker-tls).",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Code: "dane_handshake_ok",
|
||||||
|
Message: "TLS handshake succeeds on every probed endpoint.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
80
checker/rules_match.go
Normal file
80
checker/rules_match.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordsMatchChainRule is the core DANE check: for every endpoint whose
|
||||||
|
// handshake succeeded, at least one declared TLSA record must match the
|
||||||
|
// certificate chain presented by the server (RFC 6698 §2.1 OR semantics).
|
||||||
|
//
|
||||||
|
// This is the most common DANE outage vector, a certificate rotation
|
||||||
|
// without a matching TLSA rollover, so it deserves its own rule and its
|
||||||
|
// own per-endpoint states.
|
||||||
|
type recordsMatchChainRule struct{}
|
||||||
|
|
||||||
|
func (r *recordsMatchChainRule) Name() string { return "dane.records_match_chain" }
|
||||||
|
func (r *recordsMatchChainRule) Description() string {
|
||||||
|
return "Verifies that at least one TLSA record matches the certificate chain presented by each endpoint."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordsMatchChainRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
rc := loadRuleContext(ctx, obs)
|
||||||
|
if rc.err != nil {
|
||||||
|
return []sdk.CheckState{observationErrorState(rc.err)}
|
||||||
|
}
|
||||||
|
var out []sdk.CheckState
|
||||||
|
tested := 0
|
||||||
|
for _, t := range rc.data.Targets {
|
||||||
|
probe := rc.probes[t.Ref]
|
||||||
|
if !probeUsable(probe) {
|
||||||
|
continue // covered by probeAvailableRule / handshakeOKRule
|
||||||
|
}
|
||||||
|
if len(t.Records) == 0 {
|
||||||
|
continue // covered by hasRecordsRule
|
||||||
|
}
|
||||||
|
tested++
|
||||||
|
subj := targetSubject(t)
|
||||||
|
meta := targetMeta(t)
|
||||||
|
|
||||||
|
s := summarizeMatches(t, probe)
|
||||||
|
meta["matched"] = s.matched
|
||||||
|
meta["unmatched"] = s.unmatched
|
||||||
|
|
||||||
|
if s.matched > 0 {
|
||||||
|
out = append(out, sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Code: "dane_match_ok",
|
||||||
|
Subject: subj,
|
||||||
|
Message: fmt.Sprintf("%d/%d TLSA record(s) match the presented certificate chain.", s.matched, s.matched+s.unmatched),
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := "No TLSA record matches the presented certificate chain."
|
||||||
|
if s.firstUnmatchedReason != "" {
|
||||||
|
msg += " " + s.firstUnmatchedReason
|
||||||
|
}
|
||||||
|
meta["first_unmatched_index"] = s.firstUnmatchedIdx
|
||||||
|
out = append(out, sdk.CheckState{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Code: "dane_no_match",
|
||||||
|
Subject: subj,
|
||||||
|
Message: msg,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
if tested == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Code: "dane_records_match_chain_skipped",
|
||||||
|
Message: "No usable probe/records pair to evaluate.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
61
checker/rules_pkix.go
Normal file
61
checker/rules_pkix.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pkixChainValidRule reports whether endpoints that publish PKIX-dependent
|
||||||
|
// TLSA usages (0 or 1) also present a certificate chain that validates
|
||||||
|
// against the system trust store. DANE usages 2/3 are unaffected and
|
||||||
|
// skipped entirely by this rule.
|
||||||
|
type pkixChainValidRule struct{}
|
||||||
|
|
||||||
|
func (r *pkixChainValidRule) Name() string { return "dane.pkix_chain_valid" }
|
||||||
|
func (r *pkixChainValidRule) Description() string {
|
||||||
|
return "When TLSA usages 0 or 1 are published, verifies the certificate chain also validates against system trust roots."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pkixChainValidRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
rc := loadRuleContext(ctx, obs)
|
||||||
|
if rc.err != nil {
|
||||||
|
return []sdk.CheckState{observationErrorState(rc.err)}
|
||||||
|
}
|
||||||
|
var out []sdk.CheckState
|
||||||
|
tested := 0
|
||||||
|
for _, t := range rc.data.Targets {
|
||||||
|
probe := rc.probes[t.Ref]
|
||||||
|
if !probeUsable(probe) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hasPKIXUsage(t) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tested++
|
||||||
|
if probe.ChainValid == nil || !*probe.ChainValid {
|
||||||
|
out = append(out, sdk.CheckState{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Code: "dane_pkix_chain_invalid",
|
||||||
|
Subject: targetSubject(t),
|
||||||
|
Message: "Usage 0/1 requires a publicly-trusted chain, but the certificate chain did not validate against system roots.",
|
||||||
|
Meta: targetMeta(t),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
if tested == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Code: "dane_pkix_chain_valid_skipped",
|
||||||
|
Message: "No endpoint publishes PKIX-dependent TLSA usages (0/1).",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Code: "dane_pkix_chain_valid_ok",
|
||||||
|
Message: "Every endpoint with PKIX-dependent usages presents a publicly-trusted chain.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
61
checker/rules_probe.go
Normal file
61
checker/rules_probe.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeAvailableRule reports whether the downstream TLS checker has probed
|
||||||
|
// every endpoint we published. Absent probes are common immediately after a
|
||||||
|
// new TLSA record is published and should not flap the service red.
|
||||||
|
type probeAvailableRule struct{}
|
||||||
|
|
||||||
|
func (r *probeAvailableRule) Name() string { return "dane.probe_available" }
|
||||||
|
func (r *probeAvailableRule) Description() string {
|
||||||
|
return "Verifies a TLS probe is available for every DANE endpoint so the chain can be compared to TLSA records."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *probeAvailableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
rc := loadRuleContext(ctx, obs)
|
||||||
|
if rc.err != nil {
|
||||||
|
return []sdk.CheckState{observationErrorState(rc.err)}
|
||||||
|
}
|
||||||
|
if rc.relatedErr != nil {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Code: "dane_observation_warning",
|
||||||
|
Message: rc.relatedErr.Error(),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if len(rc.data.Targets) == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Code: "dane_probe_available_skipped",
|
||||||
|
Message: "No DANE endpoints to probe.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
out := make([]sdk.CheckState, 0, len(rc.data.Targets))
|
||||||
|
for _, t := range rc.data.Targets {
|
||||||
|
subj := targetSubject(t)
|
||||||
|
meta := targetMeta(t)
|
||||||
|
if rc.probes[t.Ref] == nil {
|
||||||
|
out = append(out, sdk.CheckState{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Code: "dane_no_probe",
|
||||||
|
Subject: subj,
|
||||||
|
Message: "No TLS probe available yet for this endpoint; re-evaluate after the next checker-tls cycle.",
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Code: "dane_probe_available_ok",
|
||||||
|
Subject: subj,
|
||||||
|
Message: "TLS probe available for this endpoint.",
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
99
checker/rules_records.go
Normal file
99
checker/rules_records.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hasRecordsRule reports whether the TLSAs service declares any TLSA record
|
||||||
|
// at all. Without records there is nothing for DANE to validate.
|
||||||
|
type hasRecordsRule struct{}
|
||||||
|
|
||||||
|
func (r *hasRecordsRule) Name() string { return "dane.has_records" }
|
||||||
|
func (r *hasRecordsRule) Description() string {
|
||||||
|
return "Verifies that at least one TLSA record is declared on the service."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *hasRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
rc := loadRuleContext(ctx, obs)
|
||||||
|
if rc.err != nil {
|
||||||
|
return []sdk.CheckState{observationErrorState(rc.err)}
|
||||||
|
}
|
||||||
|
var states []sdk.CheckState
|
||||||
|
for _, inv := range rc.data.Invalid {
|
||||||
|
states = append(states, sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Code: "dane_invalid_owner",
|
||||||
|
Subject: inv.Owner,
|
||||||
|
Message: fmt.Sprintf("TLSA record %q is unusable: %s", inv.Owner, inv.Reason),
|
||||||
|
Meta: map[string]any{"owner": inv.Owner, "reason": inv.Reason},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(rc.data.Targets) == 0 {
|
||||||
|
if len(states) > 0 {
|
||||||
|
// Records exist but none are usable; flag the aggregate too so
|
||||||
|
// the UI doesn't only show per-record errors.
|
||||||
|
owners := make([]string, 0, len(rc.data.Invalid))
|
||||||
|
for _, inv := range rc.data.Invalid {
|
||||||
|
owners = append(owners, inv.Owner)
|
||||||
|
}
|
||||||
|
states = append(states, sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Code: "dane_no_usable_records",
|
||||||
|
Message: fmt.Sprintf("No usable TLSA records (all %d declared records are malformed: %s).", len(rc.data.Invalid), strings.Join(owners, ", ")),
|
||||||
|
})
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Code: "dane_no_records",
|
||||||
|
Message: "No TLSA records declared on this service.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
states = append(states, sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Code: "dane_has_records_ok",
|
||||||
|
Message: "TLSA records are declared for all bound endpoints.",
|
||||||
|
Meta: map[string]any{"endpoints": len(rc.data.Targets)},
|
||||||
|
})
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnssecValidatedRule reports whether the TLSA records this checker is
|
||||||
|
// evaluating were fetched over a DNSSEC-validated path. Without DNSSEC,
|
||||||
|
// DANE is a downgrade primitive: an on-path attacker can forge TLSA
|
||||||
|
// answers and any "match" the rest of the rules report is meaningless.
|
||||||
|
// The rule only emits when the collector recorded a validation status:
|
||||||
|
// in managed mode the records come from the user's authoritative zone
|
||||||
|
// config and DNSSEC posture is checked by a different checker.
|
||||||
|
type dnssecValidatedRule struct{}
|
||||||
|
|
||||||
|
func (r *dnssecValidatedRule) Name() string { return "dane.dnssec_validated" }
|
||||||
|
func (r *dnssecValidatedRule) Description() string {
|
||||||
|
return "Verifies the TLSA records were fetched via a DNSSEC-validating resolver (AD bit set)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dnssecValidatedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
rc := loadRuleContext(ctx, obs)
|
||||||
|
if rc.err != nil {
|
||||||
|
return []sdk.CheckState{observationErrorState(rc.err)}
|
||||||
|
}
|
||||||
|
if rc.data.DNSSECValidated == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if *rc.data.DNSSECValidated {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Code: "dane_dnssec_validated",
|
||||||
|
Message: "TLSA records were fetched over a DNSSEC-validated path (AD bit set).",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Code: "dane_dnssec_unvalidated",
|
||||||
|
Message: "TLSA records were fetched without DNSSEC validation (resolver did not set the AD bit). DANE matches are not trustworthy without DNSSEC.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
276
checker/rules_test.go
Normal file
276
checker/rules_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
|
tlscontract "git.happydns.org/checker-tls/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockObs is a lightweight ObservationGetter for rule unit tests.
|
||||||
|
type mockObs struct {
|
||||||
|
dane *DANEData
|
||||||
|
daneErr error
|
||||||
|
probes map[string]tls.TLSProbe
|
||||||
|
relatedErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
|
||||||
|
if m.daneErr != nil {
|
||||||
|
return m.daneErr
|
||||||
|
}
|
||||||
|
if key != ObservationKeyDANE || m.dane == nil {
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m.dane)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockObs) GetRelated(_ context.Context, key sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||||
|
if m.relatedErr != nil {
|
||||||
|
return nil, m.relatedErr
|
||||||
|
}
|
||||||
|
if key != tls.ObservationKeyTLSProbes || m.probes == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
Probes map[string]tls.TLSProbe `json:"probes"`
|
||||||
|
}{Probes: m.probes}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
return []sdk.RelatedObservation{{
|
||||||
|
CheckerID: "tls",
|
||||||
|
Key: tls.ObservationKeyTLSProbes,
|
||||||
|
Data: b,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTarget(host string, port uint16, recs []TLSARecord) TargetResult {
|
||||||
|
t := TargetResult{
|
||||||
|
Owner: tlsaOwnerName(port, "tcp", host),
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
Proto: "tcp",
|
||||||
|
Records: recs,
|
||||||
|
}
|
||||||
|
t.Ref = tlscontract.Ref(tlscontract.TLSEndpoint{Host: host, Port: port, SNI: host})
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasRecordsRule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := &hasRecordsRule{}
|
||||||
|
|
||||||
|
// No records, no invalid → unknown
|
||||||
|
obs := &mockObs{dane: &DANEData{}}
|
||||||
|
st := r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_no_records" {
|
||||||
|
t.Errorf("no records: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records present → ok
|
||||||
|
obs = &mockObs{dane: &DANEData{Targets: []TargetResult{makeTarget("a.example.com", 443, []TLSARecord{{}})}}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_has_records_ok" {
|
||||||
|
t.Errorf("ok: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid records, no targets → error states
|
||||||
|
obs = &mockObs{dane: &DANEData{Invalid: []InvalidRecord{{Owner: "_x._tcp", Reason: "bad port"}}}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) < 2 {
|
||||||
|
t.Fatalf("expected per-record + aggregate, got %+v", st)
|
||||||
|
}
|
||||||
|
if st[0].Code != "dane_invalid_owner" || st[len(st)-1].Code != "dane_no_usable_records" {
|
||||||
|
t.Errorf("invalid only: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observation read error
|
||||||
|
obs = &mockObs{daneErr: errors.New("boom")}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_observation_error" {
|
||||||
|
t.Errorf("err: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeAvailableRule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := &probeAvailableRule{}
|
||||||
|
tgt := makeTarget("a.example.com", 443, []TLSARecord{{Usage: UsageDANEEE}})
|
||||||
|
|
||||||
|
// Probe present
|
||||||
|
leaf := fakeCert([]byte("l"), []byte("s"))
|
||||||
|
obs := &mockObs{
|
||||||
|
dane: &DANEData{Targets: []TargetResult{tgt}},
|
||||||
|
probes: map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf}}},
|
||||||
|
}
|
||||||
|
st := r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_probe_available_ok" {
|
||||||
|
t.Errorf("ok: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe absent
|
||||||
|
obs.probes = map[string]tls.TLSProbe{}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_no_probe" {
|
||||||
|
t.Errorf("missing: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No targets at all
|
||||||
|
obs = &mockObs{dane: &DANEData{}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_probe_available_skipped" {
|
||||||
|
t.Errorf("empty: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Related-fetch error surfaces as warning state.
|
||||||
|
obs = &mockObs{dane: &DANEData{Targets: []TargetResult{tgt}}, relatedErr: errors.New("upstream down")}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_observation_warning" {
|
||||||
|
t.Errorf("relatedErr: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandshakeOKRule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := &handshakeOKRule{}
|
||||||
|
tgt := makeTarget("a.example.com", 443, []TLSARecord{{Usage: UsageDANEEE}})
|
||||||
|
leaf := fakeCert([]byte("l"), []byte("s"))
|
||||||
|
|
||||||
|
// All good.
|
||||||
|
obs := &mockObs{
|
||||||
|
dane: &DANEData{Targets: []TargetResult{tgt}},
|
||||||
|
probes: map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf}}},
|
||||||
|
}
|
||||||
|
st := r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_handshake_ok" {
|
||||||
|
t.Errorf("ok: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handshake failed.
|
||||||
|
obs.probes = map[string]tls.TLSProbe{tgt.Ref: {Error: "tls: bad cert"}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_handshake_failed" {
|
||||||
|
t.Errorf("failed: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordsMatchChainRule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := &recordsMatchChainRule{}
|
||||||
|
leaf := fakeCert([]byte("leaf"), []byte("ls"))
|
||||||
|
|
||||||
|
tgt := makeTarget("a.example.com", 443, []TLSARecord{
|
||||||
|
{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256, Certificate: leaf.SPKISHA256},
|
||||||
|
})
|
||||||
|
obs := &mockObs{
|
||||||
|
dane: &DANEData{Targets: []TargetResult{tgt}},
|
||||||
|
probes: map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf}}},
|
||||||
|
}
|
||||||
|
st := r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_match_ok" {
|
||||||
|
t.Errorf("match ok: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same target, wrong cert hash → no match (crit).
|
||||||
|
tgt.Records[0].Certificate = "deadbeef"
|
||||||
|
obs.dane = &DANEData{Targets: []TargetResult{tgt}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_no_match" {
|
||||||
|
t.Errorf("no match: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No probe usable → skipped.
|
||||||
|
obs.probes = map[string]tls.TLSProbe{}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_records_match_chain_skipped" {
|
||||||
|
t.Errorf("skipped: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPKIXChainValidRule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := &pkixChainValidRule{}
|
||||||
|
leaf := fakeCert([]byte("l"), []byte("s"))
|
||||||
|
bTrue, bFalse := true, false
|
||||||
|
|
||||||
|
// PKIX usage + valid chain → ok.
|
||||||
|
tgt := makeTarget("a.example.com", 443, []TLSARecord{{Usage: UsagePKIXEE}})
|
||||||
|
obs := &mockObs{
|
||||||
|
dane: &DANEData{Targets: []TargetResult{tgt}},
|
||||||
|
probes: map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf}, ChainValid: &bTrue}},
|
||||||
|
}
|
||||||
|
st := r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_pkix_chain_valid_ok" {
|
||||||
|
t.Errorf("ok: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKIX usage + invalid chain → crit.
|
||||||
|
obs.probes = map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf}, ChainValid: &bFalse}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_pkix_chain_invalid" {
|
||||||
|
t.Errorf("invalid: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DANE-only usages → skipped (rule does not apply).
|
||||||
|
tgt.Records = []TLSARecord{{Usage: UsageDANEEE}}
|
||||||
|
obs.dane = &DANEData{Targets: []TargetResult{tgt}}
|
||||||
|
obs.probes = map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf}}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_pkix_chain_valid_skipped" {
|
||||||
|
t.Errorf("skipped: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageCoherentRule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := &usageCoherentRule{}
|
||||||
|
leaf := fakeCert([]byte("l"), []byte("ls"))
|
||||||
|
mid := fakeCert([]byte("m"), []byte("ms"))
|
||||||
|
|
||||||
|
// EE record whose hash matches the intermediate → warn.
|
||||||
|
tgt := makeTarget("a.example.com", 443, []TLSARecord{{
|
||||||
|
Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256,
|
||||||
|
Certificate: mid.SPKISHA256,
|
||||||
|
}})
|
||||||
|
obs := &mockObs{
|
||||||
|
dane: &DANEData{Targets: []TargetResult{tgt}},
|
||||||
|
probes: map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf, mid}}},
|
||||||
|
}
|
||||||
|
st := r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_usage_incoherent" {
|
||||||
|
t.Errorf("incoherent: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EE matching leaf → ok.
|
||||||
|
tgt.Records[0].Certificate = leaf.SPKISHA256
|
||||||
|
obs.dane = &DANEData{Targets: []TargetResult{tgt}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_usage_coherent_ok" {
|
||||||
|
t.Errorf("coherent ok: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-cert chain → skipped.
|
||||||
|
obs.probes = map[string]tls.TLSProbe{tgt.Ref: {Chain: []tls.CertInfo{leaf}}}
|
||||||
|
st = r.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) != 1 || st[0].Code != "dane_usage_coherent_skipped" {
|
||||||
|
t.Errorf("skipped: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRules_ObservationError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
obs := &mockObs{daneErr: errors.New("read failed")}
|
||||||
|
for _, rule := range Rules() {
|
||||||
|
st := rule.Evaluate(context.Background(), obs, nil)
|
||||||
|
if len(st) == 0 || st[0].Code != "dane_observation_error" {
|
||||||
|
t.Errorf("%s: expected observation_error, got %+v", rule.Name(), st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
checker/rules_usage.go
Normal file
86
checker/rules_usage.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
tls "git.happydns.org/checker-tls/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usageCoherentRule flags TLSA records whose declared usage contradicts the
|
||||||
|
// chain slot their hash actually matches, typically a record published as
|
||||||
|
// usage 1 or 3 (end-entity) whose hash in fact matches an intermediate.
|
||||||
|
// That is almost always a publisher error: the intended usage was 0 or 2.
|
||||||
|
type usageCoherentRule struct{}
|
||||||
|
|
||||||
|
func (r *usageCoherentRule) Name() string { return "dane.usage_coherent" }
|
||||||
|
func (r *usageCoherentRule) Description() string {
|
||||||
|
return "Flags TLSA records whose declared usage does not match the chain slot they actually hash (e.g. usage 3 matching an intermediate)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *usageCoherentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
rc := loadRuleContext(ctx, obs)
|
||||||
|
if rc.err != nil {
|
||||||
|
return []sdk.CheckState{observationErrorState(rc.err)}
|
||||||
|
}
|
||||||
|
var out []sdk.CheckState
|
||||||
|
tested := 0
|
||||||
|
for _, t := range rc.data.Targets {
|
||||||
|
probe := rc.probes[t.Ref]
|
||||||
|
if !probeUsable(probe) || len(probe.Chain) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tested++
|
||||||
|
warn := suspiciousUsage(t, probe)
|
||||||
|
if warn != "" {
|
||||||
|
out = append(out, sdk.CheckState{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Code: "dane_usage_incoherent",
|
||||||
|
Subject: targetSubject(t),
|
||||||
|
Message: warn,
|
||||||
|
Meta: targetMeta(t),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
if tested == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Code: "dane_usage_coherent_skipped",
|
||||||
|
Message: "No multi-cert chain probed yet; cannot assess usage coherence.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Code: "dane_usage_coherent_ok",
|
||||||
|
Message: "End-entity TLSA records match end-entity certificates on every probed chain.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspiciousUsage returns a human-readable hint when a record hash matches a
|
||||||
|
// chain slot that contradicts its declared usage (e.g. usage 3 whose hash
|
||||||
|
// actually matches the intermediate), almost always a publisher error. Used
|
||||||
|
// by both usageCoherentRule and the HTML report.
|
||||||
|
func suspiciousUsage(t TargetResult, p *tls.TLSProbe) string {
|
||||||
|
if p == nil || len(p.Chain) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range t.Records {
|
||||||
|
if r.Usage != UsageDANEEE && r.Usage != UsagePKIXEE {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range p.Chain[1:] {
|
||||||
|
cand, err := recordCandidate(r, c)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(cand, r.Certificate) {
|
||||||
|
return "A record declared with usage 1/3 (end-entity) actually matches an intermediate certificate. It should probably use usage 0 or 2 (trust-anchor) instead."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
128
checker/types.go
Normal file
128
checker/types.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// Package checker implements the DANE/TLSA checker for happyDomain.
|
||||||
|
//
|
||||||
|
// This checker is bound to the svcs.TLSAs service. Collect takes the TLSA
|
||||||
|
// records the user published (or plans to publish) for the service, derives
|
||||||
|
// one TLS endpoint per distinct (port, proto, base name), and declares those
|
||||||
|
// endpoints as tls.endpoint.v1 discovery entries. checker-tls then probes
|
||||||
|
// them; on the next evaluation, this checker reads the related TLS probes
|
||||||
|
// via obs.GetRelated and verifies each TLSA record matches the certificate
|
||||||
|
// chain the probe observed.
|
||||||
|
//
|
||||||
|
// The user-visible contract matches what DANE deployers expect:
|
||||||
|
//
|
||||||
|
// - Usage 0 (PKIX-TA) / 1 (PKIX-EE): also require the PKIX chain to be
|
||||||
|
// publicly trusted.
|
||||||
|
// - Usage 2 (DANE-TA) / 3 (DANE-EE): trust the TLSA as the anchor; PKIX
|
||||||
|
// validity is informational.
|
||||||
|
// - Selector 0 (Cert) / 1 (SPKI) and matching types 0/1/2 (Full/SHA-256/
|
||||||
|
// SHA-512) are matched against the chain slot implied by the usage.
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ObservationKeyDANE is the observation key this checker writes.
|
||||||
|
const ObservationKeyDANE = "dane_checks"
|
||||||
|
|
||||||
|
// Option ids on CheckerOptions.
|
||||||
|
const (
|
||||||
|
// OptionService is auto-filled by the happyDomain host with the
|
||||||
|
// svcs.TLSAs service payload this checker is bound to.
|
||||||
|
OptionService = "service"
|
||||||
|
|
||||||
|
// OptionDomain is auto-filled with the domain apex. TLSA owner names
|
||||||
|
// in the service are relative to this apex.
|
||||||
|
OptionDomain = "domain_name"
|
||||||
|
|
||||||
|
// OptionSubdomain is the optional sub-zone under which the TLSAs
|
||||||
|
// service lives (matches the svcs.TLSAs analyzer's subdomain bucket).
|
||||||
|
OptionSubdomain = "subdomain"
|
||||||
|
|
||||||
|
// OptionProbeTimeoutMs is how long each underlying TLS probe is allowed.
|
||||||
|
// Passed through to checker-tls verbatim via the discovery entry options.
|
||||||
|
OptionProbeTimeoutMs = "probeTimeoutMs"
|
||||||
|
|
||||||
|
// OptionSTARTTLS is an optional per-endpoint STARTTLS hint keyed by
|
||||||
|
// "<port>/<proto>" → RFC 6335 service name (e.g. "25/tcp" → "smtp",
|
||||||
|
// "587/tcp" → "submission"). Common ports auto-map via a built-in table.
|
||||||
|
OptionSTARTTLS = "starttls"
|
||||||
|
|
||||||
|
// OptionDNSSECValidated reports whether the TLSA records the host
|
||||||
|
// submitted to this checker came from a DNSSEC-validated lookup.
|
||||||
|
// Only set by the standalone interactive flow; absent in managed mode
|
||||||
|
// where TLSA records come from the user's authoritative zone config.
|
||||||
|
OptionDNSSECValidated = "dnssec_validated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Severity constants mirror checker-tls.
|
||||||
|
const (
|
||||||
|
SeverityCrit = "crit"
|
||||||
|
SeverityWarn = "warn"
|
||||||
|
SeverityInfo = "info"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLSA field enum constants (RFC 6698 §2.1).
|
||||||
|
const (
|
||||||
|
UsagePKIXTA uint8 = 0
|
||||||
|
UsagePKIXEE uint8 = 1
|
||||||
|
UsageDANETA uint8 = 2
|
||||||
|
UsageDANEEE uint8 = 3
|
||||||
|
|
||||||
|
SelectorCert uint8 = 0
|
||||||
|
SelectorSPKI uint8 = 1
|
||||||
|
|
||||||
|
MatchingFull uint8 = 0
|
||||||
|
MatchingSHA256 uint8 = 1
|
||||||
|
MatchingSHA512 uint8 = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// DANEData is the full payload the checker writes under ObservationKeyDANE.
|
||||||
|
type DANEData struct {
|
||||||
|
// Targets is one entry per (port, proto, basename) triplet extracted
|
||||||
|
// from the TLSAs service.
|
||||||
|
Targets []TargetResult `json:"targets"`
|
||||||
|
// Invalid lists TLSA records that could not be parsed into a usable
|
||||||
|
// endpoint (malformed owner name, out-of-range port, etc.). They are
|
||||||
|
// surfaced by hasRecordsRule so a misconfigured zone fails loudly
|
||||||
|
// instead of silently passing as "no records".
|
||||||
|
Invalid []InvalidRecord `json:"invalid,omitempty"`
|
||||||
|
// DNSSECValidated reflects whether the resolver that fetched the TLSA
|
||||||
|
// records set the AD bit. Only populated by the standalone interactive
|
||||||
|
// flow (lookupTLSA); nil in managed mode where records come from the
|
||||||
|
// user's zone config and DNSSEC posture is checked elsewhere.
|
||||||
|
DNSSECValidated *bool `json:"dnssec_validated,omitempty"`
|
||||||
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidRecord describes a TLSA record dropped during Collect.
|
||||||
|
type InvalidRecord struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetResult groups all TLSA records declared on a single endpoint and
|
||||||
|
// carries enough context to render an actionable HTML row per endpoint.
|
||||||
|
type TargetResult struct {
|
||||||
|
// Owner is the fully qualified DANE owner name (_<port>._<proto>.<host>).
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
// Host is the connection target (typically the base name the TLSA
|
||||||
|
// records live under, or its MX/SRV target when relevant).
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port uint16 `json:"port"`
|
||||||
|
Proto string `json:"proto"`
|
||||||
|
STARTTLS string `json:"starttls,omitempty"`
|
||||||
|
|
||||||
|
// Ref ties this target to the tls.endpoint.v1 discovery entry the
|
||||||
|
// checker emitted, so the rule can pick the matching RelatedObservation.
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
|
||||||
|
// Records are the TLSA records declared for this endpoint.
|
||||||
|
Records []TLSARecord `json:"records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSARecord is a user-facing view of a single dns.TLSA record.
|
||||||
|
type TLSARecord struct {
|
||||||
|
Usage uint8 `json:"usage"`
|
||||||
|
Selector uint8 `json:"selector"`
|
||||||
|
MatchingType uint8 `json:"matching_type"`
|
||||||
|
Certificate string `json:"certificate"` // lowercase hex
|
||||||
|
}
|
||||||
17
go.mod
Normal file
17
go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
module git.happydns.org/checker-dane
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.happydns.org/checker-sdk-go v1.4.0
|
||||||
|
git.happydns.org/checker-tls v0.6.2
|
||||||
|
github.com/miekg/dns v1.1.72
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
|
)
|
||||||
18
go.sum
Normal file
18
go.sum
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs=
|
||||||
|
git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||||
|
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
|
||||||
|
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
23
main.go
Normal file
23
main.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
dane "git.happydns.org/checker-dane/checker"
|
||||||
|
"git.happydns.org/checker-sdk-go/checker/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
dane.Version = Version
|
||||||
|
|
||||||
|
srv := server.New(dane.Provider())
|
||||||
|
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
plugin/plugin.go
Normal file
16
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Command plugin is the happyDomain plugin entrypoint for the DANE/TLSA
|
||||||
|
// checker. Built with -buildmode=plugin and loaded at runtime.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
dane "git.happydns.org/checker-dane/checker"
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
|
dane.Version = Version
|
||||||
|
prvd := dane.Provider()
|
||||||
|
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue