Skip to content

Key Management


Key Hierarchy

Master Key (256-bit) └── AES-256-GCM wraps → Per-User Key (256-bit) └── AES-256-CTR encrypts → File content └── HMAC-SHA256 over → IV + Ciphertext


Master Key

Property Detail
Size 256 bits (32 bytes)
Storage MASTER_KEY environment variable (hex-encoded)
Format 64 hex characters
Access get_master_key() in lenzeye_encryption_service.py
Rotation Never rotate without re-encrypting all user keys

Generating a New Master Key

python import os print(os.urandom(32).hex()) # 64 hex chars — copy to MASTER_KEY env var

Or via admin dashboard: Admin Panel → Encryption Management → Generate Master Key

Master key loss = permanent data loss

If the master key is lost, all per-user keys are unrecoverable, making all encrypted files permanently unreadable. Back up the master key in at least two secure locations (e.g., encrypted password manager + offline backup).


Per-User Keys

Property Detail
Size 256 bits (32 bytes)
Generation os.urandom(32)
Storage Encrypted with master key via AES-256-GCM, stored in user_encryption_key table
Retrieval Decrypted on-demand with master key
Lifespan Permanent (until rotated)
Active key One is_active=True row per user

Key Storage Format

```python

Encryption

nonce = os.urandom(12) # 96-bit nonce for GCM aesgcm = AESGCM(master_key) encrypted_key = aesgcm.encrypt(nonce, raw_user_key, associated_data=None) stored_value = base64.b64encode(nonce + encrypted_key).decode()

Decryption

decoded = base64.b64decode(stored_value) nonce = decoded[:12] ciphertext = decoded[12:] raw_user_key = aesgcm.decrypt(nonce, ciphertext, associated_data=None) ```


Key Version

Each user key has a key_version — SHA-256 hash of the raw key bytes:

python key_version = hashlib.sha256(raw_user_key).hexdigest()

  • Stored in UserEncryptionKey.key_version
  • Also stored in S3 object metadata: lenzeye-key-version
  • On download: server reads key_version from S3 metadata, queries DB for the matching key row, decrypts it
  • This allows correct key selection even after key rotation

Key Rotation

```python def rotate_user_key(user) -> str: # 1. Set current active key to is_active=False current_key = UserEncryptionKey.query.filter_by( user_id=user.id, is_active=True ).first() current_key.is_active = False

# 2. Generate new key
new_raw_key = os.urandom(32)
new_key_version = hashlib.sha256(new_raw_key).hexdigest()

# 3. Encrypt with master key and store
encrypted = encrypt_with_master_key(new_raw_key)
db.session.add(UserEncryptionKey(
    user_id=user.id,
    key_value=encrypted,
    key_version=new_key_version,
    is_active=True
))
db.session.commit()

```

Key rotation does NOT re-encrypt existing files

Old files remain encrypted with the old key. The old key row (is_active=False) is retained in the DB permanently. On download, the key_version from S3 metadata identifies which key to use.


get_or_create_user_key()

Used at upload initiation:

```python def get_or_create_user_key(user) -> tuple[bytes, str]: key_row = UserEncryptionKey.query.filter_by( user_id=user.id, is_active=True ).first()

if key_row:
    raw_key = decrypt_with_master_key(key_row.key_value)
    return raw_key, key_row.key_version
else:
    return generate_and_store_new_key(user)

```

Exactly 2 DB reads per file (user lookup + key lookup). The result is sealed into the session token — no further DB reads for the rest of the upload.


TL;DR

Two-tier key system: Master key (env var) wraps per-user key (DB, AES-GCM). Per-user key encrypts files (AES-CTR). Key version: SHA-256 of raw key, stored in S3 metadata for decryption routing after rotation. Rotation: New key created, old key retained. Old files still decryptable. New files use new key. Critical: Never lose master key. Never rotate master key without re-wrapping all user keys.