ADR-002: Envelope encryption
Table of contents
Status
Accepted
Date
2026-01-03
Context
The nr-vault extension needs to encrypt secrets at rest in the database. The encryption approach must:
- Protect secrets even if the database is compromised
- Allow efficient key rotation without re-encrypting all secret values
- Use well-audited, modern cryptographic primitives
- Integrate with PHP's native cryptography libraries
Problem statement
How should secrets be encrypted to provide strong security while enabling efficient operations like key rotation?
Decision drivers
- Security: Must use authenticated encryption (AEAD)
- Key rotation: Master key changes should not require re-encrypting values
- Performance: Encryption/decryption must be fast
- Simplicity: Use PHP's built-in libsodium, no external dependencies
- Memory safety: Sensitive data must be cleared from memory
Considered options
Option 1: Direct encryption with master key
Encrypt each secret directly with the master key.
Pros:
- Simple implementation
- Single key to manage
Cons:
- Master key rotation requires re-encrypting ALL secrets
- Same key used for all secrets (higher exposure risk)
Option 2: Envelope encryption (DEK/KEK)
Two-layer encryption: unique Data Encryption Key (DEK) per secret, encrypted with Master Key (KEK).
Pros:
- Master key rotation only re-encrypts DEKs (fast)
- Each secret has unique encryption key
- Industry-standard pattern (AWS KMS, Google Cloud KMS)
Cons:
- Slightly more complex implementation
- More data to store (encrypted DEK + nonces)
Decision
We chose envelope encryption with AES-256-GCM (primary) or XChaCha20-Poly1305 (fallback) because:
- Efficient key rotation: Only DEKs need re-encryption, not secret values
- Defense in depth: Unique key per secret limits blast radius
- Industry standard: Proven pattern used by major cloud providers
- Modern algorithms: Both are AEAD with strong security properties
Implementation
Encryption flow
Envelope encryption process
1. Generate unique DEK (32 bytes) for the secret
2. Generate two random nonces (12 or 24 bytes each)
3. Encrypt DEK with master key: encryptedDek = AEAD(DEK, masterKey, dekNonce)
4. Encrypt secret with DEK: encryptedValue = AEAD(secret, DEK, valueNonce)
5. Calculate SHA-256 checksum for change detection
6. Clear sensitive data from memory (sodium_memzero)
7. Store: encryptedValue, encryptedDek, dekNonce, valueNonce, checksum
Copied!
Decryption flow
Envelope decryption process
1. Retrieve master key from provider
2. Decrypt DEK: DEK = AEAD_decrypt(encryptedDek, masterKey, dekNonce)
3. Decrypt secret: secret = AEAD_decrypt(encryptedValue, DEK, valueNonce)
4. Clear DEK and master key from memory
5. Return plaintext secret
Copied!
Algorithm selection
Classes/Crypto/EncryptionService.php
private function useAes256Gcm(): bool
{
// Use AES-256-GCM if hardware acceleration available
// Otherwise fall back to XChaCha20-Poly1305
if (!sodium_crypto_aead_aes256gcm_is_available()) {
return false;
}
return !$this->configuration->preferXChaCha20();
}
private function getNonceLength(): int
{
return $this->useAes256Gcm()
? SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES // 12 bytes
: SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; // 24 bytes
}
Copied!
Memory safety
Secure memory handling
try {
$dek = $this->generateDek();
$encryptedValue = $this->encryptWithKey($plaintext, $dek, $valueNonce);
// ... store encrypted data
} finally {
sodium_memzero($dek);
sodium_memzero($masterKey);
sodium_memzero($plaintext);
}
Copied!
Master key rotation
With envelope encryption, rotating the master key is efficient:
Re-encrypting DEKs only
public function reEncryptDek(
string $encryptedDek,
string $dekNonce,
string $identifier,
string $oldMasterKey,
string $newMasterKey,
): array {
// Decrypt DEK with old master key
$dek = $this->decryptDek($encryptedDek, $dekNonce, $identifier, $oldMasterKey);
// Re-encrypt DEK with new master key
$newNonce = random_bytes($this->getNonceLength());
$newEncryptedDek = $this->encryptWithKey($dek, $newMasterKey, $newNonce);
sodium_memzero($dek);
return ['encrypted_dek' => $newEncryptedDek, 'dek_nonce' => $newNonce];
}
Copied!
Database storage
Encrypted data columns
encrypted_value mediumblob, -- AEAD ciphertext + auth tag
encrypted_dek text, -- Base64-encoded encrypted DEK
dek_nonce varchar(24) NOT NULL, -- Base64-encoded DEK nonce
value_nonce varchar(24) NOT NULL, -- Base64-encoded value nonce
encryption_version int unsigned, -- For algorithm migrations
value_checksum char(64) NOT NULL, -- SHA-256 for change detection
Copied!
Consequences
Positive
- Fast key rotation: Only DEKs re-encrypted, O(n) simple operations
- Unique keys per secret: Compromise of one DEK doesn't expose others
- Hardware acceleration: AES-256-GCM uses AES-NI when available
- Authenticated encryption: Tampering is detected and rejected
- Memory safety: Sensitive data cleared immediately after use
Negative
- More storage: Each secret requires DEK + two nonces
- Complexity: Two-layer encryption requires careful implementation
- Algorithm migration: Changing algorithms requires re-encryption
Risks
- Master key loss = all secrets unrecoverable (mitigate with secure backups)
- Memory-based attacks could capture keys during brief window of use