From 6516a28cddc17f3688d096c8d32fa7c7cdcde473 Mon Sep 17 00:00:00 2001 From: antomy-gc Date: Thu, 12 Jan 2023 22:59:07 +0300 Subject: [PATCH] Secrets encryption in database (#1475) closes #101 Added secrets encryption in database - Google TINK or simple AES as encryption mechanisms - Keys rotation support on TINK - Existing SecretService is wrapped by encryption layer - Encryption can be enabled and disabled at any time Co-authored-by: Kuzmin Ilya Co-authored-by: 6543 <6543@obermui.de> --- cmd/server/flags.go | 19 ++ cmd/server/server.go | 11 +- cmd/server/setup.go | 2 +- .../30-administration/10-server-config.md | 20 +++ docs/docs/30-administration/40-encryption.md | 64 +++++++ go.mod | 5 +- go.sum | 2 + server/model/encryption.go | 41 +++++ server/model/secret.go | 1 + server/plugins/encryption/aes.go | 67 +++++++ server/plugins/encryption/aes_builder.go | 66 +++++++ server/plugins/encryption/aes_encryption.go | 83 +++++++++ server/plugins/encryption/aes_state.go | 101 +++++++++++ server/plugins/encryption/aes_test.go | 50 ++++++ server/plugins/encryption/constants.go | 99 +++++++++++ server/plugins/encryption/encryption.go | 72 ++++++++ .../plugins/encryption/encryption_builder.go | 73 ++++++++ server/plugins/encryption/no_encryption.go | 51 ++++++ server/plugins/encryption/tink.go | 62 +++++++ server/plugins/encryption/tink_builder.go | 77 ++++++++ server/plugins/encryption/tink_keyset.go | 74 ++++++++ .../plugins/encryption/tink_keyset_watcher.go | 61 +++++++ server/plugins/encryption/tink_state.go | 146 ++++++++++++++++ .../encryption/wrapper/store/constants.go | 32 ++++ .../encryption/wrapper/store/secret_store.go | 165 ++++++++++++++++++ .../wrapper/store/secret_store_wrapper.go | 123 +++++++++++++ server/store/datastore/secret.go | 9 +- server/store/datastore/secret_test.go | 11 ++ server/store/datastore/server_config.go | 14 +- server/store/mocks/store.go | 38 ++++ server/store/store.go | 2 + 31 files changed, 1633 insertions(+), 8 deletions(-) create mode 100644 docs/docs/30-administration/40-encryption.md create mode 100644 server/model/encryption.go create mode 100644 server/plugins/encryption/aes.go create mode 100644 server/plugins/encryption/aes_builder.go create mode 100644 server/plugins/encryption/aes_encryption.go create mode 100644 server/plugins/encryption/aes_state.go create mode 100644 server/plugins/encryption/aes_test.go create mode 100644 server/plugins/encryption/constants.go create mode 100644 server/plugins/encryption/encryption.go create mode 100644 server/plugins/encryption/encryption_builder.go create mode 100644 server/plugins/encryption/no_encryption.go create mode 100644 server/plugins/encryption/tink.go create mode 100644 server/plugins/encryption/tink_builder.go create mode 100644 server/plugins/encryption/tink_keyset.go create mode 100644 server/plugins/encryption/tink_keyset_watcher.go create mode 100644 server/plugins/encryption/tink_state.go create mode 100644 server/plugins/encryption/wrapper/store/constants.go create mode 100644 server/plugins/encryption/wrapper/store/secret_store.go create mode 100644 server/plugins/encryption/wrapper/store/secret_store_wrapper.go diff --git a/cmd/server/flags.go b/cmd/server/flags.go index e2f6ce183..9c5323beb 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -525,4 +525,23 @@ var flags = []cli.Flag{ Hidden: true, // TODO(485) temporary workaround to not hit api rate limits }, + // + // secrets encryption in DB + // + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_ENCRYPTION_KEY"}, + Name: "encryption-raw-key", + Usage: "Raw encryption key", + FilePath: os.Getenv("WOODPECKER_ENCRYPTION_KEY_FILE"), + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_ENCRYPTION_TINK_KEYSET_FILE"}, + Name: "encryption-tink-keyset", + Usage: "Google tink AEAD-compatible keyset file to encrypt secrets in DB", + }, + &cli.BoolFlag{ + EnvVars: []string{"WOODPECKER_ENCRYPTION_DISABLE"}, + Name: "encryption-disable-flag", + Usage: "Flag to decrypt all encrypted data and disable encryption on server", + }, } diff --git a/cmd/server/server.go b/cmd/server/server.go index ccb78f004..3d86565ab 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -44,6 +44,8 @@ import ( "github.com/woodpecker-ci/woodpecker/server/logging" "github.com/woodpecker-ci/woodpecker/server/model" "github.com/woodpecker-ci/woodpecker/server/plugins/config" + "github.com/woodpecker-ci/woodpecker/server/plugins/encryption" + encryptedStore "github.com/woodpecker-ci/woodpecker/server/plugins/encryption/wrapper/store" "github.com/woodpecker-ci/woodpecker/server/pubsub" "github.com/woodpecker-ci/woodpecker/server/router" "github.com/woodpecker-ci/woodpecker/server/router/middleware" @@ -260,6 +262,13 @@ func setupEvilGlobals(c *cli.Context, v store.Store, f forge.Forge) { // forge server.Config.Services.Forge = f + // encryption + encryptedSecretStore := encryptedStore.NewSecretStore(v) + err := encryption.Encryption(c, v).WithClient(encryptedSecretStore).Build() + if err != nil { + log.Fatal().Err(err).Msg("could not create encryption service") + } + // services server.Config.Services.Queue = setupQueue(c, v) server.Config.Services.Logs = logging.New() @@ -268,7 +277,7 @@ func setupEvilGlobals(c *cli.Context, v store.Store, f forge.Forge) { log.Error().Err(err).Msg("could not create pubsub service") } server.Config.Services.Registries = setupRegistryService(c, v) - server.Config.Services.Secrets = setupSecretService(c, v) + server.Config.Services.Secrets = setupSecretService(c, encryptedSecretStore) server.Config.Services.Environ = setupEnvironService(c, v) server.Config.Services.Membership = setupMembershipService(c, f) diff --git a/cmd/server/setup.go b/cmd/server/setup.go index 0125cdd2e..c0220fcc2 100644 --- a/cmd/server/setup.go +++ b/cmd/server/setup.go @@ -164,7 +164,7 @@ func setupQueue(c *cli.Context, s store.Store) queue.Queue { return queue.WithTaskStore(queue.New(c.Context), s) } -func setupSecretService(c *cli.Context, s store.Store) model.SecretService { +func setupSecretService(c *cli.Context, s model.SecretStore) model.SecretService { return secrets.New(c.Context, s) } diff --git a/docs/docs/30-administration/10-server-config.md b/docs/docs/30-administration/10-server-config.md index bb93e0f72..62cb9fe68 100644 --- a/docs/docs/30-administration/10-server-config.md +++ b/docs/docs/30-administration/10-server-config.md @@ -290,6 +290,26 @@ WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker? Read the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath +### `WOODPECKER_ENCRYPTION_KEY` +> Default: empty + +Encryption key used to encrypt secrets in DB. See [secrets encryption](./40-encryption.md) + +### `WOODPECKER_ENCRYPTION_KEY_FILE` +> Default: empty + +Read the value for `WOODPECKER_ENCRYPTION_KEY` from the specified filepath + +### `WOODPECKER_ENCRYPTION_TINK_KEYSET_FILE` +> Default: empty + +Filepath to encryption keyset used to encrypt secrets in DB. See [secrets encryption](./40-encryption.md) + +### `WOODPECKER_ENCRYPTION_DISABLE` +> Default: empty + +Boolean flag to decrypt secrets in DB and disable server encryption. See [secrets encryption](./40-encryption.md) + ### `WOODPECKER_PROMETHEUS_AUTH_TOKEN` > Default: empty diff --git a/docs/docs/30-administration/40-encryption.md b/docs/docs/30-administration/40-encryption.md new file mode 100644 index 000000000..78aab9130 --- /dev/null +++ b/docs/docs/30-administration/40-encryption.md @@ -0,0 +1,64 @@ +# Secrets encryption + +By default, Woodpecker does not encrypt secrets in its database. You can enable encryption +using simple AES key or more advanced [Google TINK](https://developers.google.com/tink) encryption. + +## Common + +### Enabling secrets encryption + +To enable secrets encryption and encrypt all existing secrets in database set +`WOODPECKER_ENCRYPTION_KEY`, `WOODPECKER_ENCRYPTION_KEY_FILE` or `WOODPECKER_ENCRYPTION_TINK_KEYSET_PATH` environment +variable depending on encryption method of your choice. + +After encryption is enabled you will be unable to start Woodpecker server without providing valid encryption key! + +### Disabling encryption and decrypting all secrets + +To disable secrets encryption and decrypt database you need to start server with valid +`WOODPECKER_ENCRYPTION_KEY` or `WOODPECKER_ENCRYPTION_TINK_KEYSET_FILE` environment variable set depending on +enabled encryption method, and `WOODPECKER_ENCRYPTION_DISABLE` set to true. + +After secrets was decrypted server will proceed working in unencrypted mode. You will not need to use "disable encryption" +variable or encryption keys to start server anymore. + + +## AES +Simple AES encryption. + +### Configuration +You can manage encryption on server using these environment variables: +- `WOODPECKER_ENCRYPTION_KEY` - encryption key +- `WOODPECKER_ENCRYPTION_KEY_FILE` - file to read encryption key from +- `WOODPECKER_ENCRYPTION_DISABLE` - disable encryption flag used to decrypt all data on server + +## TINK +TINK uses AEAD encryption instead of simple AES and supports key rotation. + +### Configuration +You can manage encryption on server using these two environment variables: +- `WOODPECKER_ENCRYPTION_TINK_KEYSET_FILE` - keyset filepath +- `WOODPECKER_ENCRYPTION_DISABLE` - disable encryption flag used to decrypt all data on server + +### Encryption keys +You will need plaintext AEAD-compatible Google TINK keyset to encrypt your data. + +To generate it and then rotate keys if needed, install `tinkey`([installation guide](https://developers.google.com/tink/install-tinkey)) + +Keyset contains one or more keys, used to encrypt or decrypt your data, and primary key ID, used to determine which key +to use while encrypting new data. + +Keyset generation example: +```shell +tinkey create-keyset --key-template AES256_GCM --out-format json --out keyset.json +``` + +### Key rotation +Use `tinkey` to rotate encryption keys in your existing keyset: +```shell +tinkey rotate-keyset --in keyset_v1.json --out keyset_v2.json --key-template AES256_GCM +``` + +Then you just need to replace server keyset file with the new one. At the moment server detects new encryption +keyset it will re-encrypt all existing secrets with the new key, so you will be unable to start server with previous +keyset anymore. diff --git a/go.mod b/go.mod index 913ecaa7d..19473806f 100644 --- a/go.mod +++ b/go.mod @@ -15,12 +15,14 @@ require ( github.com/docker/go-units v0.5.0 github.com/drone/envsubst v1.0.3 github.com/franela/goblin v0.0.0-20211003143422-0a4f594942bf + github.com/fsnotify/fsnotify v1.5.1 github.com/gin-gonic/gin v1.8.1 github.com/go-ap/httpsig v0.0.0-20210714162115-62a09257db51 github.com/go-sql-driver/mysql v1.6.0 github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 github.com/golang-jwt/jwt/v4 v4.4.2 github.com/google/go-github/v39 v39.2.0 + github.com/google/tink/go v1.7.0 github.com/gorilla/securecookie v1.1.1 github.com/joho/godotenv v1.4.0 github.com/lafriks/ttlcache/v3 v3.2.0 @@ -39,6 +41,7 @@ require ( github.com/urfave/cli/v2 v2.20.2 github.com/xanzy/go-gitlab v0.73.1 github.com/xeipuuv/gojsonschema v1.2.0 + golang.org/x/crypto v0.1.0 golang.org/x/exp v0.0.0-20221031165847-c99f073a8326 golang.org/x/net v0.4.0 golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 @@ -66,7 +69,6 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/emicklei/go-restful/v3 v3.9.0 // indirect github.com/fatih/color v1.13.0 // indirect - github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-logr/logr v1.2.3 // indirect @@ -127,7 +129,6 @@ require ( go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.8.0 // indirect go.uber.org/zap v1.23.0 // indirect - golang.org/x/crypto v0.1.0 // indirect golang.org/x/mod v0.6.0 // indirect golang.org/x/sys v0.3.0 // indirect golang.org/x/term v0.3.0 // indirect diff --git a/go.sum b/go.sum index 1c4e24e59..387b2ff47 100644 --- a/go.sum +++ b/go.sum @@ -298,6 +298,8 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= +github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= diff --git a/server/model/encryption.go b/server/model/encryption.go new file mode 100644 index 000000000..d3d987850 --- /dev/null +++ b/server/model/encryption.go @@ -0,0 +1,41 @@ +// Copyright 2022 Woodpecker 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 model + +// EncryptionBuilder is user API to obtain correctly configured encryption +type EncryptionBuilder interface { + WithClient(client EncryptionClient) EncryptionBuilder + Build() error +} + +type EncryptionServiceBuilder interface { + WithClients(clients []EncryptionClient) EncryptionServiceBuilder + Build() (EncryptionService, error) +} + +type EncryptionService interface { + Encrypt(plaintext, associatedData string) (string, error) + Decrypt(ciphertext, associatedData string) (string, error) + Disable() error +} + +type EncryptionClient interface { + // SetEncryptionService should be used only by EncryptionServiceBuilder + SetEncryptionService(encryption EncryptionService) error + // EnableEncryption should encrypt all service data + EnableEncryption() error + // MigrateEncryption should decrypt all existing data and encrypt it with new encryption service + MigrateEncryption(newEncryption EncryptionService) error +} diff --git a/server/model/secret.go b/server/model/secret.go index 2d426db13..429dcf24e 100644 --- a/server/model/secret.go +++ b/server/model/secret.go @@ -63,6 +63,7 @@ type SecretStore interface { OrgSecretList(string) ([]*Secret, error) GlobalSecretFind(string) (*Secret, error) GlobalSecretList() ([]*Secret, error) + SecretListAll() ([]*Secret, error) } // Secret represents a secret variable, such as a password or token. diff --git a/server/plugins/encryption/aes.go b/server/plugins/encryption/aes.go new file mode 100644 index 000000000..24d7b91e6 --- /dev/null +++ b/server/plugins/encryption/aes.go @@ -0,0 +1,67 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "crypto/cipher" + "encoding/base64" + "fmt" + + "github.com/google/tink/go/subtle/random" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/store" +) + +type aesEncryptionService struct { + cipher cipher.AEAD + keyID string + store store.Store + clients []model.EncryptionClient +} + +func (svc *aesEncryptionService) Encrypt(plaintext, associatedData string) (string, error) { + msg := []byte(plaintext) + aad := []byte(associatedData) + + nonce := random.GetRandomBytes(uint32(AESGCMSIVNonceSize)) + ciphertext := svc.cipher.Seal(nil, nonce, msg, aad) + + result := make([]byte, 0, AESGCMSIVNonceSize+len(ciphertext)) + result = append(result, nonce...) + result = append(result, ciphertext...) + + return base64.StdEncoding.EncodeToString(result), nil +} + +func (svc *aesEncryptionService) Decrypt(ciphertext, associatedData string) (string, error) { + bytes, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf(errTemplateBase64DecryptionFailed, err) + } + + nonce := bytes[:AESGCMSIVNonceSize] + message := bytes[AESGCMSIVNonceSize:] + + plaintext, err := svc.cipher.Open(nil, nonce, message, []byte(associatedData)) + if err != nil { + return "", fmt.Errorf(errTemplateDecryptionFailed, err) + } + return string(plaintext), nil +} + +func (svc *aesEncryptionService) Disable() error { + return svc.disable() +} diff --git a/server/plugins/encryption/aes_builder.go b/server/plugins/encryption/aes_builder.go new file mode 100644 index 000000000..ece66c0e6 --- /dev/null +++ b/server/plugins/encryption/aes_builder.go @@ -0,0 +1,66 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/store" +) + +type aesConfiguration struct { + password string + store store.Store + clients []model.EncryptionClient +} + +func newAES(ctx *cli.Context, s store.Store) model.EncryptionServiceBuilder { + key := ctx.String(rawKeyConfigFlag) + return &aesConfiguration{key, s, nil} +} + +func (c aesConfiguration) WithClients(clients []model.EncryptionClient) model.EncryptionServiceBuilder { + c.clients = clients + return c +} + +func (c aesConfiguration) Build() (model.EncryptionService, error) { + svc := &aesEncryptionService{ + cipher: nil, + store: c.store, + clients: c.clients, + } + err := svc.initClients() + if err != nil { + return nil, fmt.Errorf(errTemplateFailedInitializingClients, err) + } + + err = svc.loadCipher(c.password) + if err != nil { + return nil, fmt.Errorf(errTemplateAesFailedLoadingCipher, err) + } + + err = svc.validateKey() + if err == errEncryptionNotEnabled { + err = svc.enable() + } + if err != nil { + return nil, fmt.Errorf(errTemplateFailedValidatingKey, err) + } + return svc, nil +} diff --git a/server/plugins/encryption/aes_encryption.go b/server/plugins/encryption/aes_encryption.go new file mode 100644 index 000000000..fab993d22 --- /dev/null +++ b/server/plugins/encryption/aes_encryption.go @@ -0,0 +1,83 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "crypto/aes" + "crypto/cipher" + "errors" + "fmt" + + "github.com/woodpecker-ci/woodpecker/server/store/types" + "golang.org/x/crypto/bcrypt" + + "golang.org/x/crypto/sha3" +) + +func (svc *aesEncryptionService) loadCipher(password string) error { + key, err := svc.hash([]byte(password)) + if err != nil { + return fmt.Errorf(errTemplateAesFailedGeneratingKey, err) + } + keyHash, err := bcrypt.GenerateFromPassword(key, bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf(errTemplateAesFailedGeneratingKeyID, err) + } + svc.keyID = string(keyHash) + + block, err := aes.NewCipher(key) + if err != nil { + return fmt.Errorf(errTemplateAesFailedLoadingCipher, err) + } + + aead, err := cipher.NewGCM(block) + if err != nil { + return fmt.Errorf(errTemplateAesFailedLoadingCipher, err) + } + svc.cipher = aead + return nil +} + +func (svc *aesEncryptionService) validateKey() error { + ciphertextSample, err := svc.store.ServerConfigGet(ciphertextSampleConfigKey) + if errors.Is(err, types.RecordNotExist) { + return errEncryptionNotEnabled + } else if err != nil { + return fmt.Errorf(errTemplateFailedLoadingServerConfig, err) + } + + plaintext, err := svc.Decrypt(ciphertextSample, keyIDAssociatedData) + if plaintext != svc.keyID { + return errEncryptionKeyInvalid + } else if err != nil { + return err + } + return nil +} + +func (svc *aesEncryptionService) hash(data []byte) ([]byte, error) { + result := make([]byte, 32) + sha := sha3.NewShake256() + + _, err := sha.Write(data) + if err != nil { + return nil, fmt.Errorf(errTemplateAesFailedCalculatingHash, err) + } + _, err = sha.Read(result) + if err != nil { + return nil, fmt.Errorf(errTemplateAesFailedCalculatingHash, err) + } + return result, nil +} diff --git a/server/plugins/encryption/aes_state.go b/server/plugins/encryption/aes_state.go new file mode 100644 index 000000000..470e80f5e --- /dev/null +++ b/server/plugins/encryption/aes_state.go @@ -0,0 +1,101 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "fmt" + + "github.com/rs/zerolog/log" +) + +func (svc *aesEncryptionService) initClients() error { + for _, client := range svc.clients { + err := client.SetEncryptionService(svc) + if err != nil { + return fmt.Errorf(errTemplateFailedInitializingClients, err) + } + } + log.Info().Msg(logMessageClientsInitialized) + return nil +} + +func (svc *aesEncryptionService) enable() error { + err := svc.callbackOnEnable() + if err != nil { + return fmt.Errorf(errTemplateFailedEnablingEncryption, err) + } + err = svc.updateCiphertextSample() + if err != nil { + return fmt.Errorf(errTemplateFailedEnablingEncryption, err) + } + log.Warn().Msg(logMessageEncryptionEnabled) + return nil +} + +func (svc *aesEncryptionService) disable() error { + err := svc.callbackOnDisable() + if err != nil { + return fmt.Errorf(errTemplateFailedDisablingEncryption, err) + } + err = svc.deleteCiphertextSample() + if err != nil { + return fmt.Errorf(errTemplateFailedDisablingEncryption, err) + } + log.Warn().Msg(logMessageEncryptionDisabled) + return nil +} + +func (svc *aesEncryptionService) updateCiphertextSample() error { + ciphertext, err := svc.Encrypt(svc.keyID, keyIDAssociatedData) + if err != nil { + return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) + } + err = svc.store.ServerConfigSet(ciphertextSampleConfigKey, ciphertext) + if err != nil { + return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) + } + log.Info().Msg(logMessageEncryptionKeyRegistered) + return nil +} + +func (svc *aesEncryptionService) deleteCiphertextSample() error { + err := svc.store.ServerConfigDelete(ciphertextSampleConfigKey) + if err != nil { + err = fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) + } + return err +} + +func (svc *aesEncryptionService) callbackOnEnable() error { + for _, client := range svc.clients { + err := client.EnableEncryption() + if err != nil { + return fmt.Errorf(errTemplateFailedEnablingEncryption, err) + } + } + log.Info().Msg(logMessageClientsEnabled) + return nil +} + +func (svc *aesEncryptionService) callbackOnDisable() error { + for _, client := range svc.clients { + err := client.MigrateEncryption(&noEncryption{}) + if err != nil { + return fmt.Errorf(errTemplateFailedDisablingEncryption, err) + } + } + log.Info().Msg(logMessageEncryptionDisabled) + return nil +} diff --git a/server/plugins/encryption/aes_test.go b/server/plugins/encryption/aes_test.go new file mode 100644 index 000000000..658f178c6 --- /dev/null +++ b/server/plugins/encryption/aes_test.go @@ -0,0 +1,50 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "testing" + + "github.com/google/tink/go/subtle/random" + "github.com/stretchr/testify/assert" +) + +func TestShortMessageLongKey(t *testing.T) { + aes := &aesEncryptionService{} + err := aes.loadCipher(string(random.GetRandomBytes(32))) + assert.Nil(t, err) + + input := string(random.GetRandomBytes(4)) + cipher, err := aes.Encrypt(input, "") + assert.Nil(t, err) + + output, err := aes.Decrypt(cipher, "") + assert.Nil(t, err) + assert.Equal(t, input, output) +} + +func TestLongMessageShortKey(t *testing.T) { + aes := &aesEncryptionService{} + err := aes.loadCipher(string(random.GetRandomBytes(12))) + assert.Nil(t, err) + + input := string(random.GetRandomBytes(1024)) + cipher, err := aes.Encrypt(input, "") + assert.Nil(t, err) + + output, err := aes.Decrypt(cipher, "") + assert.Nil(t, err) + assert.Equal(t, input, output) +} diff --git a/server/plugins/encryption/constants.go b/server/plugins/encryption/constants.go new file mode 100644 index 000000000..01a62f8c1 --- /dev/null +++ b/server/plugins/encryption/constants.go @@ -0,0 +1,99 @@ +// Copyright 2023 Woodpecker 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 encryption + +import "errors" + +// common +const ( + rawKeyConfigFlag = "encryption-raw-key" + tinkKeysetFilepathConfigFlag = "encryption-tink-keyset" + disableEncryptionConfigFlag = "encryption-disable-flag" + + ciphertextSampleConfigKey = "encryption-ciphertext-sample" + + keyTypeTink = "tink" + keyTypeRaw = "raw" + keyTypeNone = "none" + + keyIDAssociatedData = "Primary key id" + AESGCMSIVNonceSize = 12 +) + +var ( + errEncryptionNotEnabled = errors.New("encryption is not enabled") + errEncryptionKeyInvalid = errors.New("encryption key is invalid") + errEncryptionKeyRotated = errors.New("encryption key is being rotated") +) + +const ( + // error wrapping templates + errTemplateFailedInitializingUnencrypted = "failed initializing server in unencrypted mode: %w" + errTemplateFailedInitializing = "failed initializing encryption service: %w" + errTemplateFailedEnablingEncryption = "failed enabling encryption: %w" + errTemplateFailedRotatingEncryption = "failed rotating encryption: %w" + errTemplateFailedDisablingEncryption = "failed disabling encryption: %w" + errTemplateFailedLoadingServerConfig = "failed to load server encryption config: %w" + errTemplateFailedUpdatingServerConfig = "failed updating server encryption configuration: %w" + errTemplateFailedInitializingClients = "failed initializing encryption clients: %w" + errTemplateFailedValidatingKey = "failed validating encryption key: %w" + errTemplateEncryptionFailed = "encryption error: %w" + errTemplateBase64DecryptionFailed = "decryption error: Base64 decryption failed. Cause: %w" + errTemplateDecryptionFailed = "decryption error: %w" + + // error messages + errMessageTemplateUnsupportedKeyType = "unsupported encryption key type: %s" + errMessageCantUseBothServices = "can not use raw encryption key and tink keyset at the same time" + errMessageNoKeysProvided = "encryption enabled but no keys provided" + errMessageFailedRotatingEncryption = "failed rotating encryption" + + // log messages + logMessageEncryptionEnabled = "encryption enabled" + logMessageEncryptionDisabled = "encryption disabled" + logMessageEncryptionKeyRegistered = "registered new encryption key" + logMessageClientsInitialized = "initialized encryption on registered clients" + logMessageClientsEnabled = "enabled encryption on registered services" + logMessageClientsRotated = "updated encryption key on registered services" + logMessageClientsDecrypted = "disabled encryption on registered services" +) + +// tink +const ( + // error wrapping templates + errTemplateTinkFailedLoadingKeyset = "failed loading encryption keyset: %w" + errTemplateTinkFailedValidatingKeyset = "failed validating encryption keyset: %w" + errTemplateTinkFailedInitializeFileWatcher = "failed initializing keyset file watcher: %w" + errTemplateTinkFailedSubscribeKeysetFileChanges = "failed subscribing on encryption keyset file changes: %w" + errTemplateTinkFailedOpeningKeyset = "failed opening encryption keyset file: %w" + errTemplateTinkFailedReadingKeyset = "failed reading encryption keyset from file: %w" + errTemplateTinkFailedInitializingAEAD = "failed initializing AEAD instance: %w" + + // error messages + errMessageTinkKeysetFileWatchFailed = "failed watching encryption keyset file changes" + + // log message templates + logTemplateTinkKeysetFileChanged = "changes detected in encryption keyset file: '%s'. Encryption service will be reloaded" + logTemplateTinkLoadingKeyset = "loading encryption keyset from file: %s" + logTemplateTinkFailedClosingKeysetFile = "could not close keyset file: %s" +) + +// aes +const ( + // error wrapping templates + errTemplateAesFailedLoadingCipher = "failed loading encryption cipher: %w" + errTemplateAesFailedCalculatingHash = "failed calculating hash: %w" + errTemplateAesFailedGeneratingKey = "failed generating key from passphrase: %w" + errTemplateAesFailedGeneratingKeyID = "failed generating key id: %w" +) diff --git a/server/plugins/encryption/encryption.go b/server/plugins/encryption/encryption.go new file mode 100644 index 000000000..accc5aee9 --- /dev/null +++ b/server/plugins/encryption/encryption.go @@ -0,0 +1,72 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/store" +) + +type builder struct { + store store.Store + ctx *cli.Context + clients []model.EncryptionClient +} + +func Encryption(ctx *cli.Context, s store.Store) model.EncryptionBuilder { + return &builder{store: s, ctx: ctx} +} + +func (b builder) WithClient(client model.EncryptionClient) model.EncryptionBuilder { + b.clients = append(b.clients, client) + return b +} + +func (b builder) Build() error { + enabled, err := b.isEnabled() + if err != nil { + return err + } + + disableFlag := b.ctx.Bool(disableEncryptionConfigFlag) + + keyType, err := b.detectKeyType() + if err != nil { + return err + } + + if !enabled && (disableFlag || keyType == keyTypeNone) { + _, err := noEncryptionBuilder{}.WithClients(b.clients).Build() + if err != nil { + return fmt.Errorf(errTemplateFailedInitializingUnencrypted, err) + } + } + svc, err := b.getService(keyType) + if err != nil { + return fmt.Errorf(errTemplateFailedInitializing, err) + } + + if disableFlag { + err := svc.Disable() + if err != nil { + return err + } + } + return nil +} diff --git a/server/plugins/encryption/encryption_builder.go b/server/plugins/encryption/encryption_builder.go new file mode 100644 index 000000000..b02894759 --- /dev/null +++ b/server/plugins/encryption/encryption_builder.go @@ -0,0 +1,73 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "errors" + "fmt" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/store/types" +) + +func (b builder) getService(keyType string) (model.EncryptionService, error) { + if keyType == keyTypeNone { + return nil, errors.New(errMessageNoKeysProvided) + } + + builder, err := b.serviceBuilder(keyType) + if err != nil { + return nil, err + } + + svc, err := builder.WithClients(b.clients).Build() + if err != nil { + return nil, err + } + return svc, nil +} + +func (b builder) isEnabled() (bool, error) { + _, err := b.store.ServerConfigGet(ciphertextSampleConfigKey) + if err != nil && !errors.Is(err, types.RecordNotExist) { + return false, fmt.Errorf(errTemplateFailedLoadingServerConfig, err) + } + return err == nil, nil +} + +func (b builder) detectKeyType() (string, error) { + rawKeyPresent := b.ctx.IsSet(rawKeyConfigFlag) + tinkKeysetPresent := b.ctx.IsSet(tinkKeysetFilepathConfigFlag) + if rawKeyPresent && tinkKeysetPresent { + return "", errors.New(errMessageCantUseBothServices) + } else if rawKeyPresent { + return keyTypeRaw, nil + } else if tinkKeysetPresent { + return keyTypeTink, nil + } + return keyTypeNone, nil +} + +func (b builder) serviceBuilder(keyType string) (model.EncryptionServiceBuilder, error) { + if keyType == keyTypeTink { + return newTink(b.ctx, b.store), nil + } else if keyType == keyTypeRaw { + return newAES(b.ctx, b.store), nil + } else if keyType == keyTypeNone { + return &noEncryptionBuilder{}, nil + } else { + return nil, fmt.Errorf(errMessageTemplateUnsupportedKeyType, keyType) + } +} diff --git a/server/plugins/encryption/no_encryption.go b/server/plugins/encryption/no_encryption.go new file mode 100644 index 000000000..3978f24ea --- /dev/null +++ b/server/plugins/encryption/no_encryption.go @@ -0,0 +1,51 @@ +// Copyright 2023 Woodpecker 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 encryption + +import "github.com/woodpecker-ci/woodpecker/server/model" + +type noEncryptionBuilder struct { + clients []model.EncryptionClient +} + +func (b noEncryptionBuilder) WithClients(clients []model.EncryptionClient) model.EncryptionServiceBuilder { + b.clients = clients + return b +} + +func (b noEncryptionBuilder) Build() (model.EncryptionService, error) { + svc := &noEncryption{} + for _, client := range b.clients { + err := client.SetEncryptionService(svc) + if err != nil { + return nil, err + } + } + return svc, nil +} + +type noEncryption struct{} + +func (svc *noEncryption) Encrypt(plaintext, _ string) (string, error) { + return plaintext, nil +} + +func (svc *noEncryption) Decrypt(ciphertext, _ string) (string, error) { + return ciphertext, nil +} + +func (svc *noEncryption) Disable() error { + return nil +} diff --git a/server/plugins/encryption/tink.go b/server/plugins/encryption/tink.go new file mode 100644 index 000000000..a677187ef --- /dev/null +++ b/server/plugins/encryption/tink.go @@ -0,0 +1,62 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "encoding/base64" + "fmt" + + "github.com/fsnotify/fsnotify" + "github.com/google/tink/go/tink" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/store" +) + +type tinkEncryptionService struct { + keysetFilePath string + primaryKeyID string + encryption tink.AEAD + store store.Store + keysetFileWatcher *fsnotify.Watcher + clients []model.EncryptionClient +} + +func (svc *tinkEncryptionService) Encrypt(plaintext, associatedData string) (string, error) { + msg := []byte(plaintext) + aad := []byte(associatedData) + ciphertext, err := svc.encryption.Encrypt(msg, aad) + if err != nil { + return "", fmt.Errorf(errTemplateEncryptionFailed, err) + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (svc *tinkEncryptionService) Decrypt(ciphertext, associatedData string) (string, error) { + ct, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf(errTemplateBase64DecryptionFailed, err) + } + + plaintext, err := svc.encryption.Decrypt(ct, []byte(associatedData)) + if err != nil { + return "", fmt.Errorf(errTemplateDecryptionFailed, err) + } + return string(plaintext), nil +} + +func (svc *tinkEncryptionService) Disable() error { + return svc.disable() +} diff --git a/server/plugins/encryption/tink_builder.go b/server/plugins/encryption/tink_builder.go new file mode 100644 index 000000000..4dce14dc5 --- /dev/null +++ b/server/plugins/encryption/tink_builder.go @@ -0,0 +1,77 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/store" +) + +type tinkConfiguration struct { + keysetFilePath string + store store.Store + clients []model.EncryptionClient +} + +func newTink(ctx *cli.Context, s store.Store) model.EncryptionServiceBuilder { + filepath := ctx.String(tinkKeysetFilepathConfigFlag) + return &tinkConfiguration{filepath, s, nil} +} + +func (c tinkConfiguration) WithClients(clients []model.EncryptionClient) model.EncryptionServiceBuilder { + c.clients = clients + return c +} + +func (c tinkConfiguration) Build() (model.EncryptionService, error) { + svc := &tinkEncryptionService{ + keysetFilePath: c.keysetFilePath, + primaryKeyID: "", + encryption: nil, + store: c.store, + keysetFileWatcher: nil, + clients: c.clients, + } + err := svc.initClients() + if err != nil { + return nil, fmt.Errorf(errTemplateFailedInitializingClients, err) + } + + err = svc.loadKeyset() + if err != nil { + return nil, fmt.Errorf(errTemplateTinkFailedLoadingKeyset, err) + } + + err = svc.validateKeyset() + if err == errEncryptionNotEnabled { + err = svc.enable() + } else if err == errEncryptionKeyRotated { + err = svc.rotate() + } + + if err != nil { + return nil, fmt.Errorf(errTemplateTinkFailedValidatingKeyset, err) + } + + err = svc.initFileWatcher() + if err != nil { + return nil, fmt.Errorf(errTemplateTinkFailedInitializeFileWatcher, err) + } + return svc, nil +} diff --git a/server/plugins/encryption/tink_keyset.go b/server/plugins/encryption/tink_keyset.go new file mode 100644 index 000000000..2278b8f54 --- /dev/null +++ b/server/plugins/encryption/tink_keyset.go @@ -0,0 +1,74 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "errors" + "fmt" + "os" + "strconv" + + "github.com/google/tink/go/aead" + "github.com/google/tink/go/insecurecleartextkeyset" + "github.com/google/tink/go/keyset" + "github.com/rs/zerolog/log" + + "github.com/woodpecker-ci/woodpecker/server/store/types" +) + +func (svc *tinkEncryptionService) loadKeyset() error { + log.Warn().Msgf(logTemplateTinkLoadingKeyset, svc.keysetFilePath) + file, err := os.Open(svc.keysetFilePath) + if err != nil { + return fmt.Errorf(errTemplateTinkFailedOpeningKeyset, err) + } + defer func(file *os.File) { + err = file.Close() + if err != nil { + log.Err(err).Msgf(logTemplateTinkFailedClosingKeysetFile, svc.keysetFilePath) + } + }(file) + + jsonKeyset := keyset.NewJSONReader(file) + keysetHandle, err := insecurecleartextkeyset.Read(jsonKeyset) + if err != nil { + return fmt.Errorf(errTemplateTinkFailedReadingKeyset, err) + } + svc.primaryKeyID = strconv.FormatUint(uint64(keysetHandle.KeysetInfo().PrimaryKeyId), 10) + + encryptionInstance, err := aead.New(keysetHandle) + if err != nil { + return fmt.Errorf(errTemplateTinkFailedInitializingAEAD, err) + } + svc.encryption = encryptionInstance + return nil +} + +func (svc *tinkEncryptionService) validateKeyset() error { + ciphertextSample, err := svc.store.ServerConfigGet(ciphertextSampleConfigKey) + if errors.Is(err, types.RecordNotExist) { + return errEncryptionNotEnabled + } else if err != nil { + return fmt.Errorf(errTemplateFailedLoadingServerConfig, err) + } + + plaintext, err := svc.Decrypt(ciphertextSample, keyIDAssociatedData) + if plaintext != svc.primaryKeyID { + return errEncryptionKeyRotated + } else if err != nil { + return fmt.Errorf(errTemplateFailedValidatingKey, err) + } + return nil +} diff --git a/server/plugins/encryption/tink_keyset_watcher.go b/server/plugins/encryption/tink_keyset_watcher.go new file mode 100644 index 000000000..15c0e3ec5 --- /dev/null +++ b/server/plugins/encryption/tink_keyset_watcher.go @@ -0,0 +1,61 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "fmt" + + "github.com/fsnotify/fsnotify" + "github.com/rs/zerolog/log" +) + +// Watch keyset file events to detect key rotations and hot reload keys +func (svc *tinkEncryptionService) initFileWatcher() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf(errTemplateTinkFailedSubscribeKeysetFileChanges, err) + } + err = watcher.Add(svc.keysetFilePath) + if err != nil { + return fmt.Errorf(errTemplateTinkFailedSubscribeKeysetFileChanges, err) + } + + svc.keysetFileWatcher = watcher + go svc.handleFileEvents() + return nil +} + +func (svc *tinkEncryptionService) handleFileEvents() { + for { + select { + case event, ok := <-svc.keysetFileWatcher.Events: + if !ok { + log.Fatal().Msg(errMessageTinkKeysetFileWatchFailed) + } + if (event.Op == fsnotify.Write) || (event.Op == fsnotify.Create) { + log.Warn().Msgf(logTemplateTinkKeysetFileChanged, event.Name) + err := svc.rotate() + if err != nil { + log.Fatal().Err(err).Msgf(errMessageFailedRotatingEncryption) + } + return + } + case err, ok := <-svc.keysetFileWatcher.Errors: + if !ok { + log.Fatal().Err(err).Msgf(errMessageTinkKeysetFileWatchFailed) + } + } + } +} diff --git a/server/plugins/encryption/tink_state.go b/server/plugins/encryption/tink_state.go new file mode 100644 index 000000000..e7d539d4f --- /dev/null +++ b/server/plugins/encryption/tink_state.go @@ -0,0 +1,146 @@ +// Copyright 2023 Woodpecker 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 encryption + +import ( + "fmt" + + "github.com/rs/zerolog/log" +) + +func (svc *tinkEncryptionService) enable() error { + err := svc.callbackOnEnable() + if err != nil { + return fmt.Errorf(errTemplateFailedEnablingEncryption, err) + } + err = svc.updateCiphertextSample() + if err != nil { + return fmt.Errorf(errTemplateFailedEnablingEncryption, err) + } + log.Warn().Msg(logMessageEncryptionEnabled) + return nil +} + +func (svc *tinkEncryptionService) disable() error { + err := svc.callbackOnDisable() + if err != nil { + return fmt.Errorf(errTemplateFailedDisablingEncryption, err) + } + err = svc.deleteCiphertextSample() + if err != nil { + return fmt.Errorf(errTemplateFailedDisablingEncryption, err) + } + log.Warn().Msg(logMessageEncryptionDisabled) + return nil +} + +func (svc *tinkEncryptionService) rotate() error { + newSvc := &tinkEncryptionService{ + keysetFilePath: svc.keysetFilePath, + primaryKeyID: "", + encryption: nil, + store: svc.store, + keysetFileWatcher: nil, + clients: svc.clients, + } + err := newSvc.loadKeyset() + if err != nil { + return fmt.Errorf(errTemplateFailedRotatingEncryption, err) + } + + err = newSvc.validateKeyset() + if err == errEncryptionKeyRotated { + err = newSvc.updateCiphertextSample() + } + if err != nil { + return fmt.Errorf(errTemplateFailedRotatingEncryption, err) + } + + err = newSvc.callbackOnRotation() + if err != nil { + return fmt.Errorf(errTemplateFailedRotatingEncryption, err) + } + + err = newSvc.initFileWatcher() + if err != nil { + return fmt.Errorf(errTemplateFailedRotatingEncryption, err) + } + return nil +} + +func (svc *tinkEncryptionService) updateCiphertextSample() error { + ciphertext, err := svc.Encrypt(svc.primaryKeyID, keyIDAssociatedData) + if err != nil { + return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) + } + err = svc.store.ServerConfigSet(ciphertextSampleConfigKey, ciphertext) + if err != nil { + return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) + } + log.Info().Msg(logMessageEncryptionKeyRegistered) + return nil +} + +func (svc *tinkEncryptionService) deleteCiphertextSample() error { + err := svc.store.ServerConfigDelete(ciphertextSampleConfigKey) + if err != nil { + err = fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) + } + return err +} + +func (svc *tinkEncryptionService) initClients() error { + for _, client := range svc.clients { + err := client.SetEncryptionService(svc) + if err != nil { + return err + } + } + log.Info().Msg(logMessageClientsInitialized) + return nil +} + +func (svc *tinkEncryptionService) callbackOnEnable() error { + for _, client := range svc.clients { + err := client.EnableEncryption() + if err != nil { + return err + } + } + log.Info().Msg(logMessageClientsEnabled) + return nil +} + +func (svc *tinkEncryptionService) callbackOnRotation() error { + for _, client := range svc.clients { + err := client.MigrateEncryption(svc) + if err != nil { + return err + } + } + log.Info().Msg(logMessageClientsRotated) + return nil +} + +func (svc *tinkEncryptionService) callbackOnDisable() error { + for _, client := range svc.clients { + err := client.MigrateEncryption(&noEncryption{}) + if err != nil { + return err + } + } + log.Info().Msg(logMessageClientsDecrypted) + return nil +} diff --git a/server/plugins/encryption/wrapper/store/constants.go b/server/plugins/encryption/wrapper/store/constants.go new file mode 100644 index 000000000..136507656 --- /dev/null +++ b/server/plugins/encryption/wrapper/store/constants.go @@ -0,0 +1,32 @@ +// Copyright 2023 Woodpecker 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 store + +const ( + errMessageTemplateFailedToEnable = "failed enabling secret store encryption: %w" + errMessageTemplateFailedToMigrate = "failed migrating secret store encryption: %w" + errMessageTemplateFailedToEncryptSecret = "failed to encrypt secret id=%d: %w" + errMessageTemplateFailedToDecryptSecret = "failed to decrypt secret id=%d: %w" + errMessageTemplateStorageError = "Storage error: could not update secret in DB" + + errMessageTemplateFailedToRollbackSecretCreation = "failed creating secret: %w. Also failed deleting temporary secret record from store: %s" + + errMessageInitSeveralTimes = "attempt to init encrypted storage more than once" + + logMessageEnablingSecretsEncryption = "Encrypting all secrets in database" + logMessageEnablingSecretsEncryptionSuccess = "All secrets are encrypted" + logMessageMigratingSecretsEncryption = "Migrating encryption keys" + logMessageMigratingSecretsEncryptionSuccess = "Secrets encryption migrated successfully" +) diff --git a/server/plugins/encryption/wrapper/store/secret_store.go b/server/plugins/encryption/wrapper/store/secret_store.go new file mode 100644 index 000000000..61a27aed9 --- /dev/null +++ b/server/plugins/encryption/wrapper/store/secret_store.go @@ -0,0 +1,165 @@ +// Copyright 2023 Woodpecker 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 store + +import ( + "fmt" + + "github.com/woodpecker-ci/woodpecker/server/model" +) + +func (wrapper *EncryptedSecretStore) SecretFind(repo *model.Repo, s string) (*model.Secret, error) { + result, err := wrapper.store.SecretFind(repo, s) + if err != nil { + return nil, err + } + err = wrapper.decrypt(result) + if err != nil { + return nil, err + } + return result, nil +} + +func (wrapper *EncryptedSecretStore) SecretList(repo *model.Repo, b bool) ([]*model.Secret, error) { + results, err := wrapper.store.SecretList(repo, b) + if err != nil { + return nil, err + } + err = wrapper.decryptList(results) + if err != nil { + return nil, err + } + return results, nil +} + +func (wrapper *EncryptedSecretStore) SecretCreate(secret *model.Secret) error { + newSecret := &model.Secret{} + err := wrapper.store.SecretCreate(newSecret) + if err != nil { + return err + } + secret.ID = newSecret.ID + + err = wrapper.encrypt(secret) + if err != nil { + deleteErr := wrapper.store.SecretDelete(newSecret) + if deleteErr != nil { + return fmt.Errorf(errMessageTemplateFailedToRollbackSecretCreation, err, deleteErr.Error()) + } + return err + } + + err = wrapper.store.SecretUpdate(secret) + if err != nil { + deleteErr := wrapper.store.SecretDelete(newSecret) + if deleteErr != nil { + return fmt.Errorf(errMessageTemplateFailedToRollbackSecretCreation, err, deleteErr.Error()) + } + return err + } + + err = wrapper.decrypt(secret) + if err != nil { + return err + } + return nil +} + +func (wrapper *EncryptedSecretStore) SecretUpdate(secret *model.Secret) error { + err := wrapper.encrypt(secret) + if err != nil { + return err + } + + err = wrapper.store.SecretUpdate(secret) + if err != nil { + return err + } + + err = wrapper.decrypt(secret) + if err != nil { + return err + } + return nil +} + +func (wrapper *EncryptedSecretStore) SecretDelete(secret *model.Secret) error { + return wrapper.store.SecretDelete(secret) +} + +func (wrapper *EncryptedSecretStore) OrgSecretFind(s, s2 string) (*model.Secret, error) { + result, err := wrapper.store.OrgSecretFind(s, s2) + if err != nil { + return nil, err + } + + err = wrapper.decrypt(result) + if err != nil { + return nil, err + } + return result, nil +} + +func (wrapper *EncryptedSecretStore) OrgSecretList(s string) ([]*model.Secret, error) { + results, err := wrapper.store.OrgSecretList(s) + if err != nil { + return nil, err + } + + err = wrapper.decryptList(results) + if err != nil { + return nil, err + } + return results, nil +} + +func (wrapper *EncryptedSecretStore) GlobalSecretFind(s string) (*model.Secret, error) { + result, err := wrapper.store.GlobalSecretFind(s) + if err != nil { + return nil, err + } + + err = wrapper.decrypt(result) + if err != nil { + return nil, err + } + return result, nil +} + +func (wrapper *EncryptedSecretStore) GlobalSecretList() ([]*model.Secret, error) { + results, err := wrapper.store.GlobalSecretList() + if err != nil { + return nil, err + } + + err = wrapper.decryptList(results) + if err != nil { + return nil, err + } + return results, nil +} + +func (wrapper *EncryptedSecretStore) SecretListAll() ([]*model.Secret, error) { + results, err := wrapper.store.SecretListAll() + if err != nil { + return nil, err + } + + err = wrapper.decryptList(results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/server/plugins/encryption/wrapper/store/secret_store_wrapper.go b/server/plugins/encryption/wrapper/store/secret_store_wrapper.go new file mode 100644 index 000000000..70e7987e7 --- /dev/null +++ b/server/plugins/encryption/wrapper/store/secret_store_wrapper.go @@ -0,0 +1,123 @@ +// Copyright 2023 Woodpecker 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 store + +import ( + "errors" + "fmt" + "strconv" + + "github.com/rs/zerolog/log" + + "github.com/woodpecker-ci/woodpecker/server/model" +) + +type EncryptedSecretStore struct { + store model.SecretStore + encryption model.EncryptionService +} + +// ensure wrapper match interface +var _ model.SecretStore = new(EncryptedSecretStore) + +func NewSecretStore(secretStore model.SecretStore) *EncryptedSecretStore { + wrapper := EncryptedSecretStore{secretStore, nil} + return &wrapper +} + +func (wrapper *EncryptedSecretStore) SetEncryptionService(service model.EncryptionService) error { + if wrapper.encryption != nil { + return errors.New(errMessageInitSeveralTimes) + } + wrapper.encryption = service + return nil +} + +func (wrapper *EncryptedSecretStore) EnableEncryption() error { + log.Warn().Msg(logMessageEnablingSecretsEncryption) + secrets, err := wrapper.store.SecretListAll() + if err != nil { + return fmt.Errorf(errMessageTemplateFailedToEnable, err) + } + for _, secret := range secrets { + if err := wrapper.encrypt(secret); err != nil { + return err + } + if err := wrapper._save(secret); err != nil { + return err + } + } + log.Warn().Msg(logMessageEnablingSecretsEncryptionSuccess) + return nil +} + +func (wrapper *EncryptedSecretStore) MigrateEncryption(newEncryptionService model.EncryptionService) error { + log.Warn().Msg(logMessageMigratingSecretsEncryption) + secrets, err := wrapper.store.SecretListAll() + if err != nil { + return fmt.Errorf(errMessageTemplateFailedToMigrate, err) + } + if err := wrapper.decryptList(secrets); err != nil { + return err + } + wrapper.encryption = newEncryptionService + for _, secret := range secrets { + if err := wrapper.encrypt(secret); err != nil { + return err + } + if err := wrapper._save(secret); err != nil { + return err + } + } + log.Warn().Msg(logMessageMigratingSecretsEncryptionSuccess) + return nil +} + +func (wrapper *EncryptedSecretStore) encrypt(secret *model.Secret) error { + encryptedValue, err := wrapper.encryption.Encrypt(secret.Value, strconv.Itoa(int(secret.ID))) + if err != nil { + return fmt.Errorf(errMessageTemplateFailedToEncryptSecret, secret.ID, err) + } + secret.Value = encryptedValue + return nil +} + +func (wrapper *EncryptedSecretStore) decrypt(secret *model.Secret) error { + decryptedValue, err := wrapper.encryption.Decrypt(secret.Value, strconv.Itoa(int(secret.ID))) + if err != nil { + return fmt.Errorf(errMessageTemplateFailedToDecryptSecret, secret.ID, err) + } + secret.Value = decryptedValue + return nil +} + +func (wrapper *EncryptedSecretStore) decryptList(secrets []*model.Secret) error { + for _, secret := range secrets { + err := wrapper.decrypt(secret) + if err != nil { + return fmt.Errorf(errMessageTemplateFailedToDecryptSecret, secret.ID, err) + } + } + return nil +} + +func (wrapper *EncryptedSecretStore) _save(secret *model.Secret) error { + err := wrapper.store.SecretUpdate(secret) + if err != nil { + log.Err(err).Msg(errMessageTemplateStorageError) + return err + } + return nil +} diff --git a/server/store/datastore/secret.go b/server/store/datastore/secret.go index 36c3a577f..525dbdf2b 100644 --- a/server/store/datastore/secret.go +++ b/server/store/datastore/secret.go @@ -15,9 +15,9 @@ package datastore import ( - "github.com/woodpecker-ci/woodpecker/server/model" - "xorm.io/builder" + + "github.com/woodpecker-ci/woodpecker/server/model" ) const orderSecretsBy = "secret_name" @@ -40,6 +40,11 @@ func (s storage) SecretList(repo *model.Repo, includeGlobalAndOrgSecrets bool) ( return secrets, s.engine.Where(cond).OrderBy(orderSecretsBy).Find(&secrets) } +func (s storage) SecretListAll() ([]*model.Secret, error) { + var secrets []*model.Secret + return secrets, s.engine.Find(&secrets) +} + func (s storage) SecretCreate(secret *model.Secret) error { // only Insert set auto created ID back to object _, err := s.engine.Insert(secret) diff --git a/server/store/datastore/secret_test.go b/server/store/datastore/secret_test.go index 85c932c32..cd78ce942 100644 --- a/server/store/datastore/secret_test.go +++ b/server/store/datastore/secret_test.go @@ -78,6 +78,17 @@ func TestSecretList(t *testing.T) { assert.Len(t, list, 2) } +func TestSecretListAll(t *testing.T) { + store, closer := newTestStore(t, new(model.Secret)) + defer closer() + + createTestSecrets(t, store) + + list, err := store.SecretListAll() + assert.NoError(t, err) + assert.Len(t, list, 4) +} + func TestSecretPipelineList(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() diff --git a/server/store/datastore/server_config.go b/server/store/datastore/server_config.go index 44908a061..b5994b57c 100644 --- a/server/store/datastore/server_config.go +++ b/server/store/datastore/server_config.go @@ -17,8 +17,7 @@ func (s storage) ServerConfigGet(key string) (string, error) { func (s storage) ServerConfigSet(key, value string) error { config := &model.ServerConfig{ - Key: key, - Value: value, + Key: key, } count, err := s.engine.Count(config) @@ -26,6 +25,8 @@ func (s storage) ServerConfigSet(key, value string) error { return err } + config.Value = value + if count == 0 { _, err := s.engine.Insert(config) return err @@ -34,3 +35,12 @@ func (s storage) ServerConfigSet(key, value string) error { _, err = s.engine.Where("key = ?", config.Key).AllCols().Update(config) return err } + +func (s storage) ServerConfigDelete(key string) error { + config := &model.ServerConfig{ + Key: key, + } + + _, err := s.engine.Delete(config) + return err +} diff --git a/server/store/mocks/store.go b/server/store/mocks/store.go index 9bd692f8d..4f9794fe4 100644 --- a/server/store/mocks/store.go +++ b/server/store/mocks/store.go @@ -6,6 +6,7 @@ import ( io "io" mock "github.com/stretchr/testify/mock" + model "github.com/woodpecker-ci/woodpecker/server/model" ) @@ -1348,6 +1349,29 @@ func (_m *Store) SecretList(_a0 *model.Repo, _a1 bool) ([]*model.Secret, error) return r0, r1 } +// SecretList provides a mock function +func (_m *Store) SecretListAll() ([]*model.Secret, error) { + ret := _m.Called() + + var r0 []*model.Secret + if rf, ok := ret.Get(0).(func() []*model.Secret); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Secret) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // SecretUpdate provides a mock function with given fields: _a0 func (_m *Store) SecretUpdate(_a0 *model.Secret) error { ret := _m.Called(_a0) @@ -1397,6 +1421,20 @@ func (_m *Store) ServerConfigSet(_a0 string, _a1 string) error { return r0 } +// ServerConfigDelete provides a mock function with given fields: _a0 +func (_m *Store) ServerConfigDelete(_a0 string) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // StepChild provides a mock function with given fields: _a0, _a1, _a2 func (_m *Store) StepChild(_a0 *model.Pipeline, _a1 int, _a2 string) (*model.Step, error) { ret := _m.Called(_a0, _a1, _a2) diff --git a/server/store/store.go b/server/store/store.go index 3cd675a63..306698f82 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -122,6 +122,7 @@ type Store interface { // Secrets SecretFind(*model.Repo, string) (*model.Secret, error) SecretList(*model.Repo, bool) ([]*model.Secret, error) + SecretListAll() ([]*model.Secret, error) SecretCreate(*model.Secret) error SecretUpdate(*model.Secret) error SecretDelete(*model.Secret) error @@ -167,6 +168,7 @@ type Store interface { // ServerConfig ServerConfigGet(string) (string, error) ServerConfigSet(string, string) error + ServerConfigDelete(string) error // Cron CronCreate(*model.Cron) error