Files
mesh-catalog/modules/brother/cmd/brother/certcheck.go
T

144 lines
5.8 KiB
Go

package main
import (
"context"
"crypto/tls"
"fmt"
"net"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The module's health (issue 1510, novox/hq ADR 0240): the node-engine asks the tool brother_health, and the tool
// asks the printer for the certificates it serves, one for each key type, with no login, and compares them with
// the pinned list (the config value printer-certificate). A certificate that is not pinned (the printer was reset,
// new certificates were made, a firmware update changed them) is unhealthy and names the new fingerprint, so the
// operator can approve the new pin before the admin pages are needed. A printer that does not answer is not
// unhealthy (it is switched off, or asleep): nothing can be compared, and the answer says so.
// keyKinds are the two certificates the printer serves for one name. Which one it serves depends on the cipher
// suites the client offers, so each handshake offers only one kind's, under TLS 1.2 (where the suite decides).
var keyKinds = []struct {
Name string
Suites []uint16
}{
{"ecdsa", []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
}},
{"rsa", []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_RSA_WITH_AES_128_CBC_SHA, tls.TLS_RSA_WITH_AES_256_CBC_SHA,
}},
}
// certDial bounds one handshake and certTotal the whole check (two handshakes), so the node-engine's look, which has
// ten seconds, is always answered (issue 1510).
var (
certDial = 3 * time.Second
certTotal = 7 * time.Second
)
// Served is a certificate the printer served to a handshake of one key type.
type Served struct {
Key string `json:"key"`
Fingerprint string `json:"fingerprint,omitempty"`
Pinned bool `json:"pinned"`
Note string `json:"note,omitempty"`
}
// CertHealth is brother_health's answer: healthy, why when it is not (or what could not be compared), and what the
// printer served.
type CertHealth struct {
Healthy bool `json:"healthy"`
State string `json:"state"` // healthy, unhealthy or waits (ADR 0283)
Why string `json:"why"`
Served []Served `json:"served"`
}
// CertWaits is the answer while printer or printer-certificate is not given: the module waits for the operator,
// which is not a fault (ADR 0283), and names each setting as the SDK's health.Wait asks.
func CertWaits(s Settings) (health.Answer, bool) {
var waits []health.Wait
var names []string
for _, c := range []struct{ name, value, what string }{
{"printer", s.Printer, "the printer's address"},
{"printer-certificate", s.PrinterCertificate, "the SHA-256 fingerprints of the certificates the printer serves"},
} {
if c.value == "" {
waits = append(waits, health.Wait{Part: "the printer's certificate check", Setting: c.name, What: c.what})
names = append(names, c.name)
}
}
if len(waits) == 0 {
return health.Answer{}, false
}
return health.Waiting("waits for the operator to give the config value "+strings.Join(names, " and "), waits...), true
}
// serveAddr is the host with the HTTPS port when it names none.
func serveAddr(host string) string {
if _, _, err := net.SplitHostPort(host); err == nil {
return host
}
return net.JoinHostPort(strings.Trim(host, "[]"), "443")
}
// served is the leaf's fingerprint the printer gives a handshake that offers only these suites, or why it gave none.
func served(ctx context.Context, addr string, suites []uint16) (string, error) {
ctx, cancel := context.WithTimeout(ctx, certDial)
defer cancel()
d := tls.Dialer{NetDialer: &net.Dialer{Timeout: certDial}, Config: &tls.Config{
MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, CipherSuites: suites,
// Nothing is sent over this connection, and the certificate is only read, to be compared with the pins.
InsecureSkipVerify: true,
}}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return "", err
}
defer conn.Close()
state := conn.(*tls.Conn).ConnectionState()
if len(state.PeerCertificates) == 0 {
return "", fmt.Errorf("the printer served no certificate")
}
return Fingerprint(state.PeerCertificates[0].Raw), nil
}
// CheckCertificates compares what the printer serves with the pins. The login is never used.
func CheckCertificates(ctx context.Context, host, pins string) CertHealth {
ctx, cancel := context.WithTimeout(ctx, certTotal)
defer cancel()
want := pinList(pins)
addr := serveAddr(host)
h := CertHealth{Healthy: true, State: "healthy"}
var changed, unreachable []string
for _, k := range keyKinds {
s := Served{Key: k.Name}
fp, err := served(ctx, addr, k.Suites)
if err != nil {
s.Note = err.Error()
unreachable = append(unreachable, k.Name+": "+err.Error())
} else {
s.Fingerprint, s.Pinned = fp, slices.Contains(want, fp)
if !s.Pinned {
changed = append(changed, fmt.Sprintf("the %s certificate is now %s", k.Name, fp))
}
}
h.Served = append(h.Served, s)
}
switch {
case len(changed) > 0:
h.Healthy, h.State = false, "unhealthy"
h.Why = fmt.Sprintf("the printer serves a certificate that is not pinned: %s; the pinned printer-certificate is %s. If the printer was reset or given new certificates, set printer-certificate to the new fingerprint(s) (with the old ones kept if they still serve)",
strings.Join(changed, "; "), strings.Join(want, ", "))
case len(unreachable) == len(keyKinds):
h.Why = "the printer gave no certificate, so none was compared (it may be switched off or asleep): " + strings.Join(unreachable, "; ")
}
return h
}