diff --git a/daemon/api.go b/daemon/api.go index 0d860e28314..6b25a12f760 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -85,6 +85,7 @@ var api = []*Command{ registryCmd, noticesCmd, noticeCmd, + systemSecurebootCmd, } const ( diff --git a/daemon/api_system_secureboot.go b/daemon/api_system_secureboot.go new file mode 100644 index 00000000000..98ea45fc58a --- /dev/null +++ b/daemon/api_system_secureboot.go @@ -0,0 +1,166 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package daemon + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/snapcore/snapd/overlord/auth" + "github.com/snapcore/snapd/overlord/fdestate" +) + +var systemSecurebootCmd = &Command{ + // TODO GET returning whether secure boot is relevant for the system? + + Path: "/v2/system-secureboot", + POST: postSystemSecurebootAction, + WriteAccess: interfaceProviderRootAccess{ + // TODO find a specialized interface for this, but for now assume that + // requests will come only from snaps plugging fwupd interface on the + // slot side, which also allows manipulation of EFI variables + Interfaces: []string{"fwupd"}, + }, +} + +func postSystemSecurebootAction(c *Command, r *http.Request, user *auth.UserState) Response { + contentType := r.Header.Get("Content-Type") + + switch contentType { + case "application/json": + return postSystemSecurebootActionJSON(c, r) + default: + return BadRequest("unexpected content type: %q", contentType) + } +} + +type securebootRequest struct { + Action string `json:"action,omitempty"` + + // Payload is a base64 encoded binary blob, is used in + // efi-secureboot-db-prepare action, and carries the DBX update content. The + // blob is in the range from few kB to tens of kBs + Payload string `json:"payload,omitempty"` + + // DB is used with efi-secureboot-db-prepare action, and indicates the + // secureboot keys DB which is a target of the action, possible values are + // PK, KEK, DB, DBX + DB string `json:"db,omitempty"` +} + +func (r *securebootRequest) Validate() error { + switch r.Action { + case "efi-secureboot-update-startup", "efi-secureboot-update-db-cleanup": + if r.DB != "" { + return fmt.Errorf("unexpected key DB for action %q", r.Action) + } + + if len(r.Payload) > 0 { + return fmt.Errorf("unexpected payload for action %q", r.Action) + } + case "efi-secureboot-update-db-prepare": + switch r.DB { + case "PK", "KEK", "DB", "DBX": + default: + return fmt.Errorf("invalid key DB %q", r.DB) + } + + if len(r.Payload) == 0 { + return errors.New("update payload not provided") + } + default: + return fmt.Errorf("unsupported EFI secure boot action %q", r.Action) + } + return nil +} + +func postSystemSecurebootActionJSON(c *Command, r *http.Request) Response { + var req securebootRequest + + decoder := json.NewDecoder(r.Body) + + if err := decoder.Decode(&req); err != nil { + return BadRequest("cannot decode request body: %v", err) + } + + if decoder.More() { + return BadRequest("extra content found in request body") + } + + if err := req.Validate(); err != nil { + return BadRequest(err.Error()) + } + + switch req.Action { + case "efi-secureboot-update-startup": + return postSystemActionEFISecurebootUpdateStartup(c) + case "efi-secureboot-update-db-cleanup": + return postSystemActionEFISecurebootUpdateDBCleanup(c) + case "efi-secureboot-update-db-prepare": + return postSystemActionEFISecurebootUpdateDBPrepare(c, &req) + default: + return InternalError("support for EFI secure boot action %q is not implemented", req.Action) + } +} + +var fdestateEFISecureBootDBUpdatePrepare = fdestate.EFISecureBootDBUpdatePrepare + +func postSystemActionEFISecurebootUpdateDBPrepare(c *Command, req *securebootRequest) Response { + if req.DB != "DBX" { + return InternalError("support for key DB %q is not implemented", req.DB) + } + + payload, err := base64.StdEncoding.DecodeString(req.Payload) + if err != nil { + return BadRequest("cannot decode payload: %v", err) + } + + err = fdestateEFISecureBootDBUpdatePrepare(c.d.state, + fdestate.EFISecurebootDBX, // only DBX updates are supported + payload) + if err != nil { + return BadRequest("cannot notify of update prepare: %v", err) + } + + return SyncResponse(nil) +} + +var fdestateEFISecureBootDBUpdateCleanup = fdestate.EFISecureBootDBUpdateCleanup + +func postSystemActionEFISecurebootUpdateDBCleanup(c *Command) Response { + if err := fdestateEFISecureBootDBUpdateCleanup(c.d.state); err != nil { + return BadRequest("cannot notify of update cleanup: %v", err) + } + + return SyncResponse(nil) +} + +var fdestateEFISecureBootDBManagerStartup = fdestate.EFISecureBootDBManagerStartup + +func postSystemActionEFISecurebootUpdateStartup(c *Command) Response { + if err := fdestateEFISecureBootDBManagerStartup(c.d.state); err != nil { + return BadRequest("cannot notify of manager startup: %v", err) + } + + return SyncResponse(nil) +} diff --git a/daemon/api_system_secureboot_test.go b/daemon/api_system_secureboot_test.go new file mode 100644 index 00000000000..4e4a017143c --- /dev/null +++ b/daemon/api_system_secureboot_test.go @@ -0,0 +1,251 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package daemon_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/daemon" + "github.com/snapcore/snapd/overlord/fdestate" + "github.com/snapcore/snapd/overlord/state" +) + +var _ = Suite(&systemSecurebootSuite{}) + +type systemSecurebootSuite struct { + apiBaseSuite +} + +func (s *systemSecurebootSuite) SetUpTest(c *C) { + s.apiBaseSuite.SetUpTest(c) + + s.expectRootAccess() + s.expectWriteAccess(daemon.InterfaceProviderRootAccess{ + Interfaces: []string{"fwupd"}, + }) + + s.AddCleanup(daemon.MockFdestateEFISecureBootDBUpdatePrepare(func(st *state.State, db fdestate.EFISecurebooKeystDB, payload []byte) error { + panic("unexpected call") + })) + s.AddCleanup(daemon.MockFdestateEFISecureBootDBUpdateCleanup(func(st *state.State) error { + panic("unexpected call") + })) + s.AddCleanup(daemon.MockFdestateEFISecureBootDBManagerStartup(func(st *state.State) error { + panic("unexpected call") + })) +} + +func (s *systemSecurebootSuite) TestEFISecurebootContentType(c *C) { + s.daemon(c) + + body := strings.NewReader(`{"action": "blah"}`) + req, err := http.NewRequest("POST", "/v2/system-secureboot", body) + c.Assert(err, IsNil) + + rsp := s.errorReq(c, req, nil) + c.Assert(rsp.Status, Equals, 400) + c.Assert(rsp.Message, Equals, `unexpected content type: ""`) +} + +func (s *systemSecurebootSuite) TestEFISecurebootBogusAction(c *C) { + s.daemon(c) + + body := strings.NewReader(`{"action": "blah"}`) + req, err := http.NewRequest("POST", "/v2/system-secureboot", body) + c.Assert(err, IsNil) + req.Header.Add("Content-Type", "application/json") + + rsp := s.errorReq(c, req, nil) + c.Assert(rsp.Status, Equals, 400) + c.Assert(rsp.Message, Equals, `unsupported EFI secure boot action "blah"`) +} + +func (s *systemSecurebootSuite) TestEFISecurebootUpdateStartup(c *C) { + s.daemon(c) + + startupCalls := 0 + s.AddCleanup(daemon.MockFdestateEFISecureBootDBManagerStartup(func(st *state.State) error { + startupCalls++ + return nil + })) + + body := strings.NewReader(`{"action": "efi-secureboot-update-startup"}`) + req, err := http.NewRequest("POST", "/v2/system-secureboot", body) + c.Assert(err, IsNil) + req.RemoteAddr = "pid=100;uid=0;socket=;" + req.Header.Add("Content-Type", "application/json") + + rsp := s.syncReq(c, req, nil) + c.Assert(rsp.Status, Equals, 200) + + c.Check(startupCalls, Equals, 1) +} + +func (s *systemSecurebootSuite) TestEFISecurebootUpdateDBCleanup(c *C) { + s.daemon(c) + + cleanupCalls := 0 + s.AddCleanup(daemon.MockFdestateEFISecureBootDBUpdateCleanup(func(st *state.State) error { + cleanupCalls++ + return nil + })) + + body := strings.NewReader(`{"action": "efi-secureboot-update-db-cleanup"}`) + req, err := http.NewRequest("POST", "/v2/system-secureboot", body) + c.Assert(err, IsNil) + req.RemoteAddr = "pid=100;uid=0;socket=;" + req.Header.Add("Content-Type", "application/json") + + rsp := s.syncReq(c, req, nil) + c.Assert(rsp.Status, Equals, 200) + + c.Check(cleanupCalls, Equals, 1) +} + +func (s *systemSecurebootSuite) TestEFISecurebootUpdateDBPrepareNoData(c *C) { + s.daemon(c) + + body := strings.NewReader(`{ + "action": "efi-secureboot-update-db-prepare", + "db": "DBX" +}`) + req, err := http.NewRequest("POST", "/v2/system-secureboot", body) + c.Assert(err, IsNil) + req.RemoteAddr = "pid=100;uid=0;socket=;" + req.Header.Add("Content-Type", "application/json") + + rsp := s.errorReq(c, req, nil) + c.Assert(rsp.Status, Equals, 400) + c.Check(rsp.Message, Matches, "update payload not provided") +} + +func (s *systemSecurebootSuite) TestEFISecurebootUpdateDBPrepareBogusDB(c *C) { + s.daemon(c) + + body := strings.NewReader(`{ + "action": "efi-secureboot-update-db-prepare", + "db": "FOO" +}`) + req, err := http.NewRequest("POST", "/v2/system-secureboot", body) + c.Assert(err, IsNil) + req.RemoteAddr = "pid=100;uid=0;socket=;" + req.Header.Add("Content-Type", "application/json") + + rsp := s.errorReq(c, req, nil) + c.Assert(rsp.Status, Equals, 400) + c.Check(rsp.Message, Equals, `invalid key DB "FOO"`) +} + +func (s *systemSecurebootSuite) TestEFISecurebootUpdateDBPrepareBadPayload(c *C) { + s.daemon(c) + + body := strings.NewReader(`{ + "action": "efi-secureboot-update-db-prepare", + "db": "DBX", + "payload": "123" +}`) + req, err := http.NewRequest("POST", "/v2/system-secureboot", body) + c.Assert(err, IsNil) + req.RemoteAddr = "pid=100;uid=0;socket=;" + req.Header.Add("Content-Type", "application/json") + + rsp := s.errorReq(c, req, nil) + c.Assert(rsp.Status, Equals, 400) + c.Check(rsp.Message, Matches, `cannot decode payload: illegal base64 .*`) +} + +func (s *systemSecurebootSuite) TestEFISecurebootUpdateDBPrepareHappy(c *C) { + s.daemon(c) + + updatePrepareCalls := 0 + s.AddCleanup(daemon.MockFdestateEFISecureBootDBUpdatePrepare(func(st *state.State, db fdestate.EFISecurebooKeystDB, payload []byte) error { + c.Check(db, Equals, fdestate.EFISecurebootDBX) + c.Check(payload, DeepEquals, []byte("payload")) + updatePrepareCalls++ + return nil + })) + + body, err := json.Marshal(map[string]any{ + "action": "efi-secureboot-update-db-prepare", + "db": "DBX", + "payload": base64.StdEncoding.EncodeToString([]byte("payload")), + }) + c.Assert(err, IsNil) + req, err := http.NewRequest("POST", "/v2/system-secureboot", bytes.NewReader(body)) + c.Assert(err, IsNil) + req.RemoteAddr = "pid=100;uid=0;socket=;" + req.Header.Add("Content-Type", "application/json") + + rsp := s.syncReq(c, req, nil) + c.Assert(rsp.Status, Equals, 200) + + c.Check(updatePrepareCalls, Equals, 1) +} + +func (s *systemSecurebootSuite) TestSecurebootRequestValidate(c *C) { + r := daemon.SecurebootRequest{ + Action: "foo", + } + c.Check(r.Validate(), ErrorMatches, `unsupported EFI secure boot action "foo"`) + + r = daemon.SecurebootRequest{ + Action: "efi-secureboot-update-startup", + DB: "DBX", + } + c.Check(r.Validate(), ErrorMatches, `unexpected key DB for action "efi-secureboot-update-startup"`) + + r = daemon.SecurebootRequest{ + Action: "efi-secureboot-update-db-cleanup", + Payload: "123", + } + c.Check(r.Validate(), ErrorMatches, `unexpected payload for action "efi-secureboot-update-db-cleanup"`) + + r = daemon.SecurebootRequest{ + Action: "efi-secureboot-update-db-prepare", + DB: "FOO", + } + c.Check(r.Validate(), ErrorMatches, `invalid key DB "FOO"`) + + r = daemon.SecurebootRequest{ + Action: "efi-secureboot-update-db-prepare", + DB: "DBX", + } + c.Check(r.Validate(), ErrorMatches, `update payload not provided`) + + // valid + for _, r := range []daemon.SecurebootRequest{{ + Action: "efi-secureboot-update-db-prepare", + DB: "DBX", + Payload: "123", + }, { + Action: "efi-secureboot-update-db-cleanup", + }, { + Action: "efi-secureboot-update-startup", + }} { + c.Logf("testing valid request %+v", r) + c.Check(r.Validate(), IsNil) + } +} diff --git a/daemon/export_api_system_secureboot_test.go b/daemon/export_api_system_secureboot_test.go new file mode 100644 index 00000000000..d489c93e1db --- /dev/null +++ b/daemon/export_api_system_secureboot_test.go @@ -0,0 +1,52 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package daemon + +import ( + "github.com/snapcore/snapd/overlord/fdestate" + "github.com/snapcore/snapd/overlord/state" + "github.com/snapcore/snapd/testutil" +) + +type SecurebootRequest = securebootRequest + +func MockFdestateEFISecureBootDBUpdatePrepare( + f func(st *state.State, db fdestate.EFISecurebooKeystDB, payload []byte) error, +) (restore func()) { + restore = testutil.Backup(&fdestateEFISecureBootDBUpdatePrepare) + fdestateEFISecureBootDBUpdatePrepare = f + return restore +} + +func MockFdestateEFISecureBootDBUpdateCleanup( + f func(st *state.State) error, +) (restore func()) { + restore = testutil.Backup(&fdestateEFISecureBootDBUpdateCleanup) + fdestateEFISecureBootDBUpdateCleanup = f + return restore +} + +func MockFdestateEFISecureBootDBManagerStartup( + f func(st *state.State) error, +) (restore func()) { + restore = testutil.Backup(&fdestateEFISecureBootDBManagerStartup) + fdestateEFISecureBootDBManagerStartup = f + return restore +}