checker-dane/checker/rule.go

287 lines
8.5 KiB
Go

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"
)
// Rule returns the DANE/TLSA matching rule.
func Rule() sdk.CheckRule {
return &daneRule{}
}
type daneRule struct{}
func (r *daneRule) Name() string { return "dane_tlsa_match" }
func (r *daneRule) Description() string {
return "Verifies each TLSA record matches the certificate chain presented by the corresponding TLS endpoint."
}
// Evaluate walks each target, fetches the related checker-tls probe keyed on
// the same Ref we emitted in DiscoverEntries, and compares every TLSA record
// against the chain. It returns one CheckState per target so the UI can
// surface per-endpoint status; unmatched records aggregate into a single
// critical state with the first non-matching record's detail.
func (r *daneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data DANEData
if err := obs.Get(ctx, ObservationKeyDANE, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to read %s: %v", ObservationKeyDANE, err),
Code: "dane_observation_error",
}}
}
if len(data.Targets) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Message: "No TLSA records declared on this service.",
Code: "dane_no_records",
}}
}
// Index related TLS probes by Ref so we can pair them to targets.
probes, warn := relatedTLSProbes(ctx, obs)
out := make([]sdk.CheckState, 0, len(data.Targets))
for _, t := range data.Targets {
out = append(out, evaluateTarget(t, probes))
}
if warn != "" {
out = append([]sdk.CheckState{{
Status: sdk.StatusError,
Message: warn,
Code: "dane_observation_warning",
}}, out...)
}
return out
}
// evaluateTarget matches a single target's TLSA records against the cached
// TLS probe and distills the outcome into a CheckState. The matched/unmatched
// split mirrors what DANE deployers care about most:
//
// - match_ok: at least one TLSA record in the RRset matches (DANE uses
// OR semantics per RFC 6698 §2.1).
// - no_match: no record matched, usually a certificate rotation without
// TLSA rollover, which is the single most common DANE outage cause.
// - no_probe: checker-tls has not yet probed this endpoint; rule should
// not flap red on the first run after publication.
func evaluateTarget(t TargetResult, probes map[string]*tls.TLSProbe) sdk.CheckState {
subject := fmt.Sprintf("%s:%d (%s)", t.Host, t.Port, t.Proto)
meta := map[string]any{
"host": t.Host,
"port": t.Port,
"proto": t.Proto,
"owner": t.Owner,
"starttls": t.STARTTLS,
"records": len(t.Records),
}
probe := probes[t.Ref]
if probe == nil {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Code: "dane_no_probe",
Subject: subject,
Message: "No TLS probe available yet for this endpoint; re-evaluate after the next checker-tls cycle.",
Meta: meta,
}
}
if probe.Error != "" || len(probe.Chain) == 0 {
return sdk.CheckState{
Status: sdk.StatusCrit,
Code: "dane_handshake_failed",
Subject: subject,
Message: "TLS handshake failed, cannot validate DANE: " + probe.Error,
Meta: meta,
}
}
// Per-record classification: matched / not_matched / unsupported.
var matched, unmatched int
var firstUnmatchedIdx = -1
var firstUnmatchedReason string
usages := map[uint8]int{}
for i, rec := range t.Records {
usages[rec.Usage]++
ok, reason := matchRecord(rec, probe)
if ok {
matched++
continue
}
unmatched++
if firstUnmatchedIdx < 0 {
firstUnmatchedIdx = i
firstUnmatchedReason = reason
}
}
// PKIX-dependent usages (0/1) require a valid public chain; call it
// out explicitly so users can tell "chain invalid" apart from "hash
// mismatch".
pkixRequired := usages[UsagePKIXTA]+usages[UsagePKIXEE] > 0
if pkixRequired && (probe.ChainValid == nil || !*probe.ChainValid) {
return sdk.CheckState{
Status: sdk.StatusCrit,
Code: "dane_pkix_chain_invalid",
Subject: subject,
Message: "Usage 0/1 requires a publicly-trusted chain, but the certificate chain did not validate against system roots.",
Meta: meta,
}
}
meta["matched"] = matched
meta["unmatched"] = unmatched
switch {
case matched > 0:
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "dane_match_ok",
Subject: subject,
Message: fmt.Sprintf("%d/%d TLSA record(s) match the presented certificate chain.", matched, matched+unmatched),
Meta: meta,
}
default:
msg := "No TLSA record matches the presented certificate chain."
if firstUnmatchedReason != "" {
msg += " " + firstUnmatchedReason
}
meta["first_unmatched_index"] = firstUnmatchedIdx
return sdk.CheckState{
Status: sdk.StatusCrit,
Code: "dane_no_match",
Subject: subject,
Message: msg,
Meta: meta,
}
}
}
// 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)
}
for _, c := range slots {
got, err := recordCandidate(rec, c)
if err != nil {
return false, err.Error()
}
if strings.EqualFold(got, rec.Certificate) {
return true, ""
}
}
return false, fmt.Sprintf("expected %s, got none matching in chain", truncHex(rec.Certificate))
}
// 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 := base64.StdEncoding.DecodeString(c.DERBase64)
if err != nil {
return "", fmt.Errorf("decode cert DER: %w", 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 := base64.StdEncoding.DecodeString(c.SPKIDERBase64)
if err != nil {
return "", fmt.Errorf("decode SPKI DER: %w", 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, string) {
related, err := obs.GetRelated(ctx, tls.ObservationKeyTLSProbes)
if err != nil {
return nil, "related TLS probes unavailable: " + err.Error()
}
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
}