From 35f05325ae8bd86e34cc57f689295f555c927db2 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 31 Aug 2026 17:15:29 -0400 Subject: [PATCH 1/3] chore(sdk): add direct tests for createKeyAccess, encryptMetadata and tdfSalt createKeyAccess builds the manifest key access object and picks the wrapping scheme from the KAS key algorithm. Today the only thing exercising it is TDFSuite, which asserts that a round trip against its own fake KAS succeeds -- so it cannot pin which KAO fields are populated, cannot catch an error path that silently returns a zero value, and says nothing about encryptMetadata or tdfSalt, which have no direct coverage. Adds, test-only: - the RSA KAO field shape, including the deliberately empty ephemeralPublicKey and the schemaVersion the KAS dispatches on - empty and malformed KAS public keys are rejected - an EC-wrapped DEK unwrapped the way service/kas/access/rewrap.go does it, so the envelope is checked to be AES-GCM over the HKDF output rather than merely tagged "ec-wrapped" - encryptMetadata's envelope shape, key sensitivity, empty input, and unusable key - tdfSalt determinism and length The three hybrid KEM schemes already round-trip in tdf_hybrid_test.go and are not repeated. Landing this ahead of the DSPX-2604 work that deletes the duplicate key-wrapping implementation in sdk/experimental/tdf makes that deletion checkable as a de-duplication rather than a coverage loss. Signed-off-by: David Mihalcik --- sdk/key_access_test.go | 191 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 sdk/key_access_test.go diff --git a/sdk/key_access_test.go b/sdk/key_access_test.go new file mode 100644 index 0000000000..e3ab3af46c --- /dev/null +++ b/sdk/key_access_test.go @@ -0,0 +1,191 @@ +package sdk + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "strings" + "testing" + + "github.com/opentdf/platform/lib/ocrypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These cover createKeyAccess and its helpers directly, without going through +// CreateTDF. TDFSuite exercises the same code but only asserts that a round +// trip succeeds against its own fake KAS, so it cannot pin the KAO field shape +// or the error paths, and encryptMetadata and tdfSalt have no direct coverage +// at all. + +const ( + testKAS1URL = "https://kas1.example.com/" + + // A real RSA-2048 public key. Generating one per test run is slow enough + // to notice across this many subtests. + testRSAPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtQ2ZuyT/p32SFmWTj+wQ +huQwR4IJSzlJ7CqZ4fOXw90rA2joK27dIGiHrtkQHGhS4SK1mvkYyJaREoppMFRc +AyZWCgixbSdwYJS/KN0hjLIdhtkdBlZDaZN2ayTf2sZjWzOLL2cYzzVsAy9tGL8a +bMqf91DEHv+l58fPxmbJ/i6YFFQoOEsyWnPhXdiExe6poQDCHJFYYOp6iu5kOPWr +jKFj9eGXuFR/CJQ/uxTSM+8/7Ejmi8Oa52TQAUhMPH0U1CRFm/NuiFoFissa0jJC +J3k6syxvf45mPrbtlhcELskXrquDtJOpIMQmEwfuV4j8iLNwVlsR2tAbClJi6UOy +SQIDAQAB +-----END PUBLIC KEY-----` + + testMetadata = "test metadata content" +) + +func testSymKey(t *testing.T) []byte { + t.Helper() + symKey := make([]byte, kKeySize) + _, err := rand.Read(symKey) + require.NoError(t, err) + return symKey +} + +// TestCreateKeyAccessRSA pins the shape of the default (RSA) wrapping path: +// which manifest fields are populated and which are deliberately left empty. +func TestCreateKeyAccessRSA(t *testing.T) { + symKey := testSymKey(t) + + kao, err := createKeyAccess( + KASInfo{URL: testKAS1URL, PublicKey: testRSAPublicKey, KID: "test-kid", Algorithm: "rsa:2048"}, + symKey, PolicyBinding{Alg: "HS256", Hash: "test-binding"}, "encrypted-metadata", "split-1", + ) + require.NoError(t, err) + + assert.Equal(t, kWrapped, kao.KeyType) + assert.Equal(t, testKAS1URL, kao.KasURL) + assert.Equal(t, "test-kid", kao.KID) + assert.Equal(t, kKasProtocol, kao.Protocol) + assert.Equal(t, "split-1", kao.SplitID) + assert.Equal(t, keyAccessSchemaVersion, kao.SchemaVersion) + assert.Equal(t, "encrypted-metadata", kao.EncryptedMetadata) + assert.NotEmpty(t, kao.WrappedKey) + assert.Empty(t, kao.EphemeralPublicKey, "RSA wrapping emits no ephemeral key") +} + +func TestCreateKeyAccessRejectsBadPublicKey(t *testing.T) { + symKey := testSymKey(t) + + for _, tc := range []struct{ name, pubKey string }{ + {"empty", ""}, + {"malformed", "invalid-pem-data"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := createKeyAccess( + KASInfo{URL: testKAS1URL, PublicKey: tc.pubKey, Algorithm: "rsa:2048"}, + symKey, PolicyBinding{}, "", "split-1", + ) + require.Error(t, err) + }) + } +} + +// TestCreateKeyAccessECUnwrap wraps a DEK to an EC KAS key and then unwraps it +// exactly the way service/kas/access/rewrap.go does for "ec-wrapped". +// Asserting only on KeyType would not catch the envelope being, say, a raw XOR +// of the HKDF output instead of AES-GCM. +func TestCreateKeyAccessECUnwrap(t *testing.T) { + symKey := testSymKey(t) + + ecKeyPair, err := ocrypto.NewECKeyPair(ocrypto.ECCModeSecp256r1) + require.NoError(t, err) + kasPubPEM, err := ecKeyPair.PublicKeyInPemFormat() + require.NoError(t, err) + kasPrivPEM, err := ecKeyPair.PrivateKeyInPemFormat() + require.NoError(t, err) + + kao, err := createKeyAccess( + KASInfo{URL: testKAS1URL, PublicKey: kasPubPEM, KID: "test-kid", Algorithm: "ec:secp256r1"}, + symKey, PolicyBinding{}, "", "split-1", + ) + require.NoError(t, err) + require.Equal(t, kECWrapped, kao.KeyType) + require.NotEmpty(t, kao.EphemeralPublicKey) + assert.True(t, strings.HasPrefix(kao.EphemeralPublicKey, "-----BEGIN PUBLIC KEY-----")) + assert.True(t, strings.HasSuffix(kao.EphemeralPublicKey, "-----END PUBLIC KEY-----\n")) + + keySize, err := ocrypto.GetECKeySize([]byte(kao.EphemeralPublicKey)) + require.NoError(t, err) + mode, err := ocrypto.ECSizeToMode(keySize) + require.NoError(t, err) + + block, _ := pem.Decode([]byte(kao.EphemeralPublicKey)) + require.NotNil(t, block) + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + require.NoError(t, err) + ecPub, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok) + compressed, err := ocrypto.CompressedECPublicKey(mode, *ecPub) + require.NoError(t, err) + + priv, err := ocrypto.ECPrivateKeyFromPem([]byte(kasPrivPEM)) + require.NoError(t, err) + dec, err := ocrypto.NewSaltedECDecryptor(priv, tdfSalt(), nil) + require.NoError(t, err) + + wrapped, err := ocrypto.Base64Decode([]byte(kao.WrappedKey)) + require.NoError(t, err) + unwrapped, err := dec.DecryptWithEphemeralKey(wrapped, compressed) + require.NoError(t, err, "KAS must be able to unwrap the EC-wrapped DEK") + assert.Equal(t, symKey, unwrapped) +} + +// The three hybrid KEM schemes are round-tripped in tdf_hybrid_test.go and are +// deliberately not repeated here. + +func TestEncryptMetadata(t *testing.T) { + symKey := testSymKey(t) + + t.Run("produces a base64 EncryptedMetadata envelope", func(t *testing.T) { + encrypted, err := encryptMetadata(symKey, testMetadata) + require.NoError(t, err) + require.NotEmpty(t, encrypted) + + decodedJSON, err := ocrypto.Base64Decode([]byte(encrypted)) + require.NoError(t, err) + + var encMeta EncryptedMetadata + require.NoError(t, json.Unmarshal(decodedJSON, &encMeta)) + require.NotEmpty(t, encMeta.Cipher) + require.NotEmpty(t, encMeta.Iv) + + _, err = ocrypto.Base64Decode([]byte(encMeta.Iv)) + require.NoError(t, err) + _, err = ocrypto.Base64Decode([]byte(encMeta.Cipher)) + require.NoError(t, err) + }) + + t.Run("different keys produce different ciphertext", func(t *testing.T) { + otherKey := testSymKey(t) + + a, err := encryptMetadata(symKey, testMetadata) + require.NoError(t, err) + b, err := encryptMetadata(otherKey, testMetadata) + require.NoError(t, err) + assert.NotEqual(t, a, b) + }) + + t.Run("empty metadata still yields an envelope", func(t *testing.T) { + encrypted, err := encryptMetadata(symKey, "") + require.NoError(t, err) + assert.NotEmpty(t, encrypted) + }) + + t.Run("errors on an unusable key", func(t *testing.T) { + _, err := encryptMetadata([]byte{}, testMetadata) + require.Error(t, err) + }) +} + +func TestTdfSalt(t *testing.T) { + salt1 := tdfSalt() + salt2 := tdfSalt() + + assert.Equal(t, salt1, salt2, "tdfSalt must be deterministic") + assert.Len(t, salt1, 32, "SHA-256 output") +} From f4e21269ae607d75e76c2f24e4e2807e8329c8f0 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 4 Sep 2026 16:33:32 -0400 Subject: [PATCH 2/3] test(sdk): pin TDF salt golden value --- sdk/key_access_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/key_access_test.go b/sdk/key_access_test.go index e3ab3af46c..5f72f47c3e 100644 --- a/sdk/key_access_test.go +++ b/sdk/key_access_test.go @@ -4,6 +4,7 @@ import ( "crypto/ecdsa" "crypto/rand" "crypto/x509" + "encoding/hex" "encoding/json" "encoding/pem" "strings" @@ -183,9 +184,8 @@ func TestEncryptMetadata(t *testing.T) { } func TestTdfSalt(t *testing.T) { - salt1 := tdfSalt() - salt2 := tdfSalt() - - assert.Equal(t, salt1, salt2, "tdfSalt must be deterministic") - assert.Len(t, salt1, 32, "SHA-256 output") + assert.Equal(t, + "aa17cf44585fe15fd634c27b9512d842b42af1bac6178d92161edb4e2abf8197", + hex.EncodeToString(tdfSalt()), + ) } From 8bd900e8453a57dd0f1c2eec67d96846c716f69e Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 4 Sep 2026 16:34:06 -0400 Subject: [PATCH 3/3] test(sdk): verify key access metadata semantics --- sdk/key_access_test.go | 61 ++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/sdk/key_access_test.go b/sdk/key_access_test.go index 5f72f47c3e..6753d868fe 100644 --- a/sdk/key_access_test.go +++ b/sdk/key_access_test.go @@ -47,6 +47,29 @@ func testSymKey(t *testing.T) []byte { return symKey } +func decodeEncryptedMetadata(t *testing.T, encrypted string) []byte { + t.Helper() + + decodedJSON, err := ocrypto.Base64Decode([]byte(encrypted)) + require.NoError(t, err) + + var encMeta EncryptedMetadata + require.NoError(t, json.Unmarshal(decodedJSON, &encMeta)) + require.NotEmpty(t, encMeta.Cipher) + require.NotEmpty(t, encMeta.Iv) + + iv, err := ocrypto.Base64Decode([]byte(encMeta.Iv)) + require.NoError(t, err) + require.Len(t, iv, ocrypto.GcmStandardNonceSize) + + ciphertext, err := ocrypto.Base64Decode([]byte(encMeta.Cipher)) + require.NoError(t, err) + require.GreaterOrEqual(t, len(ciphertext), ocrypto.GcmStandardNonceSize) + assert.Equal(t, iv, ciphertext[:ocrypto.GcmStandardNonceSize]) + + return ciphertext +} + // TestCreateKeyAccessRSA pins the shape of the default (RSA) wrapping path: // which manifest fields are populated and which are deliberately left empty. func TestCreateKeyAccessRSA(t *testing.T) { @@ -65,6 +88,7 @@ func TestCreateKeyAccessRSA(t *testing.T) { assert.Equal(t, "split-1", kao.SplitID) assert.Equal(t, keyAccessSchemaVersion, kao.SchemaVersion) assert.Equal(t, "encrypted-metadata", kao.EncryptedMetadata) + assert.Equal(t, PolicyBinding{Alg: "HS256", Hash: "test-binding"}, kao.PolicyBinding) assert.NotEmpty(t, kao.WrappedKey) assert.Empty(t, kao.EphemeralPublicKey, "RSA wrapping emits no ephemeral key") } @@ -142,39 +166,42 @@ func TestCreateKeyAccessECUnwrap(t *testing.T) { func TestEncryptMetadata(t *testing.T) { symKey := testSymKey(t) - t.Run("produces a base64 EncryptedMetadata envelope", func(t *testing.T) { + t.Run("round trips a base64 EncryptedMetadata envelope", func(t *testing.T) { encrypted, err := encryptMetadata(symKey, testMetadata) require.NoError(t, err) require.NotEmpty(t, encrypted) - decodedJSON, err := ocrypto.Base64Decode([]byte(encrypted)) + ciphertext := decodeEncryptedMetadata(t, encrypted) + gcm, err := ocrypto.NewAESGcm(symKey) require.NoError(t, err) - - var encMeta EncryptedMetadata - require.NoError(t, json.Unmarshal(decodedJSON, &encMeta)) - require.NotEmpty(t, encMeta.Cipher) - require.NotEmpty(t, encMeta.Iv) - - _, err = ocrypto.Base64Decode([]byte(encMeta.Iv)) - require.NoError(t, err) - _, err = ocrypto.Base64Decode([]byte(encMeta.Cipher)) + plaintext, err := gcm.Decrypt(ciphertext) require.NoError(t, err) + assert.Equal(t, testMetadata, string(plaintext)) }) - t.Run("different keys produce different ciphertext", func(t *testing.T) { + t.Run("a different key cannot decrypt the ciphertext", func(t *testing.T) { otherKey := testSymKey(t) - a, err := encryptMetadata(symKey, testMetadata) + encrypted, err := encryptMetadata(symKey, testMetadata) require.NoError(t, err) - b, err := encryptMetadata(otherKey, testMetadata) + ciphertext := decodeEncryptedMetadata(t, encrypted) + + gcm, err := ocrypto.NewAESGcm(otherKey) require.NoError(t, err) - assert.NotEqual(t, a, b) + _, err = gcm.Decrypt(ciphertext) + require.Error(t, err) }) - t.Run("empty metadata still yields an envelope", func(t *testing.T) { + t.Run("empty metadata round trips through an envelope", func(t *testing.T) { encrypted, err := encryptMetadata(symKey, "") require.NoError(t, err) - assert.NotEmpty(t, encrypted) + ciphertext := decodeEncryptedMetadata(t, encrypted) + + gcm, err := ocrypto.NewAESGcm(symKey) + require.NoError(t, err) + plaintext, err := gcm.Decrypt(ciphertext) + require.NoError(t, err) + assert.Empty(t, plaintext) }) t.Run("errors on an unusable key", func(t *testing.T) {