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 }