diff --git a/cmd/ssiservice/main.go b/cmd/ssiservice/main.go index 796e13057..560e07086 100644 --- a/cmd/ssiservice/main.go +++ b/cmd/ssiservice/main.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/signal" + "path" "strconv" "syscall" "time" @@ -52,7 +53,9 @@ func run() error { logrus.Infof("loading config from env var path: %s", envConfigPath) configPath = envConfigPath } - cfg, err := config.LoadConfig(configPath) + + dir, file := path.Split(configPath) + cfg, err := config.LoadConfig(file, os.DirFS(dir)) if err != nil { logrus.Fatalf("could not instantiate config: %s", err.Error()) } diff --git a/config/config.go b/config/config.go index 7d1433a1a..370256be4 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "io/fs" "os" "path/filepath" "reflect" @@ -72,6 +73,10 @@ type ServicesConfig struct { StorageOptions []storage.Option `toml:"storage_option"` ServiceEndpoint string `toml:"service_endpoint"` + // Application level encryption configuration. Defines how values are encrypted before they are stored in the + // configured KV store. + AppLevelEncryptionConfiguration EncryptionConfig `toml:"storage_encryption,omitempty"` + // Embed all service-specific configs here. The order matters: from which should be instantiated first, to last KeyStoreConfig KeyStoreServiceConfig `toml:"keystore,omitempty"` DIDConfig DIDServiceConfig `toml:"did,omitempty"` @@ -94,14 +99,34 @@ type BaseServiceConfig struct { type KeyStoreServiceConfig struct { *BaseServiceConfig - // The URI for the master key. We use tink for envelope encryption as described in https://github.com/google/tink/blob/9bc2667963e20eb42611b7581e570f0dddf65a2b/docs/KEY-MANAGEMENT.md#key-management-with-tink - // When left empty, then a random key is generated and used. + // Configuration describing the encryption of the private keys that are under ssi-service's custody. + EncryptionConfig +} + +type EncryptionConfig struct { + DisableEncryption bool `toml:"disable_encryption"` + + // The URI for a master key. We use tink for envelope encryption as described in https://github.com/google/tink/blob/9bc2667963e20eb42611b7581e570f0dddf65a2b/docs/KEY-MANAGEMENT.md#key-management-with-tink + // When left empty and DisableEncryption is off, then a random key is generated and used. This random key is persisted unencrypted in the + // configured storage. Production deployments should never leave this field empty. MasterKeyURI string `toml:"master_key_uri"` - // Path for credentials. Required when using an external KMS. More info at https://github.com/google/tink/blob/9bc2667963e20eb42611b7581e570f0dddf65a2b/docs/KEY-MANAGEMENT.md#credentials + // Path for credentials. Required when MasterKeyURI is set. More info at https://github.com/google/tink/blob/9bc2667963e20eb42611b7581e570f0dddf65a2b/docs/KEY-MANAGEMENT.md#credentials KMSCredentialsPath string `toml:"kms_credentials_path"` } +func (e EncryptionConfig) GetMasterKeyURI() string { + return e.MasterKeyURI +} + +func (e EncryptionConfig) GetKMSCredentialsPath() string { + return e.KMSCredentialsPath +} + +func (e EncryptionConfig) EncryptionEnabled() bool { + return !e.DisableEncryption +} + func (k *KeyStoreServiceConfig) IsEmpty() bool { if k == nil { return true @@ -109,6 +134,18 @@ func (k *KeyStoreServiceConfig) IsEmpty() bool { return reflect.DeepEqual(k, &KeyStoreServiceConfig{}) } +func (k *KeyStoreServiceConfig) GetMasterKeyURI() string { + return k.MasterKeyURI +} + +func (k *KeyStoreServiceConfig) GetKMSCredentialsPath() string { + return k.KMSCredentialsPath +} + +func (k *KeyStoreServiceConfig) EncryptionEnabled() bool { + return !k.DisableEncryption +} + type DIDServiceConfig struct { *BaseServiceConfig Methods []string `toml:"methods"` @@ -211,7 +248,10 @@ func (p *WebhookServiceConfig) IsEmpty() bool { // LoadConfig attempts to load a TOML config file from the given path, and coerce it into our object model. // Before loading, defaults are applied on certain properties, which are overwritten if specified in the TOML file. -func LoadConfig(path string) (*SSIServiceConfig, error) { +func LoadConfig(path string, fs fs.FS) (*SSIServiceConfig, error) { + if fs == nil { + fs = os.DirFS(".") + } loadDefaultConfig, err := checkValidConfigPath(path) if err != nil { return nil, errors.Wrap(err, "validate config path") @@ -226,7 +266,7 @@ func LoadConfig(path string) (*SSIServiceConfig, error) { if loadDefaultConfig { defaultServicesConfig := getDefaultServicesConfig() config.Services = defaultServicesConfig - } else if err = loadTOMLConfig(path, &config); err != nil { + } else if err = loadTOMLConfig(path, &config, fs); err != nil { return nil, errors.Wrap(err, "load toml config") } @@ -234,9 +274,25 @@ func LoadConfig(path string) (*SSIServiceConfig, error) { return nil, errors.Wrap(err, "apply env variables") } + if err = validateConfig(&config); err != nil { + return nil, errors.Wrap(err, "validating config values") + } + return &config, nil } +func validateConfig(s *SSIServiceConfig) error { + if s.Server.Environment == EnvironmentProd { + if s.Services.KeyStoreConfig.DisableEncryption { + return errors.New("prod environment cannot disable key encryption") + } + if s.Services.AppLevelEncryptionConfiguration.DisableEncryption { + logrus.Warn("prod environment detected without app level encryption. This is strongly discouraged.") + } + } + return nil +} + func checkValidConfigPath(path string) (bool, error) { // no path, load default config defaultConfig := false @@ -314,9 +370,13 @@ func getDefaultServicesConfig() ServicesConfig { } } -func loadTOMLConfig(path string, config *SSIServiceConfig) error { +func loadTOMLConfig(path string, config *SSIServiceConfig, fs fs.FS) error { // load from TOML file - if _, err := toml.DecodeFile(path, &config); err != nil { + file, err := fs.Open(path) + if err != nil { + return errors.Wrapf(err, "opening path %s", path) + } + if _, err := toml.NewDecoder(file).Decode(&config); err != nil { return errors.Wrapf(err, "could not load config: %s", path) } diff --git a/config/config_test.go b/config/config_test.go index 12cefe6da..d629f9470 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,20 +1,32 @@ package config import ( + "embed" "testing" "github.com/stretchr/testify/assert" ) -func TestConfig(t *testing.T) { - config, err := LoadConfig(Filename) - assert.NoError(t, err) - assert.NotEmpty(t, config) +//go:embed testdata +var testdata embed.FS - assert.False(t, config.Server.ReadTimeout.String() == "") - assert.False(t, config.Server.WriteTimeout.String() == "") - assert.False(t, config.Server.ShutdownTimeout.String() == "") - assert.False(t, config.Server.APIHost == "") +func TestLoadConfig(t *testing.T) { + t.Run("returns no errors when passed in file", func(t *testing.T) { + config, err := LoadConfig(Filename, nil) + assert.NoError(t, err) + assert.NotEmpty(t, config) - assert.NotEmpty(t, config.Services.StorageProvider) + assert.False(t, config.Server.ReadTimeout.String() == "") + assert.False(t, config.Server.WriteTimeout.String() == "") + assert.False(t, config.Server.ShutdownTimeout.String() == "") + assert.False(t, config.Server.APIHost == "") + + assert.NotEmpty(t, config.Services.StorageProvider) + }) + + t.Run("returns errors when prod disables encryption", func(t *testing.T) { + _, err := LoadConfig("testdata/test1.toml", testdata) + assert.Error(t, err) + assert.ErrorContains(t, err, "prod environment cannot disable key encryption") + }) } diff --git a/config/dev.toml b/config/dev.toml index a3f41683f..70e87d59b 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -29,6 +29,10 @@ service_endpoint = "http://localhost:3000" # example bolt config with filepath option storage = "bolt" +[services.storage_encryption] +# encryption +disable_encryption = true + [[services.storage_option]] id = "boltdb-filepath-option" option = "bolt.db" diff --git a/config/prod.toml b/config/prod.toml index 025a003ee..dc11c4413 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -20,7 +20,12 @@ log_location = "log" log_level = "info" enable_schema_caching = true -enable_allow_all_cors = true +enable_allow_all_cors = false + +[services.storage_encryption] +# master_key_uri = "gcp-kms://projects/*/locations/*/keyRings/*/cryptoKeys/*" +# kms_credentials_path = "credentials.json" +disable_encryption = false # Storage Configuration [services] @@ -38,6 +43,7 @@ option = "password" # per-service configuration [services.keystore] name = "keystore" +disable_encryption = false # master_key_uri = "gcp-kms://projects/*/locations/*/keyRings/*/cryptoKeys/*" # kms_credentials_path = "credentials.json" diff --git a/config/test.toml b/config/test.toml index 141c6a4c6..02399cc05 100644 --- a/config/test.toml +++ b/config/test.toml @@ -25,6 +25,11 @@ log_level = "warn" enable_schema_caching = true enable_allow_all_cors = true +[services.storage_encryption] +# master_key_uri = "gcp-kms://projects/*/locations/*/keyRings/*/cryptoKeys/*" +# kms_credentials_path = "credentials.json" +disable_encryption = false + # Storage Configuration [services] service_endpoint = "http://localhost:8080" @@ -43,6 +48,7 @@ option = "password" name = "keystore" # master_key_uri = "gcp-kms://projects/*/locations/*/keyRings/*/cryptoKeys/*" # kms_credentials_path = "credentials.json" +disable_encryption = false [services.did] name = "did" diff --git a/config/testdata/test1.toml b/config/testdata/test1.toml new file mode 100644 index 000000000..f96f2191e --- /dev/null +++ b/config/testdata/test1.toml @@ -0,0 +1,6 @@ +[server] +env = "prod" # either 'dev', 'test', or 'prod' + +[services.keystore] +name = "keystore" +disable_encryption = true \ No newline at end of file diff --git a/doc/STORAGE.md b/doc/STORAGE.md index 7d539f51e..34420f9c4 100644 --- a/doc/STORAGE.md +++ b/doc/STORAGE.md @@ -70,4 +70,55 @@ For a working example, see this [dev.toml file](https://github.com/TBD54566975/s You need to implement the [ServiceStorage interface](../pkg/storage/storage.go), similar to how [Redis](../pkg/storage/redis.go) is implemented. For an example, see [this PR](https://github.com/TBD54566975/ssi-service/pull/590/files#diff-606358579107e7ad1221525001aed8c776a141d4cc5aab9ef7a3ddbcec10d9f9) -which introduces the SQL based implementation. \ No newline at end of file +which introduces the SQL based implementation. + +## Encryption + +SSI Service supports application level encryption of values before sending them to the configured KV store. Please note +that keys (i.e. the key of the KV store) are not currently encrypted. See the [Privacy Considerations](#privacy-considerations) for more information. +A MasterKey is used (a.k.a. a Data Encryption Key or DEK) to encrypt all data before it's sent to the configured storage. +The MasterKey can be stored in the configured storage system or in an external Key Management System (KMS) like GCP KMS or AWS KMS. +When storing locally, the key will be automatically generated if it doesn't exist already. + +**For production deployments, it is strongly recommended to store the MasterKey in an external KMS.** + +To use an external KMS: +1. Create a symmetric encryption key in your KMS. You MUST select the algorithm that uses AES-256 block cipher in Galois/Counter Mode (GCM). At the time of writing, this is the only algorithm supported by AWS and GCP. +2. Set the `master_key_uri` field of the `[services.storage_encryption]` section using the format described in [tink](https://github.com/google/tink/blob/9bc2667963e20eb42611b7581e570f0dddf65a2b/docs/KEY-MANAGEMENT.md#key-management-systems) + (we use the tink library under the hood). +3. Set the `kms_credentials_path` field of the `[services.storage_encryption]` section to point to your credentials file, according to [this section](https://github.com/google/tink/blob/9bc2667963e20eb42611b7581e570f0dddf65a2b/docs/KEY-MANAGEMENT.md#credentials). +4. Win! + +Below, there is an example snippet of what the TOML configuration should look like. +```toml +[services.storage_encryption] +# Make sure the following values are valid. +master_key_uri = "gcp-kms://projects/*/locations/*/keyRings/*/cryptoKeys/*" +kms_credentials_path = "credentials.json" +disable_encryption = false +``` + +Storing the MasterKey in the configured storage system is done with the following options in your TOML configuration. + +```toml +[services.storage_encryption] +# ensure that master_key_uri is NOT set. +disable_encryption = false +``` + +Disabling app level encryption is also possible using the following options in your TOML configuration: + +```toml +[services.storage_encryption] +# encryption +disable_encryption = true +``` + +### Privacy Considerations + +From the perspective of SSI-Service, all keys are stored in plaintext (this doesn't preclude configuring encryption at rest +in your deployment of the storage configuration). Making all keys readable by any actor may have an impact in your organization's +use cases around privacy. You should consider whether this is acceptable. Notably, a DID that was created by SSI Service +is stored as a key. This can fit some definition of PII, as it could be correlated to identify and individual. + +Encrypting keys is being considered in https://github.com/TBD54566975/ssi-service/issues/603. \ No newline at end of file diff --git a/pkg/encryption/encryption.go b/pkg/encryption/encryption.go new file mode 100644 index 000000000..64a0feba1 --- /dev/null +++ b/pkg/encryption/encryption.go @@ -0,0 +1,164 @@ +package encryption + +import ( + "context" + "strings" + + sdkutil "github.com/TBD54566975/ssi-sdk/util" + "github.com/google/tink/go/aead" + "github.com/google/tink/go/core/registry" + "github.com/google/tink/go/integration/awskms" + "github.com/google/tink/go/integration/gcpkms" + "github.com/google/tink/go/keyset" + "github.com/google/tink/go/tink" + "github.com/pkg/errors" + "github.com/tbd54566975/ssi-service/internal/util" + "google.golang.org/api/option" +) + +// Encrypter the interface for any encrypter implementation. +type Encrypter interface { + Encrypt(ctx context.Context, plaintext, contextData []byte) ([]byte, error) +} + +// Decrypter is the interface for any decrypter. May be AEAD or Hybrid. +type Decrypter interface { + // Decrypt decrypts ciphertext. The second parameter may be treated as associated data for AEAD (as abstracted in + // https://datatracker.ietf.org/doc/html/rfc5116), or as contextInfofor HPKE (https://www.rfc-editor.org/rfc/rfc9180.html) + Decrypt(ctx context.Context, ciphertext, contextInfo []byte) ([]byte, error) +} + +type KeyResolver func(ctx context.Context) ([]byte, error) + +type XChaCha20Poly1305Encrypter struct { + keyResolver KeyResolver +} + +func NewXChaCha20Poly1305EncrypterWithKey(key []byte) *XChaCha20Poly1305Encrypter { + return &XChaCha20Poly1305Encrypter{func(ctx context.Context) ([]byte, error) { + return key, nil + }} +} + +func NewXChaCha20Poly1305EncrypterWithKeyResolver(resolver KeyResolver) *XChaCha20Poly1305Encrypter { + return &XChaCha20Poly1305Encrypter{resolver} +} + +func (k XChaCha20Poly1305Encrypter) Encrypt(ctx context.Context, plaintext, _ []byte) ([]byte, error) { + // encrypt key before storing + key, err := k.keyResolver(ctx) + if err != nil { + return nil, errors.Wrap(err, "resolving key") + } + encryptedKey, err := util.XChaCha20Poly1305Encrypt(key, plaintext) + if err != nil { + return nil, sdkutil.LoggingErrorMsgf(err, "could not encrypt key") + } + return encryptedKey, nil +} + +func (k XChaCha20Poly1305Encrypter) Decrypt(ctx context.Context, ciphertext, _ []byte) ([]byte, error) { + if ciphertext == nil { + return nil, nil + } + + key, err := k.keyResolver(ctx) + if err != nil { + return nil, errors.Wrap(err, "resolving key") + } + // decrypt key before unmarshaling + decryptedKey, err := util.XChaCha20Poly1305Decrypt(key, ciphertext) + if err != nil { + return nil, sdkutil.LoggingErrorMsgf(err, "could not decrypt key") + } + + return decryptedKey, nil +} + +var _ Decrypter = (*XChaCha20Poly1305Encrypter)(nil) +var _ Encrypter = (*XChaCha20Poly1305Encrypter)(nil) + +type noopDecrypter struct{} + +func (n noopDecrypter) Decrypt(_ context.Context, ciphertext, _ []byte) ([]byte, error) { + return ciphertext, nil +} + +type noopEncrypter struct{} + +func (n noopEncrypter) Encrypt(_ context.Context, plaintext, _ []byte) ([]byte, error) { + return plaintext, nil +} + +var _ Decrypter = (*noopDecrypter)(nil) +var _ Encrypter = (*noopEncrypter)(nil) + +var ( + NoopDecrypter = noopDecrypter{} + NoopEncrypter = noopEncrypter{} +) + +type wrappedEncrypter struct { + tink.AEAD +} + +func (w wrappedEncrypter) Encrypt(_ context.Context, plaintext, contextData []byte) ([]byte, error) { + return w.AEAD.Encrypt(plaintext, contextData) +} + +var _ Encrypter = (*wrappedEncrypter)(nil) + +type wrappedDecrypter struct { + tink.AEAD +} + +func (w wrappedDecrypter) Decrypt(_ context.Context, ciphertext, contextInfo []byte) ([]byte, error) { + return w.AEAD.Decrypt(ciphertext, contextInfo) +} + +var _ Decrypter = (*wrappedDecrypter)(nil) + +const ( + gcpKMSScheme = "gcp-kms" + awsKMSScheme = "aws-kms" +) + +type ExternalEncryptionConfig interface { + GetMasterKeyURI() string + GetKMSCredentialsPath() string + EncryptionEnabled() bool +} + +func NewExternalEncrypter(ctx context.Context, cfg ExternalEncryptionConfig) (Encrypter, Decrypter, error) { + if !cfg.EncryptionEnabled() { + return NoopEncrypter, NoopDecrypter, nil + } + var client registry.KMSClient + var err error + switch { + case strings.HasPrefix(cfg.GetMasterKeyURI(), gcpKMSScheme): + client, err = gcpkms.NewClientWithOptions(ctx, cfg.GetMasterKeyURI(), option.WithCredentialsFile(cfg.GetKMSCredentialsPath())) + if err != nil { + return nil, nil, errors.Wrap(err, "creating gcp kms client") + } + case strings.HasPrefix(cfg.GetMasterKeyURI(), awsKMSScheme): + client, err = awskms.NewClientWithCredentials(cfg.GetMasterKeyURI(), cfg.GetKMSCredentialsPath()) + if err != nil { + return nil, nil, errors.Wrap(err, "creating aws kms client") + } + default: + return nil, nil, errors.Errorf("master_key_uri value %q is not supported", cfg.GetMasterKeyURI()) + } + // TODO: move client registration to be per request (i.e. when things are encrypted/decrypted). https://github.com/TBD54566975/ssi-service/issues/598 + registry.RegisterKMSClient(client) + dek := aead.AES256GCMKeyTemplate() + kh, err := keyset.NewHandle(aead.KMSEnvelopeAEADKeyTemplate(cfg.GetMasterKeyURI(), dek)) + if err != nil { + return nil, nil, errors.Wrap(err, "creating keyset handle") + } + a, err := aead.New(kh) + if err != nil { + return nil, nil, errors.Wrap(err, "creating aead from key handl") + } + return wrappedEncrypter{a}, wrappedDecrypter{a}, nil +} diff --git a/pkg/encryption/encryption_test.go b/pkg/encryption/encryption_test.go new file mode 100644 index 000000000..ad1eddbe4 --- /dev/null +++ b/pkg/encryption/encryption_test.go @@ -0,0 +1,93 @@ +package encryption + +import ( + "context" + "testing" + + "github.com/TBD54566975/ssi-sdk/crypto" + sdkutil "github.com/TBD54566975/ssi-sdk/util" + "github.com/mr-tron/base58" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/tbd54566975/ssi-service/internal/util" + "golang.org/x/crypto/chacha20poly1305" +) + +func createServiceKey() (key string, err error) { + keyBytes, err := util.GenerateSalt(chacha20poly1305.KeySize) + if err != nil { + err = errors.Wrap(err, "generating bytes for service key") + return "", sdkutil.LoggingError(err) + } + + key = base58.Encode(keyBytes) + return +} + +func TestEncryptDecryptAllKeyTypes(t *testing.T) { + serviceKeyEncoded, err := createServiceKey() + assert.NoError(t, err) + serviceKey, err := base58.Decode(serviceKeyEncoded) + assert.NoError(t, err) + assert.NotEmpty(t, serviceKey) + encrypter := NewXChaCha20Poly1305EncrypterWithKeyResolver(func(ctx context.Context) ([]byte, error) { + return serviceKey, nil + }) + + tests := []struct { + kt crypto.KeyType + }{ + { + kt: crypto.Ed25519, + }, + { + kt: crypto.X25519, + }, + { + kt: crypto.SECP256k1, + }, + { + kt: crypto.P224, + }, + { + kt: crypto.P256, + }, + { + kt: crypto.P384, + }, + { + kt: crypto.P521, + }, + { + kt: crypto.RSA, + }, + } + for _, test := range tests { + t.Run(string(test.kt), func(t *testing.T) { + // generate a new key based on the given key type + _, privKey, err := crypto.GenerateKeyByKeyType(test.kt) + assert.NoError(t, err) + assert.NotEmpty(t, privKey) + + // serialize the key before encryption + privKeyBytes, err := crypto.PrivKeyToBytes(privKey) + assert.NoError(t, err) + assert.NotEmpty(t, privKeyBytes) + + // encrypt the serviceKey using our service serviceKey + encryptedKey, err := encrypter.Encrypt(context.Background(), privKeyBytes, nil) + assert.NoError(t, err) + assert.NotEmpty(t, encryptedKey) + + // decrypt the serviceKey using our service serviceKey + decryptedKey, err := encrypter.Decrypt(context.Background(), encryptedKey, nil) + assert.NoError(t, err) + assert.NotEmpty(t, decryptedKey) + + // reconstruct the key from its serialized form + privKeyReconstructed, err := crypto.BytesToPrivKey(decryptedKey, test.kt) + assert.NoError(t, err) + assert.EqualValues(t, privKey, privKeyReconstructed) + }) + } +} diff --git a/pkg/server/router/did_test.go b/pkg/server/router/did_test.go index 8952ad9f5..97d25c89d 100644 --- a/pkg/server/router/did_test.go +++ b/pkg/server/router/did_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/tbd54566975/ssi-service/pkg/service/common" "github.com/tbd54566975/ssi-service/pkg/testutil" + "gopkg.in/h2non/gock.v1" "github.com/tbd54566975/ssi-service/config" "github.com/tbd54566975/ssi-service/pkg/service/did" @@ -189,12 +190,22 @@ func TestDIDRouter(t *testing.T) { assert.ElementsMatch(tt, supported.Methods, []didsdk.Method{didsdk.KeyMethod, didsdk.WebMethod}) + gock.Off() + gock.New("https://example.com"). + Get("/.well-known/did.json"). + Reply(200). + BodyString("") // bad key type createOpts := did.CreateWebDIDOptions{DIDWebID: "did:web:example.com"} _, err = didService.CreateDIDByMethod(context.Background(), did.CreateDIDRequest{Method: didsdk.WebMethod, KeyType: "bad", Options: createOpts}) assert.Error(tt, err) assert.Contains(tt, err.Error(), "could not generate key for did:web") + gock.Off() + gock.New("https://example.com"). + Get("/.well-known/did.json"). + Reply(200). + BodyString("") // good key type createDIDResponse, err := didService.CreateDIDByMethod(context.Background(), did.CreateDIDRequest{Method: didsdk.WebMethod, KeyType: crypto.Ed25519, Options: createOpts}) assert.NoError(tt, err) @@ -211,6 +222,11 @@ func TestDIDRouter(t *testing.T) { // make sure it's the same value assert.Equal(tt, createDIDResponse.DID.ID, getDIDResponse.DID.ID) + gock.Off() + gock.New("https://tbd.website"). + Get("/.well-known/did.json"). + Reply(200). + BodyString("") // create a second DID createOpts = did.CreateWebDIDOptions{DIDWebID: "did:web:tbd.website"} createDIDResponse2, err := didService.CreateDIDByMethod(context.Background(), did.CreateDIDRequest{Method: didsdk.WebMethod, KeyType: crypto.Ed25519, Options: createOpts}) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 2b1e89c2c..dca596fad 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -11,7 +11,6 @@ import ( "github.com/TBD54566975/ssi-sdk/credential/exchange" "github.com/gin-gonic/gin" - "github.com/tbd54566975/ssi-service/internal/util" "github.com/tbd54566975/ssi-service/pkg/service/issuance" "github.com/tbd54566975/ssi-service/pkg/service/manifest/model" @@ -49,7 +48,7 @@ func TestMain(t *testing.M) { func TestHealthCheckAPI(t *testing.T) { shutdown := make(chan os.Signal, 1) - serviceConfig, err := config.LoadConfig("") + serviceConfig, err := config.LoadConfig("", nil) assert.NoError(t, err) server, err := NewSSIServer(shutdown, *serviceConfig) assert.NoError(t, err) @@ -77,7 +76,7 @@ func TestReadinessAPI(t *testing.T) { }) shutdown := make(chan os.Signal, 1) - serviceConfig, err := config.LoadConfig("") + serviceConfig, err := config.LoadConfig("", nil) assert.NoError(t, err) serviceConfig.Services.StorageOptions = []storage.Option{ { @@ -230,8 +229,9 @@ func testKeyStoreService(t *testing.T, db storage.ServiceStorage) (*keystore.Ser } // create a keystore service - require.NoError(t, keystore.EnsureServiceKeyExists(serviceConfig, db)) - factory := keystore.NewKeyStoreServiceFactory(serviceConfig, db) + encrypter, decrypter, err := keystore.NewServiceEncryption(db, serviceConfig.EncryptionConfig, keystore.ServiceKeyEncryptionKey) + require.NoError(t, err) + factory := keystore.NewKeyStoreServiceFactory(serviceConfig, db, encrypter, decrypter) keystoreService, err := factory(db) require.NoError(t, err) require.NotEmpty(t, keystoreService) diff --git a/pkg/server/server_webhook_test.go b/pkg/server/server_webhook_test.go index ec6f15300..09d7229c3 100644 --- a/pkg/server/server_webhook_test.go +++ b/pkg/server/server_webhook_test.go @@ -48,7 +48,7 @@ func TestSimpleWebhook(t *testing.T) { defer testServer.Close() shutdown := make(chan os.Signal, 1) - serviceConfig, err := config.LoadConfig("") + serviceConfig, err := config.LoadConfig("", nil) assert.NoError(t, err) serviceConfig.Server.APIHost = "0.0.0.0:" + freePort() diff --git a/pkg/service/did/web.go b/pkg/service/did/web.go index 11601c995..158b207e8 100644 --- a/pkg/service/did/web.go +++ b/pkg/service/did/web.go @@ -65,7 +65,7 @@ func (h *webHandler) CreateDID(ctx context.Context, request CreateDIDRequest) (* err := didWeb.Validate(ctx) if err != nil { - return nil, errors.Wrap(err, "could not validate did:web") + return nil, errors.Wrap(err, "could not validate if did:web exists externally") } exists, err := h.storage.DIDExists(ctx, opts.DIDWebID) diff --git a/pkg/service/keystore/service.go b/pkg/service/keystore/service.go index a773de0f2..cb8c4b5c2 100644 --- a/pkg/service/keystore/service.go +++ b/pkg/service/keystore/service.go @@ -10,14 +10,13 @@ import ( "github.com/mr-tron/base58" "github.com/pkg/errors" "github.com/sirupsen/logrus" - "golang.org/x/crypto/chacha20poly1305" - - "github.com/tbd54566975/ssi-service/internal/keyaccess" - "github.com/tbd54566975/ssi-service/config" + "github.com/tbd54566975/ssi-service/internal/keyaccess" "github.com/tbd54566975/ssi-service/internal/util" + "github.com/tbd54566975/ssi-service/pkg/encryption" "github.com/tbd54566975/ssi-service/pkg/service/framework" "github.com/tbd54566975/ssi-service/pkg/storage" + "golang.org/x/crypto/chacha20poly1305" ) type ServiceFactory func(storage.Tx) (*Service, error) @@ -50,20 +49,17 @@ func (s Service) Config() config.KeyStoreServiceConfig { } func NewKeyStoreService(config config.KeyStoreServiceConfig, s storage.ServiceStorage) (*Service, error) { - if err := EnsureServiceKeyExists(config, s); err != nil { - return nil, sdkutil.LoggingErrorMsg(err, "initializing keystore") + encrypter, decrypter, err := NewServiceEncryption(s, config.EncryptionConfig, ServiceKeyEncryptionKey) + if err != nil { + return nil, errors.Wrap(err, "creating new encryption") } - factory := NewKeyStoreServiceFactory(config, s) + + factory := NewKeyStoreServiceFactory(config, s, encrypter, decrypter) return factory(s) } -func NewKeyStoreServiceFactory(config config.KeyStoreServiceConfig, s storage.ServiceStorage) ServiceFactory { +func NewKeyStoreServiceFactory(config config.KeyStoreServiceConfig, s storage.ServiceStorage, encrypter encryption.Encrypter, decrypter encryption.Decrypter) ServiceFactory { return func(tx storage.Tx) (*Service, error) { - encrypter, decrypter, err := newEncryption(s, config) - if err != nil { - return nil, errors.Wrap(err, "creating new encryption") - } - // Next, instantiate the key storage keyStoreStorage, err := NewKeyStoreStorage(s, encrypter, decrypter, tx) if err != nil { @@ -180,24 +176,6 @@ func GenerateServiceKey() (key string, err error) { return } -// EncryptKey encrypts another key with the service key using xchacha20-poly1305 -func EncryptKey(serviceKey, key []byte) ([]byte, error) { - encryptedKey, err := util.XChaCha20Poly1305Encrypt(serviceKey, key) - if err != nil { - return nil, errors.Wrap(err, "encrypting key with service key") - } - return encryptedKey, nil -} - -// DecryptKey encrypts another key with the service key using xchacha20-poly1305 -func DecryptKey(serviceKey, encryptedKey []byte) ([]byte, error) { - decryptedKey, err := util.XChaCha20Poly1305Decrypt(serviceKey, encryptedKey) - if err != nil { - return nil, errors.Wrap(err, "decrypting key with service key") - } - return decryptedKey, nil -} - // Sign fetches the key in the store, and uses it to sign data. Data should be json or json-serializable. func (s Service) Sign(ctx context.Context, keyID string, data any) (*keyaccess.JWT, error) { gotKey, err := s.GetKey(ctx, GetKeyRequest{ID: keyID}) diff --git a/pkg/service/keystore/service_test.go b/pkg/service/keystore/service_test.go index 16c28ba58..7adf5eafe 100644 --- a/pkg/service/keystore/service_test.go +++ b/pkg/service/keystore/service_test.go @@ -12,7 +12,6 @@ import ( "github.com/mr-tron/base58" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/tbd54566975/ssi-service/config" "github.com/tbd54566975/ssi-service/pkg/storage" ) @@ -23,71 +22,6 @@ func TestGenerateServiceKey(t *testing.T) { assert.NotEmpty(t, key) } -func TestEncryptDecryptAllKeyTypes(t *testing.T) { - serviceKeyEncoded, err := GenerateServiceKey() - assert.NoError(t, err) - serviceKey, err := base58.Decode(serviceKeyEncoded) - assert.NoError(t, err) - assert.NotEmpty(t, serviceKey) - - tests := []struct { - kt crypto.KeyType - }{ - { - kt: crypto.Ed25519, - }, - { - kt: crypto.X25519, - }, - { - kt: crypto.SECP256k1, - }, - { - kt: crypto.P224, - }, - { - kt: crypto.P256, - }, - { - kt: crypto.P384, - }, - { - kt: crypto.P521, - }, - { - kt: crypto.RSA, - }, - } - for _, test := range tests { - t.Run(string(test.kt), func(t *testing.T) { - // generate a new key based on the given key type - _, privKey, err := crypto.GenerateKeyByKeyType(test.kt) - assert.NoError(t, err) - assert.NotEmpty(t, privKey) - - // serialize the key before encryption - privKeyBytes, err := crypto.PrivKeyToBytes(privKey) - assert.NoError(t, err) - assert.NotEmpty(t, privKeyBytes) - - // encrypt the serviceKey using our service serviceKey - encryptedKey, err := EncryptKey(serviceKey, privKeyBytes) - assert.NoError(t, err) - assert.NotEmpty(t, encryptedKey) - - // decrypt the serviceKey using our service serviceKey - decryptedKey, err := DecryptKey(serviceKey, encryptedKey) - assert.NoError(t, err) - assert.NotEmpty(t, decryptedKey) - - // reconstruct the key from its serialized form - privKeyReconstructed, err := crypto.BytesToPrivKey(decryptedKey, test.kt) - assert.NoError(t, err) - assert.EqualValues(t, privKey, privKeyReconstructed) - }) - } -} - func TestStoreAndGetKey(t *testing.T) { keyStore, err := createKeyStoreService(t) assert.NoError(t, err) diff --git a/pkg/service/keystore/storage.go b/pkg/service/keystore/storage.go index ad25ba1d3..097c893ef 100644 --- a/pkg/service/keystore/storage.go +++ b/pkg/service/keystore/storage.go @@ -2,7 +2,6 @@ package keystore import ( "context" - "strings" "time" "github.com/TBD54566975/ssi-sdk/crypto" @@ -10,19 +9,9 @@ import ( sdkutil "github.com/TBD54566975/ssi-sdk/util" "github.com/benbjohnson/clock" "github.com/goccy/go-json" - "github.com/google/tink/go/aead" - "github.com/google/tink/go/core/registry" - "github.com/google/tink/go/integration/awskms" - "github.com/google/tink/go/integration/gcpkms" - "github.com/google/tink/go/keyset" - "github.com/google/tink/go/tink" "github.com/mr-tron/base58" "github.com/pkg/errors" - "google.golang.org/api/option" - - "github.com/tbd54566975/ssi-service/config" - - "github.com/tbd54566975/ssi-service/internal/util" + "github.com/tbd54566975/ssi-service/pkg/encryption" "github.com/tbd54566975/ssi-service/pkg/storage" ) @@ -57,24 +46,26 @@ const ( namespace = "keystore" serviceInternalSuffix = "service-internal" publicNamespaceSuffix = "public-keys" - skKey = "ssi-service-key" keyNotFoundErrMsg = "key not found" + + ServiceKeyEncryptionKey = "ssi-service-key-encryption-key" + ServiceDataEncryptionKey = "ssi-service-data-key" ) var ( - serviceNamespace = storage.Join(namespace, serviceInternalSuffix) - publicKeyNamespace = storage.Join(namespace, publicNamespaceSuffix) + serviceInternalNamespace = storage.Join(namespace, serviceInternalSuffix) + publicKeyNamespace = storage.Join(namespace, publicNamespaceSuffix) ) type Storage struct { db storage.ServiceStorage tx storage.Tx - encrypter Encrypter - decrypter Decrypter + encrypter encryption.Encrypter + decrypter encryption.Decrypter Clock clock.Clock } -func NewKeyStoreStorage(db storage.ServiceStorage, e Encrypter, d Decrypter, writer storage.Tx) (*Storage, error) { +func NewKeyStoreStorage(db storage.ServiceStorage, e encryption.Encrypter, d encryption.Decrypter, writer storage.Tx) (*Storage, error) { s := &Storage{ db: db, encrypter: e, @@ -85,41 +76,26 @@ func NewKeyStoreStorage(db storage.ServiceStorage, e Encrypter, d Decrypter, wri if writer != nil { s.tx = writer } + if s.encrypter == nil { + s.encrypter = encryption.NoopEncrypter + } + if s.decrypter == nil { + s.decrypter = encryption.NoopDecrypter + } return s, nil } -type wrappedEncrypter struct { - tink.AEAD -} - -func (w wrappedEncrypter) Encrypt(_ context.Context, plaintext, contextData []byte) ([]byte, error) { - return w.AEAD.Encrypt(plaintext, contextData) -} - -type wrappedDecrypter struct { - tink.AEAD -} - -func (w wrappedDecrypter) Decrypt(_ context.Context, ciphertext, contextInfo []byte) ([]byte, error) { - return w.AEAD.Decrypt(ciphertext, contextInfo) -} - -const ( - gcpKMSScheme = "gcp-kms" - awsKMSScheme = "aws-kms" -) - -// EnsureServiceKeyExists makes sure that the service key that will be used for encryption exists. This function is +// ensureEncryptionKeyExists makes sure that the service key that will be used for encryption exists. This function is // idempotent, so that multiple instances of ssi-service can call it on boot. -func EnsureServiceKeyExists(config config.KeyStoreServiceConfig, provider storage.ServiceStorage) error { - if config.MasterKeyURI != "" { +func ensureEncryptionKeyExists(config encryption.ExternalEncryptionConfig, provider storage.ServiceStorage, namespace, encryptionMaterialKey string) error { + if config.GetMasterKeyURI() != "" { return nil } watchKeys := []storage.WatchKey{{ Namespace: namespace, - Key: skKey, + Key: encryptionMaterialKey, }} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -127,7 +103,7 @@ func EnsureServiceKeyExists(config config.KeyStoreServiceConfig, provider storag _, err := provider.Execute(ctx, func(ctx context.Context, tx storage.Tx) (any, error) { // Create the key only if it doesn't already exist. - gotKey, err := getServiceKey(ctx, provider) + gotKey, err := getServiceKey(ctx, provider, namespace, encryptionMaterialKey) if gotKey == nil && err.Error() == keyNotFoundErrMsg { serviceKey, err := GenerateServiceKey() if err != nil { @@ -137,7 +113,7 @@ func EnsureServiceKeyExists(config config.KeyStoreServiceConfig, provider storag key := ServiceKey{ Base58Key: serviceKey, } - if err := storeServiceKey(ctx, tx, key); err != nil { + if err := storeServiceKey(ctx, tx, key, namespace, encryptionMaterialKey); err != nil { return nil, err } return nil, nil @@ -150,62 +126,41 @@ func EnsureServiceKeyExists(config config.KeyStoreServiceConfig, provider storag return nil } -// newEncryption creates a pair of Encrypter and Decrypter. The service key must have been created before this function -// is called. EnsureServiceKeyExists can be used to make sure the service key exists. -func newEncryption(db storage.ServiceStorage, cfg config.KeyStoreServiceConfig) (Encrypter, Decrypter, error) { - if len(cfg.MasterKeyURI) != 0 { +// NewServiceEncryption creates a pair of Encrypter and Decrypter with the given configuration. +func NewServiceEncryption(db storage.ServiceStorage, cfg encryption.ExternalEncryptionConfig, key string) (encryption.Encrypter, encryption.Decrypter, error) { + if !cfg.EncryptionEnabled() { + return nil, nil, nil + } + + if len(cfg.GetMasterKeyURI()) != 0 { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return NewExternalEncrypter(ctx, cfg) + return encryption.NewExternalEncrypter(ctx, cfg) } - return &encrypter{db}, &decrypter{db}, nil -} - -func NewExternalEncrypter(ctx context.Context, cfg config.KeyStoreServiceConfig) (Encrypter, Decrypter, error) { - var client registry.KMSClient - var err error - switch { - case strings.HasPrefix(cfg.MasterKeyURI, gcpKMSScheme): - client, err = gcpkms.NewClientWithOptions(ctx, cfg.MasterKeyURI, option.WithCredentialsFile(cfg.KMSCredentialsPath)) - if err != nil { - return nil, nil, errors.Wrap(err, "creating gcp kms client") - } - case strings.HasPrefix(cfg.MasterKeyURI, awsKMSScheme): - client, err = awskms.NewClientWithCredentials(cfg.MasterKeyURI, cfg.KMSCredentialsPath) - if err != nil { - return nil, nil, errors.Wrap(err, "creating aws kms client") - } - default: - return nil, nil, errors.Errorf("master_key_uri value %q is not supported", cfg.MasterKeyURI) - } - registry.RegisterKMSClient(client) - dek := aead.AES256GCMKeyTemplate() - kh, err := keyset.NewHandle(aead.KMSEnvelopeAEADKeyTemplate(cfg.MasterKeyURI, dek)) - if err != nil { - return nil, nil, errors.Wrap(err, "creating keyset handle") - } - a, err := aead.New(kh) - if err != nil { - return nil, nil, errors.Wrap(err, "creating aead from key handl") + if err := ensureEncryptionKeyExists(cfg, db, serviceInternalNamespace, key); err != nil { + return nil, nil, errors.Wrap(err, "ensuring that the encryption key exists") } - return wrappedEncrypter{a}, wrappedDecrypter{a}, nil + encSuite := encryption.NewXChaCha20Poly1305EncrypterWithKeyResolver(func(ctx context.Context) ([]byte, error) { + return getServiceKey(ctx, db, serviceInternalNamespace, key) + }) + return encSuite, encSuite, nil } // TODO(gabe): support more robust service key operations, including rotation, and caching -func storeServiceKey(ctx context.Context, tx storage.Tx, key ServiceKey) error { +func storeServiceKey(ctx context.Context, tx storage.Tx, key ServiceKey, namespace string, skKey string) error { keyBytes, err := json.Marshal(key) if err != nil { return sdkutil.LoggingErrorMsg(err, "could not marshal service key") } - if err = tx.Write(ctx, serviceNamespace, skKey, keyBytes); err != nil { + if err = tx.Write(ctx, namespace, skKey, keyBytes); err != nil { return sdkutil.LoggingErrorMsg(err, "could store marshal service key") } return nil } -func getServiceKey(ctx context.Context, db storage.ServiceStorage) ([]byte, error) { - storedKeyBytes, err := db.Read(ctx, serviceNamespace, skKey) +func getServiceKey(ctx context.Context, db storage.ServiceStorage, namespace, skKey string) ([]byte, error) { + storedKeyBytes, err := db.Read(ctx, namespace, skKey) if err != nil { return nil, sdkutil.LoggingErrorMsg(err, "could not get service key") } @@ -226,56 +181,6 @@ func getServiceKey(ctx context.Context, db storage.ServiceStorage) ([]byte, erro return keyBytes, nil } -// Encrypter the interface for any encrypter implementation. -type Encrypter interface { - Encrypt(ctx context.Context, plaintext, contextData []byte) ([]byte, error) -} - -// Decrypter is the interface for any decrypter. May be AEAD or Hybrid. -type Decrypter interface { - // Decrypt decrypts ciphertext. The second parameter may be treated as associated data for AEAD (as abstracted in - // https://datatracker.ietf.org/doc/html/rfc5116), or as contextInfofor HPKE (https://www.rfc-editor.org/rfc/rfc9180.html) - Decrypt(ctx context.Context, ciphertext, contextInfo []byte) ([]byte, error) -} - -type encrypter struct { - db storage.ServiceStorage -} - -func (e encrypter) Encrypt(ctx context.Context, plaintext, _ []byte) ([]byte, error) { - // get service key - serviceKey, err := getServiceKey(ctx, e.db) - if err != nil { - return nil, err - } - // encrypt key before storing - encryptedKey, err := util.XChaCha20Poly1305Encrypt(serviceKey, plaintext) - if err != nil { - return nil, sdkutil.LoggingErrorMsgf(err, "could not encrypt key") - } - return encryptedKey, nil -} - -type decrypter struct { - db storage.ServiceStorage -} - -func (d decrypter) Decrypt(ctx context.Context, ciphertext, _ []byte) ([]byte, error) { - // get service key - serviceKey, err := getServiceKey(ctx, d.db) - if err != nil { - return nil, err - } - - // decrypt key before unmarshaling - decryptedKey, err := util.XChaCha20Poly1305Decrypt(serviceKey, ciphertext) - if err != nil { - return nil, sdkutil.LoggingErrorMsgf(err, "could not decrypt key") - } - - return decryptedKey, nil -} - func (kss *Storage) StoreKey(ctx context.Context, key StoredKey) error { // TODO(gabe): conflict checking on key id id := key.ID diff --git a/pkg/service/manifest/storage/storage.go b/pkg/service/manifest/storage/storage.go index 534f9e30a..e4a4050d5 100644 --- a/pkg/service/manifest/storage/storage.go +++ b/pkg/service/manifest/storage/storage.go @@ -246,7 +246,7 @@ func (ms *Storage) StoreReviewApplication(ctx context.Context, applicationID str if approved { m["status"] = opsubmission.StatusApproved } - if _, err := ms.db.Update(ctx, credential.ApplicationNamespace, applicationID, m); err != nil { + if _, err := storage.Update(ctx, ms.db, credential.ApplicationNamespace, applicationID, m); err != nil { return nil, nil, errors.Wrap(err, "updating application") } @@ -254,7 +254,7 @@ func (ms *Storage) StoreReviewApplication(ctx context.Context, applicationID str return nil, nil, errors.Wrap(err, "storing credential response") } - responseData, operationData, err := ms.db.UpdateValueAndOperation(ctx, responseNamespace, response.ID, + responseData, operationData, err := storage.UpdateValueAndOperation(ctx, ms.db, responseNamespace, response.ID, storage.NewUpdater(m), namespace.FromID(opID), opID, opsubmission.OperationUpdater{UpdaterWithMap: storage.NewUpdater(map[string]any{"done": true})}) if err != nil { diff --git a/pkg/service/operation/storage.go b/pkg/service/operation/storage.go index a49fd5381..c9319eaa7 100644 --- a/pkg/service/operation/storage.go +++ b/pkg/service/operation/storage.go @@ -30,8 +30,9 @@ func (s Storage) CancelOperation(ctx context.Context, id string) (*opstorage.Sto var err error switch { case strings.HasPrefix(id, submission.ParentResource): - _, opData, err = s.db.UpdateValueAndOperation( + _, opData, err = storage.UpdateValueAndOperation( ctx, + s.db, submission.Namespace, opstorage.StatusObjectID(id), storage.NewUpdater(map[string]any{ "status": submission.StatusCancelled, "reason": cancelledReason, @@ -42,8 +43,9 @@ func (s Storage) CancelOperation(ctx context.Context, id string) (*opstorage.Sto }), }) case strings.HasPrefix(id, credential.ParentResource): - _, opData, err = s.db.UpdateValueAndOperation( + _, opData, err = storage.UpdateValueAndOperation( ctx, + s.db, credential.ApplicationNamespace, opstorage.StatusObjectID(id), storage.NewUpdater(map[string]any{ "status": credential.StatusCancelled, "reason": cancelledReason, diff --git a/pkg/service/presentation/storage.go b/pkg/service/presentation/storage.go index 009d33760..431154d15 100644 --- a/pkg/service/presentation/storage.go +++ b/pkg/service/presentation/storage.go @@ -35,8 +35,9 @@ func (ps *Storage) UpdateSubmission(ctx context.Context, id string, approved boo if approved { m["status"] = opsubmission.StatusApproved } - submissionData, operationData, err := ps.db.UpdateValueAndOperation( + submissionData, operationData, err := storage.UpdateValueAndOperation( ctx, + ps.db, opsubmission.Namespace, id, storage.NewUpdater(m), diff --git a/pkg/service/service.go b/pkg/service/service.go index a93e61e2b..d3962bc36 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -4,6 +4,7 @@ import ( "fmt" sdkutil "github.com/TBD54566975/ssi-sdk/util" + "github.com/pkg/errors" "github.com/tbd54566975/ssi-service/config" "github.com/tbd54566975/ssi-service/pkg/service/credential" "github.com/tbd54566975/ssi-service/pkg/service/did" @@ -81,20 +82,30 @@ func validateServiceConfig(config config.ServicesConfig) error { // instantiateServices begins all instantiates and their dependencies func instantiateServices(config config.ServicesConfig) (*SSIService, error) { - storageProvider, err := storage.NewStorage(storage.Type(config.StorageProvider), config.StorageOptions...) + unencryptedStorageProvider, err := storage.NewStorage(storage.Type(config.StorageProvider), config.StorageOptions...) if err != nil { return nil, sdkutil.LoggingErrorMsgf(err, "could not instantiate storage provider: %s", config.StorageProvider) } + storageEncrypter, storageDecrypter, err := keystore.NewServiceEncryption(unencryptedStorageProvider, config.AppLevelEncryptionConfiguration, keystore.ServiceDataEncryptionKey) + if err != nil { + return nil, errors.Wrap(err, "creating app level encrypter") + } + storageProvider := unencryptedStorageProvider + if storageEncrypter != nil && storageDecrypter != nil { + storageProvider = storage.NewEncryptedWrapper(unencryptedStorageProvider, storageEncrypter, storageDecrypter) + } + webhookService, err := webhook.NewWebhookService(config.WebhookConfig, storageProvider) if err != nil { return nil, sdkutil.LoggingErrorMsg(err, "could not instantiate the webhook service") } - if err := keystore.EnsureServiceKeyExists(config.KeyStoreConfig, storageProvider); err != nil { - return nil, sdkutil.LoggingErrorMsg(err, "could not ensure the service key exists") + keyEncrypter, keyDecrypter, err := keystore.NewServiceEncryption(unencryptedStorageProvider, config.KeyStoreConfig.EncryptionConfig, keystore.ServiceKeyEncryptionKey) + if err != nil { + return nil, errors.Wrap(err, "creating keystore encrypter") } - keyStoreServiceFactory := keystore.NewKeyStoreServiceFactory(config.KeyStoreConfig, storageProvider) + keyStoreServiceFactory := keystore.NewKeyStoreServiceFactory(config.KeyStoreConfig, storageProvider, keyEncrypter, keyDecrypter) if err != nil { return nil, sdkutil.LoggingErrorMsg(err, "could not instantiate the keystore service factory") } diff --git a/pkg/storage/bolt.go b/pkg/storage/bolt.go index b7705a45e..2b964432a 100644 --- a/pkg/storage/bolt.go +++ b/pkg/storage/bolt.go @@ -355,55 +355,3 @@ type ResponseSettingUpdater interface { // SetUpdatedResponse sets the response that the Update method will later use to modify the data. SetUpdatedResponse([]byte) } - -// UpdateValueAndOperation updates the value stored in (namespace,key) with the new values specified in the map. -// The updated value is then stored inside the (opNamespace, opKey), and the "done" value is set to true. -func (b *BoltDB) UpdateValueAndOperation(_ context.Context, namespace, key string, updater Updater, opNamespace, opKey string, opUpdater ResponseSettingUpdater) (first, op []byte, err error) { - err = b.db.Update(func(tx *bolt.Tx) error { - if err = updateTxFn(namespace, key, updater, &first)(tx); err != nil { - return err - } - opUpdater.SetUpdatedResponse(first) - return updateTxFn(opNamespace, opKey, opUpdater, &op)(tx) - }) - return first, op, err -} - -func (b *BoltDB) Update(_ context.Context, namespace string, key string, values map[string]any) ([]byte, error) { - var updatedData []byte - err := b.db.Update(updateTxFn(namespace, key, NewUpdater(values), &updatedData)) - return updatedData, err -} - -func updateTxFn(namespace string, key string, updater Updater, updatedData *[]byte) func(tx *bolt.Tx) error { - return func(tx *bolt.Tx) error { - data, err := updateTx(tx, namespace, key, updater) - if err != nil { - return err - } - *updatedData = data - return nil - } -} - -func updateTx(tx *bolt.Tx, namespace string, key string, updater Updater) ([]byte, error) { - bucket := tx.Bucket([]byte(namespace)) - if bucket == nil { - return nil, sdkutil.LoggingNewErrorf("namespace<%s> does not exist", namespace) - } - v := bucket.Get([]byte(key)) - if v == nil { - return nil, sdkutil.LoggingNewErrorf("key not found %s", key) - } - if err := updater.Validate(v); err != nil { - return nil, sdkutil.LoggingErrorMsg(err, "validating update") - } - data, err := updater.Update(v) - if err != nil { - return nil, err - } - if err = bucket.Put([]byte(key), data); err != nil { - return nil, errors.Wrap(err, "writing to db") - } - return data, nil -} diff --git a/pkg/storage/db_test.go b/pkg/storage/db_test.go index 5c8980b53..17151c9ea 100644 --- a/pkg/storage/db_test.go +++ b/pkg/storage/db_test.go @@ -15,6 +15,7 @@ import ( "github.com/goccy/go-json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tbd54566975/ssi-service/pkg/encryption" ) func getDBImplementations(t *testing.T) []ServiceStorage { @@ -29,6 +30,13 @@ func getDBImplementations(t *testing.T) []ServiceStorage { postgresDB := setupPostgresDB(t) dbImpls = append(dbImpls, postgresDB) + key := make([]byte, 32) + dbImpls = append(dbImpls, NewEncryptedWrapper( + boltDB, + encryption.NewXChaCha20Poly1305EncrypterWithKey(key), + encryption.NewXChaCha20Poly1305EncrypterWithKey(key), + )) + return dbImpls } @@ -498,7 +506,7 @@ func TestDB_Update(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data, err = db.Update(context.Background(), namespace, tt.args.key, tt.args.values) + data, err = Update(context.Background(), db, namespace, tt.args.key, tt.args.values) if !tt.expectedError(t, err) { return } @@ -520,6 +528,19 @@ func (f testOpUpdater) SetUpdatedResponse(bytes []byte) { f.UpdaterWithMap.Values["response"] = bytes } +func TestDB_Execute(t *testing.T) { + for _, dbImpl := range getDBImplementations(t) { + db := dbImpl + _, err := db.Execute(context.Background(), func(ctx context.Context, tx Tx) (any, error) { + return nil, tx.Write(ctx, "hello", "my_key", []byte(`some bytes`)) + }, nil) + assert.NoError(t, err) + result, err := db.Read(context.Background(), "hello", "my_key") + assert.NoError(t, err) + assert.Equal(t, []byte(`some bytes`), result) + } +} + func TestDB_UpdatedSubmissionAndOperationTxFn(t *testing.T) { for _, dbImpl := range getDBImplementations(t) { db := dbImpl @@ -609,7 +630,7 @@ func TestDB_UpdatedSubmissionAndOperationTxFn(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotFirstData, gotOpData, err := db.UpdateValueAndOperation(context.Background(), tt.args.namespace, tt.args.key, tt.args.updater, tt.args.opNamespace, tt.args.opKey, testOpUpdater{ + gotFirstData, gotOpData, err := UpdateValueAndOperation(context.Background(), db, tt.args.namespace, tt.args.key, tt.args.updater, tt.args.opNamespace, tt.args.opKey, testOpUpdater{ NewUpdater(map[string]any{ "done": true, }), diff --git a/pkg/storage/encrypt.go b/pkg/storage/encrypt.go new file mode 100644 index 000000000..006f6ac13 --- /dev/null +++ b/pkg/storage/encrypt.go @@ -0,0 +1,151 @@ +package storage + +import ( + "context" + + "github.com/pkg/errors" + "github.com/tbd54566975/ssi-service/pkg/encryption" +) + +type EncryptedWrapper struct { + s ServiceStorage + encrypter encryption.Encrypter + decrypter encryption.Decrypter +} + +func NewEncryptedWrapper(s ServiceStorage, encrypter encryption.Encrypter, decrypter encryption.Decrypter) *EncryptedWrapper { + return &EncryptedWrapper{ + s: s, + encrypter: encrypter, + decrypter: decrypter, + } +} + +func (e EncryptedWrapper) Init(opts ...Option) error { + return e.s.Init(opts...) +} + +func (e EncryptedWrapper) Type() Type { + return e.s.Type() +} + +func (e EncryptedWrapper) URI() string { + return e.s.URI() +} + +func (e EncryptedWrapper) IsOpen() bool { + return e.s.IsOpen() +} + +func (e EncryptedWrapper) Close() error { + return e.s.Close() +} + +func (e EncryptedWrapper) Write(ctx context.Context, namespace, key string, value []byte) error { + encryptedData, err := e.encrypter.Encrypt(ctx, value, nil) + if err != nil { + return errors.Wrap(err, "encrypting data") + } + return e.s.Write(ctx, namespace, key, encryptedData) +} + +func (e EncryptedWrapper) WriteMany(ctx context.Context, namespace, keys []string, values [][]byte) error { + encryptedValues := make([][]byte, 0, len(values)) + for _, value := range values { + encryptedData, err := e.encrypter.Encrypt(ctx, value, nil) + if err != nil { + return errors.Wrap(err, "encrypting data") + } + encryptedValues = append(encryptedValues, encryptedData) + } + return e.s.WriteMany(ctx, namespace, keys, encryptedValues) +} + +func (e EncryptedWrapper) Read(ctx context.Context, namespace, key string) ([]byte, error) { + storedBytes, err := e.s.Read(ctx, namespace, key) + if err != nil { + return nil, err + } + decryptedData, err := e.decrypter.Decrypt(ctx, storedBytes, nil) + if err != nil { + return nil, errors.Wrap(err, "decrypting data") + } + return decryptedData, nil +} + +func (e EncryptedWrapper) Exists(ctx context.Context, namespace, key string) (bool, error) { + return e.s.Exists(ctx, namespace, key) +} + +func (e EncryptedWrapper) ReadAll(ctx context.Context, namespace string) (map[string][]byte, error) { + encryptedKeyedBytes, err := e.s.ReadAll(ctx, namespace) + if err != nil { + return nil, err + } + return e.decryptMap(ctx, encryptedKeyedBytes) +} + +func (e EncryptedWrapper) decryptMap(ctx context.Context, encryptedKeyedBytes map[string][]byte) (map[string][]byte, error) { + decryptedValues := make(map[string][]byte, len(encryptedKeyedBytes)) + for key, encryptedBytes := range encryptedKeyedBytes { + decryptedData, err := e.decrypter.Decrypt(ctx, encryptedBytes, nil) + if err != nil { + return nil, errors.Wrap(err, "decrypting data") + } + decryptedValues[key] = decryptedData + } + return decryptedValues, nil +} + +func (e EncryptedWrapper) ReadPage(ctx context.Context, namespace string, pageToken string, pageSize int) (results map[string][]byte, nextPageToken string, err error) { + encryptedResults, nextPageToken, err := e.s.ReadPage(ctx, namespace, pageToken, pageSize) + if err != nil { + return nil, "", err + } + decryptedMap, err := e.decryptMap(ctx, encryptedResults) + if err != nil { + return nil, "", err + } + return decryptedMap, nextPageToken, err +} + +func (e EncryptedWrapper) ReadPrefix(ctx context.Context, namespace, prefix string) (map[string][]byte, error) { + encryptedMap, err := e.s.ReadPrefix(ctx, namespace, prefix) + if err != nil { + return nil, err + } + return e.decryptMap(ctx, encryptedMap) +} + +func (e EncryptedWrapper) ReadAllKeys(ctx context.Context, namespace string) ([]string, error) { + return e.s.ReadAllKeys(ctx, namespace) +} + +func (e EncryptedWrapper) Delete(ctx context.Context, namespace, key string) error { + return e.s.Delete(ctx, namespace, key) +} + +func (e EncryptedWrapper) DeleteNamespace(ctx context.Context, namespace string) error { + return e.s.DeleteNamespace(ctx, namespace) +} + +type encryptedTx struct { + tx Tx + encrypter encryption.Encrypter +} + +func (m encryptedTx) Write(ctx context.Context, namespace, key string, value []byte) error { + encryptedData, err := m.encrypter.Encrypt(ctx, value, nil) + if err != nil { + return errors.Wrap(err, "encrypting data") + } + return m.tx.Write(ctx, namespace, key, encryptedData) +} + +func (e EncryptedWrapper) Execute(ctx context.Context, businessLogicFunc BusinessLogicFunc, watchKeys []WatchKey) (any, error) { + return e.s.Execute(ctx, func(ctx context.Context, tx Tx) (any, error) { + return businessLogicFunc(ctx, encryptedTx{tx: tx, encrypter: e.encrypter}) + }, watchKeys) +} + +var _ ServiceStorage = (*EncryptedWrapper)(nil) diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 6674a60d7..89131e00f 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -6,6 +6,7 @@ import ( "reflect" "strings" + "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -71,8 +72,6 @@ type ServiceStorage interface { ReadAllKeys(ctx context.Context, namespace string) ([]string, error) Delete(ctx context.Context, namespace, key string) error DeleteNamespace(ctx context.Context, namespace string) error - Update(ctx context.Context, namespace string, key string, values map[string]any) ([]byte, error) - UpdateValueAndOperation(ctx context.Context, namespace, key string, updater Updater, opNamespace, opKey string, opUpdater ResponseSettingUpdater) (first, op []byte, err error) Execute(ctx context.Context, businessLogicFunc BusinessLogicFunc, watchKeys []WatchKey) (any, error) } @@ -137,3 +136,74 @@ func Join(parts ...string) string { func MakeNamespace(ns ...string) string { return strings.Join(ns, "-") } + +// UpdateValueAndOperation updates the value stored in (namespace,key) with the new values specified in the map. +// The updated value is then stored inside the (opNamespace, opKey), and the "done" value is set to true. +func UpdateValueAndOperation(ctx context.Context, s ServiceStorage, namespace, key string, updater Updater, opNamespace, opKey string, opUpdater ResponseSettingUpdater) (first, op []byte, err error) { + type pair struct { + first []byte + second []byte + } + watchKeys := []WatchKey{ + { + Namespace: namespace, + Key: key, + }, + { + Namespace: opNamespace, + Key: opKey, + }, + } + exec, err := s.Execute(ctx, func(ctx context.Context, tx Tx) (any, error) { + first, err = update(ctx, s, tx, namespace, key, updater) + if err != nil { + return nil, err + } + opUpdater.SetUpdatedResponse(first) + op, err = update(ctx, s, tx, opNamespace, opKey, opUpdater) + if err != nil { + return nil, err + } + return &pair{first: first, second: op}, err + }, watchKeys) + if err != nil { + return nil, nil, err + } + execPair := exec.(*pair) + return execPair.first, execPair.second, nil +} + +func Update(ctx context.Context, s ServiceStorage, namespace, key string, m map[string]any) ([]byte, error) { + watchKeys := []WatchKey{ + { + Namespace: namespace, + Key: key, + }, + } + exec, err := s.Execute(ctx, func(ctx context.Context, tx Tx) (any, error) { + return update(ctx, s, tx, namespace, key, NewUpdater(m)) + }, watchKeys) + if err != nil { + return nil, err + } + execBytes := exec.([]byte) + return execBytes, nil +} + +func update(ctx context.Context, s ServiceStorage, tx Tx, namespace, key string, updater Updater) ([]byte, error) { + readData, err := s.Read(ctx, namespace, key) + if err != nil { + return nil, err + } + if err = updater.Validate(readData); err != nil { + return nil, errors.Wrap(err, "validating update") + } + updatedData, err := updater.Update(readData) + if err != nil { + return nil, err + } + if err = tx.Write(ctx, namespace, key, updatedData); err != nil { + return nil, errors.Wrap(err, "writing to db") + } + return updatedData, nil +}