Security

Encryption

Introduction

Expanse supports application-level encryption using the AES-256-GCM algorithm. This encryption exists to protect sensitive information in your application such as personally identifiable information (PII) from users.

This provides an additional layer of security to your application, even if your database is encrypted at rest, if an attacker were to gain access to the database or a snapshot of it, for example.

Configuration

Before you can use encryption in your application, you need to specify an encryption key using the APP_SECRET_KEY environment variable.

The key must be a 32-byte string. You can generate a key using the following command:

./beam encryption key generate

This will generate random, secure keys and store them in your .env file:

  • the APP_SECRET_KEY variable from which the encryption keys used to encrypt will be derived
  • the ENCRYPTION_SALT variable which will be used to derive the encryption keys

If you only want to generate either the APP_SECRET_KEY or the ENCRYPTION_SALT variable, you can use the --key or --salt option respectively:

./beam encryption key generate --key
./beam encryption key generate --salt

If you prefer to only display the keys without storing them, you can use the --show option:

./beam encryption key generate --show

If you prefer to generate the keys manually, you have to follow the following rules:

  • the APP_SECRET_KEY must be a 32-byte string
  • the ENCRYPTION_SALT must be a 16-byte or longer string

Rotating encryption keys

If you need to change the encryption key, you can specify the previous encryption keys in the APP_PREVIOUS_KEYS environment variable, separated by a comma.

APP_SECRET_KEY=s7EUCRnWEv7nsikAdSBUU3z5ZF5mCIPw
APP_PREVIOUS_KEYS=ZKF0treyt8EGBaopxs8effsNcgKLRDZp

When decrypting data, Expanse will try to decrypt the data first using the current key and, if it fails, will try to use the previous keys. That way you can gracefully change your encryption key without interruption.

Encrypting data

To encrypt data, you can use the encrypt() method from an EncryptionManager instance:

from sqlalchemy import select
from expanse.database.session import Session
from expanse.encryption.encryption_manager import EncryptionManager
from expanse.http.response import Response

from app.models.user import User


class UserController:

    def store(
        self, user_id: int, encryption: EncryptionManager, session: Session
    ) -> Response:
        user = session.scalar(select(User).where(User.id == user_id))
        user.email = encryption.encrypt(user.email)

        session.commit()
        # ...

Before encrypting the data, the encryptor will first derive a key from the configured secret key — using the derivation salt configured via the ENCRYPTION_SALT environment variable — and use it to encrypt the data.

Decrypting data

To decrypt data, you can use the decrypt() method – which accepts a Message instance – from an EncryptionManager instance:

from sqlalchemy import select
from expanse.database.session import Session
from expanse.encryption.encryption_manager import EncryptionManager
from expanse.encryption.message import Message
from expanse.http.response import Response

from app.models.user import User


class UserController:

    def store(
        self, user_id: int, encryption: EncryptionManager, session: Session
    ) -> Response:
        user = session.scalar(select(User).where(User.id == user_id))
        clear_email = encryption.decrypt(user.email)
        # ...

If the decryption fails, a DecryptionError exception will be raised.

try:
    clear_email = encryptor.decrypt(user.email)
except DecryptionError:
    # Handle the error
    ...

Purpose-bound encryption

Purpose-bound encryption allows you to encrypt data with a specific purpose in mind, ensuring that the data can only be decrypted in the context of that purpose. This adds a layer of security by preventing unauthorized decryption of the data outside its intended use case.

encrypted = encryption.encrypt("sensitive_data", purpose="user_data")
encryption.decrypt(encrypted, purpose="user_data")

If you try to decrypt the data with a different purpose, a DecryptionError will be raised:

encrypted = encryption.encrypt("sensitive_data", purpose="user_data")
try:
    encryption.decrypt(encrypted, purpose="other_purpose")
except DecryptionError:
    # Handle the error
    ...