277 lines
8.7 KiB
Go
277 lines
8.7 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"
|
|
)
|
|
|
|
// 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
|
|
}
|