diff --git a/internal/keystore/efs/efs.go b/internal/keystore/efs/efs.go new file mode 100644 index 00000000..c4cc0979 --- /dev/null +++ b/internal/keystore/efs/efs.go @@ -0,0 +1,165 @@ +// Copyright 2024 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +// Package efs implements a key-value store that +// stores keys as file names and values as encrypted +// file content. +// +// It wraps a fs store and in addition, encrypt the keys. +package efs + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "aead.dev/mem" + "github.com/minio/kes" + "github.com/minio/kes/internal/crypto" + "github.com/minio/kes/internal/fips" + "github.com/minio/kes/internal/keystore/fs" +) + +// NewStore returns a new Store that reads +// from and writes to the given directory, +// using encryption. +// +// If the directory or any parent directory +// does not exist, NewStore creates them all. +// +// It returns an error if dir exists but is +// not a directory. +func NewStore(keyPath string, keyCipher string, dir string) (*Store, error) { + fsStore, err := fs.NewStore(dir) + if err != nil { + return nil, err + } + + key, err := loadMasterKey(keyPath, keyCipher) + if err != nil { + return nil, err + } + + return &Store{key: key, fsStore: fsStore}, nil +} + +// loadMasterKey reads a secret key from a +// given path. +// +// If the key file does not exist, or contains +// an unexpected amount of bytes, it returns an error. +func loadMasterKey(keyPath string, keyCipher string) (crypto.SecretKey, error) { + file, err := os.Open(keyPath) + if errors.Is(err, os.ErrNotExist) { + return crypto.SecretKey{}, fmt.Errorf("master key not found: '%s'", keyPath) + } + if err != nil { + return crypto.SecretKey{}, err + } + defer file.Close() + + const MaxSize = crypto.SecretKeySize + 1 + value, err := io.ReadAll(mem.LimitReader(file, MaxSize)) + if err != nil { + return crypto.SecretKey{}, err + } + if err = file.Close(); err != nil { + return crypto.SecretKey{}, err + } + if len(value) != crypto.SecretKeySize { + return crypto.SecretKey{}, fmt.Errorf("invalid master key size for '%s'", keyCipher) + } + + cipher, err := crypto.ParseSecretKeyType(keyCipher) + if err != nil { + return crypto.SecretKey{}, err + } + if cipher == crypto.ChaCha20 && fips.Enabled { + return crypto.SecretKey{}, fmt.Errorf("master key algorithm '%s' not supported by FIPS 140-2", keyCipher) + } + + return crypto.NewSecretKey(cipher, value) +} + +// Store is a connection to a directory on +// the filesystem using a secret key to encrypt the files. +// +// It implements the kms.Store interface and +// acts as KMS abstraction over a filesystem. +type Store struct { + key crypto.SecretKey + fsStore *fs.Store +} + +func (s *Store) String() string { return "Encrypted Filesystem: " + s.fsStore.Dir() } + +// Status returns the current state of the Conn. +// +// In particular, it reports whether the underlying +// filesystem is accessible. +func (s *Store) Status(ctx context.Context) (kes.KeyStoreState, error) { + return s.fsStore.Status(ctx) +} + +// Create creates a new file with the given name inside +// the Conn directory if and only if no such file exists. +// +// It returns kes.ErrKeyExists if such a file already exists. +func (s *Store) Create(ctx context.Context, name string, value []byte) error { + context := fmt.Sprintf("name=%s", name) + encryptedValue, err := s.key.Encrypt(value, []byte(context)) + if err != nil { + return err + } + + return s.fsStore.Create(ctx, name, encryptedValue) +} + +// Get reads the content of the named file within the Conn +// directory. It returns kes.ErrKeyNotFound if no such file +// exists. +func (s *Store) Get(ctx context.Context, name string) ([]byte, error) { + encryptedValue, err := s.fsStore.Get(ctx, name) + if err != nil { + return nil, err + } + + context := fmt.Sprintf("name=%s", name) + value, err := s.key.Decrypt(encryptedValue, []byte(context)) + if err != nil { + return nil, err + } + + return value, nil +} + +// Delete deletes the named file within the Conn directory if +// and only if it exists. It returns kes.ErrKeyNotFound if +// no such file exists. +func (s *Store) Delete(ctx context.Context, name string) error { + return s.fsStore.Delete(ctx, name) +} + +// List returns a new Iterator over the names of +// all stored keys. +// List returns the first n key names, that start with the given +// prefix, and the next prefix from which the listing should +// continue. +// +// It returns all keys with the prefix if n < 0 and less than n +// names if n is greater than the number of keys with the prefix. +// +// An empty prefix matches any key name. At the end of the listing +// or when there are no (more) keys starting with the prefix, the +// returned prefix is empty +func (s *Store) List(ctx context.Context, prefix string, n int) ([]string, string, error) { + return s.fsStore.List(ctx, prefix, n) +} + +// Close closes the Store. +func (s *Store) Close() error { + return s.fsStore.Close() +} diff --git a/internal/keystore/fs/fs.go b/internal/keystore/fs/fs.go index dea52441..df9523a6 100644 --- a/internal/keystore/fs/fs.go +++ b/internal/keystore/fs/fs.go @@ -59,6 +59,9 @@ type Store struct { func (s *Store) String() string { return "Filesystem: " + s.dir } +// Dir returns the directory used on the filesystem. +func (s *Store) Dir() string { return s.dir } + // Status returns the current state of the Conn. // // In particular, it reports whether the underlying diff --git a/kesconf/config.go b/kesconf/config.go index d9940f43..0045380c 100644 --- a/kesconf/config.go +++ b/kesconf/config.go @@ -76,6 +76,11 @@ type ymlFile struct { FS *struct { Path env[string] `yaml:"path"` } + EncryptedFS *struct { + MasterKeyPath env[string] `yaml:"masterKeyPath"` + MasterKeyCipher env[string] `yaml:"masterKeyCipher"` + Path env[string] `yaml:"path"` + } `yaml:"encryptedfs"` KES *struct { Endpoint []env[string] `yaml:"endpoint"` Enclave env[string] `yaml:"enclave"` @@ -416,6 +421,24 @@ func ymlToKeyStore(y *ymlFile) (KeyStore, error) { } } + // Encrypted FS Keystore + if y.KeyStore.EncryptedFS != nil { + if y.KeyStore.EncryptedFS.MasterKeyPath.Value == "" { + return nil, errors.New("kesconf: invalid encryptedfs keystore: no master key path specified") + } + if y.KeyStore.EncryptedFS.MasterKeyCipher.Value == "" { + return nil, errors.New("kesconf: invalid encryptedfs keystore: no master key cipher specified") + } + if y.KeyStore.EncryptedFS.Path.Value == "" { + return nil, errors.New("kesconf: invalid encryptedfs keystore: no path specified") + } + keystore = &EncryptedFSKeyStore{ + MasterKeyPath: y.KeyStore.EncryptedFS.MasterKeyPath.Value, + MasterKeyCipher: y.KeyStore.EncryptedFS.MasterKeyCipher.Value, + Path: y.KeyStore.EncryptedFS.Path.Value, + } + } + // Hashicorp Vault Keystore if y.KeyStore.Vault != nil { if keystore != nil { diff --git a/kesconf/config_test.go b/kesconf/config_test.go index b12e4fda..47a316e6 100644 --- a/kesconf/config_test.go +++ b/kesconf/config_test.go @@ -30,6 +30,35 @@ func TestReadServerConfigYAML_FS(t *testing.T) { } } +func TestReadServerConfigYAML_EncryptedFS(t *testing.T) { + const ( + Filename = "./testdata/efs.yml" + MasterKeyPath = "./kes-master-key" + MasterKeyCipher = "master-key-cipher" + FSPath = "/tmp/keys" + ) + + config, err := ReadFile(Filename) + if err != nil { + t.Fatalf("Failed to read file '%s': %v", Filename, err) + } + + fs, ok := config.KeyStore.(*EncryptedFSKeyStore) + if !ok { + var want *EncryptedFSKeyStore + t.Fatalf("Invalid keystore: got type '%T' - want type '%T'", config.KeyStore, want) + } + if fs.MasterKeyPath != MasterKeyPath { + t.Fatalf("Invalid keystore: got master key path '%s' - want path '%s'", fs.MasterKeyPath, MasterKeyPath) + } + if fs.MasterKeyCipher != MasterKeyCipher { + t.Fatalf("Invalid keystore: got master key cipher '%s' - want cipher '%s'", fs.MasterKeyCipher, MasterKeyCipher) + } + if fs.Path != FSPath { + t.Fatalf("Invalid keystore: got path '%s' - want path '%s'", fs.Path, FSPath) + } +} + func TestReadServerConfigYAML_CustomAPI(t *testing.T) { const ( Filename = "./testdata/custom-api.yml" diff --git a/kesconf/efs_test.go b/kesconf/efs_test.go new file mode 100644 index 00000000..d812bfb7 --- /dev/null +++ b/kesconf/efs_test.go @@ -0,0 +1,307 @@ +// Copyright 2024 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +package kesconf_test + +import ( + "bytes" + "context" + "encoding/base64" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/minio/kes" + "github.com/minio/kes/kesconf" +) + +var EncryptedFSPath = flag.String("efs.path", "", "Path used for EncryptedFS tests") + +func TestEncryptedFS(t *testing.T) { + if *EncryptedFSPath == "" { + t.Skip("EncryptedFS tests disabled. Use -efs.path= to enable them") + } + + masterKey := "passwordpasswordpasswordpassword" + masterKeyPath := filepath.Join(*EncryptedFSPath, "test-master-key") + masterKeyCipher := "AES256" + if err := os.WriteFile(masterKeyPath, []byte(masterKey), 0o644); err != nil { + t.Fatalf("Failed to write master key into test dir") + } + + config := kesconf.EncryptedFSKeyStore{ + MasterKeyPath: masterKeyPath, + MasterKeyCipher: masterKeyCipher, + Path: *EncryptedFSPath, + } + + ctx, cancel := testingContext(t) + defer cancel() + + store, err := config.Connect(ctx) + if err != nil { + t.Fatal(err) + } + + t.Run("Create", func(t *testing.T) { testCreate(ctx, store, t, RandString(ranStringLength)) }) + t.Run("Get", func(t *testing.T) { testGet(ctx, store, t, RandString(ranStringLength)) }) + t.Run("Status", func(t *testing.T) { testStatus(ctx, store, t) }) + t.Run("List", func(t *testing.T) { efsTestList(ctx, store, t) }) + t.Run("BackwardCompatible", func(t *testing.T) { efsTestBackwardCompatible(ctx, store, t, *EncryptedFSPath) }) + t.Run("Encrypted", func(t *testing.T) { efsTestEnsureEncrypted(ctx, store, t, *EncryptedFSPath) }) + t.Run("EncryptionContext", func(t *testing.T) { efsTestEncryptionContext(ctx, store, t, *EncryptedFSPath) }) +} + +// test all operations +func efsTestList(ctx context.Context, store kes.KeyStore, t *testing.T) { + defer clean(ctx, store, t) + + // empty kek list + kekList, _, err := store.List(ctx, "test", 10) + if err != nil { + t.Fatalf("Failed to list store: %v", err) + } + if len(kekList) != 0 { + t.Fatalf("Unexpected kek list entries, expected empty list: %d entries", len(kekList)) + } + + // create kek + kekName := "test-kek" + kekPlaintext := "my-plaintext-kek" + err = store.Create(ctx, kekName, []byte(kekPlaintext)) + if err != nil { + t.Fatalf("Unable to create kek: %v", err) + } + + // list new kek + kekList, _, err = store.List(ctx, "test", 10) + if err != nil { + t.Fatalf("Failed to list store: %v", err) + } + if len(kekList) != 1 { + t.Fatalf("Unexpected kek list entries, expected list with one entry: %d entries", len(kekList)) + } + if kekList[0] != kekName { + t.Fatalf("Unexpected kek list entry: %s", kekList[0]) + } + + // read kek + decryptetdKek, err := store.Get(ctx, kekName) + if err != nil { + t.Fatalf("Failed to read kek: %v", err) + } + if !bytes.Equal(decryptetdKek, []byte(kekPlaintext)) { + t.Fatalf("Failed to decrypt kek: %s vs. %s", string(decryptetdKek), kekPlaintext) + } + + // delete kek + err = store.Delete(ctx, kekName) + if err != nil { + t.Fatalf("Failed to delete kek: %v", err) + } + + // empty kek list + kekList, _, err = store.List(ctx, "test", 10) + if err != nil { + t.Fatalf("Failed to list store: %v", err) + } + if len(kekList) != 0 { + t.Fatalf("Unexpected kek list entries, expected empty list: %d entries", len(kekList)) + } +} + +// ensure backward compatibility: read a known encrypted kek +func efsTestBackwardCompatible(ctx context.Context, store kes.KeyStore, t *testing.T, tmp string) { + defer clean(ctx, store, t) + + // write encrypted kek to disk + kekName := "test-kek" + kekPlaintext := "my-plaintext-kek" + encrypetdKek, err := base64.StdEncoding.DecodeString("Eu4t1j1T8CuLjgxqZoCBXguh6DJ+Jg4oZyhPUE6CNsgeGGZ3UhxQ0Eozh1A0THfsx/EK9rc97V2RTg5U") + if err != nil { + t.Fatalf("Failed to decode encrypted kek base64: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, kekName), encrypetdKek, 0o644); err != nil { + t.Fatalf("Failed to write encrypted key into test temp dir") + } + + // read kek + decryptetdKek, err := store.Get(ctx, kekName) + if err != nil { + t.Fatalf("Failed to read kek: %v", err) + } + if !bytes.Equal(decryptetdKek, []byte(kekPlaintext)) { + t.Fatalf("Failed to decrypt kek: %s vs. %s", string(decryptetdKek), kekPlaintext) + } +} + +// basic test to ensure kek was not written in plaintext to disk +func efsTestEnsureEncrypted(ctx context.Context, store kes.KeyStore, t *testing.T, tmp string) { + defer clean(ctx, store, t) + + // create kek + kekName := "test-kek" + kekPlaintext := "my-plaintext-kek" + err := store.Create(ctx, kekName, []byte(kekPlaintext)) + if err != nil { + t.Fatalf("Unable to create kek: %v", err) + } + + // ensure file on disk does not contain plaintext kek + fileContent, err := os.ReadFile(filepath.Join(tmp, kekName)) + if err != nil { + t.Fatalf("Failed to read kek file: %v", err) + } + if bytes.Equal(fileContent, []byte(kekPlaintext)) { + t.Fatalf("Content of kek file not encrypted") + } +} + +// test key context gets validated +func efsTestEncryptionContext(ctx context.Context, store kes.KeyStore, t *testing.T, tmp string) { + defer clean(ctx, store, t) + + // create kek + kekName := "test-kek" + kekPlaintext := "my-plaintext-kek" + err := store.Create(ctx, kekName, []byte(kekPlaintext)) + if err != nil { + t.Fatalf("Unable to create kek: %v", err) + } + + // copy kek + otherKekName := "other-kek" + fileContent, err := os.ReadFile(filepath.Join(tmp, kekName)) + if err != nil { + t.Fatalf("Failed to read encrypted kek file: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, otherKekName), fileContent, 0o644); err != nil { + t.Fatalf("Failed to write encrypted kek into new file: %v", err) + } + + // read other kek + _, err = store.Get(ctx, otherKekName) + if err == nil || !strings.Contains(fmt.Sprint(err), "ciphertext is not authentic") { + t.Fatalf("Expected get to fail with ciphertext is not authentic") + } +} + +// test keystore init fails if master key is missing +func TestEncryptedFSmissingMasterKey(t *testing.T) { + if *EncryptedFSPath == "" { + t.Skip("EncryptedFS tests disabled. Use -efs.path= to enable them") + } + + // missing master key + config := kesconf.EncryptedFSKeyStore{ + MasterKeyPath: filepath.Join(*EncryptedFSPath, "master-key"), + MasterKeyCipher: "AES256", + Path: *EncryptedFSPath, + } + + ctx, cancel := testingContext(t) + defer cancel() + + // init keystore fails + _, err := config.Connect(ctx) + if err == nil { + t.Fatalf("Expected init to fail on missing master key") + } +} + +// test keystore init fails if master key has unknown length +func TestEncryptedFSinvalidMasterKeyLengthToShort(t *testing.T) { + if *EncryptedFSPath == "" { + t.Skip("EncryptedFS tests disabled. Use -efs.path= to enable them") + } + + // create master key with invalid length + masterKey := "veryshortkey" + masterKeyCipher := "AES256" + masterKeyPath := filepath.Join(*EncryptedFSPath, "master-key") + if err := os.WriteFile(masterKeyPath, []byte(masterKey), 0o644); err != nil { + t.Fatalf("Failed to write master key into test temp dir") + } + defer os.Remove(masterKeyPath) + + config := kesconf.EncryptedFSKeyStore{ + MasterKeyPath: masterKeyPath, + MasterKeyCipher: masterKeyCipher, + Path: *EncryptedFSPath, + } + + ctx, cancel := testingContext(t) + defer cancel() + + // init keystore fails + _, err := config.Connect(ctx) + if err == nil { + t.Fatalf("Expected init to fail on invalid master key length") + } +} + +// test keystore init fails if master key has unknown length +func TestEncryptedFSinvalidMasterKeyLengthToLarge(t *testing.T) { + if *EncryptedFSPath == "" { + t.Skip("EncryptedFS tests disabled. Use -efs.path= to enable them") + } + + // create master key with invalid length + masterKey := "verylongverylongverylongverylongverylongverylongverylongverylong" + masterKeyCipher := "AES256" + masterKeyPath := filepath.Join(*EncryptedFSPath, "master-key") + if err := os.WriteFile(masterKeyPath, []byte(masterKey), 0o644); err != nil { + t.Fatalf("Failed to write master key into test temp dir") + } + defer os.Remove(masterKeyPath) + + config := kesconf.EncryptedFSKeyStore{ + MasterKeyPath: masterKeyPath, + MasterKeyCipher: masterKeyCipher, + Path: *EncryptedFSPath, + } + + ctx, cancel := testingContext(t) + defer cancel() + + // init keystore fails + _, err := config.Connect(ctx) + if err == nil { + t.Fatalf("Expected init to fail on invalid master key length") + } +} + +// test keystore init fails on unknown cipher +func TestEncryptedFSunknownMasterKeyCipher(t *testing.T) { + if *EncryptedFSPath == "" { + t.Skip("EncryptedFS tests disabled. Use -efs.path= to enable them") + } + + // create master key with unknown cipher + masterKey := "passwordpasswordpasswordpassword" + masterKeyCipher := "UNKNOWN" + masterKeyPath := filepath.Join(*EncryptedFSPath, "master-key") + if err := os.WriteFile(masterKeyPath, []byte(masterKey), 0o644); err != nil { + t.Fatalf("Failed to write master key into test temp dir") + } + defer os.Remove(masterKeyPath) + + config := kesconf.EncryptedFSKeyStore{ + MasterKeyPath: masterKeyPath, + MasterKeyCipher: masterKeyCipher, + Path: *EncryptedFSPath, + } + + ctx, cancel := testingContext(t) + defer cancel() + + // init keystore fails + _, err := config.Connect(ctx) + if err == nil { + t.Fatalf("Expected init to fail on unknown master key cipher") + } +} diff --git a/kesconf/file.go b/kesconf/file.go index c60218d6..76d3714a 100644 --- a/kesconf/file.go +++ b/kesconf/file.go @@ -22,6 +22,7 @@ import ( "github.com/minio/kes/internal/https" "github.com/minio/kes/internal/keystore/aws" "github.com/minio/kes/internal/keystore/azure" + "github.com/minio/kes/internal/keystore/efs" "github.com/minio/kes/internal/keystore/entrust" "github.com/minio/kes/internal/keystore/fortanix" "github.com/minio/kes/internal/keystore/fs" @@ -386,6 +387,28 @@ func (s *FSKeyStore) Connect(context.Context) (kes.KeyStore, error) { return fs.NewStore(s.Path) } +// EncryptedFSKeyStore is a structure containing the configuration +// for a simple filesystem keystore. +// +// A EncryptedFSKeyStore should only be used when testing a KES server. +type EncryptedFSKeyStore struct { + // MasterKeyPath is the path of the file containing the master key. + MasterKeyPath string + // MasterKeyCipher is the cipher to load the master key. + MasterKeyCipher string + // Path is the path to the directory that + // contains the keys. + // + // If the directory does not exist, it + // will be created. + Path string +} + +// Connect returns a kv.Store that stores key-value pairs in a path on the filesystem. +func (s *EncryptedFSKeyStore) Connect(context.Context) (kes.KeyStore, error) { + return efs.NewStore(s.MasterKeyPath, s.MasterKeyCipher, s.Path) +} + // VaultKeyStore is a structure containing the configuration // for Hashicorp Vault. type VaultKeyStore struct { diff --git a/kesconf/testdata/efs.yml b/kesconf/testdata/efs.yml new file mode 100644 index 00000000..1fe11bca --- /dev/null +++ b/kesconf/testdata/efs.yml @@ -0,0 +1,16 @@ +version: v1 + +address: 0.0.0.0:7373 + +admin: + identity: c84cc9b91ae2399b043da7eca616048d4b4200edf2ff418d8af3835911db945d + +tls: + key: ./server.key + cert: ./server.cert + +keystore: + encryptedfs: + masterKeyPath: ./kes-master-key + masterKeyCipher: master-key-cipher + path: "/tmp/keys" diff --git a/server-config.yaml b/server-config.yaml index eafc8539..3a88c6d8 100644 --- a/server-config.yaml +++ b/server-config.yaml @@ -233,6 +233,13 @@ keystore: fs: path: "" # Path to directory. Keys will be stored as files. + # Configuration for storing keys on the filesystem, + # using an ecryption encryption key. + encryptedfs: + masterKeyPath: "" # Path to secret key file with 32 bytes. + masterKeyCipher: "" # Cipher to use, AES256 or ChaCha20. Changing this value breaks any existing encrypted data. + path: "" # Path to directory. Keys will be stored as files. + # Hashicorp Vault configuration. The KES server will store/fetch # secret keys at/from Vault's key-value backend. #