checker-ssh/checker/sshfp.go

174 lines
5.9 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
"strings"
)
// analyseHandshakeHostKey flags an endpoint where the full handshake
// never yielded any host key.
func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Issue {
if !reached || len(keys) > 0 {
return nil
}
return []Issue{{
Code: "no_host_key",
Severity: SeverityCrit,
Message: "Could not retrieve any SSH host key; the full handshake failed.",
Fix: "Check that the server accepts curve25519-sha256 or a similarly modern KEX, and that firewalls don't terminate the TLS-less SSH transport mid-flight.",
Endpoint: addr,
}}
}
// analyseHostKeyStrength flags host keys whose size is below the
// minimum accepted by modern OpenSSH.
func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue {
var issues []Issue
for _, k := range keys {
if k.SSHFPAlgo == 1 && k.Bits > 0 && k.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),
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,
})
}
}
return issues
}
// 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 {
if len(keys) == 0 {
return nil
}
if !s.Present {
return []Issue{{
Code: "sshfp_missing",
Severity: SeverityInfo,
Message: "No SSHFP records published. Clients currently trust-on-first-use this server's host key.",
Fix: fmt.Sprintf("Publish SSHFP records under this service. Example for the ed25519 key: `IN SSHFP 4 2 %s`.", firstSHA256(keys)),
Endpoint: addr,
}}
}
var issues []Issue
coveredFamily := map[uint8]bool{}
for _, rr := range s.Records {
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,
})
continue
}
if !coveredFamily[k.SSHFPAlgo] {
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),
Endpoint: addr,
})
continue
}
issues = append(issues, Issue{
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),
Endpoint: addr,
})
}
return issues
}
// analyseSSHFPHashes flags a server whose published SSHFP records only
// use the deprecated SHA-1 (type 1) hash variant and where at least
// one of those records matched an observed key.
func analyseSSHFPHashes(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue {
if !s.Present {
return nil
}
matchedAny := false
for _, k := range keys {
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 {
matchedAny = true
break
}
}
if !matchedAny {
return nil
}
for _, rr := range s.Records {
if rr.Type == 2 {
return nil
}
}
return []Issue{{
Code: "sshfp_only_sha1",
Severity: SeverityWarn,
Message: "SSHFP records use only SHA-1 (type 1) fingerprints. SHA-1 is deprecated for this use.",
Fix: "Add SHA-256 (type 2) SSHFP records alongside (or instead of) the existing SHA-1 ones.",
Endpoint: addr,
}}
}
// analyseHostKeys is a convenience wrapper used by the HTML report.
// reachedKexInit signals whether the handshake made it far enough for
// the absence of host keys to be meaningful (i.e. the server accepted
// our KEXINIT).
func analyseHostKeys(addr string, keys []HostKeyInfo, s SSHFPSummary, reachedKexInit bool) []Issue {
var issues []Issue
issues = append(issues, analyseHandshakeHostKey(addr, reachedKexInit, keys)...)
issues = append(issues, analyseHostKeyStrength(addr, keys)...)
issues = append(issues, analyseSSHFPAlignment(addr, keys, s)...)
issues = append(issues, analyseSSHFPHashes(addr, keys, s)...)
return issues
}
func firstSHA256(keys []HostKeyInfo) string {
for _, k := range keys {
if k.Type == "ssh-ed25519" {
return k.SHA256
}
}
if len(keys) > 0 {
return keys[0].SHA256
}
return ""
}
func shortFP(hexFP string) string {
if len(hexFP) < 16 {
return hexFP
}
return strings.ToUpper(hexFP[:8] + ":" + hexFP[8:16] + "…")
}