diff --git a/lib/ocrypto/key_material.go b/lib/ocrypto/key_material.go new file mode 100644 index 0000000000..44230a1e3e --- /dev/null +++ b/lib/ocrypto/key_material.go @@ -0,0 +1,29 @@ +package ocrypto + +import ( + "crypto/x509" + "encoding/pem" + "strings" +) + +// IsPEMOrDERPrivateKey reports whether data appears to be an unencrypted private key +// in PEM or DER format. It does not attempt decryption or key unwrapping. +func IsPEMOrDERPrivateKey(data []byte) bool { + for block, rest := pem.Decode(data); block != nil; block, rest = pem.Decode(rest) { + if strings.Contains(block.Type, "PRIVATE KEY") { + return true + } + } + + if _, err := x509.ParsePKCS8PrivateKey(data); err == nil { + return true + } + if _, err := x509.ParsePKCS1PrivateKey(data); err == nil { + return true + } + if _, err := x509.ParseECPrivateKey(data); err == nil { + return true + } + + return false +} diff --git a/lib/ocrypto/key_material_test.go b/lib/ocrypto/key_material_test.go new file mode 100644 index 0000000000..741d96ec06 --- /dev/null +++ b/lib/ocrypto/key_material_test.go @@ -0,0 +1,48 @@ +package ocrypto + +import ( + "encoding/pem" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsPEMOrDERPrivateKey(t *testing.T) { + privateKeyFiles := []string{ + "sample-rsa-2048-01-private.pem", + "sample-ec-secp256r1-01-private.pem", + } + + for _, filename := range privateKeyFiles { + t.Run("pem-private-"+filename, func(t *testing.T) { + pemData := readTestData(t, filename) + require.True(t, IsPEMOrDERPrivateKey(pemData)) + }) + } + + t.Run("pem-public", func(t *testing.T) { + pemData := readTestData(t, "sample-rsa-2048-01-public.pem") + require.False(t, IsPEMOrDERPrivateKey(pemData)) + }) + + t.Run("der-private", func(t *testing.T) { + pemData := readTestData(t, "sample-rsa-2048-01-private.pem") + block, _ := pem.Decode(pemData) + require.NotNil(t, block) + require.True(t, IsPEMOrDERPrivateKey(block.Bytes)) + }) + + t.Run("random-bytes", func(t *testing.T) { + require.False(t, IsPEMOrDERPrivateKey([]byte("not a key"))) + }) +} + +func readTestData(t *testing.T, filename string) []byte { + t.Helper() + path := filepath.Join("testdata", filename) + data, err := os.ReadFile(path) + require.NoError(t, err) + return data +} diff --git a/service/integration/kas_registry_key_unencrypted_test.go b/service/integration/kas_registry_key_unencrypted_test.go new file mode 100644 index 0000000000..569d160237 --- /dev/null +++ b/service/integration/kas_registry_key_unencrypted_test.go @@ -0,0 +1,46 @@ +package integration + +import ( + "encoding/base64" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/kasregistry" + "github.com/opentdf/platform/service/pkg/db" +) + +func (s *KasRegistryKeySuite) Test_CreateKasKey_PEMPrivateKey_Fail() { + pemPrivateKey := "-----BEGIN PRIVATE KEY-----\nZg==\n-----END PRIVATE KEY-----\n" + encodedPem := base64.StdEncoding.EncodeToString([]byte(pemPrivateKey)) + req := kasregistry.CreateKeyRequest{ + KasId: s.kasKeys[0].KeyAccessServerID, + KeyId: validKeyID1, + KeyAlgorithm: policy.Algorithm_ALGORITHM_RSA_2048, + KeyMode: policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY, + PublicKeyCtx: &policy.PublicKeyCtx{Pem: keyCtx}, + PrivateKeyCtx: &policy.PrivateKeyCtx{ + WrappedKey: encodedPem, + KeyId: validKeyID1, + }, + } + resp, err := s.db.PolicyClient.CreateKey(s.ctx, &req) + s.Require().Error(err) + s.Require().ErrorContains(err, db.ErrUnencryptedPrivateKey.Error()) + s.Nil(resp) +} + +func (s *KasRegistryKeySuite) Test_RotateKey_PEMPrivateKey_Fail() { + keyMap := s.setupKeysForRotate(s.kasKeys[0].KeyAccessServerID) + pemPrivateKey := "-----BEGIN PRIVATE KEY-----\nZg==\n-----END PRIVATE KEY-----\n" + encodedPem := base64.StdEncoding.EncodeToString([]byte(pemPrivateKey)) + newKey := kasregistry.RotateKeyRequest_NewKey{ + KeyId: validKeyID1, + Algorithm: policy.Algorithm_ALGORITHM_EC_P256, + KeyMode: policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY, + PublicKeyCtx: &policy.PublicKeyCtx{Pem: keyCtx}, + PrivateKeyCtx: &policy.PrivateKeyCtx{WrappedKey: encodedPem, KeyId: validKeyID1}, + } + rotatedInKey, err := s.db.PolicyClient.RotateKey(s.ctx, keyMap[rotateKey], &newKey) + s.Require().Error(err) + s.Require().ErrorContains(err, db.ErrUnencryptedPrivateKey.Error()) + s.Nil(rotatedInKey) +} diff --git a/service/pkg/db/errors.go b/service/pkg/db/errors.go index f4f22b6af0..ca1d152cbc 100644 --- a/service/pkg/db/errors.go +++ b/service/pkg/db/errors.go @@ -33,6 +33,7 @@ var ( ErrCannotUpdateToUnspecified = errors.New("ErrCannotUpdateToUnspecified: cannot update to unspecified value") ErrKeyRotationFailed = errors.New("ErrTextKeyRotationFailed: key rotation failed") ErrExpectedBase64EncodedValue = errors.New("ErrExpectedBase64EncodedValue: expected base64 encoded value") + ErrUnencryptedPrivateKey = errors.New("ErrUnencryptedPrivateKey: unencrypted private key not allowed") ErrMarshalValueFailed = errors.New("ErrMashalValueFailed: failed to marshal value") ErrUnmarshalValueFailed = errors.New("ErrUnmarshalValueFailed: failed to unmarshal value") ErrNamespaceMismatch = errors.New("ErrNamespaceMismatch: namespace mismatch") @@ -128,6 +129,7 @@ const ( ErrorTextUpdateToUnspecified = "cannot update to unspecified value" ErrTextKeyRotationFailed = "key rotation failed" ErrorTextExpectedBase64EncodedValue = "expected base64 encoded value" + ErrorTextUnencryptedPrivateKey = "unencrypted private key not allowed" ErrorTextMarshalFailed = "failed to marshal value" ErrorTextUnmarsalFailed = "failed to unmarshal value" ErrorTextNamespaceMismatch = "namespace mismatch" @@ -188,6 +190,10 @@ func StatusifyError(ctx context.Context, l *logger.Logger, err error, fallbackEr l.ErrorContext(ctx, ErrorTextExpectedBase64EncodedValue, logs...) return connect.NewError(connect.CodeInvalidArgument, errors.New(ErrorTextExpectedBase64EncodedValue)) } + if errors.Is(err, ErrUnencryptedPrivateKey) { + l.ErrorContext(ctx, ErrorTextUnencryptedPrivateKey, logs...) + return connect.NewError(connect.CodeInvalidArgument, errors.New(ErrorTextUnencryptedPrivateKey)) + } if errors.Is(err, ErrMarshalValueFailed) { l.ErrorContext(ctx, ErrorTextMarshalFailed, logs...) return connect.NewError(connect.CodeInvalidArgument, errors.New(ErrorTextMarshalFailed)) diff --git a/service/policy/db/key_access_server_registry.go b/service/policy/db/key_access_server_registry.go index c7ce63af02..ee55285e68 100644 --- a/service/policy/db/key_access_server_registry.go +++ b/service/policy/db/key_access_server_registry.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/jackc/pgx/v5/pgtype" + "github.com/opentdf/platform/lib/ocrypto" "github.com/opentdf/platform/protocol/go/common" "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/protocol/go/policy/attributes" @@ -373,8 +374,15 @@ func (c PolicyDBClient) CreateKey(ctx context.Context, r *kasregistry.CreateKeyR if !isValidBase64(r.GetPublicKeyCtx().GetPem()) { return nil, errors.Join(errors.New("public key ctx"), db.ErrExpectedBase64EncodedValue) } - if (mode == int32(policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY) || mode == int32(policy.KeyMode_KEY_MODE_PROVIDER_ROOT_KEY)) && !isValidBase64(r.GetPrivateKeyCtx().GetWrappedKey()) { - return nil, errors.Join(errors.New("private key ctx"), db.ErrExpectedBase64EncodedValue) + if mode == int32(policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY) || mode == int32(policy.KeyMode_KEY_MODE_PROVIDER_ROOT_KEY) { + wrappedKey := r.GetPrivateKeyCtx().GetWrappedKey() + decodedKey, err := base64.StdEncoding.DecodeString(wrappedKey) + if err != nil { + return nil, errors.Join(errors.New("private key ctx"), db.ErrExpectedBase64EncodedValue) + } + if ocrypto.IsPEMOrDERPrivateKey(decodedKey) { + return nil, errors.Join(errors.New("private key ctx"), db.ErrUnencryptedPrivateKey) + } } // Marshal private key and public key context