246 lines
7.5 KiB
Go
246 lines
7.5 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 (
|
|
"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] + "…")
|
|
}
|