checker: enforce prober-as-observation, move all analysis to rules layer

This commit is contained in:
nemunaire 2026-05-15 17:05:53 +08:00
commit f77895dcab
12 changed files with 174 additions and 171 deletions

View file

@ -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 ""
}