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 }