Bring the branch up to main: brother pins several printer certificates (issue 1503), messenger offers unsent asks again (issue 1507), delta part 1 of 4

This commit is contained in:
2026-10-20 21:08:08 +00:00
parent bda49fb945
commit bb5d420538
9 changed files with 270 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
},
+33
View File
@@ -702,6 +702,39 @@ func (h *Holder) releaseHeld() {
}
}
// offerUnsent offers again every open ask that no channel has been sent and nothing is queued for: the outbox
// is in memory, so an ask queued when the process stopped was lost with it, and an ask is otherwise offered only
// when it is taken (issue 1507). It goes through the path a new ask takes, so the content rule, the channels'
// capabilities and the burst window apply as they do there; one already sent, queued, held or ended is left.
func (h *Holder) offerUnsent() {
h.mu.Lock()
var keys []string
for k, e := range h.asks {
if e.rec.State == StateOpen {
keys = append(keys, k)
}
}
h.mu.Unlock()
sort.Strings(keys)
now := h.Now()
for _, key := range keys {
e := h.askOf(key)
if e == nil || e.rec.State != StateOpen || !now.Before(e.rec.Ask.Expires) {
continue
}
rec := e.rec
h.mu.Lock()
r := h.open[rec.Record]
skip := r != nil && (!r.Cleared.IsZero() || len(r.Sent) > 0 || r.Pending != "" || (r.Ask != "" && r.Ask != key))
h.mu.Unlock()
if skip || (r != nil && (h.queued(r.Key) || r.silenced(now))) || (r == nil && h.queued("ask."+key)) {
continue
}
h.Logf("[messenger] the ask %s was never sent on a channel; offering it", key)
h.showAsk(key, rec, now)
}
}
// saveHeld records a held ask's change by compare-and-set, and keeps it; false when another writer was first.
func (h *Holder) saveHeld(key string, rec AskRecord, rev uint64) bool {
if h.Asks != nil {
@@ -770,6 +770,7 @@ func (h *Holder) Tick() {
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
h.sweepAsks(now)
h.releaseHeld()
h.offerUnsent()
h.breakGlass()
h.boundWalks(now)
h.duePasses(now)
@@ -0,0 +1,68 @@
package main
import (
"testing"
"time"
)
// Issue 1507: an ask was taken and queued for its burst window, the process was replaced before the flush, and
// the new process never offered it: the outbox is in memory, and an ask is offered only when it is taken. An
// open ask no channel has been sent is offered again, once, by the minute tick; one already sent is not.
// restarted is a new holder over the same stores and channels, as a replaced process is.
func restarted(t *testing.T, old *Holder, c *clock, st *memStore) *Holder {
t.Helper()
h := &Holder{Away: old.Away, Desk: old.Desk, Store: st, Now: c.now, Logf: t.Logf, Emit: old.Emit,
Bench: old.Bench, Asks: old.Asks, Node: old.Node, RootFree: old.RootFree, Zone: old.Zone, Publish: old.Publish}
h.init()
if err := h.Load(); err != nil {
t.Fatal(err)
}
h.Start()
return h
}
func TestAnAskQueuedWhenTheProcessStoppedIsOfferedByTheNextProcess(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
st := h.Store.(*memStore)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-config", heldAsk(c, "cfg-1"))
if len(tg.sends)+len(dt.sends) != 0 {
t.Fatalf("sent before the burst window")
}
// The process stops here, before the flush.
h2 := restarted(t, h, c, st)
for i := 0; i < 5; i++ {
c.pass(time.Minute)
stand(h2, c)
h2.Tick()
h2.Flush()
}
if n := len(tg.sends); n != 1 {
t.Fatalf("the ask was sent %d times on telegram, want once", n)
}
if n := len(dt.sends); n != 0 {
t.Fatalf("an approval ask was sent on the desk: %d", n)
}
}
func TestAnAskAlreadySentIsNotSentAgainAfterARestart(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
st := h.Store.(*memStore)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-config", heldAsk(c, "cfg-1"))
settle(h, c)
if len(tg.sends) != 1 {
t.Fatalf("not sent: %d", len(tg.sends))
}
h2 := restarted(t, h, c, st)
for i := 0; i < 5; i++ {
c.pass(time.Minute)
stand(h2, c)
h2.Tick()
h2.Flush()
}
if len(tg.sends) != 1 {
t.Fatalf("sent again after a restart: %d", len(tg.sends))
}
}