Judge the module healthy by the certificates the printer serves against the pinned list (issue 1510)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) " +
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user