287 lines
8.5 KiB
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
|
|
}
|