From 4264e64f9b966c5c02ecb7663792b306ddd0acba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Michaud?= Date: Sat, 28 Mar 2020 07:10:39 +0100 Subject: [PATCH] [MISC] Encrypt session data in redis store. (#789) This is a regression from v3. With this change session data is encrypted with AES-GCM using a 256-bit key derived from the provided secret. Fixes #652. --- config.template.yml | 2 +- internal/configuration/schema/validator.go | 5 ++ internal/configuration/validator/session.go | 2 +- .../configuration/validator/session_test.go | 10 ++- internal/session/encrypting_serializer.go | 66 +++++++++++++++++++ .../session/encrypting_serializer_test.go | 45 +++++++++++++ internal/session/provider_config.go | 11 ++-- internal/session/provider_config_test.go | 32 +++++++++ internal/utils/aes.go | 57 ++++++++++++++++ 9 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 internal/session/encrypting_serializer.go create mode 100644 internal/session/encrypting_serializer_test.go create mode 100644 internal/utils/aes.go diff --git a/config.template.yml b/config.template.yml index 0ed769971..9ae92436e 100644 --- a/config.template.yml +++ b/config.template.yml @@ -245,7 +245,7 @@ session: # The name of the session cookie. (default: authelia_session). name: authelia_session - # The secret to encrypt the session cookie. + # The secret to encrypt the session data. This is only used with Redis. # This secret can also be set using the env variables AUTHELIA_SESSION_SECRET secret: unsecure_session_secret diff --git a/internal/configuration/schema/validator.go b/internal/configuration/schema/validator.go index 8ad09131e..2e52a3d5d 100644 --- a/internal/configuration/schema/validator.go +++ b/internal/configuration/schema/validator.go @@ -127,3 +127,8 @@ func (v *StructValidator) HasErrors() bool { func (v *StructValidator) Errors() []error { return v.errors } + +// Clear errors +func (v *StructValidator) Clear() { + v.errors = []error{} +} diff --git a/internal/configuration/validator/session.go b/internal/configuration/validator/session.go index 4e3009990..d49ee185a 100644 --- a/internal/configuration/validator/session.go +++ b/internal/configuration/validator/session.go @@ -12,7 +12,7 @@ func ValidateSession(configuration *schema.SessionConfiguration, validator *sche configuration.Name = schema.DefaultSessionConfiguration.Name } - if configuration.Secret == "" { + if configuration.Redis != nil && configuration.Secret == "" { validator.Push(errors.New("Set secret of the session object")) } diff --git a/internal/configuration/validator/session_test.go b/internal/configuration/validator/session_test.go index 936a7fe02..29a2b1eba 100644 --- a/internal/configuration/validator/session_test.go +++ b/internal/configuration/validator/session_test.go @@ -24,13 +24,21 @@ func TestShouldSetDefaultSessionName(t *testing.T) { assert.Equal(t, "authelia_session", config.Name) } -func TestShouldRaiseErrorWhenPasswordNotSet(t *testing.T) { +func TestShouldRaiseErrorWhenRedisIsUsedAndPasswordNotSet(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultSessionConfig() config.Secret = "" ValidateSession(&config, validator) + assert.Len(t, validator.Errors(), 0) + validator.Clear() + + // Set redis config because password must be set only when redis is used. + config.Redis = &schema.RedisSessionConfiguration{} + + ValidateSession(&config, validator) + assert.Len(t, validator.Errors(), 1) assert.EqualError(t, validator.Errors()[0], "Set secret of the session object") } diff --git a/internal/session/encrypting_serializer.go b/internal/session/encrypting_serializer.go new file mode 100644 index 000000000..f54bae6b8 --- /dev/null +++ b/internal/session/encrypting_serializer.go @@ -0,0 +1,66 @@ +package session + +import ( + "crypto/sha256" + "fmt" + + "github.com/authelia/authelia/internal/utils" + "github.com/fasthttp/session" +) + +// The implementation of Encode and Decode method comes from +// https://github.com/gtank/cryptopasta/blob/master/hash.go + +// EncryptingSerializer a serializer encrypting the data with AES-GCM with 256-bit keys. +type EncryptingSerializer struct { + key [32]byte +} + +// NewEncryptingSerializer return new encrypt instance +func NewEncryptingSerializer(secret string) *EncryptingSerializer { + key := sha256.Sum256([]byte(secret)) + return &EncryptingSerializer{key} +} + +// Encode encode and encrypt session +func (e *EncryptingSerializer) Encode(src session.Dict) ([]byte, error) { + if len(src.D) == 0 { + return nil, nil + } + + dst, err := src.MarshalMsg(nil) + if err != nil { + return nil, fmt.Errorf("Unable to marshal session: %v", err) + } + + encryptedDst, err := utils.Encrypt(dst, &e.key) + if err != nil { + return nil, fmt.Errorf("Unable to encrypt session: %v", err) + } + + return encryptedDst, nil +} + +// Decode decrypt and decode session +func (e *EncryptingSerializer) Decode(dst *session.Dict, src []byte) error { + if len(src) == 0 { + return nil + } + + dst.Reset() + decryptedSrc, err := utils.Decrypt(src, &e.key) + if err != nil { + // If an error is thrown while decrypting, it's probably an old unencrypted session + // so we just unmarshall it without decrypting. It's a way to avoid a breaking change + // requiring to flush redis. + // TODO(clems4ever): remove in few months + _, uerr := dst.UnmarshalMsg(src) + if uerr != nil { + return fmt.Errorf("Unable to decrypt session: %s", err) + } + return nil + } + + _, err = dst.UnmarshalMsg(decryptedSrc) + return err +} diff --git a/internal/session/encrypting_serializer_test.go b/internal/session/encrypting_serializer_test.go new file mode 100644 index 000000000..682bb3593 --- /dev/null +++ b/internal/session/encrypting_serializer_test.go @@ -0,0 +1,45 @@ +package session + +import ( + "testing" + + "github.com/fasthttp/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShouldEncryptAndDecrypt(t *testing.T) { + payload := session.Dict{} + payload.Set("key", "value") + + dst, err := payload.MarshalMsg(nil) + require.NoError(t, err) + + serializer := NewEncryptingSerializer("asecret") + encryptedDst, err := serializer.Encode(payload) + require.NoError(t, err) + + assert.NotEqual(t, dst, encryptedDst) + + decodedPayload := session.Dict{} + err = serializer.Decode(&decodedPayload, encryptedDst) + require.NoError(t, err) + + assert.Equal(t, "value", decodedPayload.Get("key")) +} + +func TestShouldSupportUnencryptedSessionForBackwardCompatibility(t *testing.T) { + payload := session.Dict{} + payload.Set("key", "value") + + dst, err := payload.MarshalMsg(nil) + require.NoError(t, err) + + serializer := NewEncryptingSerializer("asecret") + + decodedPayload := session.Dict{} + err = serializer.Decode(&decodedPayload, dst) + require.NoError(t, err) + + assert.Equal(t, "value", decodedPayload.Get("key")) +} diff --git a/internal/session/provider_config.go b/internal/session/provider_config.go index c8e078b85..ea33a8bc3 100644 --- a/internal/session/provider_config.go +++ b/internal/session/provider_config.go @@ -42,15 +42,18 @@ func NewProviderConfig(configuration schema.SessionConfiguration) ProviderConfig // If redis configuration is provided, then use the redis provider. if configuration.Redis != nil { providerName = "redis" + serializer := NewEncryptingSerializer(configuration.Secret) providerConfig = &redis.Config{ Host: configuration.Redis.Host, Port: configuration.Redis.Port, Password: configuration.Redis.Password, // DbNumber is the fasthttp/session property for the Redis DB Index - DbNumber: configuration.Redis.DatabaseIndex, - PoolSize: 8, - IdleTimeout: 300, - KeyPrefix: "authelia-session", + DbNumber: configuration.Redis.DatabaseIndex, + PoolSize: 8, + IdleTimeout: 300, + KeyPrefix: "authelia-session", + SerializeFunc: serializer.Encode, + UnSerializeFunc: serializer.Decode, } } else { // if no option is provided, use the memory provider. providerName = "memory" diff --git a/internal/session/provider_config_test.go b/internal/session/provider_config_test.go index fa61b11c1..0741deb3e 100644 --- a/internal/session/provider_config_test.go +++ b/internal/session/provider_config_test.go @@ -1,13 +1,17 @@ package session import ( + "crypto/sha256" "testing" "time" "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/utils" + "github.com/fasthttp/session" "github.com/fasthttp/session/memory" "github.com/fasthttp/session/redis" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestShouldCreateInMemorySessionProvider(t *testing.T) { @@ -76,3 +80,31 @@ func TestShouldSetDbNumber(t *testing.T) { // DbNumber is the fasthttp/session property for the Redis DB Index assert.Equal(t, 5, pConfig.DbNumber) } + +func TestShouldUseEncryptingSerializerWithRedis(t *testing.T) { + configuration := schema.SessionConfiguration{} + configuration.Secret = "abc" + configuration.Redis = &schema.RedisSessionConfiguration{ + Host: "redis.example.com", + Port: 6379, + Password: "pass", + DatabaseIndex: 5, + } + providerConfig := NewProviderConfig(configuration) + pConfig := providerConfig.providerConfig.(*redis.Config) + + payload := session.Dict{} + payload.Set("key", "value") + + encoded, err := pConfig.SerializeFunc(payload) + require.NoError(t, err) + + // Now we try to decrypt what has been serialized + key := sha256.Sum256([]byte("abc")) + decrypted, err := utils.Decrypt(encoded, &key) + require.NoError(t, err) + + decoded := session.Dict{} + _, err = decoded.UnmarshalMsg(decrypted) + assert.Equal(t, "value", decoded.Get("key")) +} diff --git a/internal/utils/aes.go b/internal/utils/aes.go new file mode 100644 index 000000000..a137aaf3b --- /dev/null +++ b/internal/utils/aes.go @@ -0,0 +1,57 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" +) + +// Encrypt encrypts data using 256-bit AES-GCM. This both hides the content of +// the data and provides a check that it hasn't been altered. Output takes the +// form nonce|ciphertext|tag where '|' indicates concatenation. +func Encrypt(plaintext []byte, key *[32]byte) (ciphertext []byte, err error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +// Decrypt decrypts data using 256-bit AES-GCM. This both hides the content of +// the data and provides a check that it hasn't been altered. Expects input +// form nonce|ciphertext|tag where '|' indicates concatenation. +func Decrypt(ciphertext []byte, key *[32]byte) (plaintext []byte, err error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(ciphertext) < gcm.NonceSize() { + return nil, errors.New("malformed ciphertext") + } + + return gcm.Open(nil, + ciphertext[:gcm.NonceSize()], + ciphertext[gcm.NonceSize():], + nil, + ) +}