.. include:: /Includes.rst.txt
.. _adr-002-envelope-encryption:
================================
ADR-002: Envelope encryption
================================
.. contents:: Table of contents
:local:
:depth: 2
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:
1. **Efficient key rotation**: Only DEKs need re-encryption, not secret values
2. **Defense in depth**: Unique key per secret limits blast radius
3. **Industry standard**: Proven pattern used by major cloud providers
4. **Modern algorithms**: Both are AEAD with strong security properties
Implementation
==============
Encryption flow
---------------
.. code-block:: text
:caption: 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
Decryption flow
---------------
.. code-block:: text
:caption: 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
Algorithm selection
-------------------
.. code-block:: php
:caption: 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
}
Memory safety
-------------
.. code-block:: php
:caption: 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);
}
Master key rotation
-------------------
With envelope encryption, rotating the master key is efficient:
.. code-block:: php
:caption: 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];
}
Database storage
----------------
.. code-block:: sql
:caption: 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
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
References
==========
- `libsodium documentation `_
- `AWS KMS Envelope Encryption `_
- `NIST SP 800-38D (GCM) `_