Judge the module healthy by the certificates the printer serves against the pinned list (issue 1510)

This commit is contained in:
2026-10-21 01:47:25 +02:00
parent a75e11e814
commit 1e19e8ee9a
7 changed files with 253 additions and 6 deletions
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"context"
"crypto/tls"
"fmt"
"net"
"slices"
"strings"
"time"
)
// 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,
}},
}
// 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"`
Why string `json:"why"`
Served []Served `json:"served"`
}
// 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) {
d := tls.Dialer{NetDialer: &net.Dialer{Timeout: 5 * time.Second}, 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 {
want := pinList(pins)
addr := serveAddr(host)
h := CertHealth{Healthy: true}
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 = false
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
}
@@ -0,0 +1,109 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"strings"
"testing"
"time"
)
// selfSigned is a self-signed certificate over the key.
func selfSigned(t *testing.T, key any, pub any) tls.Certificate {
t.Helper()
tpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "printer.test"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), DNSNames: []string{"printer.test"}}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, pub, key)
if err != nil {
t.Fatal(err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// twoCertPrinter is a TLS server with an ECDSA and an RSA certificate, as the printer has; it answers its address
// and the two fingerprints.
func twoCertPrinter(t *testing.T) (addr, ecdsaFP, rsaFP string) {
t.Helper()
ek, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
rk, _ := rsa.GenerateKey(rand.Reader, 2048)
ec, rc := selfSigned(t, ek, &ek.PublicKey), selfSigned(t, rk, &rk.PublicKey)
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{ec, rc}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() { c.(*tls.Conn).Handshake(); c.Close() }()
}
}()
return ln.Addr().String(), Fingerprint(ec.Certificate[0]), Fingerprint(rc.Certificate[0])
}
// 1510: both certificates pinned is healthy; a changed one is unhealthy and names the new fingerprint; none
// pinned names both.
func TestTheHealthComparesBothServedCertificatesWithThePins(t *testing.T) {
addr, e, r := twoCertPrinter(t)
ctx := context.Background()
if h := CheckCertificates(ctx, addr, e+", "+r); !h.Healthy || len(h.Served) != 2 || !h.Served[0].Pinned || !h.Served[1].Pinned {
t.Errorf("both pinned: %+v", h)
}
old := strings.Repeat("AB:", 31) + "AB"
h := CheckCertificates(ctx, addr, e+", "+old) // the RSA certificate changed
if h.Healthy || !strings.Contains(h.Why, r) || strings.Contains(h.Why, "ecdsa certificate is now") {
t.Errorf("rsa changed: %+v", h)
}
h = CheckCertificates(ctx, addr, old+", "+r) // the ECDSA certificate changed
if h.Healthy || !strings.Contains(h.Why, e) || strings.Contains(h.Why, "rsa certificate is now") {
t.Errorf("ecdsa changed: %+v", h)
}
h = CheckCertificates(ctx, addr, old)
if h.Healthy || !strings.Contains(h.Why, e) || !strings.Contains(h.Why, r) || !strings.Contains(h.Why, old) {
t.Errorf("none pinned: %+v", h)
}
}
// 1510: a printer that does not answer is not an unhealthy module: nothing can be compared, and the answer says so.
func TestAPrinterThatDoesNotAnswerIsNotUnhealthy(t *testing.T) {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
ln.Close()
h := CheckCertificates(context.Background(), addr, strings.Repeat("AB:", 31)+"AB")
if !h.Healthy || !strings.Contains(h.Why, "none was compared") {
t.Errorf("%+v", h)
}
}
// 1510: brother_health is a tool of the module, read from the settings at each call.
func TestBrotherHealthIsAToolThatReadsTheSettings(t *testing.T) {
addr, e, r := twoCertPrinter(t)
js := `{"printer":"` + addr + `","printer-certificate":"` + e + `, ` + r + `"}`
run := tool(t, newRig(t, js, "pw").tl, "brother_health").Run
got, err := run(map[string]any{})
if err != nil {
t.Fatal(err)
}
if h, ok := got.(CertHealth); !ok || !h.Healthy {
t.Errorf("answer = %+v", got)
}
bad := `{"printer":"` + addr + `","printer-certificate":"` + strings.Repeat("AB:", 31) + `AB"}`
got, _ = tool(t, newRig(t, bad, "pw").tl, "brother_health").Run(map[string]any{})
if h := got.(CertHealth); h.Healthy {
t.Errorf("a wrong pin is healthy: %+v", h)
}
if _, err := tool(t, newRig(t, `{}`, "pw").tl, "brother_health").Run(map[string]any{}); err == nil {
t.Error("an unset printer is healthy")
}
}
@@ -17,7 +17,7 @@ const secretPackage = "brother/cmd/brother/internal/secret"
// toolFiles are the files the tools' bundle runs (the scanner's, the printer.status's and the printer.settings'
// ask), as opposed to the device process's: deviceproc.go, admin.go, pin.go and html.go.
var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go", "parts.go"}
var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go", "parts.go", "certcheck.go"}
// 1453: none of the tools' files imports the password-reading package or names the password's file.
func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) {
+20
View File
@@ -310,6 +310,26 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
return s.ScanNow(ctx, source, duplex)
},
},
{
Name: "brother_health",
Description: "The module's health, asked by the node-engine (novox/hq ADR 0240): the certificates the printer serves, one for each " +
"key type (ECDSA and RSA), read with no login and compared with the pinned printer-certificate list. Answers healthy, why, " +
"and what was served with each fingerprint and whether it is pinned. Not healthy when a certificate served is not pinned (the " +
"printer was reset or given new certificates): why names the new fingerprint, so the pin can be changed before the admin " +
"pages are needed. A printer that does not answer is healthy, with why saying nothing was compared. Read-only.",
Run: func(map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
if err := s.Settings.MissingPrinter(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 9*time.Second)
defer cancel()
return CheckCertificates(ctx, s.Settings.Printer, s.Settings.PrinterCertificate), nil
},
},
{
Name: "scanner.status",
Description: "Whether the configured scanner answers, and what it is doing: its state (Idle or Processing) " +
+3 -1
View File
@@ -256,7 +256,9 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) {
if !reflect.DeepEqual(bus["queues"], []any{"device-asks"}) || !reflect.DeepEqual(bus["state"], []any{"device-answers"}) {
t.Errorf("bus = %v", bus)
}
if h, _ := p["health"].(map[string]any); h["kind"] != "unit" {
// 1510: liveness is judged for every process without a declaration (ADR 0240 §1); the declared check is the
// tool that compares the printer's certificates with the pins.
if h, _ := p["health"].(map[string]any); h["kind"] != "tool" || h["tool"] != "brother_health" {
t.Errorf("health = %v", p["health"])
}
env, _ := p["env"].(map[string]any)