---
title: "Cryptography"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:security-cryptography@1.0"
source: "Security/Cryptography.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Cryptography {#cryptography}

The precise cryptographic contract, for reviewers who need more than
[Encryption architecture](https://docs.typo3.org/permalink/netresearch/nr-vault:security-encryption-architecture@1.0). 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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-002-envelope-encryption@1.0) (envelope
scheme), [ADR-032: A portable envelope codec for consumer-owned payloads](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-032-portable-envelope-codec@1.0) (framing) and
[ADR-003: Master key management](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-003-master-key-management@1.0) (key custody).

## Primitives {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:security-cryptography-hkdf@1.0). |
| Audit chain authentication | HMAC-SHA256 | Under a key derived from the master key. See [Audit evidence](https://docs.typo3.org/permalink/netresearch/nr-vault:security-audit-evidence@1.0). |
| 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 {#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()`.
1.  Two independent nonces of the algorithm's nonce length are generated,
    one for the DEK envelope and one for the value.
1.  The DEK is encrypted under the **master key**, with the secret identifier
    as associated data.
1.  The value is encrypted under the **DEK**, with the same identifier as
    associated data.
1.  A change-detection token is computed (see
    [The change-detection token](https://docs.typo3.org/permalink/netresearch/nr-vault:security-cryptography-checksum@1.0)).
1.  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](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-key-rotation@1.0) is an operation on envelopes rather than a
re-encryption of the vault.

## Algorithm agility {#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 {#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 {#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 {#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 {#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 {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-020-master-key-request-lifetime-caching@1.0).
-   **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
> `#[SensitiveParameter]` so a stack trace does not print them, and
> logs and exception messages carry `[REDACTED]` rather than values.

## Algorithm agility policy {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:security-audit-evidence-epochs@1.0)).

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.
