From f9c27288e0dfa886162319e809b5bb36d4e3a41b Mon Sep 17 00:00:00 2001
From: strantalis <strantalis@virtru.com>
Date: Thu, 12 Feb 2026 10:40:39 -0500
Subject: [PATCH 1/2] fix(policy): reject unencrypted private keys for modes
 1/2

Signed-off-by: strantalis <strantalis@virtru.com>
---
 lib/ocrypto/key_material.go                   | 29 +++++++++++
 lib/ocrypto/key_material_test.go              | 48 +++++++++++++++++++
 .../kas_registry_key_unencrypted_test.go      | 46 ++++++++++++++++++
 service/pkg/db/errors.go                      |  6 +++
 .../policy/db/key_access_server_registry.go   | 15 +++++-
 5 files changed, 142 insertions(+), 2 deletions(-)
 create mode 100644 lib/ocrypto/key_material.go
 create mode 100644 lib/ocrypto/key_material_test.go
 create mode 100644 service/integration/kas_registry_key_unencrypted_test.go

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..b7e87fef14 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,18 @@ 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()
+		if !isValidBase64(wrappedKey) {
+			return nil, errors.Join(errors.New("private key ctx"), db.ErrExpectedBase64EncodedValue)
+		}
+		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

From 3376ace7c1cc4bb870ae04f68f1c13aa093e9b8d Mon Sep 17 00:00:00 2001
From: strantalis <strantalis@virtru.com>
Date: Thu, 12 Feb 2026 12:08:54 -0500
Subject: [PATCH 2/2] fix(policy): avoid double base64 decode

Signed-off-by: strantalis <strantalis@virtru.com>
---
 service/policy/db/key_access_server_registry.go | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/service/policy/db/key_access_server_registry.go b/service/policy/db/key_access_server_registry.go
index b7e87fef14..ee55285e68 100644
--- a/service/policy/db/key_access_server_registry.go
+++ b/service/policy/db/key_access_server_registry.go
@@ -376,9 +376,6 @@ func (c PolicyDBClient) CreateKey(ctx context.Context, r *kasregistry.CreateKeyR
 	}
 	if mode == int32(policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY) || mode == int32(policy.KeyMode_KEY_MODE_PROVIDER_ROOT_KEY) {
 		wrappedKey := r.GetPrivateKeyCtx().GetWrappedKey()
-		if !isValidBase64(wrappedKey) {
-			return nil, errors.Join(errors.New("private key ctx"), db.ErrExpectedBase64EncodedValue)
-		}
 		decodedKey, err := base64.StdEncoding.DecodeString(wrappedKey)
 		if err != nil {
 			return nil, errors.Join(errors.New("private key ctx"), db.ErrExpectedBase64EncodedValue)
