diff --git a/cmd/launcher/launcher.go b/cmd/launcher/launcher.go index ecc7e2aa7..6175ee713 100644 --- a/cmd/launcher/launcher.go +++ b/cmd/launcher/launcher.go @@ -357,7 +357,7 @@ func runLauncher(ctx context.Context, cancel func(), multiSlogger, systemMultiSl if err := osquery.SetupLauncherKeys(k.ConfigStore()); err != nil { return fmt.Errorf("setting up initial launcher keys: %w", err) } - if err := agent.SetupKeys(ctx, k.Slogger(), k.ConfigStore(), false); err != nil { + if err := agent.SetupKeys(ctx, k.Slogger(), k.ConfigStore()); err != nil { return fmt.Errorf("setting up agent keys: %w", err) } @@ -419,6 +419,12 @@ func runLauncher(ctx context.Context, cancel func(), multiSlogger, systemMultiSl return fmt.Errorf("failed to create desktop runner: %w", err) } + execute, interrupt, err := agent.SetHardwareKeysRunner(ctx, k.Slogger(), k.ConfigStore(), runner) + if err != nil { + return fmt.Errorf("setting up hardware keys: %w", err) + } + runGroup.Add("hardwareKeys", execute, interrupt) + runGroup.Add("desktopRunner", runner.Execute, runner.Interrupt) controlService.RegisterConsumer(desktopMenuSubsystemName, runner) diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index f512a32f7..ade59d385 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -214,8 +214,6 @@ func runSubcommands(systemMultiSlogger *multislogger.MultiSlogger) error { run = runDownloadOsquery case "uninstall": run = runUninstall - case "secure-enclave": - run = runSecureEnclave case "watchdog": // note: this is currently only implemented for windows run = watchdog.RunWatchdogTask default: diff --git a/cmd/launcher/secure_enclave_darwin.go b/cmd/launcher/secure_enclave_darwin.go deleted file mode 100644 index 4c7d7bca4..000000000 --- a/cmd/launcher/secure_enclave_darwin.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build darwin -// +build darwin - -package main - -import ( - "errors" - "fmt" - "os" - - "github.com/kolide/krypto/pkg/echelper" - "github.com/kolide/krypto/pkg/secureenclave" - "github.com/kolide/launcher/ee/secureenclavesigner" - "github.com/kolide/launcher/pkg/log/multislogger" -) - -// runSecureEnclave performs either a create-key operation using the secure enclave. -// It's available as a separate command because launcher runs as root by default and since it's -// not in a user security context, it can't use the secure enclave directly. However, this command -// can be run in the user context using launchctl. -func runSecureEnclave(_ *multislogger.MultiSlogger, args []string) error { - // currently we are just creating key, but plan to add sign command in future - if len(args) < 1 { - return errors.New("not enough arguments, expect create_key") - } - - switch args[0] { - case secureenclavesigner.CreateKeyCmd: - return createSecureEnclaveKey() - - default: - return fmt.Errorf("unknown command %s", args[0]) - } -} - -func createSecureEnclaveKey() error { - secureEnclavePubKey, err := secureenclave.CreateKey() - if err != nil { - return fmt.Errorf("creating secure enclave key: %w", err) - } - - secureEnclavePubDer, err := echelper.PublicEcdsaToB64Der(secureEnclavePubKey) - if err != nil { - return fmt.Errorf("marshalling public key to der: %w", err) - } - - os.Stdout.Write(secureEnclavePubDer) - return nil -} diff --git a/cmd/launcher/secure_enclave_other.go b/cmd/launcher/secure_enclave_other.go deleted file mode 100644 index 62c722476..000000000 --- a/cmd/launcher/secure_enclave_other.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !darwin -// +build !darwin - -package main - -import ( - "errors" - - "github.com/kolide/launcher/pkg/log/multislogger" -) - -func runSecureEnclave(_ *multislogger.MultiSlogger, args []string) error { - return errors.New("not implemented on non darwin platforms") -} diff --git a/cmd/launcher/secure_enclave_test.go b/cmd/launcher/secure_enclave_test.go deleted file mode 100644 index 9dd3ba28f..000000000 --- a/cmd/launcher/secure_enclave_test.go +++ /dev/null @@ -1,154 +0,0 @@ -//go:build darwin -// +build darwin - -package main - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/kolide/krypto/pkg/echelper" - "github.com/kolide/launcher/ee/secureenclavesigner" - "github.com/kolide/launcher/pkg/log/multislogger" - "github.com/stretchr/testify/require" -) - -const ( - testWrappedEnvVarKey = "SECURE_ENCLAVE_TEST_WRAPPED" - testSkipSecureEnclaveTestingEnvVarKey = "SKIP_SECURE_ENCLAVE_TESTS" - macOsAppResourceDir = "../../ee/secureenclavesigner/test_app_resources" -) - -// TestSecureEnclaveTestRunner creates a MacOS app with the binary of this packages tests, then signs the app with entitlements and runs the tests. -// This is done because in order to access secure enclave to run tests, we need MacOS entitlements. -// #nosec G306 -- Need readable files -func TestSecureEnclaveTestRunner(t *testing.T) { - t.Parallel() - - if os.Getenv("CI") != "" { - t.Skipf("\nskipping because %s env var was not empty, this is being run in a CI environment without access to secure enclave", testWrappedEnvVarKey) - } - - if os.Getenv(testWrappedEnvVarKey) != "" { - t.Skipf("\nskipping because %s env var was not empty, this is the execution of the codesigned app with entitlements", testWrappedEnvVarKey) - } - - if os.Getenv(testSkipSecureEnclaveTestingEnvVarKey) != "" { - t.Skipf("\nskipping because %s env var was set", testSkipSecureEnclaveTestingEnvVarKey) - } - - t.Log("\nexecuting wrapped tests with codesigned app and entitlements") - - // set up app bundle - rootDir := t.TempDir() - appRoot := filepath.Join(rootDir, "launcher_test.app") - - // make required dirs launcher_test.app/Contents/MacOS and add files - require.NoError(t, os.MkdirAll(filepath.Join(appRoot, "Contents", "MacOS"), 0700)) - copyFile(t, filepath.Join(macOsAppResourceDir, "Info.plist"), filepath.Join(appRoot, "Contents", "Info.plist")) - copyFile(t, filepath.Join(macOsAppResourceDir, "embedded.provisionprofile"), filepath.Join(appRoot, "Contents", "embedded.provisionprofile")) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // build an executable containing the tests into the app bundle - executablePath := filepath.Join(appRoot, "Contents", "MacOS", "launcher_test") - out, err := exec.CommandContext( //nolint:forbidigo // Only used in test, don't want as standard allowedcmd - ctx, - "go", - "test", - "-c", - "--cover", - "--race", - "./", - "-o", - executablePath, - ).CombinedOutput() - - require.NoError(t, ctx.Err()) - require.NoError(t, err, string(out)) - - // sign app bundle - signApp(t, appRoot) - - // run app bundle executable - cmd := exec.CommandContext(ctx, executablePath, "-test.v") //nolint:forbidigo // Only used in test, don't want as standard allowedcmd - cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", testWrappedEnvVarKey, "true")) - out, err = cmd.CombinedOutput() - require.NoError(t, ctx.Err()) - require.NoError(t, err, string(out)) - - // ensure the test ran successfully - require.Contains(t, string(out), "PASS: TestSecureEnclaveCmd") - require.NotContains(t, string(out), "FAIL") -} - -func TestSecureEnclaveCmd(t *testing.T) { //nolint:paralleltest - if os.Getenv(testWrappedEnvVarKey) == "" { - t.Skipf("\nskipping because %s env var was empty, test not being run from codesigned app with entitlements", testWrappedEnvVarKey) - } - - t.Log("\nrunning wrapped tests with codesigned app and entitlements") - - oldStdout := os.Stdout - defer func() { - os.Stdout = oldStdout - }() - - // create a pipe to capture stdout - pipeReader, pipeWriter, err := os.Pipe() - require.NoError(t, err) - - os.Stdout = pipeWriter - - require.NoError(t, runSecureEnclave(multislogger.New(), []string{secureenclavesigner.CreateKeyCmd})) - require.NoError(t, pipeWriter.Close()) - - var buf bytes.Buffer - _, err = buf.ReadFrom(pipeReader) - require.NoError(t, err) - - // convert response to public key - createKeyResponse := buf.Bytes() - secureEnclavePubKey, err := echelper.PublicB64DerToEcdsaKey(createKeyResponse) - require.NoError(t, err) - require.NotNil(t, secureEnclavePubKey, "should be able to get public key") -} - -// #nosec G306 -- Need readable files -func copyFile(t *testing.T, source, destination string) { - bytes, err := os.ReadFile(source) - require.NoError(t, err) - require.NoError(t, os.WriteFile(destination, bytes, 0700)) -} - -// #nosec G204 -- This triggers due to using env var in cmd, making exception for test -func signApp(t *testing.T, appRootDir string) { - codeSignId := os.Getenv("MACOS_CODESIGN_IDENTITY") - require.NotEmpty(t, codeSignId, "need MACOS_CODESIGN_IDENTITY env var to sign app, such as [Mac Developer: Jane Doe (ABCD123456)]") - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - cmd := exec.CommandContext( //nolint:forbidigo // Only used in test, don't want as standard allowedcmd - ctx, - "codesign", - "--deep", - "--force", - "--options", "runtime", - "--entitlements", filepath.Join(macOsAppResourceDir, "entitlements"), - "--sign", codeSignId, - "--timestamp", - appRootDir, - ) - - out, err := cmd.CombinedOutput() - require.NoError(t, ctx.Err()) - require.NoError(t, err, string(out)) -} diff --git a/ee/agent/keys.go b/ee/agent/keys.go index b88914b66..6087a238a 100644 --- a/ee/agent/keys.go +++ b/ee/agent/keys.go @@ -3,14 +3,12 @@ package agent import ( "context" "crypto" + "crypto/ecdsa" "fmt" "log/slog" - "time" "github.com/kolide/launcher/ee/agent/keys" "github.com/kolide/launcher/ee/agent/types" - "github.com/kolide/launcher/pkg/backoff" - "github.com/kolide/launcher/pkg/traces" ) type keyInt interface { @@ -21,6 +19,7 @@ type keyInt interface { var hardwareKeys keyInt = keys.Noop var localDbKeys keyInt = keys.Noop +// HardwareKeys returns the hardware keys for the agent, it's critical to not cache this value as it may change during runtime. func HardwareKeys() keyInt { return hardwareKeys } @@ -29,10 +28,11 @@ func LocalDbKeys() keyInt { return localDbKeys } -func SetupKeys(ctx context.Context, slogger *slog.Logger, store types.GetterSetterDeleter, skipHardwareKeys bool) error { - ctx, span := traces.StartSpan(ctx) - defer span.End() +type secureEnclaveClient interface { + CreateSecureEnclaveKey(uid string) (*ecdsa.PublicKey, error) +} +func SetupKeys(_ context.Context, slogger *slog.Logger, store types.GetterSetterDeleter) error { slogger = slogger.With("component", "agentkeys") var err error @@ -43,75 +43,5 @@ func SetupKeys(ctx context.Context, slogger *slog.Logger, store types.GetterSett return fmt.Errorf("setting up local db keys: %w", err) } - if skipHardwareKeys { - return nil - } - - err = backoff.WaitFor(func() error { - hwKeys, err := setupHardwareKeys(ctx, slogger, store) - if err != nil { - return err - } - hardwareKeys = hwKeys - return nil - }, 1*time.Second, 250*time.Millisecond) - - if err != nil { - // Use of hardware keys is not fully implemented as of 2023-02-01, so log an error and move on - slogger.Log(context.TODO(), slog.LevelInfo, - "failed setting up hardware keys", - "err", err, - ) - } - - return nil -} - -// This duplicates some of pkg/osquery/extension.go but that feels like the wrong place. -// Really, we should have a simpler interface over a storage layer. -const ( - privateEccData = "privateEccData" // nolint:unused - publicEccData = "publicEccData" // nolint:unused -) - -// nolint:unused -func fetchKeyData(store types.Getter) ([]byte, []byte, error) { - pri, err := store.Get([]byte(privateEccData)) - if err != nil { - return nil, nil, err - } - - pub, err := store.Get([]byte(publicEccData)) - if err != nil { - return nil, nil, err - } - - return pri, pub, nil -} - -// nolint:unused -func storeKeyData(store types.Setter, pri, pub []byte) error { - if pri != nil { - if err := store.Set([]byte(privateEccData), pri); err != nil { - return err - } - } - - if pub != nil { - if err := store.Set([]byte(publicEccData), pub); err != nil { - return err - } - } - return nil } - -// clearKeyData is used to clear the keys as part of error handling around new keys. It is not intended to be called -// regularly, and since the path that calls it is around DB errors, it has no error handling. -// nolint:unused -func clearKeyData(slogger *slog.Logger, deleter types.Deleter) { - slogger.Log(context.TODO(), slog.LevelInfo, - "clearing keys", - ) - _ = deleter.Delete([]byte(privateEccData), []byte(publicEccData)) -} diff --git a/ee/agent/keys_darwin.go b/ee/agent/keys_darwin.go index 891969f21..9d5394795 100644 --- a/ee/agent/keys_darwin.go +++ b/ee/agent/keys_darwin.go @@ -9,19 +9,22 @@ import ( "log/slog" "github.com/kolide/launcher/ee/agent/types" - "github.com/kolide/launcher/ee/secureenclavesigner" + "github.com/kolide/launcher/ee/secureenclaverunner" "github.com/kolide/launcher/pkg/traces" ) -func setupHardwareKeys(ctx context.Context, slogger *slog.Logger, store types.GetterSetterDeleter) (keyInt, error) { +// SetHardwareKeysRunner creates a secure enclave runner and sets it as the agent hardware key as it also implements the keyInt/crypto.Signer interface. +// The returned execute and interrupt functions can be used to start and stop the secure enclave runner, generally via a run group. +func SetHardwareKeysRunner(ctx context.Context, slogger *slog.Logger, store types.GetterSetterDeleter, secureEnclaveClient secureEnclaveClient) (execute func() error, interrupt func(error), err error) { ctx, span := traces.StartSpan(ctx) defer span.End() - ses, err := secureenclavesigner.New(ctx, slogger, store) + ser, err := secureenclaverunner.New(ctx, slogger, store, secureEnclaveClient) if err != nil { traces.SetError(span, fmt.Errorf("creating secureenclave signer: %w", err)) - return nil, fmt.Errorf("creating secureenclave signer: %w", err) + return nil, nil, fmt.Errorf("creating secureenclave signer: %w", err) } - return ses, nil + hardwareKeys = ser + return ser.Execute, ser.Interrupt, nil } diff --git a/ee/agent/keys_tpm.go b/ee/agent/keys_tpm.go index 82d057ca2..0badeb9eb 100644 --- a/ee/agent/keys_tpm.go +++ b/ee/agent/keys_tpm.go @@ -5,52 +5,20 @@ package agent import ( "context" - "fmt" "log/slog" - "github.com/kolide/krypto/pkg/tpm" "github.com/kolide/launcher/ee/agent/types" - "github.com/kolide/launcher/pkg/traces" + "github.com/kolide/launcher/ee/tpmrunner" ) -func setupHardwareKeys(ctx context.Context, slogger *slog.Logger, store types.GetterSetterDeleter) (keyInt, error) { - _, span := traces.StartSpan(ctx) - defer span.End() - - priData, pubData, err := fetchKeyData(store) - if err != nil { - return nil, err - } - - if pubData == nil || priData == nil { - slogger.Log(context.TODO(), slog.LevelInfo, - "generating new keys", - ) - - var err error - priData, pubData, err = tpm.CreateKey() - if err != nil { - clearKeyData(slogger, store) - traces.SetError(span, fmt.Errorf("creating key: %w", err)) - return nil, fmt.Errorf("creating key: %w", err) - } - - span.AddEvent("new_key_created") - - if err := storeKeyData(store, priData, pubData); err != nil { - clearKeyData(slogger, store) - traces.SetError(span, fmt.Errorf("storing key: %w", err)) - return nil, fmt.Errorf("storing key: %w", err) - } - - span.AddEvent("new_key_stored") - } - - k, err := tpm.New(priData, pubData) +// SetHardwareKeysRunner creates a tpm runner and sets it as the agent hardware key as it also implements the keyInt/cyrpto.Signer interface. +// The returned execute and interrupt functions can be used to start and stop the secure enclave runner, generally via a run group. +func SetHardwareKeysRunner(ctx context.Context, slogger *slog.Logger, store types.GetterSetterDeleter, _ secureEnclaveClient) (execute func() error, interrupt func(error), err error) { + tpmRunner, err := tpmrunner.New(ctx, slogger, store) if err != nil { - traces.SetError(span, fmt.Errorf("creating tpm signer: from new key: %w", err)) - return nil, fmt.Errorf("creating tpm signer: from new key: %w", err) + return nil, nil, err } - return k, nil + hardwareKeys = tpmRunner + return tpmRunner.Execute, tpmRunner.Interrupt, nil } diff --git a/ee/debug/shipper/shipper_test.go b/ee/debug/shipper/shipper_test.go index 28f35663b..e1d73a858 100644 --- a/ee/debug/shipper/shipper_test.go +++ b/ee/debug/shipper/shipper_test.go @@ -61,7 +61,7 @@ func TestShip(t *testing.T) { //nolint:paralleltest name: "happy path with signing keys and enroll secret", mockKnapsack: func(t *testing.T) *typesMocks.Knapsack { configStore := inmemory.NewStore() - agent.SetupKeys(context.TODO(), multislogger.NewNopLogger(), configStore, true) + agent.SetupKeys(context.TODO(), multislogger.NewNopLogger(), configStore) k := typesMocks.NewKnapsack(t) k.On("EnrollSecret").Return("enroll_secret_value") diff --git a/ee/desktop/runner/runner.go b/ee/desktop/runner/runner.go index 5805e7d87..ffce886b5 100644 --- a/ee/desktop/runner/runner.go +++ b/ee/desktop/runner/runner.go @@ -4,6 +4,7 @@ package runner import ( "bufio" "context" + "crypto/ecdsa" "errors" "fmt" "io" @@ -21,6 +22,7 @@ import ( "github.com/kolide/kit/ulid" "github.com/kolide/kit/version" + "github.com/kolide/krypto/pkg/echelper" "github.com/kolide/launcher/ee/agent" "github.com/kolide/launcher/ee/agent/flags/keys" "github.com/kolide/launcher/ee/agent/types" @@ -306,6 +308,30 @@ func (r *DesktopUsersProcessesRunner) DetectPresence(reason string, interval tim return presencedetection.DetectionFailedDurationValue, fmt.Errorf("no desktop processes detected presence, last error: %w", lastErr) } +func (r *DesktopUsersProcessesRunner) CreateSecureEnclaveKey(uid string) (*ecdsa.PublicKey, error) { + if r.uidProcs == nil || len(r.uidProcs) == 0 { + return nil, errors.New("no desktop processes running") + } + + proc, ok := r.uidProcs[uid] + if !ok { + return nil, fmt.Errorf("no desktop process for uid: %s", uid) + } + + client := client.New(r.userServerAuthToken, proc.socketPath) + keyBytes, err := client.CreateSecureEnclaveKey() + if err != nil { + return nil, fmt.Errorf("creating secure enclave key: %w", err) + } + + key, err := echelper.PublicB64DerToEcdsaKey(keyBytes) + if err != nil { + return nil, fmt.Errorf("converting key bytes to ecdsa key: %w", err) + } + + return key, nil +} + // killDesktopProcesses kills any existing desktop processes func (r *DesktopUsersProcessesRunner) killDesktopProcesses(ctx context.Context) { wgDone := make(chan struct{}) diff --git a/ee/desktop/user/client/client.go b/ee/desktop/user/client/client.go index da90291dd..17a33a715 100644 --- a/ee/desktop/user/client/client.go +++ b/ee/desktop/user/client/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "time" @@ -95,6 +96,30 @@ func (c *client) DetectPresence(reason string, interval time.Duration) (time.Dur return durationSinceLastDetection, detectionErr } +func (c *client) CreateSecureEnclaveKey() ([]byte, error) { + resp, err := c.base.Post("http://unix/secure_enclave_key", "application/json", http.NoBody) + if err != nil { + return nil, fmt.Errorf("getting secure enclave key: %w", err) + } + + if resp.Body == nil { + return nil, errors.New("response body is nil") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + return body, nil +} + func (c *client) Notify(n notify.Notification) error { notificationToSend := notify.Notification{ Title: n.Title, diff --git a/ee/desktop/user/server/server.go b/ee/desktop/user/server/server.go index 654558f87..80dd5bb69 100644 --- a/ee/desktop/user/server/server.go +++ b/ee/desktop/user/server/server.go @@ -67,6 +67,7 @@ func New(slogger *slog.Logger, authedMux.HandleFunc("/refresh", userServer.refreshHandler) authedMux.HandleFunc("/show", userServer.showDesktop) authedMux.HandleFunc("/detect_presence", userServer.detectPresence) + authedMux.HandleFunc("/secure_enclave_key", userServer.createSecureEnclaveKey) userServer.server = &http.Server{ Handler: userServer.authMiddleware(authedMux), diff --git a/ee/desktop/user/server/server_darwin.go b/ee/desktop/user/server/server_darwin.go new file mode 100644 index 000000000..34326bb91 --- /dev/null +++ b/ee/desktop/user/server/server_darwin.go @@ -0,0 +1,29 @@ +//go:build darwin +// +build darwin + +package server + +import ( + "fmt" + "net/http" + + "github.com/kolide/krypto/pkg/echelper" + "github.com/kolide/krypto/pkg/secureenclave" +) + +func (s *UserServer) createSecureEnclaveKey(w http.ResponseWriter, _ *http.Request) { + key, err := secureenclave.CreateKey() + if err != nil { + http.Error(w, fmt.Errorf("creating key: %w", err).Error(), http.StatusInternalServerError) + return + } + + keyBytes, err := echelper.PublicEcdsaToB64Der(key) + if err != nil { + http.Error(w, fmt.Errorf("serializing key: %w", err).Error(), http.StatusInternalServerError) + return + } + + w.Write(keyBytes) + w.WriteHeader(http.StatusOK) +} diff --git a/ee/desktop/user/server/server_other.go b/ee/desktop/user/server/server_other.go new file mode 100644 index 000000000..348636a68 --- /dev/null +++ b/ee/desktop/user/server/server_other.go @@ -0,0 +1,10 @@ +//go:build !darwin +// +build !darwin + +package server + +import "net/http" + +func (s *UserServer) createSecureEnclaveKey(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not implemented on non darwin", http.StatusNotImplemented) +} diff --git a/ee/localserver/krypto-ec-middleware.go b/ee/localserver/krypto-ec-middleware.go index ff943cab2..4be353eb6 100644 --- a/ee/localserver/krypto-ec-middleware.go +++ b/ee/localserver/krypto-ec-middleware.go @@ -19,6 +19,7 @@ import ( "github.com/kolide/krypto" "github.com/kolide/krypto/pkg/challenge" + "github.com/kolide/launcher/ee/agent" "github.com/kolide/launcher/pkg/log/multislogger" "github.com/kolide/launcher/pkg/traces" "go.opentelemetry.io/otel/attribute" @@ -69,17 +70,16 @@ func (cmdReq v2CmdRequestType) CallbackReq() (*http.Request, error) { } type kryptoEcMiddleware struct { - localDbSigner, hardwareSigner crypto.Signer - counterParty ecdsa.PublicKey - slogger *slog.Logger + localDbSigner crypto.Signer + counterParty ecdsa.PublicKey + slogger *slog.Logger } -func newKryptoEcMiddleware(slogger *slog.Logger, localDbSigner, hardwareSigner crypto.Signer, counterParty ecdsa.PublicKey) *kryptoEcMiddleware { +func newKryptoEcMiddleware(slogger *slog.Logger, localDbSigner crypto.Signer, counterParty ecdsa.PublicKey) *kryptoEcMiddleware { return &kryptoEcMiddleware{ - localDbSigner: localDbSigner, - hardwareSigner: hardwareSigner, - counterParty: counterParty, - slogger: slogger.With("keytype", "ec"), + localDbSigner: localDbSigner, + counterParty: counterParty, + slogger: slogger.With("keytype", "ec"), } } @@ -356,8 +356,8 @@ func (e *kryptoEcMiddleware) Wrap(next http.Handler) http.Handler { // krypto library has a nil check for the object but not the funcs, so if are getting nil from the funcs, just // pass nil to krypto // hardware signing is not implemented for darwin - if runtime.GOOS != "darwin" && e.hardwareSigner != nil && e.hardwareSigner.Public() != nil { - response, err = challengeBox.Respond(e.localDbSigner, e.hardwareSigner, responseBytes) + if runtime.GOOS != "darwin" && agent.HardwareKeys() != nil && agent.HardwareKeys().Public() != nil { + response, err = challengeBox.Respond(e.localDbSigner, agent.HardwareKeys(), responseBytes) } else { response, err = challengeBox.Respond(e.localDbSigner, nil, responseBytes) } diff --git a/ee/localserver/krypto-ec-middleware_test.go b/ee/localserver/krypto-ec-middleware_test.go index 53b0d208b..4f9066729 100644 --- a/ee/localserver/krypto-ec-middleware_test.go +++ b/ee/localserver/krypto-ec-middleware_test.go @@ -189,7 +189,7 @@ func TestKryptoEcMiddleware(t *testing.T) { })).Logger // set up middlewares - kryptoEcMiddleware := newKryptoEcMiddleware(slogger, tt.localDbKey, tt.hardwareKey, counterpartyKey.PublicKey) + kryptoEcMiddleware := newKryptoEcMiddleware(slogger, tt.localDbKey, counterpartyKey.PublicKey) require.NoError(t, err) mockPresenceDetector := mocks.NewPresenceDetector(t) @@ -369,7 +369,7 @@ func Test_AllowedOrigin(t *testing.T) { })).Logger // set up middlewares - kryptoEcMiddleware := newKryptoEcMiddleware(slogger, ecdsaKey(t), nil, counterpartyKey.PublicKey) + kryptoEcMiddleware := newKryptoEcMiddleware(slogger, ecdsaKey(t), counterpartyKey.PublicKey) require.NoError(t, err) h := kryptoEcMiddleware.Wrap(testHandler) diff --git a/ee/localserver/server.go b/ee/localserver/server.go index 9646d4aee..1c5ddfa22 100644 --- a/ee/localserver/server.go +++ b/ee/localserver/server.go @@ -54,9 +54,8 @@ type localServer struct { kolideServer string cancel context.CancelFunc - myKey *rsa.PrivateKey - myLocalDbSigner crypto.Signer - myLocalHardwareSigner crypto.Signer + myKey *rsa.PrivateKey + myLocalDbSigner crypto.Signer serverKey *rsa.PublicKey serverEcKey *ecdsa.PublicKey @@ -79,13 +78,12 @@ func New(ctx context.Context, k types.Knapsack, presenceDetector presenceDetecto defer span.End() ls := &localServer{ - slogger: k.Slogger().With("component", "localserver"), - knapsack: k, - limiter: rate.NewLimiter(defaultRateLimit, defaultRateBurst), - kolideServer: k.KolideServerURL(), - myLocalDbSigner: agent.LocalDbKeys(), - myLocalHardwareSigner: agent.HardwareKeys(), - presenceDetector: presenceDetector, + slogger: k.Slogger().With("component", "localserver"), + knapsack: k, + limiter: rate.NewLimiter(defaultRateLimit, defaultRateBurst), + kolideServer: k.KolideServerURL(), + myLocalDbSigner: agent.LocalDbKeys(), + presenceDetector: presenceDetector, } // TODO: As there may be things that adjust the keys during runtime, we need to persist that across @@ -101,7 +99,7 @@ func New(ctx context.Context, k types.Knapsack, presenceDetector presenceDetecto } ls.myKey = privateKey - ecKryptoMiddleware := newKryptoEcMiddleware(k.Slogger(), ls.myLocalDbSigner, ls.myLocalHardwareSigner, *ls.serverEcKey) + ecKryptoMiddleware := newKryptoEcMiddleware(k.Slogger(), ls.myLocalDbSigner, *ls.serverEcKey) ecAuthedMux := http.NewServeMux() ecAuthedMux.HandleFunc("/", http.NotFound) ecAuthedMux.Handle("/acceleratecontrol", ls.requestAccelerateControlHandler()) diff --git a/ee/secureenclaverunner/mocks/secureEnclaveClient.go b/ee/secureenclaverunner/mocks/secureEnclaveClient.go new file mode 100644 index 000000000..910873d4b --- /dev/null +++ b/ee/secureenclaverunner/mocks/secureEnclaveClient.go @@ -0,0 +1,58 @@ +// Code generated by mockery v2.44.1. DO NOT EDIT. + +package mocks + +import ( + ecdsa "crypto/ecdsa" + + mock "github.com/stretchr/testify/mock" +) + +// SecureEnclaveClient is an autogenerated mock type for the secureEnclaveClient type +type SecureEnclaveClient struct { + mock.Mock +} + +// CreateSecureEnclaveKey provides a mock function with given fields: uid +func (_m *SecureEnclaveClient) CreateSecureEnclaveKey(uid string) (*ecdsa.PublicKey, error) { + ret := _m.Called(uid) + + if len(ret) == 0 { + panic("no return value specified for CreateSecureEnclaveKey") + } + + var r0 *ecdsa.PublicKey + var r1 error + if rf, ok := ret.Get(0).(func(string) (*ecdsa.PublicKey, error)); ok { + return rf(uid) + } + if rf, ok := ret.Get(0).(func(string) *ecdsa.PublicKey); ok { + r0 = rf(uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ecdsa.PublicKey) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewSecureEnclaveClient creates a new instance of SecureEnclaveClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSecureEnclaveClient(t interface { + mock.TestingT + Cleanup(func()) +}) *SecureEnclaveClient { + mock := &SecureEnclaveClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/ee/secureenclaverunner/secureenclaverunner.go b/ee/secureenclaverunner/secureenclaverunner.go new file mode 100644 index 000000000..b928eb02c --- /dev/null +++ b/ee/secureenclaverunner/secureenclaverunner.go @@ -0,0 +1,305 @@ +//go:build darwin +// +build darwin + +package secureenclaverunner + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os/user" + "sync" + "sync/atomic" + "time" + + "github.com/kolide/launcher/ee/agent/types" + "github.com/kolide/launcher/ee/consoleuser" + "github.com/kolide/launcher/pkg/backoff" + "github.com/kolide/launcher/pkg/traces" +) + +const ( + publicEccDataKey = "publicEccData" +) + +type secureEnclaveRunner struct { + uidPubKeyMap map[string]*ecdsa.PublicKey + uidPubKeyMapMux *sync.Mutex + secureEnclaveClient secureEnclaveClient + store types.GetterSetterDeleter + slogger *slog.Logger + interrupt chan struct{} + interrupted atomic.Bool + noConsoleUsersDelay time.Duration +} + +type secureEnclaveClient interface { + CreateSecureEnclaveKey(uid string) (*ecdsa.PublicKey, error) +} + +func New(_ context.Context, slogger *slog.Logger, store types.GetterSetterDeleter, secureEnclaveClient secureEnclaveClient) (*secureEnclaveRunner, error) { + return &secureEnclaveRunner{ + uidPubKeyMap: make(map[string]*ecdsa.PublicKey), + store: store, + secureEnclaveClient: secureEnclaveClient, + slogger: slogger.With("component", "secureenclaverunner"), + uidPubKeyMapMux: &sync.Mutex{}, + interrupt: make(chan struct{}), + noConsoleUsersDelay: 15 * time.Second, + }, nil +} + +// Public returns the public key of the current console user +// creating and peristing a new one if needed +func (ser *secureEnclaveRunner) Execute() error { + data, err := ser.store.Get([]byte(publicEccDataKey)) + if err != nil { + return fmt.Errorf("getting public ecc data from store: %w", err) + } + + if data != nil { + if err := json.Unmarshal(data, ser); err != nil { + ser.slogger.Log(context.TODO(), slog.LevelError, + "unable to unmarshal secure enclave signer, data may be corrupt, wiping", + "err", err, + ) + + if err := ser.store.Delete([]byte(publicEccDataKey)); err != nil { + ser.slogger.Log(context.TODO(), slog.LevelError, + "unable to unmarshal secure enclave signer, data may be corrupt, wiping", + "err", err, + ) + } + } + } + + durationCounter := backoff.NewMultiplicativeDurationCounter(time.Second, time.Minute) + retryTicker := time.NewTicker(time.Second) + defer retryTicker.Stop() + + inNoConsoleUsersState := false + + for { + ctx := context.Background() + _, err := ser.currentConsoleUserKey(ctx) + + switch { + + // don't have console user, so wait longer to retry + case errors.Is(err, noConsoleUsersError{}): + inNoConsoleUsersState = true + retryTicker.Reset(ser.noConsoleUsersDelay) + + // now that we have a console user, restart the backoff + case err != nil && inNoConsoleUsersState: + durationCounter.Reset() + retryTicker.Reset(durationCounter.Next()) + inNoConsoleUsersState = false + + // we have console user, but failed to get key + case err != nil: + retryTicker.Reset(durationCounter.Next()) + + // success + default: + retryTicker.Stop() + } + + // log any errors + if err != nil { + ser.slogger.Log(ctx, slog.LevelDebug, + "getting current console user key", + "err", err, + ) + } + + select { + case <-retryTicker.C: + continue + case <-ser.interrupt: + ser.slogger.Log(ctx, slog.LevelDebug, + "interrupt received, exiting secure enclave signer execute loop", + ) + return nil + } + } +} + +func (ser *secureEnclaveRunner) Interrupt(_ error) { + // Only perform shutdown tasks on first call to interrupt -- no need to repeat on potential extra calls. + if ser.interrupted.Load() { + return + } + + ser.interrupted.Store(true) + + // Tell the execute loop to stop checking, and exit + ser.interrupt <- struct{}{} +} + +// Public returns the public key of the current console user +// creating and peristing a new one if needed +func (ser *secureEnclaveRunner) Public() crypto.PublicKey { + k, err := ser.currentConsoleUserKey(context.TODO()) + if err != nil { + ser.slogger.Log(context.TODO(), slog.LevelError, + "getting public key", + "err", err, + ) + return nil + } + + // currentConsoleUserKey may return no error and a nil pointer where the inability + // to get the key is expected (see logic around calling firstConsoleUser). In this case, + // k will be a "typed" nil, as an uninitialized pointer to a ecdsa.PublicKey. We're returning + // this typed nil assigned as the crypto.PublicKey interface. This means that the interface's value + // will be nil, but it's underlying type will not be - so it will pass nil checks but panic + // when typecasted later. Explicitly return an untyped nil in this case to prevent confusion and panics later + if k == nil { + return nil + } + + return k +} + +func (ser *secureEnclaveRunner) Type() string { + return "secure_enclave" +} + +func (ser *secureEnclaveRunner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ser *secureEnclaveRunner) currentConsoleUserKey(ctx context.Context) (*ecdsa.PublicKey, error) { + ctx, span := traces.StartSpan(ctx) + defer span.End() + + ser.uidPubKeyMapMux.Lock() + defer ser.uidPubKeyMapMux.Unlock() + + cu, err := firstConsoleUser(ctx) + if err != nil { + ser.slogger.Log(ctx, slog.LevelDebug, + "getting first console user, expected when root launcher running without a logged in console user", + "err", err, + ) + + traces.SetError(span, fmt.Errorf("getting first console user: %w", err)) + return nil, nil + } + + key, ok := ser.uidPubKeyMap[cu.Uid] + if ok { + span.AddEvent("found_existing_key_for_console_user") + return key, nil + } + + key, err = ser.secureEnclaveClient.CreateSecureEnclaveKey(cu.Uid) + if err != nil { + traces.SetError(span, fmt.Errorf("creating key: %w", err)) + return nil, fmt.Errorf("creating key: %w", err) + } + + span.AddEvent("created_new_key_for_console_user") + + ser.uidPubKeyMap[cu.Uid] = key + if err := ser.save(); err != nil { + delete(ser.uidPubKeyMap, cu.Uid) + traces.SetError(span, fmt.Errorf("saving secure enclave signer: %w", err)) + return nil, fmt.Errorf("saving secure enclave signer: %w", err) + } + + span.AddEvent("saved_key_for_console_user") + return key, nil +} + +func (ser *secureEnclaveRunner) MarshalJSON() ([]byte, error) { + keyMap := make(map[string]string) + + for uid, pubKey := range ser.uidPubKeyMap { + pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return nil, fmt.Errorf("marshalling to PXIX public key: %w", err) + } + + keyMap[uid] = base64.StdEncoding.EncodeToString(pubKeyBytes) + } + + return json.Marshal(keyMap) +} + +func (ser *secureEnclaveRunner) UnmarshalJSON(data []byte) error { + if ser.uidPubKeyMap == nil { + ser.uidPubKeyMap = make(map[string]*ecdsa.PublicKey) + } + + var keyMap map[string]string + if err := json.Unmarshal(data, &keyMap); err != nil { + return fmt.Errorf("unmarshalling key data: %w", err) + } + + for k, v := range keyMap { + decoded, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return fmt.Errorf("decoding base64: %w", err) + } + + pubKey, err := x509.ParsePKIXPublicKey(decoded) + if err != nil { + return fmt.Errorf("parsing PXIX public key: %w", err) + } + + ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("public key is not ecdsa") + } + + ser.uidPubKeyMap[k] = ecdsaPubKey + } + + return nil +} + +type noConsoleUsersError struct{} + +func (noConsoleUsersError) Error() string { + return "no console users found" +} + +func firstConsoleUser(ctx context.Context) (*user.User, error) { + ctx, span := traces.StartSpan(ctx) + defer span.End() + + c, err := consoleuser.CurrentUsers(ctx) + if err != nil { + traces.SetError(span, fmt.Errorf("getting current users: %w", err)) + return nil, fmt.Errorf("getting current users: %w", err) + } + + if len(c) == 0 { + traces.SetError(span, errors.New("no console users found")) + return nil, noConsoleUsersError{} + } + + return c[0], nil +} + +func (ser *secureEnclaveRunner) save() error { + json, err := json.Marshal(ser) + if err != nil { + return fmt.Errorf("marshaling secure enclave signer: %w", err) + } + + if err := ser.store.Set([]byte(publicEccDataKey), json); err != nil { + return fmt.Errorf("setting public ecc data: %w", err) + } + + return nil +} diff --git a/ee/secureenclaverunner/secureenclaverunner_test.go b/ee/secureenclaverunner/secureenclaverunner_test.go new file mode 100644 index 000000000..6f5783158 --- /dev/null +++ b/ee/secureenclaverunner/secureenclaverunner_test.go @@ -0,0 +1,218 @@ +//go:build darwin +// +build darwin + +package secureenclaverunner + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/kolide/krypto/pkg/echelper" + "github.com/kolide/launcher/ee/agent/storage/inmemory" + "github.com/kolide/launcher/ee/secureenclaverunner/mocks" + "github.com/kolide/launcher/pkg/log/multislogger" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_secureEnclaveRunner(t *testing.T) { + t.Parallel() + + privKey, err := echelper.GenerateEcdsaKey() + require.NoError(t, err) + + t.Run("creates key in public call", func(t *testing.T) { + t.Parallel() + + secureEnclaveClientMock := mocks.NewSecureEnclaveClient(t) + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(&privKey.PublicKey, nil).Once() + ser, err := New(context.TODO(), multislogger.NewNopLogger(), inmemory.NewStore(), secureEnclaveClientMock) + require.NoError(t, err) + require.NotNil(t, ser.Public()) + + // key should have been created in public call + require.Len(t, ser.uidPubKeyMap, 1) + for _, v := range ser.uidPubKeyMap { + require.Equal(t, &privKey.PublicKey, v) + } + }) + + t.Run("creates key in execute", func(t *testing.T) { + t.Parallel() + + secureEnclaveClientMock := mocks.NewSecureEnclaveClient(t) + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(nil, errors.New("not available yet")).Once() + ser, err := New(context.TODO(), multislogger.NewNopLogger(), inmemory.NewStore(), secureEnclaveClientMock) + require.NoError(t, err) + + // iniital key should be nil since client wasn't ready + require.Nil(t, ser.Public()) + + // give error on first execute loop + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(nil, errors.New("not available yet")).Once() + + // give key on second execute loop + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(&privKey.PublicKey, nil).Once() + + go func() { + // sleep long enough to get through 2 cycles of exectue + time.Sleep(3 * time.Second) + ser.Interrupt(errors.New("test")) + }() + + require.NoError(t, ser.Execute()) + + // key should have been created in execute + require.Len(t, ser.uidPubKeyMap, 1) + for _, v := range ser.uidPubKeyMap { + require.Equal(t, &privKey.PublicKey, v) + } + }) + + t.Run("loads existing key", func(t *testing.T) { + t.Parallel() + + // populate store with key + store := inmemory.NewStore() + firstConsoleUser, err := firstConsoleUser(context.TODO()) + require.NoError(t, err) + serToSerialize := &secureEnclaveRunner{ + uidPubKeyMap: map[string]*ecdsa.PublicKey{ + firstConsoleUser.Uid: &privKey.PublicKey, + }, + } + serJson, err := json.Marshal(serToSerialize) + require.NoError(t, err) + err = store.Set([]byte(publicEccDataKey), serJson) + require.NoError(t, err) + + // create new signer with store containing key + ser, err := New(context.TODO(), multislogger.NewNopLogger(), store, nil) + require.NoError(t, err) + + go func() { + // sleep long enough to get through 2 cycles of exectue + time.Sleep(3 * time.Second) + ser.Interrupt(errors.New("test")) + }() + + require.NoError(t, ser.Execute()) + + // key should have been loaded in execute + require.Len(t, ser.uidPubKeyMap, 1) + for _, v := range ser.uidPubKeyMap { + require.Equal(t, &privKey.PublicKey, v) + } + }) + + t.Run("multiple interrupts", func(t *testing.T) { + t.Parallel() + + secureEnclaveClientMock := mocks.NewSecureEnclaveClient(t) + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(&privKey.PublicKey, nil).Once() + + ser, err := New(context.TODO(), multislogger.NewNopLogger(), inmemory.NewStore(), secureEnclaveClientMock) + require.NoError(t, err) + + go func() { + ser.Execute() + }() + + // Confirm we can call Interrupt multiple times without blocking + interruptComplete := make(chan struct{}) + expectedInterrupts := 3 + for i := 0; i < expectedInterrupts; i += 1 { + go func() { + ser.Interrupt(nil) + interruptComplete <- struct{}{} + }() + } + + receivedInterrupts := 0 + for { + if receivedInterrupts >= expectedInterrupts { + break + } + + select { + case <-interruptComplete: + receivedInterrupts += 1 + continue + case <-time.After(5 * time.Second): + t.Errorf("could not call interrupt multiple times and return within 5 seconds -- received %d interrupts before timeout", receivedInterrupts) + t.FailNow() + } + } + + require.Equal(t, expectedInterrupts, receivedInterrupts) + }) + + t.Run("no console users then creates key", func(t *testing.T) { + t.Parallel() + + secureEnclaveClientMock := mocks.NewSecureEnclaveClient(t) + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(nil, errors.New("not available yet")).Once() + ser, err := New(context.TODO(), multislogger.NewNopLogger(), inmemory.NewStore(), secureEnclaveClientMock) + require.NoError(t, err) + + // iniital key should be nil since client wasn't ready + require.Nil(t, ser.Public()) + + // set delay to 100ms for testing + ser.noConsoleUsersDelay = 100 * time.Millisecond + + // give error on first execute loop + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(nil, noConsoleUsersError{}).Once() + + // give error on first execute loop + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(nil, errors.New("some other error")).Once() + + // give key on second execute loop + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(&privKey.PublicKey, nil).Once() + + go func() { + // sleep long enough to get through 2 cycles of exectue + time.Sleep(3 * time.Second) + ser.Interrupt(errors.New("test")) + }() + + require.NoError(t, ser.Execute()) + + // key should have been loaded in execute + require.Len(t, ser.uidPubKeyMap, 1) + for _, v := range ser.uidPubKeyMap { + require.Equal(t, &privKey.PublicKey, v) + } + }) + + t.Run("no console users, handles interrupt", func(t *testing.T) { + t.Parallel() + + secureEnclaveClientMock := mocks.NewSecureEnclaveClient(t) + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(nil, errors.New("not available yet")).Once() + ser, err := New(context.TODO(), multislogger.NewNopLogger(), inmemory.NewStore(), secureEnclaveClientMock) + require.NoError(t, err) + + // iniital key should be nil since client wasn't ready + require.Nil(t, ser.Public()) + + // give error on first execute loop + secureEnclaveClientMock.On("CreateSecureEnclaveKey", mock.AnythingOfType("string")).Return(nil, noConsoleUsersError{}).Once() + + go func() { + // sleep long enough to get through 2 cycles of exectue + time.Sleep(3 * time.Second) + ser.Interrupt(errors.New("test")) + }() + + require.NoError(t, ser.Execute()) + + // no key should be created since loop didn't execute + // and public not called + require.Len(t, ser.uidPubKeyMap, 0) + }) +} diff --git a/ee/secureenclavesigner/secureenclavesigner_darwin.go b/ee/secureenclavesigner/secureenclavesigner_darwin.go deleted file mode 100644 index 130eef9b7..000000000 --- a/ee/secureenclavesigner/secureenclavesigner_darwin.go +++ /dev/null @@ -1,313 +0,0 @@ -//go:build darwin -// +build darwin - -package secureenclavesigner - -import ( - "context" - "crypto" - "crypto/ecdsa" - "crypto/x509" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "os" - "os/user" - "strings" - "sync" - - "github.com/kolide/krypto/pkg/echelper" - "github.com/kolide/launcher/ee/agent/types" - "github.com/kolide/launcher/ee/allowedcmd" - "github.com/kolide/launcher/ee/consoleuser" - "github.com/kolide/launcher/pkg/traces" -) - -const ( - CreateKeyCmd = "create-key" - PublicEccDataKey = "publicEccData" -) - -type opt func(*secureEnclaveSigner) - -type secureEnclaveSigner struct { - uidPubKeyMap map[string]*ecdsa.PublicKey - pathToLauncherBinary string - store types.GetterSetterDeleter - slogger *slog.Logger - mux *sync.Mutex -} - -func New(ctx context.Context, slogger *slog.Logger, store types.GetterSetterDeleter, opts ...opt) (*secureEnclaveSigner, error) { - ctx, span := traces.StartSpan(ctx) - defer span.End() - - ses := &secureEnclaveSigner{ - uidPubKeyMap: make(map[string]*ecdsa.PublicKey), - store: store, - slogger: slogger.With("component", "secureenclavesigner"), - mux: &sync.Mutex{}, - } - - data, err := store.Get([]byte(PublicEccDataKey)) - if err != nil { - traces.SetError(span, fmt.Errorf("getting public ecc data from store: %w", err)) - return nil, fmt.Errorf("getting public ecc data from store: %w", err) - } - - if data != nil { - if err := json.Unmarshal(data, ses); err != nil { - traces.SetError(span, fmt.Errorf("unmarshaling secure enclave signer: %w", err)) - ses.slogger.Log(ctx, slog.LevelError, - "unable to unmarshal secure enclave signer, data may be corrupt, wiping", - "err", err, - ) - - if err := store.Delete([]byte(PublicEccDataKey)); err != nil { - traces.SetError(span, fmt.Errorf("deleting corrupt public ecc data: %w", err)) - return nil, fmt.Errorf("deleting corrupt public ecc data: %w", err) - } - } - } - - for _, opt := range opts { - opt(ses) - } - - if ses.pathToLauncherBinary == "" { - p, err := os.Executable() - if err != nil { - traces.SetError(span, fmt.Errorf("getting path to launcher binary: %w", err)) - return nil, fmt.Errorf("getting path to launcher binary: %w", err) - } - - ses.pathToLauncherBinary = p - } - - // get current console user key to make sure it's available - if _, err := ses.currentConsoleUserKey(ctx); err != nil { - traces.SetError(span, fmt.Errorf("getting current console user key: %w", err)) - ses.slogger.Log(ctx, slog.LevelError, - "getting current console user key", - "err", err, - ) - - // intentionally not returning error here, because this runs on start up - // and maybe the console user or secure enclave is not available yet - } - - return ses, nil -} - -// Public returns the public key of the current console user -// creating and peristing a new one if needed -func (ses *secureEnclaveSigner) Public() crypto.PublicKey { - k, err := ses.currentConsoleUserKey(context.TODO()) - if err != nil { - ses.slogger.Log(context.TODO(), slog.LevelError, - "getting public key", - "err", err, - ) - return nil - } - - // currentConsoleUserKey may return no error and a nil pointer where the inability - // to get the key is expected (see logic around calling firstConsoleUser). In this case, - // k will be a "typed" nil, as an uninitialized pointer to a ecdsa.PublicKey. We're returning - // this typed nil assigned as the crypto.PublicKey interface. This means that the interface's value - // will be nil, but it's underlying type will not be - so it will pass nil checks but panic - // when typecasted later. Explicitly return an untyped nil in this case to prevent confusion and panics later - if k == nil { - return nil - } - - return k -} - -func (ses *secureEnclaveSigner) Type() string { - return "secure_enclave" -} - -func (ses *secureEnclaveSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { - return nil, fmt.Errorf("not implemented") -} - -func (ses *secureEnclaveSigner) currentConsoleUserKey(ctx context.Context) (*ecdsa.PublicKey, error) { - ctx, span := traces.StartSpan(ctx) - defer span.End() - - ses.mux.Lock() - defer ses.mux.Unlock() - - cu, err := firstConsoleUser(ctx) - if err != nil { - ses.slogger.Log(ctx, slog.LevelDebug, - "getting first console user, expected when root launcher running without a logged in console user", - "err", err, - ) - - traces.SetError(span, fmt.Errorf("getting first console user: %w", err)) - return nil, nil - } - - key, ok := ses.uidPubKeyMap[cu.Uid] - if ok { - span.AddEvent("found_existing_key_for_console_user") - return key, nil - } - - key, err = ses.createKey(ctx, cu) - if err != nil { - traces.SetError(span, fmt.Errorf("creating key: %w", err)) - return nil, fmt.Errorf("creating key: %w", err) - } - - span.AddEvent("created_new_key_for_console_user") - - ses.uidPubKeyMap[cu.Uid] = key - if err := ses.save(); err != nil { - delete(ses.uidPubKeyMap, cu.Uid) - traces.SetError(span, fmt.Errorf("saving secure enclave signer: %w", err)) - return nil, fmt.Errorf("saving secure enclave signer: %w", err) - } - - span.AddEvent("saved_key_for_console_user") - return key, nil -} - -func (ses *secureEnclaveSigner) MarshalJSON() ([]byte, error) { - keyMap := make(map[string]string) - - for uid, pubKey := range ses.uidPubKeyMap { - pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey) - if err != nil { - return nil, fmt.Errorf("marshalling to PXIX public key: %w", err) - } - - keyMap[uid] = base64.StdEncoding.EncodeToString(pubKeyBytes) - } - - return json.Marshal(keyMap) -} - -func (ses *secureEnclaveSigner) UnmarshalJSON(data []byte) error { - if ses.uidPubKeyMap == nil { - ses.uidPubKeyMap = make(map[string]*ecdsa.PublicKey) - } - - var keyMap map[string]string - if err := json.Unmarshal(data, &keyMap); err != nil { - return fmt.Errorf("unmarshalling key data: %w", err) - } - - for k, v := range keyMap { - decoded, err := base64.StdEncoding.DecodeString(v) - if err != nil { - return fmt.Errorf("decoding base64: %w", err) - } - - pubKey, err := x509.ParsePKIXPublicKey(decoded) - if err != nil { - return fmt.Errorf("parsing PXIX public key: %w", err) - } - - ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey) - if !ok { - return fmt.Errorf("public key is not ecdsa") - } - - ses.uidPubKeyMap[k] = ecdsaPubKey - } - - return nil -} - -func (ses *secureEnclaveSigner) createKey(ctx context.Context, u *user.User) (*ecdsa.PublicKey, error) { - ctx, span := traces.StartSpan(ctx) - defer span.End() - - cmd, err := allowedcmd.Launchctl( - ctx, - "asuser", - u.Uid, - "sudo", - "--preserve-env", - "-u", - u.Username, - ses.pathToLauncherBinary, - "secure-enclave", - CreateKeyCmd, - ) - - if err != nil { - traces.SetError(span, fmt.Errorf("creating command to create key: %w", err)) - return nil, fmt.Errorf("creating command to create key: %w", err) - } - - // skip updates since we have full path of binary - cmd.Env = append(cmd.Environ(), fmt.Sprintf("%s=%s", "LAUNCHER_SKIP_UPDATES", "true")) - out, err := cmd.CombinedOutput() - if err != nil { - traces.SetError(span, fmt.Errorf("executing launcher binary to create key: %w: %s", err, string(out))) - return nil, fmt.Errorf("executing launcher binary to create key: %w: %s", err, string(out)) - } - - pubKey, err := echelper.PublicB64DerToEcdsaKey([]byte(lastLine(out))) - if err != nil { - traces.SetError(span, fmt.Errorf("converting public key to ecdsa: %w", err)) - return nil, fmt.Errorf("converting public key to ecdsa: %w", err) - } - - return pubKey, nil -} - -// lastLine returns the last line of the out. -// This is needed because laucher sets up a logger by default. -// The last line of the output is the public key or signature. -func lastLine(out []byte) string { - outStr := string(out) - - // get last line of outstr - lastLine := "" - for _, line := range strings.Split(outStr, "\n") { - if line != "" { - lastLine = line - } - } - - return lastLine -} - -func firstConsoleUser(ctx context.Context) (*user.User, error) { - ctx, span := traces.StartSpan(ctx) - defer span.End() - - c, err := consoleuser.CurrentUsers(ctx) - if err != nil { - traces.SetError(span, fmt.Errorf("getting current users: %w", err)) - return nil, fmt.Errorf("getting current users: %w", err) - } - - if len(c) == 0 { - traces.SetError(span, errors.New("no console users found")) - return nil, errors.New("no console users found") - } - - return c[0], nil -} - -func (ses *secureEnclaveSigner) save() error { - json, err := json.Marshal(ses) - if err != nil { - return fmt.Errorf("marshaling secure enclave signer: %w", err) - } - - if err := ses.store.Set([]byte(PublicEccDataKey), json); err != nil { - return fmt.Errorf("setting public ecc data: %w", err) - } - - return nil -} diff --git a/ee/secureenclavesigner/secureenclavesigner_test.go b/ee/secureenclavesigner/secureenclavesigner_test.go deleted file mode 100644 index cd0f91206..000000000 --- a/ee/secureenclavesigner/secureenclavesigner_test.go +++ /dev/null @@ -1,171 +0,0 @@ -//go:build darwin -// +build darwin - -package secureenclavesigner - -import ( - "context" - "crypto/ecdsa" - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/kolide/krypto/pkg/echelper" - "github.com/kolide/launcher/ee/agent/storage/inmemory" - "github.com/kolide/launcher/pkg/log/multislogger" - "github.com/stretchr/testify/require" -) - -const ( - testWrappedEnvVarKey = "SECURE_ENCLAVE_TEST_WRAPPED" - testSkipSecureEnclaveTestingEnvVarKey = "SKIP_SECURE_ENCLAVE_TESTS" - macOsAppResourceDir = "./test_app_resources" -) - -func WithBinaryPath(p string) opt { - return func(ses *secureEnclaveSigner) { - ses.pathToLauncherBinary = p - } -} - -// #nosec G306 -- Need readable files -func TestSecureEnclaveSigner(t *testing.T) { - t.Parallel() - - if os.Getenv("CI") != "" { - t.Skipf("\nskipping because %s env var was not empty, this is being run in a CI environment without access to secure enclave", testWrappedEnvVarKey) - } - - if os.Getenv(testSkipSecureEnclaveTestingEnvVarKey) != "" { - t.Skipf("\nskipping because %s env var was set", testSkipSecureEnclaveTestingEnvVarKey) - } - - // put the root dir somewhere else if you want to persist the signed macos app bundle - // should build this into make at some point - rootDir := "/tmp/secure_enclave_test" - - // rootDir := t.TempDir() - appRoot := filepath.Join(rootDir, "launcher_test.app") - - // make required dirs krypto_test.app/Contents/MacOS and add files - require.NoError(t, os.MkdirAll(filepath.Join(appRoot, "Contents", "MacOS"), 0777)) - copyFile(t, filepath.Join(macOsAppResourceDir, "Info.plist"), filepath.Join(appRoot, "Contents", "Info.plist")) - copyFile(t, filepath.Join(macOsAppResourceDir, "embedded.provisionprofile"), filepath.Join(appRoot, "Contents", "embedded.provisionprofile")) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - serverPrivKey, err := echelper.GenerateEcdsaKey() - require.NoError(t, err) - - serverPubKeyDer, err := echelper.PublicEcdsaToB64Der(serverPrivKey.Public().(*ecdsa.PublicKey)) - require.NoError(t, err) - - // build the executable - executablePath := filepath.Join(appRoot, "Contents", "MacOS", "launcher_test") - out, err := exec.CommandContext( //nolint:forbidigo // Only used in test, don't want as standard allowedcmd - ctx, - "go", - "build", - "-ldflags", - fmt.Sprintf("-X github.com/kolide/launcher/ee/secureenclavesigner.TestServerPubKey=%s", string(serverPubKeyDer)), - "-tags", - "secure_enclave_test", - "-o", - executablePath, - "../../cmd/launcher", - ).CombinedOutput() - - require.NoError(t, ctx.Err()) - require.NoError(t, err, string(out)) - - // sign app bundle - signApp(t, appRoot) - - store := inmemory.NewStore() - - // create brand new signer without existing key - // ask for public first to trigger key generation - ses, err := New(context.TODO(), multislogger.NewNopLogger(), store, WithBinaryPath(executablePath)) - require.NoError(t, err, - "should be able to create secure enclave signer", - ) - - pubKey := ses.Public() - require.NotNil(t, pubKey, - "should be able to create brand new public key", - ) - - pubEcdsaKey := pubKey.(*ecdsa.PublicKey) - require.NotNil(t, pubEcdsaKey, - "public key should convert to ecdsa key", - ) - - pubKeySame := ses.Public() - require.NotNil(t, pubKeySame, - "should be able to get public key again", - ) - - pubEcdsaKeySame := pubKeySame.(*ecdsa.PublicKey) - require.NotNil(t, pubEcdsaKeySame, - "public key should convert to ecdsa key", - ) - - require.Equal(t, pubEcdsaKey, pubEcdsaKeySame, - "asking for the same public key should return the same key", - ) - - existingDataSes, err := New(context.TODO(), multislogger.NewNopLogger(), store, WithBinaryPath(executablePath)) - require.NoError(t, err, - "should be able to create secure enclave signer with existing key", - ) - - pubKeyUnmarshalled := existingDataSes.Public() - require.NotNil(t, pubKeyUnmarshalled, - "should be able to get public key from unmarshalled secure enclave signer", - ) - - pubEcdsaKeyUnmarshalled := pubKeyUnmarshalled.(*ecdsa.PublicKey) - require.NotNil(t, pubEcdsaKeyUnmarshalled, - "public key should convert to ecdsa key", - ) - - require.Equal(t, pubEcdsaKey, pubEcdsaKeyUnmarshalled, - "unmarshalled public key should be the same as original public key", - ) -} - -// #nosec G306 -- Need readable files -func copyFile(t *testing.T, source, destination string) { - bytes, err := os.ReadFile(source) - require.NoError(t, err) - require.NoError(t, os.WriteFile(destination, bytes, 0700)) -} - -// #nosec G204 -- This triggers due to using env var in cmd, making exception for test -func signApp(t *testing.T, appRootDir string) { - codeSignId := os.Getenv("MACOS_CODESIGN_IDENTITY") - require.NotEmpty(t, codeSignId, "need MACOS_CODESIGN_IDENTITY env var to sign app, such as [Mac Developer: Jane Doe (ABCD123456)]") - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - cmd := exec.CommandContext( //nolint:forbidigo // Only used in test, don't want as standard allowcmd - ctx, - "codesign", - "--deep", - "--force", - "--options", "runtime", - "--entitlements", filepath.Join(macOsAppResourceDir, "entitlements"), - "--sign", codeSignId, - "--timestamp", - appRootDir, - ) - - out, err := cmd.CombinedOutput() - require.NoError(t, ctx.Err()) - require.NoError(t, err, string(out)) -} diff --git a/ee/secureenclavesigner/test_app_resources/embedded.provisionprofile b/ee/secureenclavesigner/test_app_resources/embedded.provisionprofile deleted file mode 100644 index 37a067b99..000000000 Binary files a/ee/secureenclavesigner/test_app_resources/embedded.provisionprofile and /dev/null differ diff --git a/ee/secureenclavesigner/test_app_resources/entitlements b/ee/secureenclavesigner/test_app_resources/entitlements deleted file mode 100644 index 4debb196d..000000000 --- a/ee/secureenclavesigner/test_app_resources/entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - keychain-access-groups - - X98UFR7HA3.com.kolide.agent.dev - - - diff --git a/ee/secureenclavesigner/test_app_resources/info.plist b/ee/secureenclavesigner/test_app_resources/info.plist deleted file mode 100644 index fe801acec..000000000 --- a/ee/secureenclavesigner/test_app_resources/info.plist +++ /dev/null @@ -1,20 +0,0 @@ - - - - - CFBundleExecutable - launcher_test - CFBundleIdentifier - com.kolide.agent - CFBundleName - launcher_test - LSUIElement - - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1 - CFBundleVersion - 0.1 - - diff --git a/ee/secureenclavesigner/test_app_resources/readme.md b/ee/secureenclavesigner/test_app_resources/readme.md deleted file mode 100644 index 939c65060..000000000 --- a/ee/secureenclavesigner/test_app_resources/readme.md +++ /dev/null @@ -1,31 +0,0 @@ -# Running Tests - -The files in this directory are used only for testing. - -The secure enclave keyer requires apple entitlements in order to be able to access the secure enclave to generate keys and perform cryptographic operations. In order to do this we build the secure enclave go tests to a binary, sign that binary with the required MacOS entitlements, then execute the binary and inspect the output. This is all done via the `TestSecureEnclaveTestRunner` function. - -In order to add entitlements we first need to create a MacOS app with the following structure: - -```sh -launcher_test.app - └── Contents - ├── Info.plist - ├── MacOS - │ └── launcher_test # <- this is the go test binary mentioned above - └── embedded.provisionprofile -``` - -Then we pass the top level directory to the MacOS codsign utility. - -In order to succesfully sign the app with entitlements, there are a few steps that must be completed on the machine in order to run the tests. - -1. Follow instructions to download and install a certificate from the Apple Developer account of type "Mac Development" https://developer.apple.com/account/resources/certificates/list -1. You may need to download and install the "Apple Worldwide Developer Relations Certificate" from https://www.apple.com/certificateauthority/. Inspect the issuer cert added in the previous step to find the correct cert to add. -1. Add you device to the developer account using the "Provisioning UDID" found at Desktop Menu Applie Icon> About This Mac > More Info > System Report https://developer.apple.com/account/resources/devices/list -1. Create a provisioing profile that includes the device https://developer.apple.com/account/resources/profiles/list ... should probably include all devices on the team and be updated in the repo -1. Replace the `embedded.provisionprofile` file with the new profile - -## Skipping Tests - -- To skip these tests (e.g. while running tests on a machine which is not included in the provisioning profile), you can set the `SKIP_SECURE_ENCLAVE_TESTS` environment variable to any non-empty value - - `SKIP_SECURE_ENCLAVE_TESTS=y make test` diff --git a/ee/tpmrunner/mocks/tpmSignerCreator.go b/ee/tpmrunner/mocks/tpmSignerCreator.go new file mode 100644 index 000000000..727456cb0 --- /dev/null +++ b/ee/tpmrunner/mocks/tpmSignerCreator.go @@ -0,0 +1,104 @@ +// Code generated by mockery v2.44.1. DO NOT EDIT. + +package mocks + +import ( + crypto "crypto" + + tpm "github.com/kolide/krypto/pkg/tpm" + mock "github.com/stretchr/testify/mock" +) + +// TpmSignerCreator is an autogenerated mock type for the tpmSignerCreator type +type TpmSignerCreator struct { + mock.Mock +} + +// CreateKey provides a mock function with given fields: opts +func (_m *TpmSignerCreator) CreateKey(opts ...tpm.TpmSignerOption) ([]byte, []byte, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for CreateKey") + } + + var r0 []byte + var r1 []byte + var r2 error + if rf, ok := ret.Get(0).(func(...tpm.TpmSignerOption) ([]byte, []byte, error)); ok { + return rf(opts...) + } + if rf, ok := ret.Get(0).(func(...tpm.TpmSignerOption) []byte); ok { + r0 = rf(opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(...tpm.TpmSignerOption) []byte); ok { + r1 = rf(opts...) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]byte) + } + } + + if rf, ok := ret.Get(2).(func(...tpm.TpmSignerOption) error); ok { + r2 = rf(opts...) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// New provides a mock function with given fields: private, public +func (_m *TpmSignerCreator) New(private []byte, public []byte) (crypto.Signer, error) { + ret := _m.Called(private, public) + + if len(ret) == 0 { + panic("no return value specified for New") + } + + var r0 crypto.Signer + var r1 error + if rf, ok := ret.Get(0).(func([]byte, []byte) (crypto.Signer, error)); ok { + return rf(private, public) + } + if rf, ok := ret.Get(0).(func([]byte, []byte) crypto.Signer); ok { + r0 = rf(private, public) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(crypto.Signer) + } + } + + if rf, ok := ret.Get(1).(func([]byte, []byte) error); ok { + r1 = rf(private, public) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewTpmSignerCreator creates a new instance of TpmSignerCreator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTpmSignerCreator(t interface { + mock.TestingT + Cleanup(func()) +}) *TpmSignerCreator { + mock := &TpmSignerCreator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/ee/tpmrunner/tpmrunner.go b/ee/tpmrunner/tpmrunner.go new file mode 100644 index 000000000..0b509df44 --- /dev/null +++ b/ee/tpmrunner/tpmrunner.go @@ -0,0 +1,248 @@ +package tpmrunner + +import ( + "context" + "crypto" + "errors" + "fmt" + "io" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/kolide/krypto/pkg/tpm" + "github.com/kolide/launcher/ee/agent/types" + "github.com/kolide/launcher/pkg/backoff" + "github.com/kolide/launcher/pkg/traces" +) + +type ( + tpmRunner struct { + signer crypto.Signer + mux sync.Mutex + signerCreator tpmSignerCreator + store types.GetterSetterDeleter + slogger *slog.Logger + interrupt chan struct{} + interrupted atomic.Bool + } + + // tpmSignerCreator is an interface for creating and loading TPM signers + // useful for mocking in tests + tpmSignerCreator interface { + CreateKey(opts ...tpm.TpmSignerOption) (private []byte, public []byte, err error) + New(private, public []byte) (crypto.Signer, error) + } + + // defaultTpmSignerCreator is the default implementation of tpmSignerCreator + // using the tpm package + defaultTpmSignerCreator struct{} + + // tpmRunnerOption is a functional option for tpmRunner + // useful for setting dependencies in tests + tpmRunnerOption func(*tpmRunner) +) + +// CreateKey creates a new TPM key +func (d defaultTpmSignerCreator) CreateKey(opts ...tpm.TpmSignerOption) (private []byte, public []byte, err error) { + return tpm.CreateKey() +} + +// New creates a new TPM signer +func (d defaultTpmSignerCreator) New(private, public []byte) (crypto.Signer, error) { + return tpm.New(private, public) +} + +func New(ctx context.Context, slogger *slog.Logger, store types.GetterSetterDeleter, opts ...tpmRunnerOption) (*tpmRunner, error) { + tpmRunner := &tpmRunner{ + store: store, + slogger: slogger.With("component", "tpmrunner"), + interrupt: make(chan struct{}), + signerCreator: defaultTpmSignerCreator{}, + } + + for _, opt := range opts { + opt(tpmRunner) + } + + return tpmRunner, nil +} + +// Public returns the public key of the current console user +// creating and peristing a new one if needed +func (tr *tpmRunner) Execute() error { + durationCounter := backoff.NewMultiplicativeDurationCounter(time.Second, time.Minute) + retryTicker := time.NewTicker(durationCounter.Next()) + defer retryTicker.Stop() + + for { + // try to create signer if we don't have one + if tr.signer == nil { + ctx := context.Background() + if err := tr.loadOrCreateKeys(ctx); err != nil { + tr.slogger.Log(ctx, slog.LevelError, + "loading or creating keys in execute loop", + "err", err, + ) + } + } + + if tr.signer != nil { + retryTicker.Stop() + } + + select { + case <-retryTicker.C: + retryTicker.Reset(durationCounter.Next()) + continue + case <-tr.interrupt: + tr.slogger.Log(context.TODO(), slog.LevelDebug, + "interrupt received, exiting secure enclave signer execute loop", + ) + return nil + } + } +} + +func (tr *tpmRunner) Interrupt(_ error) { + // Only perform shutdown tasks on first call to interrupt -- no need to repeat on potential extra calls. + if tr.interrupted.Load() { + return + } + + tr.interrupted.Store(true) + + // Tell the execute loop to stop checking, and exit + tr.interrupt <- struct{}{} +} + +// Public returns the public hardware key +func (tr *tpmRunner) Public() crypto.PublicKey { + if tr.signer != nil { + return tr.signer.Public() + } + + if err := tr.loadOrCreateKeys(context.Background()); err != nil { + tr.slogger.Log(context.Background(), slog.LevelError, + "loading or creating keys in public call", + "err", err, + ) + + return nil + } + + return tr.signer.Public() +} + +func (tr *tpmRunner) Type() string { + return "tpm" +} + +func (tr *tpmRunner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + if tr.signer == nil { + return nil, errors.New("no signer available") + } + + return tr.signer.Sign(rand, digest, opts) +} + +// This duplicates some of pkg/osquery/extension.go but that feels like the wrong place. +// Really, we should have a simpler interface over a storage layer. +const ( + privateEccData = "privateEccData" + publicEccData = "publicEccData" +) + +func fetchKeyData(store types.Getter) ([]byte, []byte, error) { + pri, err := store.Get([]byte(privateEccData)) + if err != nil { + return nil, nil, err + } + + pub, err := store.Get([]byte(publicEccData)) + if err != nil { + return nil, nil, err + } + + return pri, pub, nil +} + +func storeKeyData(store types.Setter, pri, pub []byte) error { + if pri != nil { + if err := store.Set([]byte(privateEccData), pri); err != nil { + return err + } + } + + if pub != nil { + if err := store.Set([]byte(publicEccData), pub); err != nil { + return err + } + } + + return nil +} + +// clearKeyData is used to clear the keys as part of error handling around new keys. It is not intended to be called +// regularly, and since the path that calls it is around DB errors, it has no error handling. +func clearKeyData(slogger *slog.Logger, deleter types.Deleter) { + slogger.Log(context.TODO(), slog.LevelInfo, + "clearing keys", + ) + _ = deleter.Delete([]byte(privateEccData), []byte(publicEccData)) +} + +func (tr *tpmRunner) loadOrCreateKeys(ctx context.Context) error { + ctx, span := traces.StartSpan(ctx) + defer span.End() + + tr.mux.Lock() + defer tr.mux.Unlock() + + priData, pubData, err := fetchKeyData(tr.store) + if err != nil { + thisErr := fmt.Errorf("fetching key data for data store: %w", err) + traces.SetError(span, thisErr) + return thisErr + } + + if pubData == nil || priData == nil { + tr.slogger.Log(ctx, slog.LevelInfo, + "generating new tpm keys", + ) + + var err error + priData, pubData, err = tr.signerCreator.CreateKey() + if err != nil { + thisErr := fmt.Errorf("creating key: %w", err) + traces.SetError(span, thisErr) + + clearKeyData(tr.slogger, tr.store) + return thisErr + } + + if err := storeKeyData(tr.store, priData, pubData); err != nil { + thisErr := fmt.Errorf("storing key data: %w", err) + traces.SetError(span, thisErr) + + clearKeyData(tr.slogger, tr.store) + return thisErr + } + + span.AddEvent("generated_new_tpm_keys") + } + + k, err := tr.signerCreator.New(priData, pubData) + if err != nil { + thisErr := fmt.Errorf("creating tpm signer: %w", err) + traces.SetError(span, thisErr) + return thisErr + } + + tr.signer = k + + span.AddEvent("created_tpm_signer") + + return nil +} diff --git a/ee/tpmrunner/tpmrunner_test.go b/ee/tpmrunner/tpmrunner_test.go new file mode 100644 index 000000000..4f7257a34 --- /dev/null +++ b/ee/tpmrunner/tpmrunner_test.go @@ -0,0 +1,126 @@ +package tpmrunner + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kolide/krypto/pkg/echelper" + "github.com/kolide/launcher/ee/agent/storage/inmemory" + "github.com/kolide/launcher/ee/tpmrunner/mocks" + "github.com/kolide/launcher/pkg/log/multislogger" + "github.com/stretchr/testify/require" +) + +func withTpmSignerCreator(tpmSignerCreator tpmSignerCreator) tpmRunnerOption { + return func(t *tpmRunner) { + t.signerCreator = tpmSignerCreator + } +} + +func Test_tpmRunner(t *testing.T) { + t.Parallel() + + privKey, err := echelper.GenerateEcdsaKey() + require.NoError(t, err) + + fakePrivData, fakePubData := []byte("fake priv data"), []byte("fake pub data") + + t.Run("creates key in execute", func(t *testing.T) { + t.Parallel() + + tpmSignerCreatorMock := mocks.NewTpmSignerCreator(t) + tpmRunner, err := New(context.TODO(), multislogger.NewNopLogger(), inmemory.NewStore(), withTpmSignerCreator(tpmSignerCreatorMock)) + require.NoError(t, err) + + tpmSignerCreatorMock.On("CreateKey").Return(nil, nil, errors.New("not available yet")).Once() + require.Nil(t, tpmRunner.Public()) + + tpmSignerCreatorMock.On("CreateKey").Return(nil, nil, errors.New("not available yet")).Once() + tpmSignerCreatorMock.On("CreateKey").Return(fakePrivData, fakePubData, nil).Once() + tpmSignerCreatorMock.On("New", fakePrivData, fakePubData).Return(privKey, nil).Once() + + go func() { + // sleep long enough to get through 2 cycles of execute + time.Sleep(3 * time.Second) + tpmRunner.Interrupt(errors.New("test")) + }() + + require.NoError(t, tpmRunner.Execute()) + require.NotNil(t, tpmRunner.Public()) + }) + + t.Run("loads existing key", func(t *testing.T) { + t.Parallel() + + // populate store with key info + store := inmemory.NewStore() + store.Set([]byte(privateEccData), fakePrivData) + store.Set([]byte(publicEccData), fakePubData) + + tpmSignerCreatorMock := mocks.NewTpmSignerCreator(t) + tpmRunner, err := New(context.TODO(), multislogger.NewNopLogger(), store, withTpmSignerCreator(tpmSignerCreatorMock)) + require.NoError(t, err) + + tpmSignerCreatorMock.On("New", fakePrivData, fakePubData).Return(privKey, nil).Once() + + // the call to public should load the key from the store and signer creator should not be called any more after + require.NotNil(t, tpmRunner.Public()) + + go func() { + // sleep long enough to get through 2 cycles of exectue + time.Sleep(3 * time.Second) + tpmRunner.Interrupt(errors.New("test")) + }() + + require.NoError(t, tpmRunner.Execute()) + require.NotNil(t, tpmRunner.Public()) + }) + + t.Run("test multiple interrupts", func(t *testing.T) { + t.Parallel() + + tpmSignerCreatorMock := mocks.NewTpmSignerCreator(t) + tpmRunner, err := New(context.TODO(), multislogger.NewNopLogger(), inmemory.NewStore(), withTpmSignerCreator(tpmSignerCreatorMock)) + require.NoError(t, err) + + tpmSignerCreatorMock.On("CreateKey").Return(nil, nil, errors.New("not available yet")).Once() + require.Nil(t, tpmRunner.Public()) + + tpmSignerCreatorMock.On("CreateKey").Return(fakePrivData, fakePubData, nil).Once() + tpmSignerCreatorMock.On("New", fakePrivData, fakePubData).Return(privKey, nil).Once() + + go func() { + tpmRunner.Execute() + }() + + // Confirm we can call Interrupt multiple times without blocking + interruptComplete := make(chan struct{}) + expectedInterrupts := 3 + for i := 0; i < expectedInterrupts; i += 1 { + go func() { + tpmRunner.Interrupt(nil) + interruptComplete <- struct{}{} + }() + } + + receivedInterrupts := 0 + for { + if receivedInterrupts >= expectedInterrupts { + break + } + + select { + case <-interruptComplete: + receivedInterrupts += 1 + continue + case <-time.After(5 * time.Second): + t.Errorf("could not call interrupt multiple times and return within 5 seconds -- received %d interrupts before timeout", receivedInterrupts) + t.FailNow() + } + } + + require.Equal(t, expectedInterrupts, receivedInterrupts) + }) +} diff --git a/pkg/backoff/counter.go b/pkg/backoff/counter.go new file mode 100644 index 000000000..6cb4915ca --- /dev/null +++ b/pkg/backoff/counter.go @@ -0,0 +1,40 @@ +package backoff + +import ( + "time" +) + +type durationCounter struct { + count int + baseInterval, maxInterval time.Duration + calcNext func(count int, baseDuration time.Duration) time.Duration +} + +// Next increments the count and returns the base interval multiplied by the count. +// If the result is greater than the maxDuration, maxDuration is returned. +func (dc *durationCounter) Next() time.Duration { + dc.count++ + interval := dc.calcNext(dc.count, dc.baseInterval) + if interval > dc.maxInterval { + return dc.maxInterval + } + return interval +} + +// Reset resets the count to 0. +func (dc *durationCounter) Reset() { + dc.count = 0 +} + +// NewMultiplicativeDurationCounter creates a new durationCounter that multiplies the base interval by the count. +// Count is incremented each time Next() is called and returns the base interval multiplied by the count. +// If the result is greater than the maxDuration, maxDuration is returned. +func NewMultiplicativeDurationCounter(baseDuration, maxDuration time.Duration) *durationCounter { + return &durationCounter{ + baseInterval: baseDuration, + maxInterval: maxDuration, + calcNext: func(count int, baseInterval time.Duration) time.Duration { + return baseInterval * time.Duration(count) + }, + } +} diff --git a/pkg/backoff/counter_test.go b/pkg/backoff/counter_test.go new file mode 100644 index 000000000..b64c5463f --- /dev/null +++ b/pkg/backoff/counter_test.go @@ -0,0 +1,74 @@ +package backoff + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMultiplicativeCounter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseInterval time.Duration + maxInterval time.Duration + expected []time.Duration + }{ + { + name: "seconds", + baseInterval: time.Second, + maxInterval: 5 * time.Second, + expected: []time.Duration{ + time.Second, // 1s + 2 * time.Second, // 2s + 3 * time.Second, // 3s + 4 * time.Second, // 4s + 5 * time.Second, // 5s (max interval) + 5 * time.Second, // capped at max interval + }, + }, + { + name: "minutes", + baseInterval: time.Minute, + maxInterval: 3 * time.Minute, + expected: []time.Duration{ + time.Minute, // 1m + 2 * time.Minute, // 2m + 3 * time.Minute, // 3m (max interval) + 3 * time.Minute, // capped at max interval + 3 * time.Minute, // capped at max interval + }, + }, + { + name: "combo", + baseInterval: (1 * time.Minute) + (30 * time.Second), + maxInterval: 5 * time.Minute, + expected: []time.Duration{ + (1 * time.Minute) + (30 * time.Second), // 1m30s + 2 * ((1 * time.Minute) + (30 * time.Second)), // 3m + 3 * ((1 * time.Minute) + (30 * time.Second)), // 4m30s + 5 * time.Minute, // 5m + 5 * time.Minute, // 5m + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ec := NewMultiplicativeDurationCounter(tt.baseInterval, tt.maxInterval) + for _, expected := range tt.expected { + require.Equal(t, expected, ec.Next()) + } + + ec.Reset() + + for _, expected := range tt.expected { + require.Equal(t, expected, ec.Next()) + } + }) + } +}