Please Login
To Access this page you are required to Login, please Login.
diff --git a/modules/brother/cmd/brother/admin.go b/modules/brother/cmd/brother/admin.go new file mode 100644 index 00000000..ce7aed23 --- /dev/null +++ b/modules/brother/cmd/brother/admin.go @@ -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" +} diff --git a/modules/brother/cmd/brother/admin_test.go b/modules/brother/cmd/brother/admin_test.go new file mode 100644 index 00000000..f346d8cd --- /dev/null +++ b/modules/brother/cmd/brother/admin_test.go @@ -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("
nothing"), 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(``)) + })) + 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) + } +} diff --git a/modules/brother/cmd/brother/html.go b/modules/brother/cmd/brother/html.go new file mode 100644 index 00000000..a3f5e136 --- /dev/null +++ b/modules/brother/cmd/brother/html.go @@ -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:], "") + if end < 0 { + i = len(s) + } else { + i += end + 3 + } + continue + case strings.HasPrefix(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 +} diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index ea3daea0..557ee790 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -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) + } +} diff --git a/modules/brother/cmd/brother/pin.go b/modules/brother/cmd/brother/pin.go new file mode 100644 index 00000000..08702820 --- /dev/null +++ b/modules/brother/cmd/brother/pin.go @@ -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), + } +} diff --git a/modules/brother/cmd/brother/settings.go b/modules/brother/cmd/brother/settings.go index b15175e3..a643b500 100644 --- a/modules/brother/cmd/brother/settings.go +++ b/modules/brother/cmd/brother/settings.go @@ -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 +} diff --git a/modules/brother/cmd/brother/testdata/admin/login-page-headers.txt b/modules/brother/cmd/brother/testdata/admin/login-page-headers.txt new file mode 100644 index 00000000..d68f9a23 --- /dev/null +++ b/modules/brother/cmd/brother/testdata/admin/login-page-headers.txt @@ -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 + diff --git a/modules/brother/cmd/brother/testdata/admin/login-page.html b/modules/brother/cmd/brother/testdata/admin/login-page.html new file mode 100644 index 00000000..9ac63bd5 --- /dev/null +++ b/modules/brother/cmd/brother/testdata/admin/login-page.html @@ -0,0 +1,3 @@ + + +
To Access this page you are required to Login, please Login.