Let printer-certificate pin several fingerprints, since the printer serves an ECDSA and an RSA certificate (issue 1503)
mesh/merge-gate head 6312bc421964 merged into trunk c294f7bd16dc — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 6312bc421964 merged into trunk c294f7bd16dc — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered

This commit is contained in:
2026-10-20 22:43:02 +02:00
parent c294f7bd16
commit 6312bc4219
6 changed files with 168 additions and 15 deletions
+7 -4
View File
@@ -34,7 +34,7 @@ A scanner that refuses or gives no page files nothing and says nothing on the bu
| `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none |
| `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none |
| `printer` | the printer's host, such as `192.0.2.10`, for its admin page and IPP (required, trusted) | none |
| `printer-certificate` | the SHA-256 fingerprint of the one certificate the admin page may serve, `AA:BB:...` (required, trusted) | none |
| `printer-certificate` | the SHA-256 fingerprint of the certificate the admin page may serve, `AA:BB:...`; several, separated by commas, when it serves more than one (required, trusted) | none |
| `resolution` | dpi asked: 100, 200, 300 or 600 | 300 |
| `color-mode` | `color` or `gray` | `color` |
| `crop` | cut the document out of the lid's white | true |
@@ -67,9 +67,12 @@ makes it (mode 0755) when it files the first scan.
### Trust and the password
The admin page is HTTPS with a self-signed certificate. The module verifies no chain: it accepts the one certificate
whose SHA-256 fingerprint is `printer-certificate` and refuses any other, in the handshake, so no password is sent
to it; the error names both fingerprints. Get the fingerprint from the device's certificate page, and give it as 32
upper-case hex pairs joined by colons.
whose SHA-256 fingerprint is one of those in `printer-certificate` and refuses any other, in the handshake, so no
password is sent to it; the error names the served and the pinned fingerprints. Give each as 32 upper-case hex pairs
joined by colons, several separated by commas. The printer serves two self-signed certificates for one name, an
ECDSA one and an RSA one, and which it serves depends on the signature algorithms the client offers, so pin both
(issue 1503): read them with `openssl s_client -connect <printer>:443 -sigalgs ECDSA+SHA256` and `-sigalgs
RSA-PSS+SHA256`, each piped to `openssl x509 -noout -fingerprint -sha256`.
The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it
at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-device`, which the
+26 -9
View File
@@ -19,19 +19,36 @@ func Fingerprint(der []byte) string {
return strings.Join(parts, ":")
}
// pinned is the check on the certificate a printer serves: the leaf's fingerprint must be the one pinned, and
// any other certificate is refused, with both fingerprints in the message. It runs in the handshake, so a
// refused certificate is refused before one byte of a request (a password) is sent.
func pinned(want string) func([][]byte, [][]*x509.Certificate) error {
want = strings.ToUpper(strings.TrimSpace(want))
// pinList splits the config value printer-certificate into its fingerprints: one or more, separated by commas
// with optional spaces, upper-cased. The printer serves two certificates for one name, an ECDSA one and an RSA
// one, and which it serves depends on the client, so the operator pins both (issue 1503).
func pinList(value string) []string {
var out []string
for _, p := range strings.Split(value, ",") {
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
out = append(out, p)
}
}
return out
}
// pinned is the check on the certificate a printer serves: the leaf's fingerprint must be one of those pinned,
// and any other certificate is refused, with the served and every pinned fingerprint in the message. It runs in
// the handshake, so a refused certificate is refused before one byte of a request (a password) is sent.
func pinned(value string) func([][]byte, [][]*x509.Certificate) error {
want := pinList(value)
list := strings.Join(want, ", ")
return func(raw [][]byte, _ [][]*x509.Certificate) error {
if len(raw) == 0 {
return fmt.Errorf("the printer served no certificate; the pinned printer-certificate is %s", want)
return fmt.Errorf("the printer served no certificate; the pinned printer-certificate is %s", list)
}
if got := Fingerprint(raw[0]); got != want {
return fmt.Errorf("the printer's certificate is %s, but the pinned printer-certificate is %s: refused", got, want)
got := Fingerprint(raw[0])
for _, w := range want {
if got == w {
return nil
}
}
return nil
return fmt.Errorf("the printer's certificate is %s, but the pinned printer-certificate is %s: refused", got, list)
}
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"context"
"encoding/pem"
"os"
"regexp"
"strings"
"testing"
)
// Captured from the printer 2026-10-20 (testdata/pin): it serves two self-signed certificates for one name, an
// ECDSA one to a client offering ECDSA and an RSA one to a client offering RSA only (issue 1503).
const (
printerECDSAPrint = "EA:B4:B7:0F:96:CD:A6:2B:61:53:2E:F0:A8:D6:9F:42:EA:9F:66:0A:82:5B:C2:43:0B:97:C5:9A:7A:B4:18:5B"
printerRSAPrint = "7A:3D:E2:A4:1E:AB:A9:7B:E6:61:0A:C4:18:DD:68:FF:9B:A5:77:E8:02:7F:7E:F8:97:D0:3B:82:A2:89:7D:F4"
)
func capturedDER(t *testing.T, name string) []byte {
t.Helper()
raw, err := os.ReadFile("testdata/pin/" + name)
if err != nil {
t.Fatal(err)
}
b, _ := pem.Decode(raw)
if b == nil {
t.Fatalf("%s is not PEM", name)
}
return b.Bytes
}
// 1503/AC1: the two captured certificates have the two fingerprints, and a pin listing both accepts each.
func TestThePrintersTwoCapturedCertificatesAreBothAcceptedByAPinListingBoth(t *testing.T) {
both := printerECDSAPrint + ", " + printerRSAPrint
for file, want := range map[string]string{"ecdsa.pem": printerECDSAPrint, "rsa.pem": printerRSAPrint} {
der := capturedDER(t, file)
if got := Fingerprint(der); got != want {
t.Fatalf("%s has fingerprint %s, want %s", file, got, want)
}
if err := pinned(both)([][]byte{der}, nil); err != nil {
t.Errorf("%s refused by a pin listing both: %v", file, err)
}
}
}
// 1503/AC1: a server presenting one certificate is accepted when the pin lists its fingerprint among two.
func TestAPinListingTwoFingerprintsAcceptsTheCertificateTheServerPresents(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
a, err := NewAdmin(f.host(), printerECDSAPrint+","+f.print(), "hunter2")
if err != nil {
t.Fatal(err)
}
if err := a.Login(context.Background()); err != nil {
t.Fatalf("a pin listing the served certificate was refused: %v", err)
}
}
// 1503/AC2: a pin that lists only another certificate still refuses, and names every pinned fingerprint.
func TestAPinListingOnlyOtherCertificatesStillRefuses(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
a, _ := NewAdmin(f.host(), printerECDSAPrint+" , "+printerRSAPrint, "hunter2")
err := a.Login(context.Background())
if err == nil {
t.Fatal("a certificate that is in no pinned fingerprint was accepted")
}
for _, want := range []string{f.print(), printerECDSAPrint, printerRSAPrint} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not name %s", err, want)
}
}
if len(f.requests) != 0 {
t.Errorf("the server was sent %v", f.requests)
}
}
// 1503/AC1: a single fingerprint is still a valid pin, in either case.
func TestASingleFingerprintStillPins(t *testing.T) {
der := capturedDER(t, "rsa.pem")
for _, p := range []string{printerRSAPrint, strings.ToLower(printerRSAPrint), " " + printerRSAPrint + " "} {
if err := pinned(p)([][]byte{der}, nil); err != nil {
t.Errorf("pin %q refused: %v", p, err)
}
}
if err := pinned(printerECDSAPrint)([][]byte{der}, nil); err == nil {
t.Error("the other certificate's pin accepted the RSA certificate")
}
}
// 1503/AC3: the manifest's pattern takes one or more fingerprints, separated by commas and optional spaces, and
// nothing else.
func TestThePrinterCertificatePatternTakesACommaSeparatedList(t *testing.T) {
m := readManifest(t)
re := regexp.MustCompile(`^(?:` + m.Config["printer-certificate"]["pattern"].(string) + `)$`)
for _, ok := range []string{printerRSAPrint, printerECDSAPrint + "," + printerRSAPrint, printerECDSAPrint + ", " + printerRSAPrint} {
if !re.MatchString(ok) {
t.Errorf("pattern refuses %q", ok)
}
}
for _, bad := range []string{"", printerRSAPrint + ",", "," + printerRSAPrint, printerECDSAPrint + printerRSAPrint,
printerECDSAPrint + ",," + printerRSAPrint, printerECDSAPrint + ";" + printerRSAPrint, printerRSAPrint + ",EAB4", strings.ToLower(printerRSAPrint)} {
if re.MatchString(bad) {
t.Errorf("pattern admits %q", bad)
}
}
}
+10
View File
@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBVzCB/qADAgECAgQoWaUiMAoGCCqGSM49BAMCMCAxHjAcBgNVBAMTFUJSVzU4
Q0RDOURGQzgwQi5sb2NhbDAgFw0wMDAxMDEwMDAwMDBaGA8yMTEwMTIzMTIzNTk1
OVowIDEeMBwGA1UEAxMVQlJXNThDREM5REZDODBCLmxvY2FsMFkwEwYHKoZIzj0C
AQYIKoZIzj0DAQcDQgAENrR0q9JKcMSypD7yVGMzXpaarlLA3nc4ju1gh9ymeqjy
xyfdiAy08GgvGKZMv+EaqH1xoAaaglmF8Dhgn0GrS6MkMCIwIAYDVR0RBBkwF4IV
QlJXNThDREM5REZDODBCLmxvY2FsMAoGCCqGSM49BAMCA0gAMEUCIQCTpBb5+82s
f/OXfEA77SJREW+6UHFgaDruuJ06wdTouwIgUUr5FkoGzsunwrYAZmqcBw96UKK5
o5tz3ZLrtSke6VM=
-----END CERTIFICATE-----
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC5DCCAcygAwIBAgIEIvomBDANBgkqhkiG9w0BAQsFADAgMR4wHAYDVQQDExVC
Ulc1OENEQzlERkM4MEIubG9jYWwwIBcNMDAwMTAxMDAwMDAwWhgPMjExMDEyMzEy
MzU5NTlaMCAxHjAcBgNVBAMTFUJSVzU4Q0RDOURGQzgwQi5sb2NhbDCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMGbBrytUZsYjcH/d75H1ax+EMKElDWX
p+bnafu5vB0WooGCG7gYbCGuYJRnI+PtFGEVwPlOQojiGmvuDVO2THs+kefcAi/c
7inSrD550kBsfWYVVhyZ3e0tK+UsEgTC9QQ0fdi3AFWnS68L93ooPkCgVBCl1B/c
5VsoGOtdFGgDLPJlmf22KxfSjI2+0cB4/f+VJX+1AoP71s1A4NN8okfuEL+mC1h1
tbzaT+MLTtgxAFMu//tF3287yVLyG+vSBAbX7pujpBsrzBzv5mn2dZrlPwDjD17w
oqfjfuu1ERpxn3fpBtexqXV3ybw2ftGGOVtn9kV2Po/vTTfGMgrz33UCAwEAAaMk
MCIwIAYDVR0RBBkwF4IVQlJXNThDREM5REZDODBCLmxvY2FsMA0GCSqGSIb3DQEB
CwUAA4IBAQBfP6IfdFsaWyc8FHooiRW/oS9OlZxDUrmUrdNP9doRq7PROM2bCnIZ
D6q5B34bLFxHmitMLpy3MKV475yrS83ges5k5rlUPlRFIJaSCpNY6xRwUZdGgurX
h8KdHERXmtCuj1egkTJ9SRAchjlgdQevVJRgeFUCdVZ63NVIBy37zcIVBHOB5R3L
V9lMijXwV2ow9DnXARdXYeSn98XoOMVNyL8lHESyHPN2cS8fijunudGtoieSA0TK
NpAr18jfGBYvM35AYwvSFGqo/yECCfKVHup2HykhOqhWnrvv8OyHmsvdYn2s9bx1
lT903TyZyla6PjeA/8dGj4eJHkVIKv87
-----END CERTIFICATE-----
+2 -2
View File
@@ -34,8 +34,8 @@
},
"printer-certificate": {
"type": "string",
"description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons. The module trusts that certificate alone and refuses any other; the operator's, with no default.",
"pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}$",
"description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons; several, separated by commas, when the printer serves more than one (it serves an ECDSA and an RSA certificate for one name). The module trusts those certificates alone and refuses any other; the operator's, with no default.",
"pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}(, *([0-9A-F]{2}:){31}[0-9A-F]{2})*$",
"trusted": true,
"required": true
},