// 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 . // // 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 . 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 { 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 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.", 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 } // 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 { 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 { sha1Match, sha256Match := keyMatchesSSHFP(k, s) if sha256Match || sha1Match { continue } 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.", algo, sha256hex), 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.", algo, sha256hex), 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 { sha1Match, sha256Match := keyMatchesSSHFP(k, s) if sha256Match || sha1Match { 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" { _, sha256hex := hostKeyFingerprints(k) return sha256hex } } if len(keys) > 0 { _, sha256hex := hostKeyFingerprints(keys[0]) return sha256hex } return "" } func shortFP(hexFP string) string { if len(hexFP) < 16 { return hexFP } return strings.ToUpper(hexFP[:8] + ":" + hexFP[8:16] + "…") }