From 5e63743243ca5cd990d773a089d48dd513f388c5 Mon Sep 17 00:00:00 2001 From: Niels de Vos Date: Tue, 2 Mar 2021 18:48:05 +0100 Subject: [PATCH] util: add SecretsMetadataKMS This new KMS is based on the (default) SecretsKMS, but instead of using the passphrase for all volumes, the passphrase is used to encrypt/decrypt a Data-Encryption-Key that is stored in the metadata of the volume. CC: Patrick Uiterwijk - for encryption guidance Signed-off-by: Niels de Vos --- internal/util/crypto.go | 2 + internal/util/secretskms.go | 155 +++++++++++++++++++++++++++++++ internal/util/secretskms_test.go | 92 ++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 internal/util/secretskms_test.go diff --git a/internal/util/crypto.go b/internal/util/crypto.go index 65b4e424f..e0634ec71 100644 --- a/internal/util/crypto.go +++ b/internal/util/crypto.go @@ -235,6 +235,8 @@ func GetKMS(tenant, kmsID string, secrets map[string]string) (EncryptionKMS, err } switch kmsType { + case kmsTypeSecretsMetadata: + return initSecretsMetadataKMS(kmsID, secrets) case kmsTypeVault: return InitVaultKMS(kmsID, kmsConfig, secrets) case kmsTypeVaultTokens: diff --git a/internal/util/secretskms.go b/internal/util/secretskms.go index ea6f70179..ec1544f78 100644 --- a/internal/util/secretskms.go +++ b/internal/util/secretskms.go @@ -17,7 +17,15 @@ limitations under the License. package util import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" "errors" + "fmt" + "io" + + "golang.org/x/crypto/scrypt" ) const ( @@ -26,6 +34,10 @@ const ( // Default KMS type defaultKMSType = "default" + + // kmsTypeSecretsMetadata is the SecretsKMS with per-volume encryption, + // where the DEK is stored in the metadata of the volume itself. + kmsTypeSecretsMetadata = "metadata" ) // SecretsKMS is default KMS implementation that means no KMS is in use. @@ -72,3 +84,146 @@ func (kms SecretsKMS) StoreDEK(key, value string) error { func (kms SecretsKMS) RemoveDEK(key string) error { return nil } + +// SecretsMetadataKMS is a KMS based on the SecretsKMS, but stores the +// Data-Encryption-Key (DEK) in the metadata of the volume. +type SecretsMetadataKMS struct { + SecretsKMS + + encryptionKMSID string +} + +// initSecretsMetadataKMS initializes a SecretsMetadataKMS that wraps a +// SecretsKMS, so that the passphrase from the StorageClass secrets can be used +// for encrypting/decrypting DEKs that are stored in a detached DEKStore. +func initSecretsMetadataKMS(encryptionKMSID string, secrets map[string]string) (EncryptionKMS, error) { + eKMS, err := initSecretsKMS(secrets) + if err != nil { + return nil, err + } + + sKMS, ok := eKMS.(SecretsKMS) + if !ok { + return nil, fmt.Errorf("failed to convert %T to SecretsKMS", eKMS) + } + + smKMS := SecretsMetadataKMS{} + smKMS.SecretsKMS = sKMS + smKMS.encryptionKMSID = encryptionKMSID + + return smKMS, nil +} + +// GetID is returning ID representing the SecretsMetadataKMS. +func (kms SecretsMetadataKMS) GetID() string { + return kms.encryptionKMSID +} + +// Destroy frees all used resources. +func (kms SecretsMetadataKMS) Destroy() { + kms.SecretsKMS.Destroy() +} + +func (kms SecretsMetadataKMS) requiresDEKStore() DEKStoreType { + return DEKStoreMetadata +} + +// encryptedMetedataDEK contains the encrypted DEK and the Nonce that was used +// during encryption. This structure is stored (in JSON format) in the DEKStore +// that is linked to this KMS provider. +type encryptedMetedataDEK struct { + // DEK is the encrypted data-encryption-key for the volume. + DEK []byte `json:"dek"` + // Nonce is a random byte slice to guarantee the uniqueness of the + // encrypted DEK. + Nonce []byte `json:"nonce"` +} + +// EncryptDEK encrypts the plainDEK with a key derived from the passphrase from +// the SecretsKMS and the volumeID. +// The resulting encryptedDEK contains a JSON with the encrypted DEK and the +// nonce that was used for encrypting. +func (kms SecretsMetadataKMS) EncryptDEK(volumeID, plainDEK string) (string, error) { + // use the passphrase from the SecretsKMS + passphrase, err := kms.SecretsKMS.FetchDEK(volumeID) + if err != nil { + return "", fmt.Errorf("failed to get passphrase: %w", err) + } + + aead, err := generateCipher(passphrase, volumeID) + if err != nil { + return "", fmt.Errorf("failed to generate cipher: %w", err) + } + + emd := encryptedMetedataDEK{} + emd.Nonce, err = generateNonce(aead.NonceSize()) + if err != nil { + return "", fmt.Errorf("failed to generated nonce: %w", err) + } + emd.DEK = aead.Seal(nil, emd.Nonce, []byte(plainDEK), nil) + + emdData, err := json.Marshal(&emd) + if err != nil { + return "", fmt.Errorf("failed to convert "+ + "encryptedMetedataDEK to JSON: %w", err) + } + + return string(emdData), nil +} + +// DecryptDEK takes the JSON formatted `encryptedMetedataDEK` contents, and it +// fetches SecretsKMS passphase to decrypt the DEK. +func (kms SecretsMetadataKMS) DecryptDEK(volumeID, encryptedDEK string) (string, error) { + // use the passphrase from the SecretsKMS + passphrase, err := kms.SecretsKMS.FetchDEK(volumeID) + if err != nil { + return "", fmt.Errorf("failed to get passphrase: %w", err) + } + + aead, err := generateCipher(passphrase, volumeID) + if err != nil { + return "", fmt.Errorf("failed to generate cipher: %w", err) + } + + emd := encryptedMetedataDEK{} + err = json.Unmarshal([]byte(encryptedDEK), &emd) + if err != nil { + return "", fmt.Errorf("failed to convert data to "+ + "encryptedMetedataDEK: %w", err) + } + + dek, err := aead.Open(nil, emd.Nonce, emd.DEK, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt DEK: %w", err) + } + + return string(dek), nil +} + +// generateCipher returns a AEAD cipher based on a passphrase and salt +// (volumeID). The cipher can then be used to encrypt/decrypt the DEK. +func generateCipher(passphrase, salt string) (cipher.AEAD, error) { + key, err := scrypt.Key([]byte(passphrase), []byte(salt), 32768, 8, 1, 32) + if err != nil { + return nil, err + } + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(blockCipher) + if err != nil { + return nil, err + } + return aead, nil +} + +// generateNonce returns a byte slice with random contents. +func generateNonce(size int) ([]byte, error) { + nonce := make([]byte, size) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + return nonce, nil +} diff --git a/internal/util/secretskms_test.go b/internal/util/secretskms_test.go new file mode 100644 index 000000000..75ff71b90 --- /dev/null +++ b/internal/util/secretskms_test.go @@ -0,0 +1,92 @@ +/* +Copyright 2021 The Ceph-CSI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateNonce(t *testing.T) { + size := 64 + nonce, err := generateNonce(size) + assert.Equal(t, size, len(nonce)) + assert.NoError(t, err) +} + +func TestGenerateCipher(t *testing.T) { + // nolint:gosec // this passphrase is intentionally hardcoded + passphrase := "my-cool-luks-passphrase" + salt := "unique-id-for-the-volume" + + aead, err := generateCipher(passphrase, salt) + assert.NoError(t, err) + assert.NotNil(t, aead) +} + +func TestInitSecretsMetadataKMS(t *testing.T) { + secrets := map[string]string{} + + // passphrase it not set, init should fail + kms, err := initSecretsMetadataKMS("secrets-metadata-unit-test", secrets) + assert.Error(t, err) + assert.Nil(t, kms) + + // set a passphrase to get a working KMS + secrets[encryptionPassphraseKey] = "my-passphrase-from-kubernetes" + + kms, err = initSecretsMetadataKMS("secrets-metadata-unit-test", secrets) + assert.NoError(t, err) + require.NotNil(t, kms) + assert.Equal(t, "secrets-metadata-unit-test", kms.GetID()) + assert.Equal(t, DEKStoreMetadata, kms.requiresDEKStore()) +} + +func TestWorkflowSecretsMetadataKMS(t *testing.T) { + secrets := map[string]string{ + encryptionPassphraseKey: "my-passphrase-from-kubernetes", + } + volumeID := "csi-vol-1b00f5f8-b1c1-11e9-8421-9243c1f659f0" + + kms, err := initSecretsMetadataKMS("secrets-metadata-unit-test", secrets) + assert.NoError(t, err) + require.NotNil(t, kms) + + // plainDEK is the (LUKS) passphrase for the volume + plainDEK, err := generateNewEncryptionPassphrase() + assert.NoError(t, err) + assert.NotEqual(t, "", plainDEK) + + encryptedDEK, err := kms.EncryptDEK(volumeID, plainDEK) + assert.NoError(t, err) + assert.NotEqual(t, "", encryptedDEK) + assert.NotEqual(t, plainDEK, encryptedDEK) + + // with an incorrect volumeID, decrypting should fail + decryptedDEK, err := kms.DecryptDEK("incorrect-volumeID", encryptedDEK) + assert.Error(t, err) + assert.Equal(t, "", decryptedDEK) + assert.NotEqual(t, plainDEK, decryptedDEK) + + // with the right volumeID, decrypting should return the plainDEK + decryptedDEK, err = kms.DecryptDEK(volumeID, encryptedDEK) + assert.NoError(t, err) + assert.NotEqual(t, "", decryptedDEK) + assert.Equal(t, plainDEK, decryptedDEK) +}