-
Notifications
You must be signed in to change notification settings - Fork 24
feat: add AES protected key interface and implementation #2599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
strantalis
merged 7 commits into
opentdf:main
from
strantalis:dspx-1474/expose-aes-protected-key
Sep 2, 2025
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
26827e2
feat(ocrypto): add AES protected key interface and implementation
strantalis a3e24d3
fix(ocrypto): apply security improvements to AESProtectedKey
strantalis 119dbc9
cleanup lint issues
strantalis 227c2aa
gemini performance improvements recommendations
strantalis 2960978
some minor changes
strantalis c5bfa48
return error if nil encapsulator on export
strantalis c7f33b0
fix lint issue
strantalis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
package ocrypto | ||
|
||
import ( | ||
"context" | ||
) | ||
|
||
// Encapsulator enables key encapsulation with a public key | ||
type Encapsulator interface { | ||
// Encapsulate wraps a secret key with the encapsulation key | ||
Encapsulate(dek ProtectedKey) ([]byte, error) | ||
|
||
// Encrypt wraps a secret key with the encapsulation key | ||
Encrypt(data []byte) ([]byte, error) | ||
|
||
// PublicKeyAsPEM exports the public key, used to encapsulate the value, in Privacy-Enhanced Mail format, | ||
// or the empty string if not present. | ||
PublicKeyAsPEM() (string, error) | ||
|
||
// For EC schemes, this method returns the public part of the ephemeral key. | ||
// Otherwise, it returns nil. | ||
EphemeralKey() []byte | ||
dmihalcik-virtru marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// ProtectedKey represents a decrypted key with operations that can be performed on it | ||
type ProtectedKey interface { | ||
// VerifyBinding checks if the policy binding matches the given policy data | ||
VerifyBinding(ctx context.Context, policy, policyBinding []byte) error | ||
|
||
// Export returns the raw key data, optionally encrypting it with the provided encapsulator | ||
Export(encapsulator Encapsulator) ([]byte, error) | ||
|
||
// DecryptAESGCM decrypts encrypted policies and metadata | ||
DecryptAESGCM(iv []byte, body []byte, tagSize int) ([]byte, error) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
package ocrypto | ||
|
||
import ( | ||
"context" | ||
"crypto/hmac" | ||
"crypto/sha256" | ||
"errors" | ||
"fmt" | ||
) | ||
|
||
var ( | ||
// ErrEmptyKeyData is returned when the key data is empty | ||
ErrEmptyKeyData = errors.New("key data is empty") | ||
// ErrPolicyHMACMismatch is returned when policy binding verification fails | ||
ErrPolicyHMACMismatch = errors.New("policy HMAC mismatch") | ||
) | ||
|
||
// AESProtectedKey implements the ProtectedKey interface with an in-memory secret key | ||
type AESProtectedKey struct { | ||
rawKey []byte | ||
aesGcm AesGcm | ||
} | ||
strantalis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
var _ ProtectedKey = (*AESProtectedKey)(nil) | ||
|
||
// NewAESProtectedKey creates a new instance of AESProtectedKey | ||
func NewAESProtectedKey(rawKey []byte) (*AESProtectedKey, error) { | ||
if len(rawKey) == 0 { | ||
return nil, ErrEmptyKeyData | ||
} | ||
// Create a defensive copy of the key | ||
keyCopy := append([]byte{}, rawKey...) | ||
|
||
// Pre-initialize the AES-GCM cipher for performance | ||
aesGcm, err := NewAESGcm(keyCopy) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to initialize AES-GCM cipher: %w", err) | ||
} | ||
|
||
return &AESProtectedKey{ | ||
rawKey: keyCopy, | ||
aesGcm: aesGcm, | ||
}, nil | ||
} | ||
|
||
// DecryptAESGCM decrypts data using AES-GCM with the protected key | ||
func (k *AESProtectedKey) DecryptAESGCM(iv []byte, body []byte, tagSize int) ([]byte, error) { | ||
// Use the pre-initialized AES-GCM cipher for better performance | ||
decryptedData, err := k.aesGcm.DecryptWithIVAndTagSize(iv, body, tagSize) | ||
if err != nil { | ||
return nil, fmt.Errorf("AES-GCM decryption failed: %w", err) | ||
} | ||
|
||
return decryptedData, nil | ||
} | ||
|
||
// Export returns the raw key data, optionally encrypting it with the provided Encapsulator | ||
func (k *AESProtectedKey) Export(encapsulator Encapsulator) ([]byte, error) { | ||
if encapsulator == nil { | ||
// Return error if encapsulator is nil | ||
return nil, errors.New("encapsulator cannot be nil") | ||
} | ||
|
||
// Encrypt the key data before returning | ||
encryptedKey, err := encapsulator.Encrypt(k.rawKey) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to encrypt key data for export: %w", err) | ||
} | ||
|
||
return encryptedKey, nil | ||
} | ||
|
||
// VerifyBinding checks if the policy binding matches the given policy data | ||
func (k *AESProtectedKey) VerifyBinding(_ context.Context, policy, policyBinding []byte) error { | ||
actualHMAC := k.generateHMACDigest(policy) | ||
|
||
if !hmac.Equal(actualHMAC, policyBinding) { | ||
return ErrPolicyHMACMismatch | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// generateHMACDigest is a helper to generate an HMAC digest from a message using the key | ||
func (k *AESProtectedKey) generateHMACDigest(msg []byte) []byte { | ||
mac := hmac.New(sha256.New, k.rawKey) | ||
mac.Write(msg) | ||
return mac.Sum(nil) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,205 @@ | ||
package ocrypto | ||
|
||
import ( | ||
"context" | ||
"crypto/rand" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
const testKey = "test-key-12345678901234567890123" | ||
|
||
func TestNewAESProtectedKey(t *testing.T) { | ||
key := make([]byte, 32) | ||
_, err := rand.Read(key) | ||
require.NoError(t, err) | ||
|
||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
assert.NotNil(t, protectedKey) | ||
assert.Equal(t, key, protectedKey.rawKey) | ||
strantalis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
func TestAESProtectedKey_DecryptAESGCM(t *testing.T) { | ||
// Generate a random 256-bit key | ||
key := make([]byte, 32) | ||
_, err := rand.Read(key) | ||
require.NoError(t, err) | ||
|
||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
// Test data | ||
plaintext := []byte("Hello, World!") | ||
|
||
// Encrypt the data first using the same key | ||
aesGcm, err := NewAESGcm(key) | ||
require.NoError(t, err) | ||
|
||
encrypted, err := aesGcm.Encrypt(plaintext) | ||
require.NoError(t, err) | ||
|
||
// Extract IV and ciphertext (first 12 bytes are IV for GCM standard nonce size) | ||
iv := encrypted[:GcmStandardNonceSize] | ||
ciphertext := encrypted[GcmStandardNonceSize:] | ||
|
||
// Test decryption | ||
decrypted, err := protectedKey.DecryptAESGCM(iv, ciphertext, 16) // 16 is standard GCM tag size | ||
require.NoError(t, err) | ||
assert.Equal(t, plaintext, decrypted) | ||
} | ||
|
||
func TestAESProtectedKey_DecryptAESGCM_InvalidKey(t *testing.T) { | ||
// Empty key should fail | ||
_, err := NewAESProtectedKey([]byte{}) | ||
require.Error(t, err) | ||
assert.ErrorIs(t, err, ErrEmptyKeyData) | ||
} | ||
|
||
func TestAESProtectedKey_Export_NoEncapsulator(t *testing.T) { | ||
key := []byte(testKey) // 32 bytes | ||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
exported, err := protectedKey.Export(nil) | ||
require.Error(t, err) | ||
require.ErrorContains(t, err, "encapsulator cannot be nil") | ||
assert.Nil(t, exported) | ||
} | ||
|
||
func TestAESProtectedKey_Export_WithEncapsulator(t *testing.T) { | ||
key := []byte(testKey) // 32 bytes | ||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
// Mock encapsulator | ||
mockEncapsulator := &mockEncapsulator{ | ||
encryptFunc: func(data []byte) ([]byte, error) { | ||
// Simple XOR encryption for testing | ||
result := make([]byte, len(data)) | ||
for i, b := range data { | ||
result[i] = b ^ 0xFF | ||
} | ||
return result, nil | ||
}, | ||
} | ||
|
||
exported, err := protectedKey.Export(mockEncapsulator) | ||
require.NoError(t, err) | ||
|
||
// Verify it was encrypted (should be different from original) | ||
assert.NotEqual(t, key, exported) | ||
assert.Len(t, exported, len(key)) | ||
|
||
// Verify we can decrypt it back | ||
for i, b := range exported { | ||
assert.Equal(t, key[i], b^0xFF) | ||
} | ||
} | ||
|
||
func TestAESProtectedKey_Export_EncapsulatorError(t *testing.T) { | ||
key := []byte(testKey) // 32 bytes | ||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
mockEncapsulator := &mockEncapsulator{ | ||
encryptFunc: func(_ []byte) ([]byte, error) { | ||
return nil, assert.AnError | ||
}, | ||
} | ||
|
||
_, err = protectedKey.Export(mockEncapsulator) | ||
require.Error(t, err) | ||
assert.Contains(t, err.Error(), "failed to encrypt key data for export") | ||
} | ||
|
||
func TestAESProtectedKey_VerifyBinding(t *testing.T) { | ||
key := []byte(testKey) // 32 bytes | ||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
policy := []byte("test-policy-data") | ||
ctx := context.Background() | ||
|
||
// Generate the expected HMAC | ||
expectedHMAC := protectedKey.generateHMACDigest(policy) | ||
|
||
// Verify binding should succeed with correct HMAC | ||
err = protectedKey.VerifyBinding(ctx, policy, expectedHMAC) | ||
assert.NoError(t, err) | ||
} | ||
|
||
func TestAESProtectedKey_VerifyBinding_Mismatch(t *testing.T) { | ||
key := []byte(testKey) // 32 bytes | ||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
policy := []byte("test-policy-data") | ||
wrongBinding := []byte("wrong-binding-data") | ||
ctx := context.Background() | ||
|
||
err = protectedKey.VerifyBinding(ctx, policy, wrongBinding) | ||
require.Error(t, err) | ||
assert.Equal(t, ErrPolicyHMACMismatch, err) | ||
} | ||
|
||
func TestAESProtectedKey_VerifyBinding_DifferentPolicyData(t *testing.T) { | ||
key := []byte(testKey) // 32 bytes | ||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
ctx := context.Background() | ||
|
||
// Generate HMAC for first policy | ||
policy1 := []byte("policy-data-1") | ||
hmac1 := protectedKey.generateHMACDigest(policy1) | ||
|
||
// Try to verify with different policy data | ||
policy2 := []byte("policy-data-2") | ||
err = protectedKey.VerifyBinding(ctx, policy2, hmac1) | ||
require.Error(t, err) | ||
assert.Equal(t, ErrPolicyHMACMismatch, err) | ||
} | ||
|
||
func TestAESProtectedKey_InterfaceCompliance(t *testing.T) { | ||
key := make([]byte, 32) | ||
protectedKey, err := NewAESProtectedKey(key) | ||
require.NoError(t, err) | ||
|
||
// Ensure it implements the ProtectedKey interface | ||
assert.Implements(t, (*ProtectedKey)(nil), protectedKey) | ||
} | ||
|
||
// Mock encapsulator for testing | ||
type mockEncapsulator struct { | ||
encryptFunc func([]byte) ([]byte, error) | ||
publicKeyPEMFunc func() (string, error) | ||
ephemeralKeyFunc func() []byte | ||
} | ||
|
||
func (m *mockEncapsulator) Encapsulate(_ ProtectedKey) ([]byte, error) { | ||
return nil, nil | ||
} | ||
|
||
func (m *mockEncapsulator) Encrypt(data []byte) ([]byte, error) { | ||
if m.encryptFunc != nil { | ||
return m.encryptFunc(data) | ||
} | ||
return data, nil | ||
} | ||
|
||
func (m *mockEncapsulator) PublicKeyAsPEM() (string, error) { | ||
if m.publicKeyPEMFunc != nil { | ||
return m.publicKeyPEMFunc() | ||
} | ||
return "", nil | ||
} | ||
|
||
func (m *mockEncapsulator) EphemeralKey() []byte { | ||
if m.ephemeralKeyFunc != nil { | ||
return m.ephemeralKeyFunc() | ||
} | ||
return nil | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.