brother: the printer config and admin-password secret, HTTPS pinned to one certificate, and the login by the form's own field (issue 1453)

This commit is contained in:
2026-10-20 21:10:14 +02:00
parent 78f9731d4a
commit 2b62a1819e
12 changed files with 901 additions and 11 deletions
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
)
// Admin is a session on the printer's admin web page, over HTTPS to the one certificate pinned (ADR 0467
// clauses 3 and 4). The device names the password field itself, so every login reads the form first.
type Admin struct {
Host string
Password string
HTTP *http.Client
}
// loginPath is a page that needs a login: asking for it is answered by the login page.
const loginPath = "/general/status.html"
// maxPage bounds a page of the admin site.
const maxPage = 4 << 20
// NewAdmin is a client for the printer's host that trusts the certificate with this fingerprint alone, with a
// cookie jar for the session.
func NewAdmin(host, fingerprint, password string) (*Admin, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &Admin{
Host: host,
Password: password,
HTTP: &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
Transport: &http.Transport{
TLSClientConfig: pinnedTLS(fingerprint),
DisableKeepAlives: true,
},
},
}, nil
}
func (a *Admin) url(path string) string { return "https://" + a.Host + path }
// fetch is one request; the body is read whole, up to maxPage. The final URL is the one after redirects.
func (a *Admin) fetch(ctx context.Context, method, rawURL string, form url.Values) ([]byte, *url.URL, error) {
var req *http.Request
var err error
if form != nil {
req, err = http.NewRequestWithContext(ctx, method, rawURL, strings.NewReader(form.Encode()))
if err == nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
} else {
req, err = http.NewRequestWithContext(ctx, method, rawURL, nil)
}
if err != nil {
return nil, nil, err
}
resp, err := a.HTTP.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(io.LimitReader(resp.Body, maxPage+1))
if err != nil {
return nil, nil, fmt.Errorf("the page %s cannot be read: %w", rawURL, err)
}
if len(buf) > maxPage {
return nil, nil, fmt.Errorf("the page %s is larger than %d bytes", rawURL, maxPage)
}
if resp.StatusCode >= 400 {
return nil, nil, fmt.Errorf("the printer answered %s for %s", resp.Status, resp.Request.URL.Path)
}
return buf, resp.Request.URL, nil
}
// passwordForm is the form of a page that holds a password field, or nil.
func passwordForm(p Page) (*Form, *Field) {
for i := range p.Forms {
for j := range p.Forms[i].Fields {
if p.Forms[i].Fields[j].Type == "password" {
return &p.Forms[i], &p.Forms[i].Fields[j]
}
}
}
return nil, nil
}
// Login asks for a page that needs one, reads the login form it is answered with, and posts the form's own
// fields with the password in the field the form names. A login is failed when the answer is a login page
// again.
func (a *Admin) Login(ctx context.Context) error {
body, at, err := a.fetch(ctx, http.MethodGet, a.url(loginPath), nil)
if err != nil {
return fmt.Errorf("the printer's admin page cannot be asked for its login: %w", err)
}
form, pw := passwordForm(ParsePage(body))
if form == nil {
return fmt.Errorf("the printer's answer to %s holds no login form with a password field", loginPath)
}
target, err := at.Parse(form.Action)
if err != nil {
return fmt.Errorf("the login form's action %q is not a URL: %w", form.Action, err)
}
if target.Host != a.Host {
return fmt.Errorf("the login form posts to %s, not to the printer %s: the password is not sent", target.Host, a.Host)
}
vals := url.Values{}
for _, f := range form.Fields {
if f.Type == "hidden" {
vals.Set(f.Name, f.Value)
}
}
vals.Set(pw.Name, a.Password)
answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals)
if err != nil {
return fmt.Errorf("the login was not answered: %w", err)
}
if again, _ := passwordForm(ParsePage(answer)); again != nil {
return fmt.Errorf("the login failed: the printer showed its login page again, so the secret admin-password is not its password")
}
return nil
}
// Get is a page of the admin site, after a login.
func (a *Admin) Get(ctx context.Context, path string) ([]byte, *url.URL, error) {
return a.fetch(ctx, http.MethodGet, a.url(path), nil)
}
// Logout ends the session through the logout link or form of a page of it, and says what it did. A page with
// none leaves the session to expire, and the answer says so.
func (a *Admin) Logout(ctx context.Context, page []byte, at *url.URL) string {
p := ParsePage(page)
isLogout := func(s string) bool {
s = strings.ToLower(strings.ReplaceAll(s, " ", ""))
return strings.Contains(s, "logout") || strings.Contains(s, "logoff")
}
for _, f := range p.Forms {
if !isLogout(f.Action) {
continue
}
target, err := at.Parse(f.Action)
if err != nil || target.Host != a.Host {
continue
}
vals := url.Values{}
for _, fl := range f.Fields {
if fl.Type == "hidden" {
vals.Set(fl.Name, fl.Value)
}
}
method := http.MethodPost
var send url.Values = vals
if f.Method == "get" {
method, send = http.MethodGet, nil
target.RawQuery = vals.Encode()
}
if _, _, err := a.fetch(ctx, method, target.String(), send); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
for _, l := range p.Links {
if !isLogout(l.Href) && !isLogout(l.Text) {
continue
}
target, err := at.Parse(l.Href)
if err != nil || target.Host != a.Host {
continue
}
if _, _, err := a.fetch(ctx, http.MethodGet, target.String(), nil); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
return "no logout link or form on the page: the session is left to expire on the printer"
}
+230
View File
@@ -0,0 +1,230 @@
package main
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// fakePrinter is a TLS server standing in for the printer's admin page. Its login page is the captured one
// (testdata/admin/login-page.html, 2026-10-20) with the password field's name changed to the one the test
// chooses, as the real device generates it. The pages behind the login are SYNTHETIC (testdata/admin/
// synthetic-*.html): the real ones arrive in round 2 of issue 1453.
type fakePrinter struct {
*httptest.Server
field string // the password field's name the login form gives
password string
mu sync.Mutex
requests []string // method and path of every request
posted []url.Values // every form body posted
session string // the cookie value of the one logged-in session
loggedIn map[string]bool // cookie values that are logged in
pages map[string]string // path -> body behind the login
}
func adminFixture(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "admin", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
func newFakePrinter(t *testing.T, field, password string) *fakePrinter {
t.Helper()
f := &fakePrinter{field: field, password: password, loggedIn: map[string]bool{}, pages: map[string]string{}}
login := strings.ReplaceAll(adminFixture(t, "login-page.html"), "B1891", field)
synthetic := adminFixture(t, "synthetic-status.html")
f.pages["/general/status.html"] = synthetic
f.pages["/general/other.html"] = adminFixture(t, "synthetic-settings.html")
authed := func(r *http.Request) bool {
c, err := r.Cookie("session")
return err == nil && f.loggedIn[c.Value]
}
mux := http.NewServeMux()
mux.HandleFunc("/etc/loginerror.html", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(login))
})
mux.HandleFunc("/home/status.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Method != http.MethodPost {
if authed(r) {
w.Write([]byte(synthetic))
return
}
w.Write([]byte(login))
return
}
r.ParseForm()
f.posted = append(f.posted, r.PostForm)
if r.PostForm.Get(field) != password || r.PostForm.Get("loginurl") == "" {
w.Write([]byte(login))
return
}
f.session = "s" + field
f.loggedIn[f.session] = true
http.SetCookie(w, &http.Cookie{Name: "session", Value: f.session, Path: "/"})
w.Write([]byte(synthetic))
})
mux.HandleFunc("/home/logout.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if c, err := r.Cookie("session"); err == nil {
delete(f.loggedIn, c.Value)
}
w.Write([]byte(login))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
body, ok := f.pages[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
if !authed(r) {
http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
w.Write([]byte(body))
})
f.Server = httptest.NewTLSServer(mux)
t.Cleanup(f.Close)
return f
}
func (f *fakePrinter) host() string { return strings.TrimPrefix(f.URL, "https://") }
// print is the fingerprint of the certificate the server serves.
func (f *fakePrinter) print() string { return Fingerprint(f.TLS.Certificates[0].Certificate[0]) }
func (f *fakePrinter) loggedInNow() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.loggedIn)
}
// 1453/AC2: a certificate whose fingerprint is not the pinned one is refused, the error names both
// fingerprints, and the server saw no request at all, so no password was sent.
func TestACertificateOtherThanThePinnedOneIsRefusedBeforeAnythingIsSent(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
other := strings.Repeat("AA:", 31) + "AA"
a, err := NewAdmin(f.host(), other, "hunter2")
if err != nil {
t.Fatal(err)
}
err = a.Login(context.Background())
if err == nil {
t.Fatal("a certificate that is not the pinned one was accepted")
}
for _, want := range []string{f.print(), other} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not name the fingerprint %s", err, want)
}
}
if len(f.requests) != 0 || len(f.posted) != 0 {
t.Errorf("the server was sent %v and the forms %v: the password may have gone", f.requests, f.posted)
}
}
// 1453/AC2: the pinned certificate itself is accepted, in either case of hex digits.
func TestThePinnedCertificateIsAccepted(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
a, _ := NewAdmin(f.host(), strings.ToLower(f.print()), "hunter2")
if err := a.Login(context.Background()); err != nil {
t.Fatal(err)
}
}
// 1453/AC3: the login posts the password in the field the form names, whatever the name is (the test changes
// it), along with the form's hidden fields, and the session cookie it gets is the one later pages are asked with.
func TestLoginUsesThePasswordFieldNameTheFormGives(t *testing.T) {
for _, name := range []string{"B1891", "Zq77x"} {
f := newFakePrinter(t, name, "s3cret pass")
a, _ := NewAdmin(f.host(), f.print(), "s3cret pass")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatalf("field %s: %v", name, err)
}
if len(f.posted) != 1 {
t.Fatalf("field %s: %d forms posted", name, len(f.posted))
}
if got := f.posted[0].Get(name); got != "s3cret pass" {
t.Errorf("field %s: the password field holds %q", name, got)
}
if f.posted[0].Get("loginurl") != "/general/status.html" {
t.Errorf("field %s: the form's hidden loginurl was %q", name, f.posted[0].Get("loginurl"))
}
if len(f.posted[0]) != 2 {
t.Errorf("field %s: posted %v, want the password and loginurl only", name, f.posted[0])
}
body, _, err := a.Get(ctx, "/general/other.html")
if err != nil || !strings.Contains(string(body), "Synthetic settings") {
t.Errorf("field %s: a page behind the login was not given with the session cookie: %v %.60s", name, err, body)
}
}
}
// 1453/AC4: a wrong password is a failed login, said so: the login page comes back.
func TestAWrongPasswordIsAFailedLogin(t *testing.T) {
f := newFakePrinter(t, "B1891", "right")
a, _ := NewAdmin(f.host(), f.print(), "wrong")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "login failed") {
t.Fatalf("error = %v, want a failed login", err)
}
if f.loggedInNow() != 0 {
t.Errorf("a session was opened")
}
}
// 1453: the logout link on a page ends the session; a page with none says so.
func TestLogoutEndsTheSessionAndAPageWithoutOneSaysSo(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
a, _ := NewAdmin(f.host(), f.print(), "pw")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatal(err)
}
body, at, err := a.Get(ctx, "/general/status.html")
if err != nil {
t.Fatal(err)
}
if got := a.Logout(ctx, body, at); got != "logged out" {
t.Errorf("logout = %q", got)
}
if f.loggedInNow() != 0 {
t.Errorf("the session is still open")
}
if got := a.Logout(ctx, []byte("<html><body>nothing</body></html>"), at); !strings.Contains(got, "no logout") {
t.Errorf("logout without a link = %q", got)
}
}
// 1453: the module never sends the password to a host other than the printer, even if the form says so.
func TestThePasswordIsNotPostedToAnotherHost(t *testing.T) {
// A login form whose action names another host.
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<form method="post" action="https://elsewhere.example/x"><input type="password" name="p"/></form>`))
}))
srv.TLS = &tls.Config{}
srv.StartTLS()
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "https://")
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "not sent") {
t.Fatalf("error = %v", err)
}
}
+309
View File
@@ -0,0 +1,309 @@
package main
import (
"html"
"regexp"
"strings"
"unicode/utf8"
)
// The printer's admin pages are small, tolerant HTML (XHTML 1.0 with unclosed inputs). Only what the module
// reads is taken out of them: the title, the links, and the forms with their fields.
// Link is an anchor of a page.
type Link struct {
Href string `json:"href"`
Text string `json:"text"`
}
// Field is a named control of a form. A password field's value is never kept (ADR 0467 clause 4).
type Field struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
Label string `json:"label"`
Options []string `json:"options,omitempty"`
id string
}
// Form is a form of a page with its named fields.
type Form struct {
Action string `json:"action"`
Method string `json:"method"`
Fields []Field `json:"fields"`
}
// Page is what a page of the admin site holds.
type Page struct {
Title string `json:"title"`
Links []Link `json:"links"`
Forms []Form `json:"forms"`
}
var attrRE = regexp.MustCompile(`([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?`)
// attrs reads a tag's attributes, names in lower case and values unescaped.
func attrs(s string) map[string]string {
out := map[string]string{}
for _, m := range attrRE.FindAllStringSubmatch(s, -1) {
v := m[2] + m[3] + m[4]
out[strings.ToLower(m[1])] = html.UnescapeString(v)
}
return out
}
func squash(s string) string { return strings.Join(strings.Fields(html.UnescapeString(s)), " ") }
// latin1 turns a body that is not UTF-8 into UTF-8, reading each byte as the character of the same number:
// the printer's pages say iso-8859-1.
func latin1(b []byte) string {
if utf8.Valid(b) {
return string(b)
}
r := make([]rune, len(b))
for i, c := range b {
r[i] = rune(c)
}
return string(r)
}
// tagEnd is the index of the '>' that closes the tag starting at s[0], skipping quoted values, or -1.
func tagEnd(s string) int {
var q byte
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case q != 0:
if c == q {
q = 0
}
case c == '"' || c == '\'':
q = c
case c == '>':
return i
}
}
return -1
}
// ParsePage reads a page. Unknown markup is skipped, and what is not closed is closed at the end.
func ParsePage(body []byte) Page {
s := latin1(body)
var p Page
var (
inTitle bool
title strings.Builder
link *Link
formOpen bool
form Form
labelFor string
inLabel bool
label strings.Builder
labelIDs = map[string]string{}
wrapped []int // fields inside a label that names none, by index in form.Fields
selIdx = -1
inOption bool
optValue string
optHasV bool
optSel bool
optText strings.Builder
inTextA bool
textA strings.Builder
textAIdx = -1
)
finishOption := func() {
if !inOption || selIdx < 0 {
inOption = false
return
}
v := optValue
if !optHasV {
v = squash(optText.String())
}
f := &form.Fields[selIdx]
f.Options = append(f.Options, v)
if optSel {
f.Value = v
}
inOption = false
}
closeForm := func() {
finishOption()
selIdx = -1
if formOpen {
p.Forms = append(p.Forms, form)
}
formOpen = false
form = Form{}
}
addField := func(f Field) {
form.Fields = append(form.Fields, f)
if inLabel && labelFor == "" {
wrapped = append(wrapped, len(form.Fields)-1)
}
}
text := func(t string) {
switch {
case inTitle:
title.WriteString(t)
case inOption:
optText.WriteString(t)
case inTextA:
textA.WriteString(t)
}
if link != nil {
link.Text += t
}
if inLabel {
label.WriteString(t)
}
}
i := 0
for i < len(s) {
lt := strings.IndexByte(s[i:], '<')
if lt < 0 {
text(s[i:])
break
}
text(s[i : i+lt])
i += lt
switch {
case strings.HasPrefix(s[i:], "<!--"):
end := strings.Index(s[i:], "-->")
if end < 0 {
i = len(s)
} else {
i += end + 3
}
continue
case strings.HasPrefix(s[i:], "<!") || strings.HasPrefix(s[i:], "<?"):
end := strings.IndexByte(s[i:], '>')
if end < 0 {
i = len(s)
} else {
i += end + 1
}
continue
}
end := tagEnd(s[i:])
if end < 0 {
break
}
raw := s[i+1 : i+end]
i += end + 1
closing := strings.HasPrefix(raw, "/")
raw = strings.TrimSuffix(strings.TrimPrefix(raw, "/"), "/")
name := raw
rest := ""
if sp := strings.IndexAny(raw, " \t\r\n"); sp >= 0 {
name, rest = raw[:sp], raw[sp:]
}
name = strings.ToLower(name)
if !closing && (name == "script" || name == "style") {
if e := strings.Index(strings.ToLower(s[i:]), "</"+name); e >= 0 {
i += e
} else {
i = len(s)
}
continue
}
a := map[string]string{}
if !closing {
a = attrs(rest)
}
switch name {
case "title":
inTitle = !closing
case "a":
if closing {
if link != nil {
link.Text = squash(link.Text)
p.Links = append(p.Links, *link)
}
link = nil
} else if href, ok := a["href"]; ok {
link = &Link{Href: href}
}
case "form":
closeForm()
if !closing {
formOpen = true
form = Form{Action: a["action"], Method: strings.ToLower(a["method"])}
if form.Method == "" {
form.Method = "get"
}
}
case "label":
if closing {
t := squash(label.String())
if labelFor != "" {
labelIDs[labelFor] = t
}
for _, k := range wrapped {
form.Fields[k].Label = t
}
inLabel, labelFor, wrapped = false, "", nil
label.Reset()
} else {
inLabel, labelFor = true, a["for"]
label.Reset()
}
case "input":
if closing || a["name"] == "" {
break
}
typ := strings.ToLower(a["type"])
if typ == "" {
typ = "text"
}
if typ == "submit" || typ == "button" || typ == "image" || typ == "reset" {
break
}
f := Field{Name: a["name"], Type: typ, Value: a["value"], id: a["id"]}
if typ == "password" {
f.Value = ""
}
addField(f)
case "select":
finishOption()
selIdx = -1
if !closing && a["name"] != "" {
addField(Field{Name: a["name"], Type: "select", id: a["id"]})
selIdx = len(form.Fields) - 1
}
case "option":
finishOption()
if !closing && selIdx >= 0 {
inOption = true
optValue, optHasV = a["value"], false
if _, ok := a["value"]; ok {
optHasV = true
}
_, optSel = a["selected"]
optText.Reset()
}
case "textarea":
if closing {
if inTextA && textAIdx >= 0 {
form.Fields[textAIdx].Value = html.UnescapeString(textA.String())
}
inTextA = false
} else if a["name"] != "" {
addField(Field{Name: a["name"], Type: "textarea", id: a["id"]})
inTextA, textAIdx = true, len(form.Fields)-1
textA.Reset()
}
}
}
closeForm()
p.Title = squash(title.String())
for fi := range p.Forms {
for k := range p.Forms[fi].Fields {
f := &p.Forms[fi].Fields[k]
if f.Label == "" && f.id != "" {
f.Label = labelIDs[f.id]
}
}
}
return p
}
+41 -9
View File
@@ -5,18 +5,20 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
)
type manifest struct {
Emits []string `json:"emits"`
Seats []seat `json:"seats"`
Claims []seat `json:"claims"`
Tools []string `json:"tools"`
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
Build struct {
Emits []string `json:"emits"`
OwnSecrets map[string]map[string]any `json:"own-secrets"`
Seats []seat `json:"seats"`
Claims []seat `json:"claims"`
Tools []string `json:"tools"`
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
@@ -73,7 +75,7 @@ func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) {
// code reads is declared, taken by the file and in its template, with the default the code lays under it.
func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
m := readManifest(t)
keys := []string{"scanner", "scan-folder", "resolution", "color-mode", "crop"}
keys := []string{"scanner", "scan-folder", "printer", "printer-certificate", "resolution", "color-mode", "crop"}
for _, k := range keys {
if _, ok := m.Config[k]; !ok {
t.Errorf("config does not declare %s", k)
@@ -82,6 +84,13 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
if len(m.Config) != len(keys) {
t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys))
}
// The printer's pins and addresses are written into the file as they are given, and match what the
// code's own validators accept (ADR 0467 clause 3).
fp := regexp.MustCompile(m.Config["printer-certificate"]["pattern"].(string))
host := regexp.MustCompile(m.Config["printer"]["pattern"].(string))
if !fp.MatchString(strings.Repeat("0A:", 31)+"0A") || fp.MatchString("0a:0a") || !host.MatchString("192.0.2.10") || host.MatchString("a b/c") {
t.Errorf("the printer patterns do not say a host and a SHA-256 fingerprint")
}
var taken []any
for _, r := range m.Resources {
if r["id"] == "settings" {
@@ -118,7 +127,7 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want)
}
}
for _, k := range []string{"scanner", "scan-folder"} {
for _, k := range []string{"scanner", "scan-folder", "printer", "printer-certificate"} {
if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true {
t.Errorf("%s is the operator's: required and trusted", k)
}
@@ -162,3 +171,26 @@ func TestTheScanModuleIsGone(t *testing.T) {
t.Errorf("modules/scan still exists (err %v)", err)
}
}
// 1453/AC4 (ADR 0467 clause 4): the admin password is the module's own secret, issued by the operator (never
// generated), and reaches the code as the file BROTHER_ADMIN_PASSWORD names.
func TestTheAdminPasswordIsTheOperatorsOwnSecret(t *testing.T) {
m := readManifest(t)
s, ok := m.OwnSecrets["admin-password"]
if !ok {
t.Fatal("own-secrets does not declare admin-password")
}
if s["issued-by"] != "outside" {
t.Errorf("issued-by = %v, want outside: the mesh never generates the device's password", s["issued-by"])
}
path, _ := s["path"].(string)
var env string
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
env, _ = a["env"].(map[string]any)["BROTHER_ADMIN_PASSWORD"].(string)
}
}
if path == "" || env != path {
t.Errorf("the secret's path %q and BROTHER_ADMIN_PASSWORD %q differ", path, env)
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"fmt"
"strings"
)
// Fingerprint is the SHA-256 of a certificate's DER bytes, as upper-case hex pairs joined by colons: the
// form the printer's own certificate page and the config value printer-certificate give (ADR 0467 clause 3).
func Fingerprint(der []byte) string {
sum := sha256.Sum256(der)
parts := make([]string, len(sum))
for i, b := range sum {
parts[i] = fmt.Sprintf("%02X", b)
}
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))
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)
}
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)
}
return nil
}
}
// pinnedTLS is the TLS configuration for the printer's admin page: the printer's certificate is self-signed,
// so no chain is verified; the pinned fingerprint is the whole of the trust (ADR 0467 clause 3).
func pinnedTLS(fingerprint string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
// The chain is not verified because the pinned fingerprint is checked in its place, below.
InsecureSkipVerify: true,
VerifyPeerCertificate: pinned(fingerprint),
}
}
+48 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
// Settings are the module's five config values, as the mesh writes them into settings.json (ADR 0348).
// Settings are the module's seven config values, as the mesh writes them into settings.json (ADR 0348).
type Settings struct {
// Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL.
Scanner string
@@ -21,6 +21,11 @@ type Settings struct {
Resolution int
// ColorMode is "color" or "gray".
ColorMode string
// Printer is the device's host (with a port if it is not 443), such as 192.0.2.10: the host of its admin
// page and its IPP service (ADR 0467 clause 3).
Printer string
// PrinterCertificate is the SHA-256 fingerprint of the one certificate the device's admin page may serve.
PrinterCertificate string
// Crop says whether the document is cut out of the platen's white.
Crop bool
}
@@ -52,6 +57,12 @@ func LoadSettings(path string) (Settings, error) {
if v, ok := in["color-mode"].(string); ok && v != "" {
s.ColorMode = v
}
if v, ok := in["printer"].(string); ok {
s.Printer = strings.TrimSpace(v)
}
if v, ok := in["printer-certificate"].(string); ok {
s.PrinterCertificate = strings.TrimSpace(v)
}
if v, ok := in["crop"].(bool); ok {
s.Crop = v
}
@@ -74,3 +85,39 @@ func (s Settings) Missing() error {
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
strings.Join(unset, " and "))
}
// MissingPrinter is nil when the two values the printer's verbs need are set, and otherwise says which is not:
// nothing is contacted before that (1453/AC4).
func (s Settings) MissingPrinter() error {
var unset []string
if s.Printer == "" {
unset = append(unset, "printer")
}
if s.PrinterCertificate == "" {
unset = append(unset, "printer-certificate")
}
if len(unset) == 0 {
return nil
}
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
strings.Join(unset, " and "))
}
// AdminSecret is the module's own secret admin-password: the device's admin password, given by the operator
// at the desk and never generated or rotated by the mesh (ADR 0467 clause 4). path is the file the mesh
// writes it to. A secret not yet given is said by name, and the device is not contacted.
func AdminSecret(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("BROTHER_ADMIN_PASSWORD names no file: the module's secret admin-password does not reach the code")
}
raw, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("the secret admin-password cannot be read: %w", err)
}
pw := strings.TrimRight(string(raw), "\r\n")
if pw == "" {
return "", fmt.Errorf("the secret admin-password is not given: the printer's admin password waits for the operator, " +
"who gives it at their desk (ADR 0283)")
}
return pw, nil
}
@@ -0,0 +1,11 @@
HTTP/1.1 200 OK
Cache-Control: no-store
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Length: 2974
Content-Type: text/html
Content-Language: en-gb
Connection: close
X-XSS-Protection: 1; mode=block
Pragma: no-cache
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html lang="en-gb" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-gb"><head><meta http-equiv="Content-Script-Type" content="text/javascript" /><meta http-equiv="content-style-type" content="text/css" /><meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" /><meta http-equiv="X-UA-Compatible" content="IE=edge"><script type="text/javascript" src="/common/js/ews.js"></script><link rel="stylesheet" type="text/css" href="../common/css/mobile_common.css" />
<link rel="stylesheet" type="text/css" href="../common/css/mobile_ews.css" />
<title>Brother MFC-L8390CDW series</title><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" /></head><body><div id="headerFrame"><div id="menuButton" layout="pc" onclick="toggleMenu();"><img src="/common/images/menu_icon.gif" alt="Menu"></div><div id="modelName" layout="pc">MFC-L8390CDW series</div><div id="corporateLogo" layout="pc"><img src="/common/images/logo.gif" alt="Brother" /></div></div></div><div id="Menu" layout="pc"><div id="navigationContainer"><div id="navMenu"><div id="LayoutButton"><img id="PCButton" layout="pc" src="/common/images/chevron_left.gif" onclick="changeLayout('user_layout','mobile');"></img><img id="MobileButton" layout="pc" src="/common/images/pin.gif" onclick="changeLayout('user_layout','pc');"></img></div><hr><div id="LogInOutBox"><form method="post" action="/home/status.html"><div><input type="password" id="LogBox" name="B1891" /><input type="hidden" name="loginurl" value="/general/status.html"/><input id="login" type="submit" value="Login" /></div></form></div><div class="links"><a href="https://update.brother.co.jp/device/redirect.aspx?redirecttype=6&country=w&lang=en&device=MFC-L8390CDW series&alias=aqfs" target="_blank">Trouble logging in?</a></div><hr><div id="menu_tree_root"><div class="menuLv1"><a href="#" onclick="expandMenu('home_sub');">Home</a><div id="home_sub" class="open"><div class="menuLv2"><a href="/home/status.html">Status</a></div><div class="menuLv2"><a href="/home/reflesh.html" class="subMenu">Auto&#32;Refresh&#32;Interval</a></div></div></div><hr></div></div></div></div><div id="Page" layout="pc"><div id="rightFrameContainer"><div id="rightFrame"><div id="mainContent"><div id="pageTitle"><h2>Please&#32;Login</h2></div><div id="pageContents"><div class="contentsGroup"><p class="noteMessage">To&#32;Access&#32;this&#32;page&#32;you&#32;are&#32;required&#32;to&#32;Login,&#32;please&#32;Login.</p></div></div></div></div></div></div></div><div id="footerFrame">Copyright(C) 2000-2023 Brother Industries, Ltd. All Rights Reserved.</div><script type="text/javascript" src="/common/js/mobilemenucontrl.js"></script><script>window.onpageshow = function(event) {if (event.persisted) { window.location.reload(); }};</script></body></html>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic settings</title></head><body><a href="/home/logout.html">Logout</a><a href="/general/status.html">Status</a><form method="post" action="/general/other.html"><table><tr><td><label for="lock">Scan&#32;lock</label></td><td><input type="checkbox" id="lock" name="lock" value="on"/></td></tr><tr><td><label for="name">Name</label></td><td><input type="text" id="name" name="node" value="BRN-SYNTH"/></td></tr><tr><td><label for="mode">Mode</label></td><td><select id="mode" name="mode"><option value="a">Auto</option><option value="m" selected="selected">Manual</option></select></td></tr><tr><td><label>Secret<input type="password" name="pw" value="shouldnotshow"/></label></td></tr><tr><td><textarea name="note">hello &amp; bye</textarea></td></tr></table><input type="hidden" name="token" value="t1"/><input type="submit" value="Save"/></form></body></html>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic status</title></head><body><div id="LogInOutBox"><a href="/home/logout.html">Logout</a></div><div id="pageTitle"><h2>Status</h2></div><div><a href="/general/other.html">Other&#32;page</a><a href="/net/net/airprint.html">AirPrint</a></div></body></html>
+2
View File
@@ -1,6 +1,8 @@
{
"scanner": "",
"scan-folder": "",
"printer": "",
"printer-certificate": "",
"resolution": 300,
"color-mode": "color",
"crop": true
+24 -1
View File
@@ -19,6 +19,20 @@
"trusted": true,
"required": true
},
"printer": {
"type": "string",
"description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.",
"pattern": "^[A-Za-z0-9.:-]+$",
"trusted": true,
"required": true
},
"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}$",
"trusted": true,
"required": true
},
"resolution": {
"type": "number",
"description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.",
@@ -48,6 +62,12 @@
"default": true
}
},
"own-secrets": {
"admin-password": {
"path": "${dir:state}/admin-password.secret",
"issued-by": "outside"
}
},
"seats": [
{
"name": "scanner",
@@ -93,6 +113,8 @@
"takes-config": [
"scanner",
"scan-folder",
"printer",
"printer-certificate",
"resolution",
"color-mode",
"crop"
@@ -114,7 +136,8 @@
"brother"
],
"env": {
"BROTHER_SETTINGS": "${dir:state}/settings.json"
"BROTHER_SETTINGS": "${dir:state}/settings.json",
"BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret"
}
},
{