diff --git a/auth_requestor.go b/auth_requestor.go index e275c08e..0a37e7ae 100644 --- a/auth_requestor.go +++ b/auth_requestor.go @@ -21,6 +21,7 @@ package secboot import ( "context" + "errors" "fmt" "strings" ) @@ -81,6 +82,20 @@ const ( UserAuthResultInvalidFormat ) +var ErrAuthRequestorNotAvailable = errors.New("the auth requestor is not available") + +// AuthRequestorStringer is used by the some implementation of [AuthRequestor] to +// obtain translated strings. +type AuthRequestorStringer interface { + // RequestUserCredentialString returns messages used by RequestUserCredential. The + // name is a string supplied via the WithAuthRequestorUserVisibleName option, and the + // path is the storage container path. + RequestUserCredentialString(name, path string, authTypes UserAuthType) (string, error) + + // NotifyUserAuthResultString returns messages used by NotifyUserAuthResult. + NotifyUserAuthResultString(name, path string, result UserAuthResult, authTypes, exhaustedAuthTypes UserAuthType) (string, error) +} + // AuthRequestor is an interface for requesting credentials. type AuthRequestor interface { // RequestUserCredential is used to request a user credential that is diff --git a/auth_requestor_auto.go b/auth_requestor_auto.go new file mode 100644 index 00000000..8909c6b9 --- /dev/null +++ b/auth_requestor_auto.go @@ -0,0 +1,96 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2026 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 secboot + +import ( + "context" + "errors" + "fmt" + "io" +) + +var ( + newPlymouthAuthRequestor = NewPlymouthAuthRequestor + newSystemdAuthRequestor = NewSystemdAuthRequestor +) + +type autoAuthRequestor struct { + requestors []AuthRequestor + lastUsed AuthRequestor +} + +func (r *autoAuthRequestor) RequestUserCredential(ctx context.Context, name, path string, authTypes UserAuthType) (string, UserAuthType, error) { + for _, req := range r.requestors { + switch cred, credType, err := req.RequestUserCredential(ctx, name, path, authTypes); { + case err == nil: + r.lastUsed = req + fallthrough + case !errors.Is(err, ErrAuthRequestorNotAvailable): + return cred, credType, err + } + } + + return "", 0, ErrAuthRequestorNotAvailable +} + +func (r *autoAuthRequestor) NotifyUserAuthResult(ctx context.Context, result UserAuthResult, authTypes, exhaustedAuthTypes UserAuthType) error { + if r.lastUsed == nil { + return errors.New("no user credential requested yet") + } + return r.lastUsed.NotifyUserAuthResult(ctx, result, authTypes, exhaustedAuthTypes) +} + +// NewAutoAuthRequestor creates an implementation of AuthRequestor that automatically +// selects the first available implementation in the following order: +// - Plymouth. +// - systemd-ask-password. +// +// The caller supplies an implementation of AuthRequestorStringer that returns messages. +// The console argument is used by the systemd-ask-password implementation of +// [AuthRequestor.NotifyUserAuthResult] where result is not [UserAuthResultSuccess]. If not +// provided, it defaults to [os.Stderr]. +func NewAutoAuthRequestor(stderr io.Writer, stringer AuthRequestorStringer) (AuthRequestor, error) { + var requestors []AuthRequestor + switch ply, err := newPlymouthAuthRequestor(stringer); { + case errors.Is(err, ErrAuthRequestorNotAvailable): + // ignore + case err != nil: + return nil, fmt.Errorf("cannot create Plymouth AuthRequestor: %w", err) + default: + requestors = append(requestors, ply) + } + + switch sd, err := newSystemdAuthRequestor(stderr, func(name, path string, authTypes UserAuthType) (string, error) { + return stringer.RequestUserCredentialString(name, path, authTypes) + }); { + case errors.Is(err, ErrAuthRequestorNotAvailable): + // ignore + case err != nil: + return nil, fmt.Errorf("cannot create systemd AuthRequestor: %w", err) + default: + requestors = append(requestors, sd) + } + + if len(requestors) == 0 { + return nil, ErrAuthRequestorNotAvailable + } + + return &autoAuthRequestor{requestors: requestors}, nil +} diff --git a/auth_requestor_auto_test.go b/auth_requestor_auto_test.go new file mode 100644 index 00000000..c7fbd22e --- /dev/null +++ b/auth_requestor_auto_test.go @@ -0,0 +1,476 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2026 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 secboot_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + + . "github.com/snapcore/secboot" + "github.com/snapcore/secboot/internal/testutil" + snapd_testutil "github.com/snapcore/snapd/testutil" + + . "gopkg.in/check.v1" +) + +type authRequestorAutoSuite struct { + snapd_testutil.BaseTest + authRequestorPlymouthTestMixin + authRequestorSystemdTestMixin +} + +func (s *authRequestorAutoSuite) SetUpTest(c *C) { + s.AddCleanup(s.authRequestorPlymouthTestMixin.setUpTest(c)) + s.AddCleanup(s.authRequestorSystemdTestMixin.setUpTest(c)) +} + +func (s *authRequestorAutoSuite) setPassphrase(c *C, passphrase string) { + s.authRequestorPlymouthTestMixin.setPassphrase(c, passphrase) + s.authRequestorSystemdTestMixin.setPassphrase(c, passphrase) +} + +var _ = Suite(&authRequestorAutoSuite{}) + +type mockAutoAuthRequestorStringer struct { + err error +} + +func (s *mockAutoAuthRequestorStringer) RequestUserCredentialString(name, path string, authType UserAuthType) (string, error) { + if s.err != nil { + return "", s.err + } + + var fmtString string + switch authType { + case UserAuthTypePassphrase: + fmtString = "Enter passphrase for %s (%s):" + case UserAuthTypePIN: + fmtString = "Enter PIN for %s (%s):" + case UserAuthTypeRecoveryKey: + fmtString = "Enter recovery key for %s (%s):" + case UserAuthTypePassphrase | UserAuthTypePIN: + fmtString = "Enter passphrase or PIN for %s (%s):" + case UserAuthTypePassphrase | UserAuthTypeRecoveryKey: + fmtString = "Enter passphrase or recovery key for %s (%s):" + case UserAuthTypePIN | UserAuthTypeRecoveryKey: + fmtString = "Enter PIN or recovery key for %s (%s):" + case UserAuthTypePassphrase | UserAuthTypePIN | UserAuthTypeRecoveryKey: + fmtString = "Enter passphrase, PIN or recovery key for %s (%s):" + default: + return "", errors.New("unexpected UserAuthType") + } + return fmt.Sprintf(fmtString, name, path), nil +} + +func (s *mockAutoAuthRequestorStringer) NotifyUserAuthResultString(name, path string, result UserAuthResult, authTypes, unavailableAuthTypes UserAuthType) (string, error) { + if s.err != nil { + return "", s.err + } + + switch result { + case UserAuthResultSuccess: + var fmtString string + switch authTypes { + case UserAuthTypePassphrase: + fmtString = "Unlocked %s (%s) successfully with passphrase" + case UserAuthTypePIN: + fmtString = "Unlocked %s (%s) successfully with PIN" + case UserAuthTypeRecoveryKey: + fmtString = "Unlocked %s (%s) successfully with recovery key" + default: + return "", errors.New("unexpected UserAuthType") + } + return fmt.Sprintf(fmtString, name, path), nil + case UserAuthResultFailed: + var b strings.Builder + + switch authTypes { + case UserAuthTypePassphrase: + io.WriteString(&b, "Incorrect passphrase") + case UserAuthTypePIN: + io.WriteString(&b, "Incorrect PIN") + case UserAuthTypeRecoveryKey: + io.WriteString(&b, "Incorrect recovery key") + case UserAuthTypePassphrase | UserAuthTypePIN: + io.WriteString(&b, "Incorrect passphrase or PIN") + case UserAuthTypePassphrase | UserAuthTypeRecoveryKey: + io.WriteString(&b, "Incorrect passphrase or recovery key") + case UserAuthTypePIN | UserAuthTypeRecoveryKey: + io.WriteString(&b, "Incorrect PIN or recovery key") + case UserAuthTypePassphrase | UserAuthTypePIN | UserAuthTypeRecoveryKey: + io.WriteString(&b, "Incorrect passphrase, PIN or recovery key") + default: + return "", errors.New("unexpected UserAuthType") + } + + switch unavailableAuthTypes { + case UserAuthType(0): + case UserAuthTypePassphrase: + io.WriteString(&b, ". No more passphrase tries remaining") + case UserAuthTypePIN: + io.WriteString(&b, ". No more PIN tries remaining") + case UserAuthTypeRecoveryKey: + io.WriteString(&b, ". No more recovery key tries remaining") + case UserAuthTypePassphrase | UserAuthTypePIN: + io.WriteString(&b, ". No more passphrase or PIN tries remaining") + case UserAuthTypePassphrase | UserAuthTypeRecoveryKey: + io.WriteString(&b, ". No more passphrase or recovery key tries remaining") + case UserAuthTypePIN | UserAuthTypeRecoveryKey: + io.WriteString(&b, ". No more PIN or recovery key tries remaining") + case UserAuthTypePassphrase | UserAuthTypePIN | UserAuthTypeRecoveryKey: + io.WriteString(&b, ". No more passphrase, PIN or recovery key tries remaining") + default: + return "", errors.New("unexpected UserAuthType") + } + + return b.String(), nil + case UserAuthResultInvalidFormat: + switch authTypes { + case UserAuthTypePIN: + return "Invalid PIN", nil + case UserAuthTypeRecoveryKey: + return "Invalid recovery key", nil + case UserAuthTypePIN | UserAuthTypeRecoveryKey: + return "Invalid PIN or recovery key", nil + default: + return "", errors.New("unexpected UserAuthType") + } + default: + return "", errors.New("unexpected UserAuthResult") + } +} + +func (s *authRequestorAutoSuite) TestNewAuthRequestor(c *C) { + sdConsole := new(bytes.Buffer) + + restore := MockNewSystemdAuthRequestor(func(console io.Writer, stringFn SystemdAuthRequestorStringFn) (AuthRequestor, error) { + c.Check(console, Equals, sdConsole) + return NewSystemdAuthRequestor(console, stringFn) + }) + defer restore() + + requestor, err := NewAutoAuthRequestor(sdConsole, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + c.Assert(requestor, NotNil) + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Assert(requestor.(*AutoAuthRequestor).Requestors(), HasLen, 2) + c.Check(requestor.(*AutoAuthRequestor).Requestors()[0], testutil.ConvertibleTo, &PlymouthAuthRequestor{}) + c.Check(requestor.(*AutoAuthRequestor).Requestors()[1], testutil.ConvertibleTo, &SystemdAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestNewAuthRequestorPlymouthNotAvailable(c *C) { + restore := MockNewPlymouthAuthRequestor(func(_ AuthRequestorStringer) (AuthRequestor, error) { + return nil, ErrAuthRequestorNotAvailable + }) + defer restore() + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + c.Assert(requestor, NotNil) + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Assert(requestor.(*AutoAuthRequestor).Requestors(), HasLen, 1) + c.Check(requestor.(*AutoAuthRequestor).Requestors()[0], testutil.ConvertibleTo, &SystemdAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestNewAuthRequestorSystemdNotAvailable(c *C) { + restore := MockNewSystemdAuthRequestor(func(_ io.Writer, _ SystemdAuthRequestorStringFn) (AuthRequestor, error) { + return nil, ErrAuthRequestorNotAvailable + }) + defer restore() + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + c.Assert(requestor, NotNil) + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Assert(requestor.(*AutoAuthRequestor).Requestors(), HasLen, 1) + c.Check(requestor.(*AutoAuthRequestor).Requestors()[0], testutil.ConvertibleTo, &PlymouthAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestNewAuthRequestorNotAvailable(c *C) { + restore := MockNewPlymouthAuthRequestor(func(_ AuthRequestorStringer) (AuthRequestor, error) { + return nil, ErrAuthRequestorNotAvailable + }) + defer restore() + + restore = MockNewSystemdAuthRequestor(func(_ io.Writer, _ SystemdAuthRequestorStringFn) (AuthRequestor, error) { + return nil, ErrAuthRequestorNotAvailable + }) + defer restore() + + _, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Check(err, ErrorMatches, "the auth requestor is not available") + c.Check(errors.Is(err, ErrAuthRequestorNotAvailable), testutil.IsTrue) +} + +func (s *authRequestorAutoSuite) TestNewAuthRequestorPlymouthError(c *C) { + _, err := NewAutoAuthRequestor(nil, nil) + c.Check(err, ErrorMatches, "cannot create Plymouth AuthRequestor: must supply an implementation of AuthRequestorStringer") +} + +func (s *authRequestorAutoSuite) TestNewAuthRequestorSystemdError(c *C) { + restore := MockNewSystemdAuthRequestor(func(_ io.Writer, _ SystemdAuthRequestorStringFn) (AuthRequestor, error) { + return nil, errors.New("some error") + }) + defer restore() + + _, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Check(err, ErrorMatches, "cannot create systemd AuthRequestor: some error") +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialPlymouth(c *C) { + // Ensure that plymouth is used first if available. + s.setPassphrase(c, "password") + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + passphrase, passphraseType, err := requestor.RequestUserCredential(context.Background(), "data", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, IsNil) + c.Check(passphrase, Equals, "password") + c.Check(passphraseType, Equals, UserAuthTypePassphrase) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "ask-for-password", "--prompt", "Enter passphrase for data (/dev/sda1):"}, + }) + c.Check(s.mockSdAskPassword.Calls(), HasLen, 0) + + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Check(requestor.(*AutoAuthRequestor).LastUsed(), testutil.ConvertibleTo, &PlymouthAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialSystemd(c *C) { + // Ensure that systemd-ask-password is used if plymouth isn't running. + s.setPassphrase(c, "password") + s.stopPlymouthd(c) + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + passphrase, passphraseType, err := requestor.RequestUserCredential(context.Background(), "data", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, IsNil) + c.Check(passphrase, Equals, "password") + c.Check(passphraseType, Equals, UserAuthTypePassphrase) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{{"plymouth", "--ping"}}) + c.Check(s.mockSdAskPassword.Calls(), DeepEquals, [][]string{{"systemd-ask-password", "--icon", "drive-harddisk", "--id", "secboot.test:/dev/sda1", "Enter passphrase for data (/dev/sda1):"}}) + + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Check(requestor.(*AutoAuthRequestor).LastUsed(), testutil.ConvertibleTo, &SystemdAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialDifferentName(c *C) { + s.setPassphrase(c, "password") + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + passphrase, passphraseType, err := requestor.RequestUserCredential(context.Background(), "foo", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, IsNil) + c.Check(passphrase, Equals, "password") + c.Check(passphraseType, Equals, UserAuthTypePassphrase) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "ask-for-password", "--prompt", "Enter passphrase for foo (/dev/sda1):"}, + }) + c.Check(s.mockSdAskPassword.Calls(), HasLen, 0) + + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Check(requestor.(*AutoAuthRequestor).LastUsed(), testutil.ConvertibleTo, &PlymouthAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialDifferentPath(c *C) { + s.setPassphrase(c, "password") + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + passphrase, passphraseType, err := requestor.RequestUserCredential(context.Background(), "data", "/dev/nvme0n1p3", UserAuthTypePassphrase) + c.Check(err, IsNil) + c.Check(passphrase, Equals, "password") + c.Check(passphraseType, Equals, UserAuthTypePassphrase) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "ask-for-password", "--prompt", "Enter passphrase for data (/dev/nvme0n1p3):"}, + }) + c.Check(s.mockSdAskPassword.Calls(), HasLen, 0) + + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Check(requestor.(*AutoAuthRequestor).LastUsed(), testutil.ConvertibleTo, &PlymouthAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialDifferentCredentialType(c *C) { + s.setPassphrase(c, "password") + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + passphrase, passphraseType, err := requestor.RequestUserCredential(context.Background(), "foo", "/dev/sda1", UserAuthTypePassphrase|UserAuthTypeRecoveryKey) + c.Check(err, IsNil) + c.Check(passphrase, Equals, "password") + c.Check(passphraseType, Equals, UserAuthTypePassphrase|UserAuthTypeRecoveryKey) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "ask-for-password", "--prompt", "Enter passphrase or recovery key for foo (/dev/sda1):"}, + }) + c.Check(s.mockSdAskPassword.Calls(), HasLen, 0) + + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Check(requestor.(*AutoAuthRequestor).LastUsed(), testutil.ConvertibleTo, &PlymouthAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialDifferentPassphrase(c *C) { + s.setPassphrase(c, "1234") + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + passphrase, passphraseType, err := requestor.RequestUserCredential(context.Background(), "data", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, IsNil) + c.Check(passphrase, Equals, "1234") + c.Check(passphraseType, Equals, UserAuthTypePassphrase) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "ask-for-password", "--prompt", "Enter passphrase for data (/dev/sda1):"}, + }) + c.Check(s.mockSdAskPassword.Calls(), HasLen, 0) + + c.Assert(requestor, testutil.ConvertibleTo, &AutoAuthRequestor{}) + c.Check(requestor.(*AutoAuthRequestor).LastUsed(), testutil.ConvertibleTo, &PlymouthAuthRequestor{}) +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialCanceledContext(c *C) { + s.setPassphrase(c, "password") + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err = requestor.RequestUserCredential(ctx, "data", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, ErrorMatches, `cannot execute plymouth --ping: context canceled`) + c.Check(errors.Is(err, context.Canceled), testutil.IsTrue) +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialPlymouthFails(c *C) { + // Ensure we get an error if any implementation fails with an unexpected error. + s.authRequestorSystemdTestMixin.setPassphrase(c, "password") + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + _, _, err = requestor.RequestUserCredential(context.Background(), "data", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, ErrorMatches, "cannot execute plymouth ask-for-password: exit status 1") +} + +func (s *authRequestorAutoSuite) TestRequestUserCredentialNotAvailable(c *C) { + // Ensure we get an appropriate error if no auth requestor is available. + s.stopPlymouthd(c) + + restore := MockNewSystemdAuthRequestor(func(_ io.Writer, _ SystemdAuthRequestorStringFn) (AuthRequestor, error) { + return nil, ErrAuthRequestorNotAvailable + }) + defer restore() + + requestor, err := NewAutoAuthRequestor(nil, new(mockAutoAuthRequestorStringer)) + c.Assert(err, IsNil) + + _, _, err = requestor.RequestUserCredential(context.Background(), "data", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, ErrorMatches, "the auth requestor is not available") + c.Check(errors.Is(err, ErrAuthRequestorNotAvailable), testutil.IsTrue) +} + +func (s *authRequestorAutoSuite) TestNotifyUserAuthResult(c *C) { + requestor := NewAutoAuthRequestorForTesting(nil, NewPlymouthAuthRequestorForTesting(new(mockPlymouthAuthRequestorStringer), &PlymouthRequestUserCredentialContext{Name: "data", Path: "/dev/sda1"})) + + c.Check(requestor.NotifyUserAuthResult(context.Background(), UserAuthResultSuccess, UserAuthTypePassphrase, 0), IsNil) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "display-message", "--text", "Unlocked data (/dev/sda1) successfully with passphrase"}, + }) +} + +func (s *authRequestorAutoSuite) TestNotifyUserAuthResultDifferentResult(c *C) { + requestor := NewAutoAuthRequestorForTesting(nil, NewPlymouthAuthRequestorForTesting(new(mockPlymouthAuthRequestorStringer), &PlymouthRequestUserCredentialContext{Name: "data", Path: "/dev/sda1"})) + + c.Check(requestor.NotifyUserAuthResult(context.Background(), UserAuthResultFailed, UserAuthTypePassphrase, 0), IsNil) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "display-message", "--text", "Incorrect passphrase"}, + }) +} + +func (s *authRequestorAutoSuite) TestNotifyUserAuthResultDifferentAuthType(c *C) { + requestor := NewAutoAuthRequestorForTesting(nil, NewPlymouthAuthRequestorForTesting(new(mockPlymouthAuthRequestorStringer), &PlymouthRequestUserCredentialContext{Name: "data", Path: "/dev/sda1"})) + + c.Check(requestor.NotifyUserAuthResult(context.Background(), UserAuthResultSuccess, UserAuthTypePIN, 0), IsNil) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "display-message", "--text", "Unlocked data (/dev/sda1) successfully with PIN"}, + }) +} + +func (s *authRequestorAutoSuite) TestNotifyUserAuthResultWithExhaustedAuthTypes(c *C) { + requestor := NewAutoAuthRequestorForTesting(nil, NewPlymouthAuthRequestorForTesting(new(mockPlymouthAuthRequestorStringer), &PlymouthRequestUserCredentialContext{Name: "data", Path: "/dev/sda1"})) + + c.Check(requestor.NotifyUserAuthResult(context.Background(), UserAuthResultFailed, UserAuthTypePassphrase, UserAuthTypePassphrase), IsNil) + + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "display-message", "--text", "Incorrect passphrase. No more passphrase tries remaining"}, + }) +} + +func (s *authRequestorAutoSuite) TestNotifyUserAuthResultNoLastUsed(c *C) { + requestor := NewAutoAuthRequestorForTesting(nil, nil) + + err := requestor.NotifyUserAuthResult(context.Background(), UserAuthResultSuccess, UserAuthTypePassphrase, 0) + c.Check(err, ErrorMatches, `no user credential requested yet`) +} + +func (s *authRequestorAutoSuite) TestNotifyUserAuthResultCanceledContext(c *C) { + requestor := NewAutoAuthRequestorForTesting(nil, NewPlymouthAuthRequestorForTesting(new(mockPlymouthAuthRequestorStringer), &PlymouthRequestUserCredentialContext{Name: "data", Path: "/dev/sda1"})) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := requestor.NotifyUserAuthResult(ctx, UserAuthResultSuccess, UserAuthTypePassphrase, 0) + c.Check(err, ErrorMatches, `cannot execute plymouth --ping: context canceled`) + c.Check(errors.Is(err, context.Canceled), testutil.IsTrue) +} + +func (s *authRequestorAutoSuite) TestNotifyUserAuthResultFail(c *C) { + requestor := NewAutoAuthRequestorForTesting(nil, NewPlymouthAuthRequestorForTesting(&mockPlymouthAuthRequestorStringer{err: errors.New("some error")}, &PlymouthRequestUserCredentialContext{Name: "data", Path: "/dev/sda1"})) + + err := requestor.NotifyUserAuthResult(context.Background(), UserAuthResultSuccess, UserAuthTypePassphrase, 0) + c.Check(err, ErrorMatches, `cannot request message string: some error`) +} diff --git a/auth_requestor_plymouth.go b/auth_requestor_plymouth.go index 33f3134d..592157d6 100644 --- a/auth_requestor_plymouth.go +++ b/auth_requestor_plymouth.go @@ -28,30 +28,34 @@ import ( "os/exec" ) -// PlymouthAuthRequestorStringer is used by the Plymouth implementation -// of [AuthRequestor] to obtain translated strings. -type PlymouthAuthRequestorStringer interface { - // RequestUserCredentialString returns messages used by RequestUserCredential. The - // name is a string supplied via the WithAuthRequestorUserVisibleName option, and the - // path is the storage container path. - RequestUserCredentialString(name, path string, authTypes UserAuthType) (string, error) - - // NotifyUserAuthResultString returns messages used by NotifyUserAuthResult. - NotifyUserAuthResultString(name, path string, result UserAuthResult, authTypes, exhaustedAuthTypes UserAuthType) (string, error) -} - type plymouthRequestUserCredentialContext struct { Name string Path string } type plymouthAuthRequestor struct { - stringer PlymouthAuthRequestorStringer + stringer AuthRequestorStringer lastRequestUserCredentialCtx plymouthRequestUserCredentialContext } +func (r *plymouthAuthRequestor) ping(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "plymouth", "--ping") + if err := cmd.Run(); err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return ErrAuthRequestorNotAvailable + } + return fmt.Errorf("cannot execute plymouth --ping: %w", err) + } + return nil +} + func (r *plymouthAuthRequestor) RequestUserCredential(ctx context.Context, name, path string, authTypes UserAuthType) (string, UserAuthType, error) { + if err := r.ping(ctx); err != nil { + return "", 0, err + } + msg, err := r.stringer.RequestUserCredentialString(name, path, authTypes) if err != nil { return "", 0, fmt.Errorf("cannot request message string: %w", err) @@ -81,6 +85,10 @@ func (r *plymouthAuthRequestor) RequestUserCredential(ctx context.Context, name, } func (r *plymouthAuthRequestor) NotifyUserAuthResult(ctx context.Context, result UserAuthResult, authTypes, exhaustedAuthTypes UserAuthType) error { + if err := r.ping(ctx); err != nil { + return err + } + msg, err := r.stringer.NotifyUserAuthResultString(r.lastRequestUserCredentialCtx.Name, r.lastRequestUserCredentialCtx.Path, result, authTypes, exhaustedAuthTypes) if err != nil { return fmt.Errorf("cannot request message string: %w", err) @@ -99,9 +107,13 @@ func (r *plymouthAuthRequestor) NotifyUserAuthResult(ctx context.Context, result // NewPlymouthAuthRequestor creates an implementation of AuthRequestor that // communicates directly with Plymouth. -func NewPlymouthAuthRequestor(stringer PlymouthAuthRequestorStringer) (AuthRequestor, error) { +func NewPlymouthAuthRequestor(stringer AuthRequestorStringer) (AuthRequestor, error) { + if _, err := exec.LookPath("plymouth"); err != nil { + return nil, ErrAuthRequestorNotAvailable + } + if stringer == nil { - return nil, errors.New("must supply an implementation of PlymouthAuthRequestorStringer") + return nil, errors.New("must supply an implementation of AuthRequestorStringer") } return &plymouthAuthRequestor{ stringer: stringer, diff --git a/auth_requestor_plymouth_test.go b/auth_requestor_plymouth_test.go index bd6f1b4c..60b323b9 100644 --- a/auth_requestor_plymouth_test.go +++ b/auth_requestor_plymouth_test.go @@ -25,6 +25,7 @@ import ( "fmt" "io" "io/ioutil" + "os" "path/filepath" "strings" @@ -36,26 +37,53 @@ import ( . "github.com/snapcore/secboot" ) -type authRequestorPlymouthSuite struct { - snapd_testutil.BaseTest - - passwordFile string - mockPlymouth *snapd_testutil.MockCmd +type authRequestorPlymouthTestMixin struct { + passwordFile string + stopPlymouthdFile string + displayMessageFailFile string + mockPlymouth *snapd_testutil.MockCmd } -func (s *authRequestorPlymouthSuite) SetUpTest(c *C) { +func (m *authRequestorPlymouthTestMixin) setUpTest(c *C) (restore func()) { dir := c.MkDir() - s.passwordFile = filepath.Join(dir, "password") // password to be returned by the mock plymouth - - plymouthBottom := `if [ "$1" = "ask-for-password" ]; then -cat %[1]s + m.passwordFile = filepath.Join(dir, "password") // password to be returned by the mock plymouth + m.stopPlymouthdFile = filepath.Join(dir, "plymouthd-stopped") + m.displayMessageFailFile = filepath.Join(dir, "display-message-fail") + + plymouthBottom := `if [ "$1" == "--ping" ] && [ -e %[1]s ]; then + exit 1 +elif [ "$1" == "ask-for-password" ]; then + cat %[2]s +elif [ "$1" == "display-message" ] && [ -e %[3]s ]; then + exit 1 fi` - s.mockPlymouth = snapd_testutil.MockCommand(c, "plymouth", fmt.Sprintf(plymouthBottom, s.passwordFile)) - s.AddCleanup(s.mockPlymouth.Restore) + m.mockPlymouth = snapd_testutil.MockCommand(c, "plymouth", fmt.Sprintf(plymouthBottom, m.stopPlymouthdFile, m.passwordFile, m.displayMessageFailFile)) + return m.mockPlymouth.Restore +} + +func (m *authRequestorPlymouthTestMixin) setPassphrase(c *C, passphrase string) { + c.Assert(ioutil.WriteFile(m.passwordFile, []byte(passphrase), 0600), IsNil) +} + +func (m *authRequestorPlymouthTestMixin) stopPlymouthd(c *C) { + f, err := os.Create(m.stopPlymouthdFile) + c.Assert(err, IsNil) + f.Close() } -func (s *authRequestorPlymouthSuite) setPassphrase(c *C, passphrase string) { - c.Assert(ioutil.WriteFile(s.passwordFile, []byte(passphrase), 0600), IsNil) +func (m *authRequestorPlymouthTestMixin) makePlymouthDisplayMessageFail(c *C) { + f, err := os.Create(m.displayMessageFailFile) + c.Assert(err, IsNil) + f.Close() +} + +type authRequestorPlymouthSuite struct { + snapd_testutil.BaseTest + authRequestorPlymouthTestMixin +} + +func (s *authRequestorPlymouthSuite) SetUpTest(c *C) { + s.AddCleanup(s.authRequestorPlymouthTestMixin.setUpTest(c)) } var _ = Suite(&authRequestorPlymouthSuite{}) @@ -191,8 +219,10 @@ func (s *authRequestorPlymouthSuite) testRequestUserCredential(c *C, params *tes c.Check(passphrase, Equals, params.passphrase) c.Check(passphraseType, Equals, params.authTypes) - c.Check(s.mockPlymouth.Calls(), HasLen, 1) - c.Check(s.mockPlymouth.Calls()[0], DeepEquals, []string{"plymouth", "ask-for-password", "--prompt", params.expectedMsg}) + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "ask-for-password", "--prompt", params.expectedMsg}, + }) c.Assert(requestor, testutil.ConvertibleTo, &PlymouthAuthRequestor{}) c.Check(requestor.(*PlymouthAuthRequestor).LastRequestUserCredentialCtx(), DeepEquals, PlymouthRequestUserCredentialContext{ @@ -311,9 +341,20 @@ func (s *authRequestorPlymouthSuite) TestRequestUserCredentialPassphraseOrPINOrR }) } +func (s *authRequestorPlymouthSuite) TestNewRequestorNotAvailable(c *C) { + old := os.Getenv("PATH") + dir := c.MkDir() + os.Setenv("PATH", dir) + defer func() { os.Setenv("PATH", old) }() + + _, err := NewPlymouthAuthRequestor(nil) + c.Check(err, ErrorMatches, `the auth requestor is not available`) + c.Check(errors.Is(err, ErrAuthRequestorNotAvailable), testutil.IsTrue) +} + func (s *authRequestorPlymouthSuite) TestNewRequestorNoStringer(c *C) { _, err := NewPlymouthAuthRequestor(nil) - c.Check(err, ErrorMatches, `must supply an implementation of PlymouthAuthRequestorStringer`) + c.Check(err, ErrorMatches, `must supply an implementation of AuthRequestorStringer`) } func (s *authRequestorPlymouthSuite) TestRequestUserCredentialObtainMessageError(c *C) { @@ -348,12 +389,25 @@ func (s *authRequestorPlymouthSuite) TestRequestUserCredentialCanceledContext(c cancel() _, _, err = requestor.RequestUserCredential(ctx, "data", "/dev/sda1", UserAuthTypePassphrase) - c.Check(err, ErrorMatches, "cannot execute plymouth ask-for-password: context canceled") + c.Check(err, ErrorMatches, "cannot execute plymouth --ping: context canceled") c.Check(errors.Is(err, context.Canceled), testutil.IsTrue) c.Assert(requestor, testutil.ConvertibleTo, &PlymouthAuthRequestor{}) c.Check(requestor.(*PlymouthAuthRequestor).LastRequestUserCredentialCtx(), DeepEquals, PlymouthRequestUserCredentialContext{}) } +func (s *authRequestorPlymouthSuite) TestRequestUserCredentialNotAvailable(c *C) { + requestor, err := NewPlymouthAuthRequestor(new(mockPlymouthAuthRequestorStringer)) + c.Assert(err, IsNil) + + s.stopPlymouthd(c) + + _, _, err = requestor.RequestUserCredential(context.Background(), "data", "/dev/sda1", UserAuthTypePassphrase) + c.Check(err, ErrorMatches, "the auth requestor is not available") + c.Check(errors.Is(err, ErrAuthRequestorNotAvailable), testutil.IsTrue) + c.Assert(requestor, testutil.ConvertibleTo, &PlymouthAuthRequestor{}) + c.Check(requestor.(*PlymouthAuthRequestor).LastRequestUserCredentialCtx(), DeepEquals, PlymouthRequestUserCredentialContext{}) +} + type testPlymouthNotifyUserAuthResultParams struct { name string path string @@ -369,8 +423,10 @@ func (s *authRequestorPlymouthSuite) testNotifyUserAuthResult(c *C, params *test c.Check(requestor.NotifyUserAuthResult(context.Background(), params.result, params.authTypes, params.unavailableAuthTypes), IsNil) - c.Check(s.mockPlymouth.Calls(), HasLen, 1) - c.Check(s.mockPlymouth.Calls()[0], DeepEquals, []string{"plymouth", "display-message", "--text", params.expectedMsg}) + c.Check(s.mockPlymouth.Calls(), DeepEquals, [][]string{ + {"plymouth", "--ping"}, + {"plymouth", "display-message", "--text", params.expectedMsg}, + }) } func (s *authRequestorPlymouthSuite) TestNotifyUserAuthResultSuccessPassphrase(c *C) { @@ -505,6 +561,17 @@ func (s *authRequestorPlymouthSuite) TestNotifyUserAuthResultInvalidPINOrRecover }) } +func (s *authRequestorPlymouthSuite) TestNotifyUserAuthResultNotAvailable(c *C) { + requestor, err := NewPlymouthAuthRequestor(new(mockPlymouthAuthRequestorStringer)) + c.Assert(err, IsNil) + + s.stopPlymouthd(c) + + err = requestor.NotifyUserAuthResult(context.Background(), UserAuthResultSuccess, UserAuthTypePassphrase, 0) + c.Check(err, ErrorMatches, "the auth requestor is not available") + c.Check(errors.Is(err, ErrAuthRequestorNotAvailable), testutil.IsTrue) +} + func (s *authRequestorPlymouthSuite) TestNotifyUserAuthResultObtainMessageError(c *C) { requestor, err := NewPlymouthAuthRequestor(&mockPlymouthAuthRequestorStringer{ err: errors.New("some error"), @@ -522,6 +589,15 @@ func (s *authRequestorPlymouthSuite) TestNotifyUserAuthResultCanceledContext(c * cancel() err := requestor.NotifyUserAuthResult(ctx, UserAuthResultSuccess, UserAuthTypePassphrase, 0) - c.Check(err, ErrorMatches, "cannot execute plymouth display-message: context canceled") + c.Check(err, ErrorMatches, "cannot execute plymouth --ping: context canceled") c.Check(errors.Is(err, context.Canceled), testutil.IsTrue) } + +func (s *authRequestorPlymouthSuite) TestNotifyUserAuthResultFailure(c *C) { + requestor := NewPlymouthAuthRequestorForTesting(new(mockPlymouthAuthRequestorStringer), &PlymouthRequestUserCredentialContext{Name: "data", Path: "/dev/sda1"}) + + s.makePlymouthDisplayMessageFail(c) + + err := requestor.NotifyUserAuthResult(context.Background(), UserAuthResultSuccess, UserAuthTypePassphrase, 0) + c.Check(err, ErrorMatches, "cannot execute plymouth display-message: exit status 1") +} diff --git a/auth_requestor_systemd.go b/auth_requestor_systemd.go index 1e4a9548..a65f7994 100644 --- a/auth_requestor_systemd.go +++ b/auth_requestor_systemd.go @@ -93,6 +93,9 @@ func (r *systemdAuthRequestor) NotifyUserAuthResult(ctx context.Context, result // the implementation of [AuthRequestor.NotifyUserAuthResult] where result is // not [UserAuthResultSuccess]. If not provided, it defaults to [os.Stderr]. func NewSystemdAuthRequestor(console io.Writer, stringFn SystemdAuthRequestorStringFn) (AuthRequestor, error) { + if _, err := exec.LookPath("systemd-ask-password"); err != nil { + return nil, ErrAuthRequestorNotAvailable + } if console == nil { console = os.Stderr } diff --git a/auth_requestor_systemd_test.go b/auth_requestor_systemd_test.go index 16671094..b861d5aa 100644 --- a/auth_requestor_systemd_test.go +++ b/auth_requestor_systemd_test.go @@ -36,24 +36,31 @@ import ( . "github.com/snapcore/secboot" ) -type authRequestorSystemdSuite struct { - snapd_testutil.BaseTest - +type authRequestorSystemdTestMixin struct { passwordFile string mockSdAskPassword *snapd_testutil.MockCmd } -func (s *authRequestorSystemdSuite) SetUpTest(c *C) { +func (m *authRequestorSystemdTestMixin) setUpTest(c *C) (restore func()) { dir := c.MkDir() - s.passwordFile = filepath.Join(dir, "password") // password to be returned by the mock sd-ask-password + m.passwordFile = filepath.Join(dir, "password") // password to be returned by the mock sd-ask-password sdAskPasswordBottom := `cat %[1]s` - s.mockSdAskPassword = snapd_testutil.MockCommand(c, "systemd-ask-password", fmt.Sprintf(sdAskPasswordBottom, s.passwordFile)) - s.AddCleanup(s.mockSdAskPassword.Restore) + m.mockSdAskPassword = snapd_testutil.MockCommand(c, "systemd-ask-password", fmt.Sprintf(sdAskPasswordBottom, m.passwordFile)) + return m.mockSdAskPassword.Restore +} + +func (m *authRequestorSystemdTestMixin) setPassphrase(c *C, passphrase string) { + c.Assert(ioutil.WriteFile(m.passwordFile, []byte(passphrase+"\n"), 0600), IsNil) +} + +type authRequestorSystemdSuite struct { + snapd_testutil.BaseTest + authRequestorSystemdTestMixin } -func (s *authRequestorSystemdSuite) setPassphrase(c *C, passphrase string) { - c.Assert(ioutil.WriteFile(s.passwordFile, []byte(passphrase+"\n"), 0600), IsNil) +func (s *authRequestorSystemdSuite) SetUpTest(c *C) { + s.AddCleanup(s.authRequestorSystemdTestMixin.setUpTest(c)) } var _ = Suite(&authRequestorSystemdSuite{}) @@ -219,6 +226,17 @@ func (s *authRequestorSystemdSuite) TestRequestUserCredentialPassphraseOrPINOrRe }) } +func (s *authRequestorSystemdSuite) TestNewRequestorNotAvailable(c *C) { + old := os.Getenv("PATH") + dir := c.MkDir() + os.Setenv("PATH", dir) + defer func() { os.Setenv("PATH", old) }() + + _, err := NewSystemdAuthRequestor(nil, nil) + c.Check(err, ErrorMatches, `the auth requestor is not available`) + c.Check(errors.Is(err, ErrAuthRequestorNotAvailable), testutil.IsTrue) +} + func (s *authRequestorSystemdSuite) TestNewRequestorNoFormatStringCallback(c *C) { _, err := NewSystemdAuthRequestor(nil, nil) c.Check(err, ErrorMatches, `must supply a SystemdAuthRequestorStringFn`) diff --git a/export_test.go b/export_test.go index 0d5c17d9..377ff677 100644 --- a/export_test.go +++ b/export_test.go @@ -74,6 +74,7 @@ type ( ActivateConfigKey = activateConfigKey ActivateOneContainerStateMachine = activateOneContainerStateMachine ActivateOneContainerStateMachineFlags = activateOneContainerStateMachineFlags + AutoAuthRequestor = autoAuthRequestor ExternalKeyData = externalKeyData ExternalUnlockKey = externalUnlockKey KdfParams = kdfParams @@ -263,6 +264,22 @@ func MockNewLUKSView(fn func(context.Context, string) (*luksview.View, error)) ( } } +func MockNewPlymouthAuthRequestor(fn func(AuthRequestorStringer) (AuthRequestor, error)) (restore func()) { + orig := newPlymouthAuthRequestor + newPlymouthAuthRequestor = fn + return func() { + newPlymouthAuthRequestor = orig + } +} + +func MockNewSystemdAuthRequestor(fn func(io.Writer, SystemdAuthRequestorStringFn) (AuthRequestor, error)) (restore func()) { + orig := newSystemdAuthRequestor + newSystemdAuthRequestor = fn + return func() { + newSystemdAuthRequestor = orig + } +} + func MockPBKDF2Benchmark(fn func(time.Duration, crypto.Hash) (uint, error)) (restore func()) { orig := pbkdf2Benchmark pbkdf2Benchmark = fn @@ -311,11 +328,26 @@ func (d *KeyData) DerivePassphraseKeys(passphrase string) (key, iv, auth []byte, return d.derivePassphraseKeys(passphrase) } +func (r *autoAuthRequestor) Requestors() []AuthRequestor { + return r.requestors +} + +func (r *autoAuthRequestor) LastUsed() AuthRequestor { + return r.lastUsed +} + +func NewAutoAuthRequestorForTesting(requestors []AuthRequestor, lastUsed AuthRequestor) *autoAuthRequestor { + return &autoAuthRequestor{ + requestors: requestors, + lastUsed: lastUsed, + } +} + func (r *plymouthAuthRequestor) LastRequestUserCredentialCtx() plymouthRequestUserCredentialContext { return r.lastRequestUserCredentialCtx } -func NewPlymouthAuthRequestorForTesting(stringer PlymouthAuthRequestorStringer, lastRequestUserCredentialCtx *plymouthRequestUserCredentialContext) *plymouthAuthRequestor { +func NewPlymouthAuthRequestorForTesting(stringer AuthRequestorStringer, lastRequestUserCredentialCtx *plymouthRequestUserCredentialContext) *plymouthAuthRequestor { if lastRequestUserCredentialCtx == nil { var zeroCtx plymouthRequestUserCredentialContext lastRequestUserCredentialCtx = &zeroCtx