[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.
pull/790/head
Clément Michaud 2020-03-28 07:10:39 +01:00 committed by GitHub
parent 537378becc
commit 4264e64f9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 223 additions and 7 deletions

View File

@ -245,7 +245,7 @@ session:
# The name of the session cookie. (default: authelia_session). # The name of the session cookie. (default: authelia_session).
name: 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 # This secret can also be set using the env variables AUTHELIA_SESSION_SECRET
secret: unsecure_session_secret secret: unsecure_session_secret

View File

@ -127,3 +127,8 @@ func (v *StructValidator) HasErrors() bool {
func (v *StructValidator) Errors() []error { func (v *StructValidator) Errors() []error {
return v.errors return v.errors
} }
// Clear errors
func (v *StructValidator) Clear() {
v.errors = []error{}
}

View File

@ -12,7 +12,7 @@ func ValidateSession(configuration *schema.SessionConfiguration, validator *sche
configuration.Name = schema.DefaultSessionConfiguration.Name configuration.Name = schema.DefaultSessionConfiguration.Name
} }
if configuration.Secret == "" { if configuration.Redis != nil && configuration.Secret == "" {
validator.Push(errors.New("Set secret of the session object")) validator.Push(errors.New("Set secret of the session object"))
} }

View File

@ -24,13 +24,21 @@ func TestShouldSetDefaultSessionName(t *testing.T) {
assert.Equal(t, "authelia_session", config.Name) assert.Equal(t, "authelia_session", config.Name)
} }
func TestShouldRaiseErrorWhenPasswordNotSet(t *testing.T) { func TestShouldRaiseErrorWhenRedisIsUsedAndPasswordNotSet(t *testing.T) {
validator := schema.NewStructValidator() validator := schema.NewStructValidator()
config := newDefaultSessionConfig() config := newDefaultSessionConfig()
config.Secret = "" config.Secret = ""
ValidateSession(&config, validator) 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.Len(t, validator.Errors(), 1)
assert.EqualError(t, validator.Errors()[0], "Set secret of the session object") assert.EqualError(t, validator.Errors()[0], "Set secret of the session object")
} }

View File

@ -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
}

View File

@ -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"))
}

View File

@ -42,15 +42,18 @@ func NewProviderConfig(configuration schema.SessionConfiguration) ProviderConfig
// If redis configuration is provided, then use the redis provider. // If redis configuration is provided, then use the redis provider.
if configuration.Redis != nil { if configuration.Redis != nil {
providerName = "redis" providerName = "redis"
serializer := NewEncryptingSerializer(configuration.Secret)
providerConfig = &redis.Config{ providerConfig = &redis.Config{
Host: configuration.Redis.Host, Host: configuration.Redis.Host,
Port: configuration.Redis.Port, Port: configuration.Redis.Port,
Password: configuration.Redis.Password, Password: configuration.Redis.Password,
// DbNumber is the fasthttp/session property for the Redis DB Index // DbNumber is the fasthttp/session property for the Redis DB Index
DbNumber: configuration.Redis.DatabaseIndex, DbNumber: configuration.Redis.DatabaseIndex,
PoolSize: 8, PoolSize: 8,
IdleTimeout: 300, IdleTimeout: 300,
KeyPrefix: "authelia-session", KeyPrefix: "authelia-session",
SerializeFunc: serializer.Encode,
UnSerializeFunc: serializer.Decode,
} }
} else { // if no option is provided, use the memory provider. } else { // if no option is provided, use the memory provider.
providerName = "memory" providerName = "memory"

View File

@ -1,13 +1,17 @@
package session package session
import ( import (
"crypto/sha256"
"testing" "testing"
"time" "time"
"github.com/authelia/authelia/internal/configuration/schema" "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/memory"
"github.com/fasthttp/session/redis" "github.com/fasthttp/session/redis"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestShouldCreateInMemorySessionProvider(t *testing.T) { 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 // DbNumber is the fasthttp/session property for the Redis DB Index
assert.Equal(t, 5, pConfig.DbNumber) 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"))
}

View File

@ -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,
)
}