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
+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-----