From f77895dcab45e6fb4e8608e76c1425b23929bd0f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 15 May 2026 17:05:53 +0800 Subject: [PATCH] checker: enforce prober-as-observation, move all analysis to rules layer --- checker/collect.go | 2 +- checker/prober.go | 92 ++++-------------------------- checker/report.go | 76 +++++++++++++------------ checker/rules.go | 2 - checker/rules_algorithms.go | 6 +- checker/rules_auth.go | 2 +- checker/rules_banner.go | 6 +- checker/rules_hostkey.go | 4 +- checker/rules_reachability.go | 18 +++--- checker/rules_sshfp.go | 4 +- checker/sshfp.go | 102 +++++++++++++++++++++++++++++----- checker/types.go | 31 ++++++----- 12 files changed, 174 insertions(+), 171 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index 9a45d95..d4e0f3f 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -103,7 +103,7 @@ func (p *sshProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any go func(ip string, port uint16) { defer wg.Done() defer func() { <-sem }() - probe := probeEndpoint(ctx, host, ip, port, timeout, includeAuthProbe, sshfp) + probe := probeEndpoint(ctx, host, ip, port, timeout, includeAuthProbe) log.Printf("checker-ssh: %s:%d banner=%q kex=%d hostkeys=%d stage=%s", ip, port, probe.Banner, len(probe.KEX), len(probe.HostKeys), probe.Stage) mu.Lock() diff --git a/checker/prober.go b/checker/prober.go index 53a8f1a..4c89b04 100644 --- a/checker/prober.go +++ b/checker/prober.go @@ -24,9 +24,6 @@ package checker import ( "bufio" "context" - "crypto/sha1" - "crypto/sha256" - "encoding/hex" "errors" "net" "strconv" @@ -40,16 +37,14 @@ import ( // triple. It never returns a Go error: every failure mode is recorded // as a raw field on SSHProbe (Stage + Error). Severity / pass/fail // classification is performed later by CheckRule.Evaluate, never here. -func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout time.Duration, includeAuthProbe bool, sshfp SSHFPSummary) SSHProbe { +func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout time.Duration, includeAuthProbe bool) SSHProbe { start := time.Now() addr := net.JoinHostPort(ip, strconv.Itoa(int(port))) p := SSHProbe{ - Host: host, - Port: port, - Address: addr, - IP: ip, - IsIPv6: strings.Contains(ip, ":"), - Stage: "dial", + Host: host, + Port: port, + IP: net.ParseIP(ip), + Stage: "dial", } dialCtx, cancel := context.WithTimeout(ctx, timeout) @@ -64,7 +59,6 @@ func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout ti } defer conn.Close() - p.TCPConnected = true p.Stage = "banner" if deadline, ok := dialCtx.Deadline(); ok { _ = conn.SetDeadline(deadline) @@ -122,9 +116,6 @@ func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout ti // We hand off to Go's ssh package for the full handshake. HostKeyCallback lets us // capture each presented key without reimplementing DH/curve25519/kyber ourselves. p.HostKeys = probeHostKeys(ctx, addr, host, srvKex.ServerHostKeyAlgorithms, timeout) - for i := range p.HostKeys { - p.HostKeys[i].applySSHFP(sshfp) - } if len(p.HostKeys) > 0 { p.Stage = "handshake_ok" } @@ -156,7 +147,7 @@ func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout ti func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeout time.Duration) []HostKeyInfo { wantFamilies := pickHostKeyFamilies(algos) - seen := map[string]bool{} // by sha256 hex, dedupe across families + seen := map[string]bool{} // by raw key bytes, dedupe across families var out []HostKeyInfo for _, algo := range wantFamilies { @@ -165,10 +156,10 @@ func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeo continue } info := describeHostKey(key) - if seen[info.SHA256] { + if seen[string(info.RawKey)] { continue } - seen[info.SHA256] = true + seen[string(info.RawKey)] = true out = append(out, info) } @@ -299,50 +290,10 @@ func extractMethodsFromAuthError(err error) []string { } func describeHostKey(key ssh.PublicKey) HostKeyInfo { - marshaled := key.Marshal() - sha2 := sha256.Sum256(marshaled) - sha1sum := sha1.Sum(marshaled) - info := HostKeyInfo{ + return HostKeyInfo{ Type: key.Type(), - SHA256: hex.EncodeToString(sha2[:]), - SHA1: hex.EncodeToString(sha1sum[:]), + RawKey: key.Marshal(), } - info.SSHFPAlgo = sshfpAlgoForKeyType(info.Type) - info.Bits = keyBits(key) - return info -} - -// keyBits returns a key-family-specific size estimate. It is advisory: -// we only use it in the report, and a server that ships an RSA key -// smaller than 2048 bits is the sort of red flag we want to show. -func keyBits(key ssh.PublicKey) int { - switch k := key.(type) { - case ssh.CryptoPublicKey: - type bitSizer interface{ Size() int } - switch p := k.CryptoPublicKey().(type) { - case bitSizer: - return p.Size() * 8 - default: - _ = p - } - } - return 0 -} - -// sshfpAlgoForKeyType maps an SSH host-key type string to the SSHFP -// algorithm number defined in RFC 4255 / RFC 6594 / RFC 7479. -func sshfpAlgoForKeyType(t string) uint8 { - switch t { - case "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512": - return 1 - case "ssh-dss": - return 2 - case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521": - return 3 - case "ssh-ed25519": - return 4 - } - return 0 } // parseBanner splits an "SSH-2.0-OpenSSH_9.3p1 Debian-1" banner into @@ -366,29 +317,6 @@ func parseBanner(b string) (proto, soft, vendor string) { return } -// applySSHFP fills in the SSHFPMatchSHA* flags based on the declared -// SSHFP records for this key's algorithm family. These are raw -// observations (the record matched this key fingerprint); any -// severity verdict about coverage lives in the SSHFP rule. -func (h *HostKeyInfo) applySSHFP(s SSHFPSummary) { - for _, rr := range s.Records { - if rr.Algorithm != h.SSHFPAlgo { - continue - } - want := strings.ToLower(rr.Fingerprint) - switch rr.Type { - case 1: - if want == h.SHA1 { - h.SSHFPMatchSHA1 = true - } - case 2: - if want == h.SHA256 { - h.SSHFPMatchSHA256 = true - } - } - } -} - // errNoHostKey is returned by fetchHostKey when the callback never // fired (e.g. transport-level error before the host key was received). // Currently only used internally for readability. diff --git a/checker/report.go b/checker/report.go index 6e808a3..8cd2c02 100644 --- a/checker/report.go +++ b/checker/report.go @@ -89,19 +89,19 @@ type reportSSHFPRecord struct { } type reportEndpoint struct { - Address string - Host string - Port uint16 - IsIPv6 bool - TCPConnected bool - Banner string - SoftwareVer string - Vendor string - ElapsedMS int64 - Error string - StatusLabel string - StatusClass string - AnyFail bool + Address string + Host string + Port uint16 + IsIPv6 bool + DialFailed bool + Banner string + SoftwareVer string + Vendor string + ElapsedMS int64 + Error string + StatusLabel string + StatusClass string + AnyFail bool HostKeys []reportHostKey AlgoTables []reportAlgoTable @@ -239,11 +239,12 @@ func buildReportData(d *SSHData, states []sdk.CheckState) reportView { matched := false for _, ep := range d.Endpoints { for _, k := range ep.HostKeys { - if k.SSHFPAlgo == rr.Algorithm { - if rr.Type == 2 && strings.EqualFold(rr.Fingerprint, k.SHA256) { + if sshfpAlgoForKeyType(k.Type) == rr.Algorithm { + sha1hex, sha256hex := hostKeyFingerprints(k) + if rr.Type == 2 && strings.EqualFold(rr.Fingerprint, sha256hex) { matched = true } - if rr.Type == 1 && strings.EqualFold(rr.Fingerprint, k.SHA1) { + if rr.Type == 1 && strings.EqualFold(rr.Fingerprint, sha1hex) { matched = true } } @@ -264,24 +265,24 @@ func buildReportData(d *SSHData, states []sdk.CheckState) reportView { for _, ep := range d.Endpoints { re := reportEndpoint{ - Address: ep.Address, - Host: ep.Host, - Port: ep.Port, - IsIPv6: ep.IsIPv6, - TCPConnected: ep.TCPConnected, - Banner: ep.Banner, - SoftwareVer: ep.SoftVer, - Vendor: ep.Vendor, - ElapsedMS: ep.ElapsedMS, - Error: ep.Error, + Address: ep.Addr(), + Host: ep.Host, + Port: ep.Port, + IsIPv6: ep.IP != nil && ep.IP.To4() == nil, + DialFailed: ep.Stage == "dial", + Banner: ep.Banner, + SoftwareVer: ep.SoftVer, + Vendor: ep.Vendor, + ElapsedMS: ep.ElapsedMS, + Error: ep.Error, } - if ep.IsIPv6 { + if ep.IP != nil && ep.IP.To4() == nil { v.AnyIPv6 = true } else { v.AnyIPv4 = true } - perEpIssues := perEp[ep.Address] + perEpIssues := perEp[ep.Addr()] // Per-endpoint status label. epWorst := SeverityOK for _, f := range perEpIssues { @@ -307,15 +308,18 @@ func buildReportData(d *SSHData, states []sdk.CheckState) reportView { } for _, k := range ep.HostKeys { + sha1hex, sha256hex := hostKeyFingerprints(k) + sha1Match, sha256Match := keyMatchesSSHFP(k, d.SSHFP) + algo := sshfpAlgoForKeyType(k.Type) rh := reportHostKey{ - Type: k.Type, - Bits: k.Bits, - SHA256: k.SHA256, - SHA1: k.SHA1, + Type: k.Type, + Bits: hostKeyBits(k), + SHA256: sha256hex, + SHA1: sha1hex, + SSHFPMatched: sha256Match || sha1Match, + SSHFPFamily: sshfpAlgoName(algo), + SSHFPSnippet: fmt.Sprintf("%d 2 %s", algo, sha256hex), } - rh.SSHFPMatched = k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 - rh.SSHFPFamily = sshfpAlgoName(k.SSHFPAlgo) - rh.SSHFPSnippet = fmt.Sprintf("%d 2 %s", k.SSHFPAlgo, k.SHA256) re.HostKeys = append(re.HostKeys, rh) } @@ -602,7 +606,7 @@ tr.info td:first-child { border-left: 3px solid #3b82f6; }
Host
{{.Host}}
IP
{{.Address}}{{if .IsIPv6}} (IPv6){{end}}
-
TCP
{{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
+
TCP
{{if .DialFailed}}✗ failed{{else}}✓ connected{{end}}
{{if .SoftwareVer}}
Version
{{.SoftwareVer}}{{if .Vendor}} ยท {{.Vendor}}{{end}}
{{end}}
Duration
{{.ElapsedMS}} ms
{{if .Error}}
Error
{{.Error}}
{{end}} diff --git a/checker/rules.go b/checker/rules.go index ddf2a83..249cece 100644 --- a/checker/rules.go +++ b/checker/rules.go @@ -28,8 +28,6 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Each concern is its own rule so results surface independently in the UI -// rather than being squashed under a single aggregated verdict. func Rules() []sdk.CheckRule { return []sdk.CheckRule{ &reachabilityRule{}, diff --git a/checker/rules_algorithms.go b/checker/rules_algorithms.go index 0c7b0a7..e8f4131 100644 --- a/checker/rules_algorithms.go +++ b/checker/rules_algorithms.go @@ -54,7 +54,7 @@ func (r *algorithmFamilyRule) Evaluate(ctx context.Context, obs sdk.ObservationG } var issues []Issue for _, ep := range eps { - issues = append(issues, analyseWeakAlgos(ep.Address, r.family, r.extract(&ep), r.table)...) + issues = append(issues, analyseWeakAlgos(ep.Addr(), r.family, r.extract(&ep), r.table)...) } if len(issues) == 0 { return []sdk.CheckState{passState(r.passCode, r.passMsg)} @@ -137,7 +137,7 @@ func (r *strictKexRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, } var issues []Issue for _, ep := range eps { - issues = append(issues, analyseStrictKex(ep.Address, ep.KEX)...) + issues = append(issues, analyseStrictKex(ep.Addr(), ep.KEX)...) } if len(issues) == 0 { return []sdk.CheckState{passState("ssh.strict_kex.ok", "Every endpoint advertises the Terrapin mitigation marker.")} @@ -165,7 +165,7 @@ func (r *preauthCompressionRule) Evaluate(ctx context.Context, obs sdk.Observati } var issues []Issue for _, ep := range eps { - issues = append(issues, analysePreauthCompression(ep.Address, ep.CompC2S)...) + issues = append(issues, analysePreauthCompression(ep.Addr(), ep.CompC2S)...) } if len(issues) == 0 { return []sdk.CheckState{passState("ssh.preauth_compression.ok", "No endpoint offers pre-authentication zlib compression.")} diff --git a/checker/rules_auth.go b/checker/rules_auth.go index 7ceeb4c..7389c87 100644 --- a/checker/rules_auth.go +++ b/checker/rules_auth.go @@ -49,7 +49,7 @@ func (r *authMethodsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette continue } probed = true - issues = append(issues, analyseAuthMethods(ep.Address, &ep)...) + issues = append(issues, analyseAuthMethods(ep.Addr(), &ep)...) } if !probed { return []sdk.CheckState{notTestedState("ssh.auth_methods.skipped", "Authentication-method enumeration disabled or not performed.")} diff --git a/checker/rules_banner.go b/checker/rules_banner.go index 31db2cf..6758838 100644 --- a/checker/rules_banner.go +++ b/checker/rules_banner.go @@ -55,7 +55,7 @@ func (r *protocolVersionRule) Evaluate(ctx context.Context, obs sdk.ObservationG states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "ssh_legacy_protocol", - Subject: ep.Address, + Subject: ep.Addr(), Message: fmt.Sprintf("Server advertises SSH protocol %q (banner %q). SSH-1 is obsolete and insecure.", ep.ProtoVer, ep.Banner), Meta: map[string]any{"fix": "Disable SSH-1 support; run an sshd that only speaks SSH-2."}, }) @@ -86,7 +86,7 @@ func (r *bannerSoftwareRule) Evaluate(ctx context.Context, obs sdk.ObservationGe } var issues []Issue for _, ep := range data.Endpoints { - issues = append(issues, analyseBannerSoftware(ep.Address, ep.Banner, ep.SoftVer)...) + issues = append(issues, analyseBannerSoftware(ep.Addr(), ep.Banner, ep.SoftVer)...) } if len(issues) == 0 { return []sdk.CheckState{passState("ssh.banner_software.ok", "All probed servers advertise a recognised OpenSSH build.")} @@ -113,7 +113,7 @@ func (r *knownVulnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter } var issues []Issue for _, ep := range data.Endpoints { - issues = append(issues, analyseBannerVulns(ep.Address, ep.Banner, ep.SoftVer)...) + issues = append(issues, analyseBannerVulns(ep.Addr(), ep.Banner, ep.SoftVer)...) } if len(issues) == 0 { return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")} diff --git a/checker/rules_hostkey.go b/checker/rules_hostkey.go index 1fee837..d5e629f 100644 --- a/checker/rules_hostkey.go +++ b/checker/rules_hostkey.go @@ -50,9 +50,9 @@ func (r *hostKeyStrengthRule) Evaluate(ctx context.Context, obs sdk.ObservationG // Also flag endpoints that reached KEXINIT but failed to // produce any host key: the handshake didn't complete. if len(ep.KEX) > 0 { - issues = append(issues, analyseHandshakeHostKey(ep.Address, true, ep.HostKeys)...) + issues = append(issues, analyseHandshakeHostKey(ep.Addr(), true, ep.HostKeys)...) } - issues = append(issues, analyseHostKeyStrength(ep.Address, ep.HostKeys)...) + issues = append(issues, analyseHostKeyStrength(ep.Addr(), ep.HostKeys)...) } if !anyKey && len(issues) == 0 { return []sdk.CheckState{notTestedState("ssh.host_key_strength.skipped", "No host key observed on any reachable endpoint.")} diff --git a/checker/rules_reachability.go b/checker/rules_reachability.go index 2aa46ca..8793f62 100644 --- a/checker/rules_reachability.go +++ b/checker/rules_reachability.go @@ -49,10 +49,10 @@ func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGett } var states []sdk.CheckState for _, ep := range data.Endpoints { - if ep.TCPConnected { + if ep.Stage != "dial" { continue } - msg := "Cannot open TCP connection to " + ep.Address + msg := "Cannot open TCP connection to " + ep.Addr() if ep.Error != "" { msg += ": " + ep.Error } @@ -60,7 +60,7 @@ func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGett Status: sdk.StatusCrit, Message: msg, Code: "tcp_unreachable", - Subject: ep.Address, + Subject: ep.Addr(), Meta: map[string]any{ "fix": "Check DNS, firewall (allow tcp/" + strconv.Itoa(int(ep.Port)) + " from the internet), and that sshd is running.", }, @@ -92,7 +92,7 @@ func (r *handshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, } var states []sdk.CheckState for _, ep := range data.Endpoints { - if !ep.TCPConnected { + if ep.Stage == "dial" { continue } switch ep.Stage { @@ -100,29 +100,29 @@ func (r *handshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "no_ssh_banner", - Subject: ep.Address, - Message: fmt.Sprintf("Server on %s did not send an SSH-2.0 banner: %s", ep.Address, ep.Error), + Subject: ep.Addr(), + Message: fmt.Sprintf("Server on %s did not send an SSH-2.0 banner: %s", ep.Addr(), ep.Error), Meta: map[string]any{"fix": "Check that an SSH daemon (not HTTP, mail, ...) listens on this port."}, }) case "banner_write": states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "banner_write_failed", - Subject: ep.Address, + Subject: ep.Addr(), Message: "Failed to send our client banner: " + ep.Error, }) case "kexinit_read": states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "kexinit_read_failed", - Subject: ep.Address, + Subject: ep.Addr(), Message: "Server did not send KEXINIT after banner: " + ep.Error, }) case "kexinit_parse": states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "kexinit_parse_failed", - Subject: ep.Address, + Subject: ep.Addr(), Message: "Malformed KEXINIT packet: " + ep.Error, }) } diff --git a/checker/rules_sshfp.go b/checker/rules_sshfp.go index 27cb3f5..3252152 100644 --- a/checker/rules_sshfp.go +++ b/checker/rules_sshfp.go @@ -48,7 +48,7 @@ func (r *sshfpAlignmentRule) Evaluate(ctx context.Context, obs sdk.ObservationGe continue } sawKey = true - issues = append(issues, analyseSSHFPAlignment(ep.Address, ep.HostKeys, data.SSHFP)...) + issues = append(issues, analyseSSHFPAlignment(ep.Addr(), ep.HostKeys, data.SSHFP)...) } if !sawKey { return []sdk.CheckState{notTestedState("ssh.sshfp_alignment.skipped", "No host key observed; SSHFP alignment cannot be assessed.")} @@ -78,7 +78,7 @@ func (r *sshfpHashRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, } var issues []Issue for _, ep := range data.Endpoints { - issues = append(issues, analyseSSHFPHashes(ep.Address, ep.HostKeys, data.SSHFP)...) + issues = append(issues, analyseSSHFPHashes(ep.Addr(), ep.HostKeys, data.SSHFP)...) } if len(issues) == 0 { return []sdk.CheckState{passState("ssh.sshfp_hash.ok", "SSHFP records include a SHA-256 (type 2) fingerprint.")} diff --git a/checker/sshfp.go b/checker/sshfp.go index 159ab1c..0298111 100644 --- a/checker/sshfp.go +++ b/checker/sshfp.go @@ -22,10 +22,55 @@ package checker import ( + "crypto/sha1" + "crypto/sha256" + "encoding/hex" "fmt" "strings" + + "golang.org/x/crypto/ssh" ) +// sshfpAlgoForKeyType maps an SSH host-key type string to the SSHFP +// algorithm number defined in RFC 4255 / RFC 6594 / RFC 7479. +func sshfpAlgoForKeyType(t string) uint8 { + switch t { + case "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512": + return 1 + case "ssh-dss": + return 2 + case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521": + return 3 + case "ssh-ed25519": + return 4 + } + return 0 +} + +// hostKeyFingerprints returns the SHA-1 and SHA-256 hex fingerprints of k. +func hostKeyFingerprints(k HostKeyInfo) (sha1hex, sha256hex string) { + sum1 := sha1.Sum(k.RawKey) + sum256 := sha256.Sum256(k.RawKey) + return hex.EncodeToString(sum1[:]), hex.EncodeToString(sum256[:]) +} + +// hostKeyBits returns the key size in bits (RSA/EC) or 0 if not applicable. +func hostKeyBits(k HostKeyInfo) int { + pub, err := ssh.ParsePublicKey(k.RawKey) + if err != nil { + return 0 + } + cp, ok := pub.(ssh.CryptoPublicKey) + if !ok { + return 0 + } + type bitSizer interface{ Size() int } + if bs, ok := cp.CryptoPublicKey().(bitSizer); ok { + return bs.Size() * 8 + } + return 0 +} + // analyseHandshakeHostKey flags an endpoint where the full handshake // never yielded any host key. func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Issue { @@ -46,11 +91,15 @@ func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Is func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue { var issues []Issue for _, k := range keys { - if k.SSHFPAlgo == 1 && k.Bits > 0 && k.Bits < 2048 { + if sshfpAlgoForKeyType(k.Type) != 1 { + continue + } + bits := hostKeyBits(k) + if bits > 0 && bits < 2048 { issues = append(issues, Issue{ Code: "short_rsa_host_key", Severity: SeverityCrit, - Message: fmt.Sprintf("RSA host key is %d bits; OpenSSH has rejected < 2048 bits since 8.2.", k.Bits), + Message: fmt.Sprintf("RSA host key is %d bits; OpenSSH has rejected < 2048 bits since 8.2.", bits), Fix: "Regenerate the host key: rm /etc/ssh/ssh_host_rsa_key && ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ''", Endpoint: addr, }) @@ -59,6 +108,29 @@ func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue { return issues } +// keyMatchesSSHFP reports whether k's fingerprints match any record in s. +func keyMatchesSSHFP(k HostKeyInfo, s SSHFPSummary) (sha1Match, sha256Match bool) { + algo := sshfpAlgoForKeyType(k.Type) + sha1hex, sha256hex := hostKeyFingerprints(k) + for _, rr := range s.Records { + if rr.Algorithm != algo { + continue + } + want := strings.ToLower(rr.Fingerprint) + switch rr.Type { + case 1: + if want == sha1hex { + sha1Match = true + } + case 2: + if want == sha256hex { + sha256Match = true + } + } + } + return +} + // analyseSSHFPAlignment returns per-key alignment issues: match, // no coverage for a key family, or mismatch between DNS and server. func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue { @@ -80,21 +152,18 @@ func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Is coveredFamily[rr.Algorithm] = true } for _, k := range keys { - if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 { - issues = append(issues, Issue{ - Code: "sshfp_verified", - Severity: SeverityInfo, - Message: fmt.Sprintf("Host key %s (%s) matches the published SSHFP record.", k.Type, shortFP(k.SHA256)), - Endpoint: addr, - }) + sha1Match, sha256Match := keyMatchesSSHFP(k, s) + if sha256Match || sha1Match { continue } - if !coveredFamily[k.SSHFPAlgo] { + algo := sshfpAlgoForKeyType(k.Type) + _, sha256hex := hostKeyFingerprints(k) + if !coveredFamily[algo] { issues = append(issues, Issue{ Code: "sshfp_not_covered", Severity: SeverityWarn, Message: fmt.Sprintf("No SSHFP record covers host-key algorithm %s.", k.Type), - Fix: fmt.Sprintf("Add `IN SSHFP %d 2 %s` to the zone.", k.SSHFPAlgo, k.SHA256), + Fix: fmt.Sprintf("Add `IN SSHFP %d 2 %s` to the zone.", algo, sha256hex), Endpoint: addr, }) continue @@ -103,7 +172,7 @@ func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Is Code: "sshfp_mismatch", Severity: SeverityCrit, Message: fmt.Sprintf("Published SSHFP record does not match the %s host key presented by %s. Either the server key was rotated without updating DNS, or the server is impersonated.", k.Type, addr), - Fix: fmt.Sprintf("Update the SSHFP record to the current fingerprint: `IN SSHFP %d 2 %s`, and investigate why DNS and the server disagree.", k.SSHFPAlgo, k.SHA256), + Fix: fmt.Sprintf("Update the SSHFP record to the current fingerprint: `IN SSHFP %d 2 %s`, and investigate why DNS and the server disagree.", algo, sha256hex), Endpoint: addr, }) } @@ -119,7 +188,8 @@ func analyseSSHFPHashes(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue } matchedAny := false for _, k := range keys { - if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 { + sha1Match, sha256Match := keyMatchesSSHFP(k, s) + if sha256Match || sha1Match { matchedAny = true break } @@ -157,11 +227,13 @@ func analyseHostKeys(addr string, keys []HostKeyInfo, s SSHFPSummary, reachedKex func firstSHA256(keys []HostKeyInfo) string { for _, k := range keys { if k.Type == "ssh-ed25519" { - return k.SHA256 + _, sha256hex := hostKeyFingerprints(k) + return sha256hex } } if len(keys) > 0 { - return keys[0].SHA256 + _, sha256hex := hostKeyFingerprints(keys[0]) + return sha256hex } return "" } diff --git a/checker/types.go b/checker/types.go index a927c6e..b520c78 100644 --- a/checker/types.go +++ b/checker/types.go @@ -27,7 +27,11 @@ // and SSHFP host-key fingerprint validation. package checker -import "time" +import ( + "net" + "strconv" + "time" +) // ObservationKeySSH is the observation key this checker writes. const ObservationKeySSH = "ssh" @@ -81,12 +85,9 @@ type SSHFPRecord struct { // SSHProbe is the outcome of probing a single SSH endpoint. type SSHProbe struct { - Host string `json:"host"` - Port uint16 `json:"port"` - Address string `json:"address"` - IP string `json:"ip,omitempty"` - IsIPv6 bool `json:"ipv6,omitempty"` - TCPConnected bool `json:"tcp_connected"` + Host string `json:"host"` + Port uint16 `json:"port"` + IP net.IP `json:"ip,omitempty"` // Banner is the SSH protocol banner (e.g. "SSH-2.0-OpenSSH_9.3p1"). Banner string `json:"banner,omitempty"` @@ -125,15 +126,15 @@ type SSHProbe struct { Stage string `json:"stage,omitempty"` } -// HostKeyInfo captures an observed host key and its computed fingerprints. +// Addr returns the "ip:port" dial string for this endpoint. +func (p SSHProbe) Addr() string { + return net.JoinHostPort(p.IP.String(), strconv.Itoa(int(p.Port))) +} + +// HostKeyInfo captures an observed host key in its SSH wire format. type HostKeyInfo struct { - Type string `json:"type"` // e.g. "ssh-ed25519" - Bits int `json:"bits,omitempty"` // key size (bits) - SHA256 string `json:"sha256"` // hex fingerprint (lowercase, no colons) - SHA1 string `json:"sha1"` // hex fingerprint (lowercase, no colons) - SSHFPAlgo uint8 `json:"sshfp_algorithm"` // the SSHFP algorithm number matching this key type - SSHFPMatchSHA256 bool `json:"sshfp_match_sha256"` - SSHFPMatchSHA1 bool `json:"sshfp_match_sha1"` + Type string `json:"type"` // e.g. "ssh-ed25519" + RawKey []byte `json:"key"` // SSH wire format (ssh.PublicKey.Marshal()) } // Issue is a single SSH finding surfaced to consumers.