Cryptography
The precise cryptographic contract, for reviewers who need more than
Encryption architecture. Every statement here is a property of
\Netresearch\,
Envelope and the master-key providers on this branch.
Design rationale lives in ADR-002: Envelope encryption (envelope scheme), ADR-032: A portable envelope codec for consumer-owned payloads (framing) and ADR-003: Master key management (key custody).
Primitives
libsodium only. There is no openssl_* fallback path anywhere in the
extension.
| Purpose | Primitive | Notes |
|---|---|---|
| Value and DEK encryption | XChaCha20-Poly1305 (IETF) or AES-256-GCM | AEAD in both cases. 256-bit keys. The secret identifier is passed as associated data, so an envelope cannot be moved to another identifier without failing authentication. |
| Key derivation | HKDF-SHA256 (
hash_) | Three distinct, domain-separated uses — see HKDF usages. |
| Audit chain authentication | HMAC-SHA256 | Under a key derived from the master key. See Audit evidence. |
| Change detection | HMAC-SHA256 over the ciphertext | A change detector, not an integrity control. Integrity is the AEAD tag's job. |
| Randomness |
random_ | Every DEK and every nonce. No counters, no derived nonces. |
Envelope scheme
Each secret carries its own DEK, wrapped by the master key:
- A fresh DEK of the algorithm's key length is generated with
random_.bytes () - Two independent nonces of the algorithm's nonce length are generated, one for the DEK envelope and one for the value.
- The DEK is encrypted under the master key, with the secret identifier as associated data.
- The value is encrypted under the DEK, with the same identifier as associated data.
- A change-detection token is computed (see The change-detection token).
- The DEK, the derived MAC key and the plaintext are wiped with
sodium_.memzero ()
Decryption reverses it: unwrap the DEK with the master key, then decrypt the
value with the DEK, then wipe the DEK. A failed AEAD tag surfaces as
Authentication failed - data may have been tampered with rather than as a
garbled plaintext.
The practical consequence is that rotating the master key never touches a
secret value: only the DEK layer is re-wrapped
(
Encryption), which is why
Key rotation is an operation on envelopes rather than a
re-encryption of the vault.
Algorithm agility
Which algorithm a given envelope uses is recorded, not re-derived. That distinction is the whole point: an envelope written on a host with hardware AES must still open on a host without it.
| Version | Constant | Algorithm resolution |
|---|---|---|
| 1 | ENCRYPTION_VERSION_LEGACY | No marker exists. Derived from host capabilities plus the
preferXChaCha20 setting, byte-identical to the behaviour that
existed before markers, so legacy rows keep decrypting exactly as
before. |
| 2 | ENCRYPTION_VERSION_CURRENT | The stored encryption_algorithm marker is authoritative. |
New envelopes are always version 2. The marker values —
xchacha20poly1305 and aes256gcm — are persisted per secret and are
byte-for-byte stable: changing one would make every secret carrying the old
string undecryptable.
The default for new secrets is XChaCha20-Poly1305, deliberately. It is
available in every libsodium build (AES-256-GCM requires hardware support),
and its 24-byte nonce makes random-nonce collisions a non-concern — so vault
contents stay portable across hosts with differing CPU capabilities. A site
can pin the other algorithm through the encryptionAlgorithm setting.
Unknown or unavailable values fail loudly. An unrecognised marker on a
version-2 row is a hard error, not a guess; an encryptionAlgorithm setting
naming an unknown or host-unavailable algorithm refuses to encrypt. For a
vault, refusing to encrypt beats encrypting with an algorithm the operator did
not choose, and refusing to decrypt beats silently trying the wrong primitive.
HKDF usages
Four derivations, each with its own info string so no two outputs can
collide even though three of them start from the same master key.
| Derivation | Input | info |
Output |
|---|---|---|---|
Master key, typo3 provider | SYS/encryptionKey | nr-vault-master-key | 32 bytes |
| Audit chain HMAC key | Master key | nr-vault-audit-hmac-v1 | 32 bytes |
| Chain-tip anchor MAC key | Master key | nr-vault-audit-anchor-v1 | 32 bytes |
| Per-secret checksum MAC key | That secret's DEK | nr-vault-checksum | 32 bytes |
The audit derivation is what gives the chain cryptographic separation from encryption key material: an attacker who somehow obtained the audit HMAC key could forge chain hashes but not decrypt anything, and vice versa.
The change-detection token
value_checksum is a keyed MAC over the ciphertext, never over the
plaintext, with a MAC key derived per secret from that secret's DEK. Two
properties follow, and both were the reason for the design:
- The stored checksum is not an offline-computable function of the plaintext, so it is no guess-confirmation oracle for someone holding the database.
- Identical plaintexts in different secrets produce different checksums, so the column leaks no equality relation between secrets.
It exists to answer "did this value change?" for the audit trail's
hash_before / hash_after fields. It is not an integrity control and is
not required to open an envelope.
Key and nonce lengths
| Item | Length | Source |
|---|---|---|
| Master key | 32 bytes | Every provider: derived (typo3), read and length-checked
(file, env), or unwrapped and length-checked
(transit). A wrong length is rejected, never padded or
truncated. |
| DEK | 32 bytes | random_bytes() at the algorithm's key length; both supported
algorithms use 256-bit keys. |
| Nonce, XChaCha20-Poly1305 | 24 bytes | SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES |
| Nonce, AES-256-GCM | 12 bytes | SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES |
| Audit HMAC key | 32 bytes | HKDF-SHA256 from the master key |
| Chain-tip anchor MAC key | 32 bytes | HKDF-SHA256 from the master key, under a distinct info string |
Nonces are random per operation and never reused across the DEK envelope and
the value envelope — two independent nonces are drawn for every encrypt()
call, and re-wrapping a DEK during master-key rotation draws a fresh one.
Constant-time comparison
Every comparison of a secret or an integrity tag uses
hash_. The comparisons that matter:
- audit chain verification — both
previous_hashandentry_hash; - chain-tip anchor comparison — the anchored tip against the stored row;
- the transit provider's check of whether a token copy is safe to wipe.
Plain !== is never used on that class of value. AEAD tag verification is
libsodium's own, and constant-time by construction.
Memory scrubbing policy
sodium_ is called on plaintexts, DEKs and derived MAC keys —
on the success path and in finally blocks, so the error paths reachable
through tampered ciphertext do not leak buffers.
Two deliberate exceptions, both documented at the call sites:
- The master key is not wiped by the encryption service. What
getMasterKey()returns is the provider's shared request-lifetime cache entry. Wiping the local reference would either be a no-op (PHP'ssodium_memzero()skips strings with a refcount above one) or corrupt the cached key for every later vault operation in the request. The provider owns that lifecycle and wipes its slot inclear. See ADR-020: Master key request-lifetime caching.Cached Key () - A token that came from configuration is not wiped. It shares its
string buffer with the configuration singleton, so zeroing it would NUL
out the stored setting for the rest of the request. Only env-derived
copies — freshly allocated by
getenv()— are wiped.
Note
Call this minimised exposure, not secure deletion. PHP offers no
guarantee that a string had no other copy before sodium_memzero()
reached it, and the process may have been swapped or dumped. The policy
shortens the window; it does not close it. Sensitive parameters are marked
# so a stack trace does not print them, and
logs and exception messages carry [REDACTED] rather than values.
Algorithm agility policy
Adding an algorithm means adding an EncryptionAlgorithm case with a new
stable string value and bumping nothing else: existing rows keep their marker
and keep decrypting. Removing one is a breaking change that requires
re-encrypting every affected secret first, because the marker is what the
decrypt path dispatches on.
The same applies to the audit chain, where the analogous version marker is
hmac_key_epoch: epochs are additive, verification dispatches per row, and
a downgrade is treated as an attack (see
Epochs: what each one binds).
The envelope framing used for portable, off-table storage
(
Envelope) is deliberately tolerant in one direction only: it
ignores unknown fields when reading, and re-wrapping rewrites just the DEK
layer on top of the body as it was read. Rebuilding the body from known
fields would make rotation lossy for an envelope written by a newer version —
irreversibly, once the old key is gone.