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_versionfrom 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.