Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions sdk/key_access_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package sdk

import (
"crypto/ecdsa"
"crypto/rand"
"crypto/x509"
"encoding/hex"
"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.
Comment thread
pflynn-virtru marked this conversation as resolved.
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
}

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The real floor is nonce + GCM tag (12 + 16 = 28), not the nonce alone — a truncated or tagless envelope would pass this.

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) {
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.Equal(t, PolicyBinding{Alg: "HS256", Hash: "test-binding"}, kao.PolicyBinding)
assert.NotEmpty(t, kao.WrappedKey)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RSA path is asserted but never round-tripped: this checks a non-empty string was produced, whereas TestCreateKeyAccessECUnwrap below proves the KAS can actually recover the DEK. The asymmetry exists because testRSAPublicKey is a bare public key with no matching private key.

The fixture to close it already exists in this package — sdk/tdf_test.go:91 defines mockRSAPublicKey1 (a CERTIFICATE PEM, which FromPublicPEM handles via getPublicPart) alongside mockRSAPrivateKey1 at sdk/tdf_test.go:108. Switching to that pair would delete the new 9-line constant and let this test decrypt kao.WrappedKey back to symKey, giving RSA the same strength as EC.

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"},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two gaps in this table, the second more interesting than the first:

  1. Only the RSA branch is exercised. The EC and KEM branches parse PublicKey through their own paths and have no malformed-input case.

  2. There's a silent-failure path here that's squarely in this PR's stated scope. createKeyAccess (sdk/tdf.go:679) dispatches on kasInfo.Algorithm, but generateWrapKeyWithRSA → ocrypto.FromPublicPEM dispatches on the PEM contents. Pass an EC PEM with Algorithm: "rsa:2048" — or an empty Algorithm, plausible if a KAS omits it — and the default branch returns an ECEncryptor (lib/ocrypto/asym_encryption.go:112-117). The result is a KAO carrying an ECIES envelope with KeyType still "wrapped" and EphemeralPublicKey empty. Encrypt succeeds; rewrap fails much later with nothing pointing back here.

That's exactly the "error path that silently returns a zero value" the description targets. Worth a case pinning the current behaviour, or a bug filed if it should be rejected outright.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming the claim in this comment, since it's the load-bearing one: service/kas/access/rewrap.go:688-755 runs the identical GetECKeySize → ECSizeToMode → pem.Decode → ParsePKIXPublicKey → CompressedECPublicKey sequence, and service/internal/security/standard_crypto.go:478 calls ocrypto.NewSaltedECDecryptor(sk, TDFSalt(), nil) with the same salt. This is faithful, and it's the strongest test in the file.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this uses the tdfSalt() under test on both sides of the round trip, so a change to the salt would move the test with the code rather than fail it. TestTdfSalt pins the value independently, so the coverage is there overall — just noting the two tests are load-bearing together.

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("round trips a base64 EncryptedMetadata envelope", func(t *testing.T) {
encrypted, err := encryptMetadata(symKey, testMetadata)
require.NoError(t, err)
require.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.Equal(t, testMetadata, string(plaintext))
})

t.Run("a different key cannot decrypt the ciphertext", func(t *testing.T) {
otherKey := testSymKey(t)

encrypted, err := encryptMetadata(symKey, testMetadata)
require.NoError(t, err)
ciphertext := decodeEncryptedMetadata(t, encrypted)

gcm, err := ocrypto.NewAESGcm(otherKey)
require.NoError(t, err)
_, err = gcm.Decrypt(ciphertext)
require.Error(t, err)
})

t.Run("empty metadata round trips through an envelope", func(t *testing.T) {
encrypted, err := encryptMetadata(symKey, "")
require.NoError(t, err)
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) {
_, err := encryptMetadata([]byte{}, testMetadata)
require.Error(t, err)
})
}

func TestTdfSalt(t *testing.T) {
assert.Equal(t,
"aa17cf44585fe15fd634c27b9512d842b42af1bac6178d92161edb4e2abf8197",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a comment noting this is sha256("TDF"), so whoever next sees this fail can regenerate the value rather than copy it out of the diff.

hex.EncodeToString(tdfSalt()),
)
}
Loading