diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index 08e350ae2..b2790e2fb 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -152,6 +152,8 @@ func runSubcommands() error { run = runDownloadOsquery case "uninstall": run = runUninstall + case "secure-enclave": + run = runSecureEnclave default: return fmt.Errorf("unknown subcommand %s", os.Args[1]) } diff --git a/cmd/launcher/secure_enclave_darwin.go b/cmd/launcher/secure_enclave_darwin.go new file mode 100644 index 000000000..cb551ad57 --- /dev/null +++ b/cmd/launcher/secure_enclave_darwin.go @@ -0,0 +1,207 @@ +//go:build darwin +// +build darwin + +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + "time" + + "github.com/kolide/kit/ulid" + "github.com/kolide/krypto/pkg/challenge" + "github.com/kolide/krypto/pkg/echelper" + "github.com/kolide/krypto/pkg/secureenclave" + "github.com/kolide/launcher/ee/agent/certs" + "github.com/kolide/launcher/ee/secureenclavesigner" + "github.com/vmihailenco/msgpack/v5" +) + +const secureEnclaveTimestampValiditySeconds = 150 + +var serverPubKeys = make(map[string]*ecdsa.PublicKey) + +// runSecureEnclave performs either a create-key or sign 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. To perform an operation, root launcher needs to +// include a challenge signed by a known server. See ee/secureenclavesigner for command data +// structure. +func runSecureEnclave(args []string) error { + if len(args) < 2 { + return errors.New("not enough arguments, expect create_key or sign ") + } + + if err := loadServerKeys(); err != nil { + return fmt.Errorf("loading server keys: %w", err) + } + + if args[1] == "" { + return errors.New("missing request") + } + + switch args[0] { + case secureenclavesigner.CreateKeyCmd: + return createSecureEnclaveKey(args[1]) + + case secureenclavesigner.SignCmd: + return signWithSecureEnclave(args[1]) + + default: + return fmt.Errorf("unknown command %s", args[0]) + } +} + +func loadServerKeys() error { + if secureenclavesigner.Undertest { + if secureenclavesigner.TestServerPubKey == "" { + return errors.New("test server public key not set") + } + + k, err := echelper.PublicB64DerToEcdsaKey([]byte(secureenclavesigner.TestServerPubKey)) + if err != nil { + return fmt.Errorf("parsing test server public key: %w", err) + } + + serverPubKeys[string(secureenclavesigner.TestServerPubKey)] = k + } + + for _, keyStr := range []string{certs.K2EccServerCert, certs.ReviewEccServerCert, certs.LocalhostEccServerCert} { + key, err := echelper.PublicPemToEcdsaKey([]byte(keyStr)) + if err != nil { + return fmt.Errorf("parsing server public key from pem: %w", err) + } + + pubB64Der, err := echelper.PublicEcdsaToB64Der(key) + if err != nil { + return fmt.Errorf("marshalling server public key to b64 der: %w", err) + } + + serverPubKeys[string(pubB64Der)] = key + } + + return nil +} + +func createSecureEnclaveKey(requestB64 string) error { + b, err := base64.StdEncoding.DecodeString(requestB64) + if err != nil { + return fmt.Errorf("decoding b64 request: %w", err) + } + + var createKeyRequest secureenclavesigner.CreateKeyRequest + if err := msgpack.Unmarshal(b, &createKeyRequest); err != nil { + return fmt.Errorf("unmarshaling msgpack request: %w", err) + } + + if err := verifySecureEnclaveChallenge(createKeyRequest.SecureEnclaveRequest); err != nil { + return fmt.Errorf("verifying challenge: %w", err) + } + + 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 +} + +func signWithSecureEnclave(signRequestB64 string) error { + b, err := base64.StdEncoding.DecodeString(signRequestB64) + if err != nil { + return fmt.Errorf("decoding b64 sign request: %w", err) + } + + var signRequest secureenclavesigner.SignRequest + if err := msgpack.Unmarshal(b, &signRequest); err != nil { + return fmt.Errorf("unmarshaling msgpack sign request: %w", err) + } + + if err := verifySecureEnclaveChallenge(signRequest.SecureEnclaveRequest); err != nil { + return fmt.Errorf("verifying challenge: %w", err) + } + + secureEnclavePubKey, err := echelper.PublicB64DerToEcdsaKey(signRequest.SecureEnclavePubKey) + if err != nil { + return fmt.Errorf("marshalling b64 der to public key: %w", err) + } + + seSigner, err := secureenclave.New(secureEnclavePubKey) + if err != nil { + return fmt.Errorf("creating secure enclave signer: %w", err) + } + + // tag the ends of the data to sign, this is intended to ensure that launcher wont + // sign arbitrary things, any party verifying the signature will need to + // handle these tags + dataToSign := []byte(fmt.Sprintf("kolide:%s:kolide", signRequest.Data)) + + innerSignResponse := secureenclavesigner.SignResponseInner{ + Nonce: fmt.Sprintf("%s%s", signRequest.BaseNonce, ulid.New()), + Timestamp: time.Now().UTC().Unix(), + Data: dataToSign, + } + + innerResponseBytes, err := msgpack.Marshal(innerSignResponse) + if err != nil { + return fmt.Errorf("marshalling inner response: %w", err) + } + + digest, err := echelper.HashForSignature(innerResponseBytes) + if err != nil { + return fmt.Errorf("hashing data for signature: %w", err) + } + + sig, err := seSigner.Sign(rand.Reader, digest, crypto.SHA256) + if err != nil { + return fmt.Errorf("signing request: %w", err) + } + + outerResponseBytes, err := msgpack.Marshal(secureenclavesigner.SignResponseOuter{ + Msg: innerResponseBytes, + Sig: sig, + }) + + if err != nil { + return fmt.Errorf("marshalling outer response: %w", err) + } + + os.Stdout.Write([]byte(base64.StdEncoding.EncodeToString(outerResponseBytes))) + return nil +} + +func verifySecureEnclaveChallenge(request secureenclavesigner.SecureEnclaveRequest) error { + challengeUnmarshalled, err := challenge.UnmarshalChallenge(request.Challenge) + if err != nil { + return fmt.Errorf("unmarshaling challenge: %w", err) + } + + serverPubKey, ok := serverPubKeys[string(request.ServerPubKey)] + if !ok { + return errors.New("server public key not found") + } + + if err := challengeUnmarshalled.Verify(*serverPubKey); err != nil { + return fmt.Errorf("verifying challenge: %w", err) + } + + // Check the timestamp, this prevents people from saving a challenge and then + // reusing it a bunch. However, it will fail if the clocks are too far out of sync. + timestampDelta := time.Now().Unix() - challengeUnmarshalled.Timestamp() + if timestampDelta > secureEnclaveTimestampValiditySeconds || timestampDelta < -secureEnclaveTimestampValiditySeconds { + return fmt.Errorf("timestamp delta %d is outside of validity range %d", timestampDelta, secureEnclaveTimestampValiditySeconds) + } + + return nil +} diff --git a/cmd/launcher/secure_enclave_other.go b/cmd/launcher/secure_enclave_other.go new file mode 100644 index 000000000..6192b77e4 --- /dev/null +++ b/cmd/launcher/secure_enclave_other.go @@ -0,0 +1,10 @@ +//go:build !darwin +// +build !darwin + +package main + +import "errors" + +func runSecureEnclave(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 new file mode 100644 index 000000000..0526d6bef --- /dev/null +++ b/cmd/launcher/secure_enclave_test.go @@ -0,0 +1,284 @@ +//go:build darwin +// +build darwin + +package main + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kolide/kit/ulid" + "github.com/kolide/krypto/pkg/challenge" + "github.com/kolide/krypto/pkg/echelper" + "github.com/kolide/launcher/ee/secureenclavesigner" + "github.com/stretchr/testify/require" + "github.com/vmihailenco/msgpack/v5" +) + +const ( + testWrappedEnvVarKey = "SECURE_ENCLAVE_TEST_WRAPPED" + 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) + } + + 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 + require.Contains(t, string(out), "PASS: TestSecureEnclaveCmd") + require.Contains(t, string(out), "PASS: TestSecureEnclaveCmdValidation") + require.NotContains(t, string(out), "FAIL") + t.Log(string(out)) +} + +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 test server private key + testServerPrivKey, err := echelper.GenerateEcdsaKey() + require.NoError(t, err) + + testServerPubKeyB64Der, err := echelper.PublicEcdsaToB64Der(&testServerPrivKey.PublicKey) + require.NoError(t, err) + + // add the test server private key to the map of server public keys + serverPubKeys[string(testServerPubKeyB64Der)] = &testServerPrivKey.PublicKey + + someData := []byte(ulid.New()) + challengeBytes, _, err := challenge.Generate(testServerPrivKey, someData, someData, someData) + require.NoError(t, err) + + requestBytes, err := msgpack.Marshal(secureenclavesigner.CreateKeyRequest{ + SecureEnclaveRequest: secureenclavesigner.SecureEnclaveRequest{ + Challenge: challengeBytes, + ServerPubKey: testServerPubKeyB64Der, + }, + }) + require.NoError(t, err) + + // create a pipe to capture stdout + pipeReader, pipeWriter, err := os.Pipe() + require.NoError(t, err) + + os.Stdout = pipeWriter + + require.NoError(t, runSecureEnclave([]string{secureenclavesigner.CreateKeyCmd, base64.StdEncoding.EncodeToString(requestBytes)})) + 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") + + dataToSign := ulid.New() + baseNonce := ulid.New() + signRequest := secureenclavesigner.SignRequest{ + SecureEnclaveRequest: secureenclavesigner.SecureEnclaveRequest{ + Challenge: challengeBytes, + ServerPubKey: testServerPubKeyB64Der, + }, + Data: []byte(dataToSign), + BaseNonce: baseNonce, + SecureEnclavePubKey: createKeyResponse, + } + + signRequestBytes := msgpackMustMarshall(t, signRequest) + + pipeReader, pipeWriter, err = os.Pipe() + require.NoError(t, err) + + os.Stdout = pipeWriter + require.NoError(t, runSecureEnclave([]string{secureenclavesigner.SignCmd, base64.StdEncoding.EncodeToString(signRequestBytes)})) + require.NoError(t, pipeWriter.Close()) + + buf = bytes.Buffer{} + _, err = buf.ReadFrom(pipeReader) + require.NoError(t, err) + + outerResponseBytes, err := base64.StdEncoding.DecodeString(string(buf.Bytes())) + require.NoError(t, err) + + var outerResponse secureenclavesigner.SignResponseOuter + require.NoError(t, msgpack.Unmarshal(outerResponseBytes, &outerResponse)) + + require.NoError(t, echelper.VerifySignature(secureEnclavePubKey, outerResponse.Msg, outerResponse.Sig)) + + var innerResponse secureenclavesigner.SignResponseInner + require.NoError(t, msgpack.Unmarshal(outerResponse.Msg, &innerResponse)) + + require.True(t, strings.HasPrefix(innerResponse.Nonce, baseNonce), "returned nonce should be concat of base nonce and generated nonce") + require.NotEmpty(t, innerResponse.Timestamp, "inner response should include timestamp") +} + +func TestSecureEnclaveCmdValidation(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") + + // no args + require.ErrorContains(t, runSecureEnclave([]string{}), "not enough arguments") + require.ErrorContains(t, runSecureEnclave([]string{"unknown", "bad request"}), "unknown command") + + for _, cmd := range []string{secureenclavesigner.CreateKeyCmd, secureenclavesigner.SignCmd} { + // bad request + require.ErrorContains(t, runSecureEnclave([]string{cmd, "bad request"}), "decoding b64") + + testServerPrivKey, err := echelper.GenerateEcdsaKey() + require.NoError(t, err) + + testServerPubKeyB64Der, err := echelper.PublicEcdsaToB64Der(&testServerPrivKey.PublicKey) + require.NoError(t, err) + + someData := []byte(ulid.New()) + challengeBox, _, err := challenge.Generate(testServerPrivKey, someData, someData, someData) + require.NoError(t, err) + + // no pub server key + require.ErrorContains(t, runSecureEnclave([]string{cmd, + base64.StdEncoding.EncodeToString( + msgpackMustMarshall(t, + secureenclavesigner.SecureEnclaveRequest{ + Challenge: challengeBox, + ServerPubKey: testServerPubKeyB64Der, + }, + ), + ), + }), "server public key not found") + + // add the test server private key to the map of server public keys + serverPubKeys[string(testServerPubKeyB64Der)] = &testServerPrivKey.PublicKey + + // sign with wrong server key + malloryServerKey, err := echelper.GenerateEcdsaKey() + require.NoError(t, err) + + malloryChallengeBox, _, err := challenge.Generate(malloryServerKey, someData, someData, someData) + require.NoError(t, err) + + // invalid signature + require.ErrorContains(t, runSecureEnclave([]string{cmd, + base64.StdEncoding.EncodeToString( + msgpackMustMarshall(t, + secureenclavesigner.SecureEnclaveRequest{ + Challenge: malloryChallengeBox, + // claim to be signed known key + ServerPubKey: testServerPubKeyB64Der, + }, + ), + ), + }), "verifying challenge") + } +} + +// #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)) +} + +func msgpackMustMarshall(t *testing.T, v interface{}) []byte { + b, err := msgpack.Marshal(v) + require.NoError(t, err) + return b +} diff --git a/ee/localserver/certs.go b/ee/agent/certs/certs.go similarity index 84% rename from ee/localserver/certs.go rename to ee/agent/certs/certs.go index 54fdbf65b..651132dd5 100644 --- a/ee/localserver/certs.go +++ b/ee/agent/certs/certs.go @@ -1,8 +1,8 @@ -package localserver +package certs // These are the hardcoded certificates const ( - k2RsaServerCert = `-----BEGIN PUBLIC KEY----- + K2RsaServerCert = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkeNJgRkJOow7LovGmrlW 1UzHkifTKQV1/8kX+p2MPLptGgPKlqpLnhZsGOhpHpswlUalgSZPyhBfM9Btdmps QZ2PkZkgEiy62PleVSBeBtpGcwHibHTGamzmKVrji9GudAvU+qapfPGnr//275/1 @@ -13,7 +13,7 @@ msGeD7hPhtdB/h0O8eBWIiOQ6fH7exl71UfGTR6pYQmJMK1ZZeT7FeWVSGkswxkV -----END PUBLIC KEY----- ` - reviewRsaServerCert = `-----BEGIN PUBLIC KEY----- + ReviewRsaServerCert = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr0VjwKya7JNRM8uiPllw An+W3SBuDkxAToqHxdRX6k2eJSIK0K4oynVIqkrP1MC2ultlIo2ZZhKYQVhQfCej 9RIBFm2wl1/daMNCpmkwu8KbsXDAVrc70yXvpzeAnh6QCnvI1PbCI6icbpVo8Wh1 @@ -24,7 +24,7 @@ AwIDAQAB -----END PUBLIC KEY----- ` - localhostRsaServerCert = `-----BEGIN PUBLIC KEY----- + LocalhostRsaServerCert = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXIB33Wvu/rn7WjSQaat 9lafwrrnmbP8NtTlOiY4b4gv/bL6nnyr21s95uKlcm+8WRbJZsch/ahrNYsdDO2Q QmfZTi7VR7/IhwyISkh/JaaBPmipO/4KfdnKOarah3F619fl4Udd973+5QK0ZQmy @@ -35,17 +35,17 @@ Skx1Y1JUHgZL9IVGMAmkJWEKoa4TPopfnr74SwpNDcU7rP86rgSIO597wMeMbnAM -----END PUBLIC KEY----- ` - k2EccServerCert = `-----BEGIN PUBLIC KEY----- + K2EccServerCert = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEmAO4tYINU14/i0LvONs1IXVwaFnF dNsydDr38XrL29kiFl+vTkp4gVx6172oNSL3KRBQmjMXqWkLNoxXaWS3uQ== -----END PUBLIC KEY-----` - reviewEccServerCert = `-----BEGIN PUBLIC KEY----- + ReviewEccServerCert = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIgYTWPi8N7b0H69tnN543HbjAoLc GINysvEwYrNoGjASt+nqzlFesagt+2A/4W7JR16nE91mbCHn+HV6x+H8gw== -----END PUBLIC KEY-----` - localhostEccServerCert = `-----BEGIN PUBLIC KEY----- + LocalhostEccServerCert = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwowFsPUaOC61LAfDz1hLnsuSDfEx SC4TSfHtbHHv3lx2/Bfu+H0szXYZ75GF/qZ5edobq3UkABN6OaFnnJId3w== -----END PUBLIC KEY-----` diff --git a/ee/localserver/krypto-ec-middleware.go b/ee/localserver/krypto-ec-middleware.go index 43dbc1864..93fe4138c 100644 --- a/ee/localserver/krypto-ec-middleware.go +++ b/ee/localserver/krypto-ec-middleware.go @@ -12,11 +12,14 @@ import ( "log/slog" "net/http" "net/url" + "runtime" "strings" "time" + "github.com/kolide/kit/ulid" "github.com/kolide/krypto" "github.com/kolide/krypto/pkg/challenge" + "github.com/kolide/launcher/ee/secureenclavesigner" "github.com/kolide/launcher/pkg/log/multislogger" "github.com/kolide/launcher/pkg/traces" "go.opentelemetry.io/otel/attribute" @@ -24,12 +27,19 @@ import ( ) const ( - timestampValidityRange = 150 + timestampValiditySeconds = 150 kolideKryptoEccHeader20230130Value = "2023-01-30" kolideKryptoHeaderKey = "X-Kolide-Krypto" kolideSessionIdHeaderKey = "X-Kolide-Session" ) +type response struct { + Nonce string + Timestamp int64 + Data []byte + UserSig []byte +} + type v2CmdRequestType struct { Path string Body []byte @@ -63,15 +73,30 @@ type kryptoEcMiddleware struct { localDbSigner, hardwareSigner crypto.Signer counterParty ecdsa.PublicKey slogger *slog.Logger + + // createUserSignerFunc is a function that creates a signer for user signature + // currently this is only applicable to macOS using secure enclave + // it's here to allow for mocking in tests + // the string parameter is the base nonce that will be combined with a nonce + // produced by the user launcher + createUserSignerFunc func(context.Context, challenge.OuterChallenge) (secureEnclaveSigner, error) +} + +type secureEnclaveSigner interface { + Public() crypto.PublicKey + Sign(baseNonce string, data []byte) (*secureenclavesigner.SignResponseOuter, error) } func newKryptoEcMiddleware(slogger *slog.Logger, localDbSigner, hardwareSigner crypto.Signer, counterParty ecdsa.PublicKey) *kryptoEcMiddleware { - return &kryptoEcMiddleware{ + k := &kryptoEcMiddleware{ localDbSigner: localDbSigner, hardwareSigner: hardwareSigner, counterParty: counterParty, slogger: slogger.With("keytype", "ec"), } + + k.createUserSignerFunc = k.createSecureEnclaveSigner + return k } // Because callback errors are effectively a shared API with K2, let's define them as a constant and not just @@ -219,7 +244,7 @@ func (e *kryptoEcMiddleware) Wrap(next http.Handler) http.Handler { // Check the timestamp, this prevents people from saving a challenge and then // reusing it a bunch. However, it will fail if the clocks are too far out of sync. timestampDelta := time.Now().Unix() - challengeBox.Timestamp() - if timestampDelta > timestampValidityRange || timestampDelta < -timestampValidityRange { + if timestampDelta > timestampValiditySeconds || timestampDelta < -timestampValiditySeconds { span.SetAttributes(attribute.Int64("timestamp_delta", timestampDelta)) span.SetStatus(codes.Error, "timestamp is out of range") e.slogger.Log(r.Context(), slog.LevelError, @@ -259,14 +284,45 @@ func (e *kryptoEcMiddleware) Wrap(next http.Handler) http.Handler { bhr := &bufferedHttpResponse{} next.ServeHTTP(bhr, newReq) - var response []byte + response := response{ + Nonce: ulid.New(), + Timestamp: time.Now().UTC().Unix(), + Data: bhr.Bytes(), + } + + if runtime.GOOS == "darwin" { + requestDataWithUserSig, err := e.addUserSignature(r.Context(), response, *challengeBox) + // if we hit an error, just log and move on for now + if err != nil { + traces.SetError(span, err) + e.slogger.Log(r.Context(), slog.LevelError, + "failed to add user signature", + "err", err, + ) + } else { + response = requestDataWithUserSig + } + } + + requestDataBytes, err := json.Marshal(response) + if err != nil { + traces.SetError(span, err) + e.slogger.Log(r.Context(), slog.LevelError, + "failed to marshal request data", + "err", err, + ) + w.WriteHeader(http.StatusUnauthorized) + return + } + + var challengeResponse []byte // it's possible the keys will be noop keys, then they will error or give nil when crypto.Signer funcs are called // 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 - if e.hardwareSigner != nil && e.hardwareSigner.Public() != nil { - response, err = challengeBox.Respond(e.localDbSigner, e.hardwareSigner, bhr.Bytes()) + if runtime.GOOS != "darwin" && e.hardwareSigner != nil && e.hardwareSigner.Public() != nil { + challengeResponse, err = challengeBox.Respond(e.localDbSigner, e.hardwareSigner, requestDataBytes) } else { - response, err = challengeBox.Respond(e.localDbSigner, nil, bhr.Bytes()) + challengeResponse, err = challengeBox.Respond(e.localDbSigner, nil, requestDataBytes) } if err != nil { @@ -282,7 +338,7 @@ func (e *kryptoEcMiddleware) Wrap(next http.Handler) http.Handler { // because the response is a []byte, we need a copy to prevent simultaneous accessing. Conviniently we can cast // it to a string, which has an implicit copy - callbackData.Response = base64.StdEncoding.EncodeToString(response) + callbackData.Response = base64.StdEncoding.EncodeToString(challengeResponse) w.Header().Add(kolideKryptoHeaderKey, kolideKryptoEccHeader20230130Value) @@ -290,9 +346,9 @@ func (e *kryptoEcMiddleware) Wrap(next http.Handler) http.Handler { // buffering the http response, so it feels a bit silly. When we ditch the v1/v2 switcher, we can // be a bit more clever and move this. if strings.HasSuffix(cmdReq.Path, ".png") { - krypto.ToPng(w, response) + krypto.ToPng(w, challengeResponse) } else { - w.Write([]byte(base64.StdEncoding.EncodeToString(response))) + w.Write([]byte(base64.StdEncoding.EncodeToString(challengeResponse))) } }) } diff --git a/ee/localserver/krypto-ec-middleware_test.go b/ee/localserver/krypto-ec-middleware_test.go index ffdda4c8c..7bd13f025 100644 --- a/ee/localserver/krypto-ec-middleware_test.go +++ b/ee/localserver/krypto-ec-middleware_test.go @@ -2,11 +2,13 @@ package localserver import ( "bytes" + "context" "crypto" "crypto/ecdsa" "crypto/rand" "encoding/base64" "encoding/json" + "fmt" "io" "log/slog" "math/big" @@ -21,11 +23,45 @@ import ( "github.com/kolide/krypto/pkg/challenge" "github.com/kolide/krypto/pkg/echelper" "github.com/kolide/launcher/ee/agent/keys" + "github.com/kolide/launcher/ee/secureenclavesigner" "github.com/kolide/launcher/pkg/log/multislogger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/vmihailenco/msgpack/v5" ) +type mockSecureEnclaveSigner struct { + t *testing.T + key *ecdsa.PrivateKey + baseNonce string +} + +func (m *mockSecureEnclaveSigner) Public() crypto.PublicKey { + return m.key.Public() +} + +func (m *mockSecureEnclaveSigner) Sign(baseNonce string, data []byte) (*secureenclavesigner.SignResponseOuter, error) { + inner := &secureenclavesigner.SignResponseInner{ + Nonce: fmt.Sprintf("%s%s", m.baseNonce, ulid.New()), + Timestamp: time.Now().Unix(), + Data: []byte(fmt.Sprintf("kolide:%s:kolide", data)), + } + + innerBytes, err := msgpack.Marshal(inner) + require.NoError(m.t, err) + + hash, err := echelper.HashForSignature(innerBytes) + require.NoError(m.t, err) + + sig, err := m.key.Sign(rand.Reader, hash, crypto.SHA256) + require.NoError(m.t, err) + + return &secureenclavesigner.SignResponseOuter{ + Msg: innerBytes, + Sig: sig, + }, nil +} + func TestKryptoEcMiddleware(t *testing.T) { t.Parallel() @@ -53,7 +89,7 @@ func TestKryptoEcMiddleware(t *testing.T) { challenge func() ([]byte, *[32]byte) loggedErr string handler http.HandlerFunc - responseData []byte + mockResponseData []byte }{ { name: "no command", @@ -91,8 +127,8 @@ func TestKryptoEcMiddleware(t *testing.T) { require.NoError(t, err) return challenge, priv }, - handler: http.NotFound, - responseData: []byte("404 page not found\n"), + handler: http.NotFound, + mockResponseData: []byte("404 page not found\n"), }, { name: "works with hardware key", @@ -130,7 +166,7 @@ func TestKryptoEcMiddleware(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - responseData := tt.responseData + responseData := tt.mockResponseData // generate the response we want the handler to return if responseData == nil { responseData = []byte(ulid.New()) @@ -169,6 +205,13 @@ func TestKryptoEcMiddleware(t *testing.T) { kryptoEcMiddleware := newKryptoEcMiddleware(slogger, tt.localDbKey, tt.hardwareKey, counterpartyKey.PublicKey) require.NoError(t, err) + kryptoEcMiddleware.createUserSignerFunc = func(ctx context.Context, c challenge.OuterChallenge) (secureEnclaveSigner, error) { + return &mockSecureEnclaveSigner{ + t: t, + key: ecdsaKey(t), + }, nil + } + // give our middleware with the test handler to the determiner h := kryptoEcMiddleware.Wrap(testHandler) @@ -201,7 +244,11 @@ func TestKryptoEcMiddleware(t *testing.T) { opened, err := responseUnmarshalled.Open(*privateEncryptionKey) require.NoError(t, err) require.Equal(t, challengeData, opened.ChallengeData) - require.Equal(t, responseData, opened.ResponseData) + + var requestData response + require.NoError(t, json.Unmarshal(opened.ResponseData, &requestData)) + + require.Equal(t, responseData, requestData.Data) require.WithinDuration(t, time.Now(), time.Unix(opened.Timestamp, 0), time.Second*5) }) } diff --git a/ee/localserver/server.go b/ee/localserver/server.go index 2d7f05789..4142791fb 100644 --- a/ee/localserver/server.go +++ b/ee/localserver/server.go @@ -18,6 +18,7 @@ import ( "github.com/kolide/krypto" "github.com/kolide/krypto/pkg/echelper" "github.com/kolide/launcher/ee/agent" + "github.com/kolide/launcher/ee/agent/certs" "github.com/kolide/launcher/ee/agent/types" "github.com/kolide/launcher/pkg/osquery" "github.com/kolide/launcher/pkg/traces" @@ -145,8 +146,8 @@ func (ls *localServer) LoadDefaultKeyIfNotSet() error { return nil } - serverRsaCertPem := k2RsaServerCert - serverEccCertPem := k2EccServerCert + serverRsaCertPem := certs.K2RsaServerCert + serverEccCertPem := certs.K2EccServerCert ctx := context.TODO() slogLevel := slog.LevelDebug @@ -157,15 +158,15 @@ func (ls *localServer) LoadDefaultKeyIfNotSet() error { "using developer certificates", ) - serverRsaCertPem = localhostRsaServerCert - serverEccCertPem = localhostEccServerCert + serverRsaCertPem = certs.LocalhostRsaServerCert + serverEccCertPem = certs.LocalhostEccServerCert case strings.HasSuffix(ls.kolideServer, ".herokuapp.com"): ls.slogger.Log(ctx, slogLevel, "using review app certificates", ) - serverRsaCertPem = reviewRsaServerCert - serverEccCertPem = reviewEccServerCert + serverRsaCertPem = certs.ReviewRsaServerCert + serverEccCertPem = certs.ReviewEccServerCert default: ls.slogger.Log(ctx, slogLevel, "using default/production certificates", diff --git a/ee/localserver/user-signature_darwin.go b/ee/localserver/user-signature_darwin.go new file mode 100644 index 000000000..100706f72 --- /dev/null +++ b/ee/localserver/user-signature_darwin.go @@ -0,0 +1,73 @@ +//go:build darwin +// +build darwin + +package localserver + +import ( + "context" + "crypto/ecdsa" + "fmt" + + "github.com/kolide/krypto/pkg/challenge" + "github.com/kolide/krypto/pkg/echelper" + "github.com/kolide/launcher/ee/consoleuser" + "github.com/kolide/launcher/ee/secureenclavesigner" + "github.com/vmihailenco/msgpack/v5" +) + +func (e *kryptoEcMiddleware) createSecureEnclaveSigner(ctx context.Context, challengeBox challenge.OuterChallenge) (secureEnclaveSigner, error) { + if e.hardwareSigner == nil || e.hardwareSigner.Public() == nil { + return nil, fmt.Errorf("no hardware signer") + } + + // get console user + uids, err := consoleuser.CurrentUids(ctx) + if err != nil { + return nil, fmt.Errorf("getting console user: %w", err) + } + + if len(uids) == 0 { + return nil, fmt.Errorf("no console user") + } + + // should only ever have 1, if we have more than one secure enclave signer will fail + serverPubKeyB64, err := echelper.PublicEcdsaToB64Der(&e.counterParty) + if err != nil { + return nil, fmt.Errorf("converting server public key to b64 der: %w", err) + } + + challengeBytes, err := challengeBox.Marshal() + if err != nil { + return nil, fmt.Errorf("marshalling challenge: %w", err) + } + + ses, err := secureenclavesigner.New(uids[0], serverPubKeyB64, challengeBytes, secureenclavesigner.WithExistingKey(e.hardwareSigner.Public().(*ecdsa.PublicKey))) + if err != nil { + return nil, fmt.Errorf("creating secure enclave signer: %w", err) + } + + return ses, nil +} + +func (e *kryptoEcMiddleware) addUserSignature(ctx context.Context, response response, challengeBox challenge.OuterChallenge) (response, error) { + ses, err := e.createUserSignerFunc(ctx, challengeBox) + if err != nil { + return response, fmt.Errorf("creating secure enclave signer: %w", err) + } + + signResponseOuter, err := ses.Sign(response.Nonce, response.Data) + if err != nil { + return response, fmt.Errorf("signing data: %w", err) + } + + var signResponseInner secureenclavesigner.SignResponseInner + if err := msgpack.Unmarshal(signResponseOuter.Msg, &signResponseInner); err != nil { + return response, fmt.Errorf("unmarshalling sign response: %w", err) + } + + response.Timestamp = signResponseInner.Timestamp + response.Nonce = signResponseInner.Nonce + response.UserSig = signResponseOuter.Sig + + return response, nil +} diff --git a/ee/localserver/user-signature_other.go b/ee/localserver/user-signature_other.go new file mode 100644 index 000000000..b95b95793 --- /dev/null +++ b/ee/localserver/user-signature_other.go @@ -0,0 +1,19 @@ +//go:build !darwin +// +build !darwin + +package localserver + +import ( + "context" + "errors" + + "github.com/kolide/krypto/pkg/challenge" +) + +func (e *kryptoEcMiddleware) createSecureEnclaveSigner(ctx context.Context, challengeBox challenge.OuterChallenge) (secureEnclaveSigner, error) { + return nil, errors.New("not implemented") +} + +func (e *kryptoEcMiddleware) addUserSignature(_ context.Context, response response, _ challenge.OuterChallenge) (response, error) { + return response, errors.New("not implemented") +} diff --git a/ee/secureenclavesigner/secureenclavesigner_darwin.go b/ee/secureenclavesigner/secureenclavesigner_darwin.go new file mode 100644 index 000000000..4d22a0c06 --- /dev/null +++ b/ee/secureenclavesigner/secureenclavesigner_darwin.go @@ -0,0 +1,232 @@ +//go:build darwin +// +build darwin + +package secureenclavesigner + +import ( + "context" + "crypto" + "crypto/ecdsa" + "encoding/base64" + "fmt" + "os" + "os/user" + "strings" + "time" + + "github.com/kolide/krypto/pkg/echelper" + "github.com/kolide/launcher/ee/allowedcmd" + "github.com/vmihailenco/msgpack/v5" +) + +const ( + CreateKeyCmd = "create-key" + SignCmd = "sign" +) + +type Opt func(*secureEnclaveSigner) + +// WithExistingKey allows you to pass the public portion of an existing +// secure enclave key to use for signing +func WithExistingKey(publicKey *ecdsa.PublicKey) Opt { + return func(ses *secureEnclaveSigner) { + ses.pubKey = publicKey + } +} + +type secureEnclaveSigner struct { + // uid is the uid of the user to run the secure enclave commands as + uid string + // username is the username of the user to run the secure enclave commands as + username string + serverPubKeyB64Der []byte + challenge []byte + pubKey *ecdsa.PublicKey + pathToLauncherBinary string +} + +func New(signingUid string, serverPubKeyB64Der []byte, challenge []byte, opts ...Opt) (*secureEnclaveSigner, error) { + ses := &secureEnclaveSigner{ + serverPubKeyB64Der: serverPubKeyB64Der, + challenge: challenge, + } + + for _, opt := range opts { + opt(ses) + } + + // look up user by uid + u, err := user.LookupId(signingUid) + if err != nil { + return nil, fmt.Errorf("looking up user by uid: %w", err) + } + + ses.uid = u.Uid + ses.username = u.Username + + if ses.pathToLauncherBinary == "" { + p, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("getting path to launcher binary: %w", err) + } + + ses.pathToLauncherBinary = p + } + + return ses, nil +} + +// Public returns the public key of the secure enclave signer +// it creates a new public key using secure enclave if a public key +// is not set +func (ses *secureEnclaveSigner) Public() crypto.PublicKey { + if ses.pubKey != nil { + return ses.pubKey + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := ses.createKey(ctx); err != nil { + return nil + } + + return ses.pubKey +} + +// Sign signs the digest using the secure enclave +// If a public key is not set, it will create a new key +func (ses *secureEnclaveSigner) Sign(baseNonce string, data []byte) (*SignResponseOuter, error) { + // create the key if we don't have it + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if ses.pubKey == nil { + if err := ses.createKey(ctx); err != nil { + return nil, fmt.Errorf("creating key: %w", err) + } + } + + pubKeyBytes, err := echelper.PublicEcdsaToB64Der(ses.pubKey) + if err != nil { + return nil, fmt.Errorf("marshalling public key to der: %w", err) + } + + signRequest := SignRequest{ + SecureEnclaveRequest: SecureEnclaveRequest{ + Challenge: ses.challenge, + ServerPubKey: ses.serverPubKeyB64Der, + }, + BaseNonce: baseNonce, + Data: data, + SecureEnclavePubKey: pubKeyBytes, + } + + signRequestMsgPack, err := msgpack.Marshal(signRequest) + if err != nil { + return nil, fmt.Errorf("marshalling sign request to msgpack: %w", err) + } + + cmd, err := allowedcmd.Launchctl( + ctx, + "asuser", + ses.uid, + "sudo", + "--preserve-env", + "-u", + ses.username, + ses.pathToLauncherBinary, + "secure-enclave", + SignCmd, + base64.StdEncoding.EncodeToString(signRequestMsgPack), + ) + + if err != nil { + return nil, fmt.Errorf("creating command to sign: %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 { + return nil, fmt.Errorf("executing launcher binary to sign: %w: %s", err, string(out)) + } + + responseRaw := []byte(lastLine(out)) + + responseBytes, err := base64.StdEncoding.DecodeString(string(responseRaw)) + if err != nil { + return nil, fmt.Errorf("decoding response from base64: %w", err) + } + + var outerResponse SignResponseOuter + if err := msgpack.Unmarshal(responseBytes, &outerResponse); err != nil { + return nil, fmt.Errorf("unmarshalling response to msgpack: %w", err) + } + + return &outerResponse, nil +} + +func (ses *secureEnclaveSigner) createKey(ctx context.Context) error { + request := CreateKeyRequest{ + SecureEnclaveRequest: SecureEnclaveRequest{ + Challenge: ses.challenge, + ServerPubKey: ses.serverPubKeyB64Der, + }, + } + + requestMsgPack, err := msgpack.Marshal(request) + if err != nil { + return fmt.Errorf("marshalling request to msgpack: %w", err) + } + + cmd, err := allowedcmd.Launchctl( + ctx, + "asuser", + ses.uid, + "sudo", + "--preserve-env", + "-u", + ses.username, + ses.pathToLauncherBinary, + "secure-enclave", + CreateKeyCmd, + base64.StdEncoding.EncodeToString(requestMsgPack), + ) + + if err != nil { + return 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 { + return fmt.Errorf("executing launcher binary to create key: %w: %s", err, string(out)) + } + + pubKey, err := echelper.PublicB64DerToEcdsaKey([]byte(lastLine(out))) + if err != nil { + return fmt.Errorf("marshalling public key to der: %w", err) + } + + ses.pubKey = pubKey + return 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 +} diff --git a/ee/secureenclavesigner/secureenclavesigner_test.go b/ee/secureenclavesigner/secureenclavesigner_test.go new file mode 100644 index 000000000..1a7a86263 --- /dev/null +++ b/ee/secureenclavesigner/secureenclavesigner_test.go @@ -0,0 +1,156 @@ +//go:build darwin +// +build darwin + +package secureenclavesigner + +import ( + "context" + "crypto/ecdsa" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "testing" + "time" + + "github.com/kolide/kit/ulid" + "github.com/kolide/krypto/pkg/challenge" + "github.com/kolide/krypto/pkg/echelper" + "github.com/stretchr/testify/require" +) + +const ( + testWrappedEnvVarKey = "SECURE_ENCLAVE_TEST_WRAPPED" + 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) + } + + // set up app bundle + 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) + + usr, err := user.Current() + require.NoError(t, err) + + someData := []byte(ulid.New()) + challenge, _, err := challenge.Generate(serverPrivKey, someData, someData, someData) + require.NoError(t, err) + + // create brand new signer without existing key + // ask for public first to trigger key generation + ses, err := New(usr.Uid, serverPubKeyDer, challenge, WithBinaryPath(executablePath)) + require.NoError(t, err) + + pubKey := ses.Public() + require.NotNil(t, pubKey) + + dataToSign := ulid.New() + + outerResponse, err := ses.Sign(ulid.New(), []byte(dataToSign)) + require.NoError(t, err) + + require.NoError(t, echelper.VerifySignature(pubKey.(*ecdsa.PublicKey), outerResponse.Msg, outerResponse.Sig)) + + // create brand new signer without existing key + // ask to sign first to trigger key generation + ses, err = New(usr.Uid, serverPubKeyDer, challenge, WithBinaryPath(executablePath)) + require.NoError(t, err) + + outerResponse, err = ses.Sign(ulid.New(), []byte(dataToSign)) + require.NoError(t, err) + + require.NoError(t, echelper.VerifySignature(ses.Public().(*ecdsa.PublicKey), outerResponse.Msg, outerResponse.Sig)) + + // create signer with existing key + ses, err = New(usr.Uid, serverPubKeyDer, challenge, WithBinaryPath(executablePath), WithExistingKey(pubKey.(*ecdsa.PublicKey))) + require.NoError(t, err) + + outerResponse, err = ses.Sign(ulid.New(), []byte(dataToSign)) + require.NoError(t, err) + + require.NoError(t, echelper.VerifySignature(pubKey.(*ecdsa.PublicKey), outerResponse.Msg, outerResponse.Sig)) + + pubKey = ses.Public() + require.NotNil(t, pubKey) +} + +// #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 new file mode 100644 index 000000000..148e28081 Binary files /dev/null and b/ee/secureenclavesigner/test_app_resources/embedded.provisionprofile differ diff --git a/ee/secureenclavesigner/test_app_resources/entitlements b/ee/secureenclavesigner/test_app_resources/entitlements new file mode 100644 index 000000000..6969bc4e3 --- /dev/null +++ b/ee/secureenclavesigner/test_app_resources/entitlements @@ -0,0 +1,8 @@ + + + keychain-access-groups + + X98UFR7HA3.com.kolide.agent + + + diff --git a/ee/secureenclavesigner/test_app_resources/info.plist b/ee/secureenclavesigner/test_app_resources/info.plist new file mode 100644 index 000000000..fe801acec --- /dev/null +++ b/ee/secureenclavesigner/test_app_resources/info.plist @@ -0,0 +1,20 @@ + + + + + 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 new file mode 100644 index 000000000..bd2d3578f --- /dev/null +++ b/ee/secureenclavesigner/test_app_resources/readme.md @@ -0,0 +1,25 @@ +# 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. Download and install a certificate from the Apple Developer account of type "Mac Development" https://developer.apple.com/account/resources/certificates/list +2. 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 +3. 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 +4. Replace the `embedded.provisionprofile` file with the new profile diff --git a/ee/secureenclavesigner/test_keys.go b/ee/secureenclavesigner/test_keys.go new file mode 100644 index 000000000..cd17735c2 --- /dev/null +++ b/ee/secureenclavesigner/test_keys.go @@ -0,0 +1,25 @@ +//go:build secure_enclave_test +// +build secure_enclave_test + +package secureenclavesigner + +// Using ldflags to set the pub key and using build tag. +// +// This kind of feels like belt and suspenders. +// +// We could probably drop the build tag and just use the -ldflag, then determine +// if we're under test by checking the value of the var set by the -ldflag, but +// that feels more tangly. +// +// We could also generate a file with the private key, add it's path to .gitignore +// and use that to test + +// Undertest is true when running secure enclave test build +const Undertest = true + +// TestServerPubKey is the public key of the server in DER format +// when building the binary for testing, we set this with -ldflags +// so the wrapper test can sign requests with the private portion +// of the key it used to set this value. +// See secureenclavesigner_test.go +var TestServerPubKey string diff --git a/ee/secureenclavesigner/test_keys_noop.go b/ee/secureenclavesigner/test_keys_noop.go new file mode 100644 index 000000000..1d49eab88 --- /dev/null +++ b/ee/secureenclavesigner/test_keys_noop.go @@ -0,0 +1,11 @@ +//go:build !secure_enclave_test +// +build !secure_enclave_test + +package secureenclavesigner + +// Undertest is true when running secure enclave test build +const Undertest = false + +// TestServerPubKey should never be set outside of testing. +// See test_keys.go. +const TestServerPubKey = "" diff --git a/ee/secureenclavesigner/types.go b/ee/secureenclavesigner/types.go new file mode 100644 index 000000000..2a27c53d6 --- /dev/null +++ b/ee/secureenclavesigner/types.go @@ -0,0 +1,35 @@ +package secureenclavesigner + +type SignRequest struct { + SecureEnclaveRequest + // Data is the data to be signed + Data []byte `msgpack:"data"` + // BaseNonce is the nonce that the secure enclave command will append it's own nonce to + BaseNonce string `msgpack:"base_nonce"` + // SecureEnclavePubKey is the B64 encoded DER of the public key to be used to sign the challenge + SecureEnclavePubKey []byte `msgpack:"secure_enclave_pub_key"` +} + +type SignResponseOuter struct { + Msg []byte `msgpack:"msg"` + Sig []byte `msgpack:"sig"` +} + +type SignResponseInner struct { + // Nonce is the the secure enclave generated nonce appended to the provided base nonce + Nonce string `msgpack:"nonce"` + Timestamp int64 `msgpack:"timestamp"` + Data []byte `msgpack:"data"` +} + +type CreateKeyRequest struct { + SecureEnclaveRequest +} + +type SecureEnclaveRequest struct { + // Challenge is the B64 encoded krypto challenge generated by the server + Challenge []byte `msgpack:"challenge"` + // ServerPubKey is the B64 encoded DER of the public key + // to be used to verify the signature of the request + ServerPubKey []byte `msgpack:"server_pub_key"` +} diff --git a/go.mod b/go.mod index da9ab44fc..8607f5d4a 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/groob/plist v0.0.0-20190114192801-a99fbe489d03 github.com/knightsc/system_policy v1.1.1-0.20211029142728-5f4c0d5419cc github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab - github.com/kolide/krypto v0.1.1-0.20231219012048-5859599c50aa + github.com/kolide/krypto v0.1.1-0.20231229162826-db516b7e0121 github.com/mat/besticon v3.9.0+incompatible github.com/mattn/go-sqlite3 v1.14.19 github.com/mixer/clock v0.0.0-20170901150240-b08e6b4da7ea @@ -109,7 +109,7 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/tevino/abool v1.2.0 // indirect github.com/tklauser/numcpus v0.6.0 // indirect - github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect + github.com/vmihailenco/msgpack/v5 v5.3.5 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 go.opentelemetry.io/otel v1.21.0 diff --git a/go.sum b/go.sum index c9bd5a693..0df375891 100644 --- a/go.sum +++ b/go.sum @@ -167,8 +167,8 @@ github.com/knightsc/system_policy v1.1.1-0.20211029142728-5f4c0d5419cc h1:g2S0GQ github.com/knightsc/system_policy v1.1.1-0.20211029142728-5f4c0d5419cc/go.mod h1:5e34JEkxWsOeAd9jvcxkz01tAY/JAGFuabGnNBJ6TT4= github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab h1:KVR7cs+oPyy85i+8t1ZaNSy1bymCy5FuWyt51pdrXu4= github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab/go.mod h1:OYYulo9tUqRadRLwB0+LE914sa1ui2yL7OrcU3Q/1XY= -github.com/kolide/krypto v0.1.1-0.20231219012048-5859599c50aa h1:3agrIh6HWiEZAvH3ubVpfsaRFsg1Ux+1S5HU+HE5pPI= -github.com/kolide/krypto v0.1.1-0.20231219012048-5859599c50aa/go.mod h1:/0sxd3OIxciTlMTeZI/9WTaUHsx/K/+3f+NbD5dywTY= +github.com/kolide/krypto v0.1.1-0.20231229162826-db516b7e0121 h1:f7APX9VNsCkD/tdlAjbU4A22FyfTOCF6QadlvnzZElg= +github.com/kolide/krypto v0.1.1-0.20231229162826-db516b7e0121/go.mod h1:/0sxd3OIxciTlMTeZI/9WTaUHsx/K/+3f+NbD5dywTY= github.com/kolide/systray v1.10.4 h1:eBhnVfhW0fGal1KBkBZC9fzRs4yrxUymgiXuQh5MBSg= github.com/kolide/systray v1.10.4/go.mod h1:FwK9yUmU3JO+vA7TOLQSFRgEQ3euLxOqic5qlBtFrik= github.com/kolide/toast v1.0.2 h1:BQlIfO3wbKIEWfF0c8v4UkdhSIZYnSWaKkZl+Yarptk=