diff --git a/modules/brother/README.md b/modules/brother/README.md index 05606b67..d092789f 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -65,7 +65,8 @@ makes it (mode 0755) when it files the first scan. |---|---| | `scanner.scan` | `source` (`glass`, default, or `feeder`) and `duplex` (feeder only): asks the device process to scan, crop (glass only), keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`, in the background, so a scan is filed whether or not the caller still waits. A glass scan is waited for up to 180 s. Several glass pages in one PDF (a page turned over, a stapled document): `document` `start` scans page 1 into a new document and answers its id (nothing is filed yet), `document` with that id scans the next page, and `document` with the id and `finish` true closes it into one multi-page PDF, filed once with one `scan.added` (nothing is scanned). The pages are kept in the bucket under `pending//` (not in memory, so a restart of the device process loses none); a document that gets no page for 30 minutes is closed and filed so by the tool process (a restart of the tool process forgets that timer; `finish` still works). The pages in the bucket are not deleted, the bucket having no delete. A feeder scan is waited for 25 s (the mesh console gives up after 30 s); if it is not done, the answer is `started: true` with its `id`, and `scanner.scan {id}` reads the outcome (`state` running, done with the event's body and `path`, or failed with `why`). The device process ends a feeder job at 20 s a page (at least 3 min, at most 30 min). A scan whose PDF the tool could not fetch (over 100 MiB) is refused before it is kept or announced. Answers the event's body and `path` | | `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 | -| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on | +| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. | +| `brother_health` | the module's health, asked by the node-engine every 60 s (ADR 0240): the certificates the printer serves, one for each key type (ECDSA and RSA, told apart by the cipher suites offered under TLS 1.2), read with no login and compared with the pinned `printer-certificate` list. Unhealthy when one is not pinned, `why` naming 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 (it may be switched off). The process's liveness is judged by the node-engine without a declaration | | `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only | ## The printer seat @@ -112,7 +113,7 @@ writes each answer, once, into the state `device-answers` under the ask's id (te title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three deliveries. It reads `settings.json` (mode 0644, no secret in it) and the state directory is 0711 so that it can -reach its own files; both are the choices that let its account read what it needs. Its health is its unit. +reach its own files; both are the choices that let its account read what it needs. Its health is `brother_health` (the printer's certificates against the pins); its liveness is judged by the node-engine. The login reads the form each time: the device generates the password field's name, so the code takes the name from the form, posts it with the form's hidden fields, and keeps the session cookie. A login is failed when the answer is diff --git a/modules/brother/cmd/brother/certcheck.go b/modules/brother/cmd/brother/certcheck.go new file mode 100644 index 00000000..c8a3bf4f --- /dev/null +++ b/modules/brother/cmd/brother/certcheck.go @@ -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 +} diff --git a/modules/brother/cmd/brother/certcheck_test.go b/modules/brother/cmd/brother/certcheck_test.go new file mode 100644 index 00000000..212c1cb8 --- /dev/null +++ b/modules/brother/cmd/brother/certcheck_test.go @@ -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") + } +} diff --git a/modules/brother/cmd/brother/device_boundary_test.go b/modules/brother/cmd/brother/device_boundary_test.go index 5c355612..57964f42 100644 --- a/modules/brother/cmd/brother/device_boundary_test.go +++ b/modules/brother/cmd/brother/device_boundary_test.go @@ -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) { diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 90c9c01a..e6aff4bb 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -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) " + diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 2291186f..ccccf852 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -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) diff --git a/modules/brother/module.json b/modules/brother/module.json index e30094fb..0e252c94 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -154,7 +154,8 @@ "printer.status", "printer.settings", "printer.print", - "printer.upload" + "printer.upload", + "brother_health" ], "resources": [ { @@ -230,7 +231,12 @@ ] }, "health": { - "kind": "unit" + "kind": "tool", + "tool": "brother_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" } } ],