Cryptography 

The precise cryptographic contract, for reviewers who need more than Encryption architecture. Every statement here is a property of \Netresearch\NrVault\Crypto\EncryptionService , EnvelopeCodec 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_hkdf() ) 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_bytes() Every DEK and every nonce. No counters, no derived nonces.

Envelope scheme 

Each secret carries its own DEK, wrapped by the master key:

  1. A fresh DEK of the algorithm's key length is generated with random_bytes() .
  2. Two independent nonces of the algorithm's nonce length are generated, one for the DEK envelope and one for the value.
  3. The DEK is encrypted under the master key, with the secret identifier as associated data.
  4. The value is encrypted under the DEK, with the same identifier as associated data.
  5. A change-detection token is computed (see The change-detection token).
  6. 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 ( EncryptionService::reEncryptDek() ), 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_equals() . The comparisons that matter:

  • audit chain verification — both previous_hash and entry_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_memzero() 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's sodium_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 in clearCachedKey() . See ADR-020: Master key request-lifetime caching.
  • 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.

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 ( EnvelopeCodec ) 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.