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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBVzCB/qADAgECAgQoWaUiMAoGCCqGSM49BAMCMCAxHjAcBgNVBAMTFUJSVzU4
|
||||
Q0RDOURGQzgwQi5sb2NhbDAgFw0wMDAxMDEwMDAwMDBaGA8yMTEwMTIzMTIzNTk1
|
||||
OVowIDEeMBwGA1UEAxMVQlJXNThDREM5REZDODBCLmxvY2FsMFkwEwYHKoZIzj0C
|
||||
AQYIKoZIzj0DAQcDQgAENrR0q9JKcMSypD7yVGMzXpaarlLA3nc4ju1gh9ymeqjy
|
||||
xyfdiAy08GgvGKZMv+EaqH1xoAaaglmF8Dhgn0GrS6MkMCIwIAYDVR0RBBkwF4IV
|
||||
QlJXNThDREM5REZDODBCLmxvY2FsMAoGCCqGSM49BAMCA0gAMEUCIQCTpBb5+82s
|
||||
f/OXfEA77SJREW+6UHFgaDruuJ06wdTouwIgUUr5FkoGzsunwrYAZmqcBw96UKK5
|
||||
o5tz3ZLrtSke6VM=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -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-----
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user