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 }