ADR-003: Master key management
Table of contents
Status
Accepted
Date
2026-01-03
Context
The envelope encryption system (see ADR-002: Envelope encryption) requires a master key to encrypt Data Encryption Keys (DEKs). The master key management approach must:
- Work in various deployment environments (development, production, cloud)
- Support key rotation without service interruption
- Integrate with existing TYPO3 security infrastructure
- Allow external secret management systems for enterprise deployments
Problem statement
How should the master key be stored, retrieved, and rotated across different deployment scenarios?
Decision drivers
- Flexibility: Support multiple key sources (file, environment, external)
- Zero-config default: Work out-of-the-box using TYPO3's encryption key
- Security: Keys should never be logged or exposed
- Rotation: Support key rotation with atomic switchover
- Extensibility: Allow custom providers for enterprise needs
Considered options
Option 1: Single hardcoded source
Always derive from TYPO3's encryption key.
Pros:
- Zero configuration
- Always available
Cons:
- No separation between TYPO3 and vault security
- Cannot use external key management
Option 2: Pluggable provider system
Interface-based providers with factory pattern for selection.
Pros:
- Flexible deployment options
- Enterprise integration (HashiCorp Vault, AWS KMS)
- Testable with mock providers
Cons:
- More complex configuration
- Multiple code paths to maintain
Decision
We chose a pluggable provider system with four built-in providers:
- typo3 (default): Derives key from TYPO3's encryption key using HKDF
- file: Reads key from filesystem with strict permissions
- env: Reads key from environment variable
- transit: Unwraps the key through HashiCorp Vault's transit engine
This provides zero-config operation while enabling enterprise deployments. Providers are resolved through a registry keyed by identifier, so a consuming extension can supply a fifth — see Provider registry below.
Implementation
Provider interface
interface MasterKeyProviderInterface
{
public function getIdentifier(): string;
public function isAvailable(): bool;
public function getMasterKey(): string;
public function storeMasterKey(string $key): void;
public function generateMasterKey(): string;
}
TYPO3 provider (default)
Uses HKDF-SHA256 to derive a vault-specific key from TYPO3's encryption key:
final class Typo3MasterKeyProvider implements MasterKeyProviderInterface
{
private const int KEY_LENGTH = 32;
private const string HKDF_INFO = 'nr-vault-master-key';
public function getMasterKey(): string
{
$encryptionKey = $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'];
return hash_hkdf(
'sha256',
$encryptionKey,
self::KEY_LENGTH,
self::HKDF_INFO,
);
}
}
The HKDF context string nr- ensures the derived key is
unique to nr-vault even if other extensions use the same derivation pattern.
File provider
Reads a 32-byte key from a file with strict permission requirements:
public function getMasterKey(): string
{
$key = file_get_contents($this->keyPath);
$key = trim($key); // Remove trailing newlines
// Handle base64-encoded keys
if (strlen($key) !== self::KEY_LENGTH) {
$decoded = base64_decode($key, true);
if ($decoded !== false && strlen($decoded) === self::KEY_LENGTH) {
return $decoded;
}
}
return $key;
}
public function storeMasterKey(string $key): void
{
file_put_contents($this->keyPath, base64_encode($key));
chmod($this->keyPath, 0o400); // Read-only for owner
}
Environment provider
Reads key from environment variable (default: NR_):
public function getMasterKey(): string
{
$key = getenv($this->envVarName);
if ($key === false || $key === '') {
throw MasterKeyException::environmentVariableNotSet($this->envVarName);
}
// Handle base64-encoded keys
$decoded = base64_decode($key, true);
if ($decoded !== false && strlen($decoded) === self::KEY_LENGTH) {
return $decoded;
}
return $key;
}
Factory with auto-detection
public function create(): MasterKeyProviderInterface
{
$provider = $this->configuration->getMasterKeyProvider();
// The hardened deny list names `typo3` and nothing else.
if (
\in_array($provider, self::FORBIDDEN_IN_HARDENED_PROFILE, true)
&& $this->configuration->getSecurityProfile()->isHardened()
) {
throw ConfigurationException::providerForbiddenInHardenedProfile($provider);
}
// The registry resolves the identifier; the factory knows no provider list.
return $this->registry->get($provider);
}
public function getAvailableProvider(): MasterKeyProviderInterface
{
// 1. An ambiguous registration is fatal, and must be seen before the
// catch below swallows ConfigurationException.
$this->registry->assertNoIdentifierConflicts();
// 2. Hardened: no auto-detection, no fallback.
if ($this->configuration->getSecurityProfile()->isHardened()) {
return $this->create();
}
// 3. Try the explicitly configured provider.
try {
$provider = $this->create();
if ($provider->isAvailable()) {
return $provider;
}
} catch (ConfigurationException) {
// Fall through to auto-detection.
}
// 4. Fallback chain over the built-in local sources only: typo3 -> env
// -> file. A registered custom provider is reached by being named,
// never by auto-detection.
$typo3Provider = new Typo3MasterKeyProvider();
if ($typo3Provider->isAvailable()) {
return $typo3Provider;
}
$envProvider = new EnvironmentMasterKeyProvider($this->configuration);
if ($envProvider->isAvailable()) {
return $envProvider;
}
$fileProvider = new FileMasterKeyProvider($this->configuration);
if ($fileProvider->isAvailable()) {
return $fileProvider;
}
// 5. Return the TYPO3 provider (will fail with clear error).
return $typo3Provider;
}
Provider registry
The set of providers is not a closed list inside the factory. Every service
tagged nr_ is collected by
Master and indexed under the identifier it returns
from
get; master names one of those
identifiers. The four built-in providers are tagged the same way and hold no
privilege the registry can see, so an extension supplying a cloud-KMS or HSM
provider registers it exactly as nr-vault registers its own
(Custom master key providers).
Three rules make the indirection safe, because an identifier decides which key source protects the vault:
- A duplicate identifier is refused, not resolved (
1789430001). Last-one-wins would let an installed extension take over the master key by choosing the namefile; first-one-wins would let load order decide the same thing. While a collision exists the registry refuses every lookup, not only the colliding name: in that state "which key source is in use?" has no answer, and serving the other names would hide the ambiguity from the operator. - A blank identifier is refused (
1789430002). The rule is about the provider, not the setting: a provider whosegetreturns an empty string names nothing an operator could configure, and indexing it underIdentifier () ''would make it the provider an explicitly emptiedmasterresolves to. An absent setting never reaches that case —Key Provider ExtensionanswersConfiguration:: get Master Key Provider () typo3until somebody configures otherwise — and an explicitly empty one finds no provider and fails the lookup, which is the intended outcome. - The ambiguity check runs before the standard-profile fallback, which
swallows
Configurationto survive an unconfigured install. Without that ordering, auto-detection would answer the custody question by load order after all.Exception
The hardened profile's deny list names typo3 and nothing else. Its demand
is that the key lives outside config/, which is
what an extension-supplied provider delivers; refusing unknown identifiers by
default would forbid exactly the deployments the profile exists to serve. What
it cannot police is what installed code does — a custom provider may derive
its key from the TYPO3 encryption key under another name. The profile
constrains configuration, not the trustworthiness of an installed extension.
Auto-detection still probes the three built-in local sources only. Falling back to a custom provider would mean adopting a key custody nobody configured.
Configuration
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['nr_vault'] = [
// A built-in identifier, or one another extension registered.
'masterKeyProvider' => 'typo3',
'masterKeySource' => 'NR_VAULT_MASTER_KEY', // env var or file path
'autoKeyPath' => 'var/secrets/vault-master.key', // auto-generated key
];
Key rotation command
# Dry run first
vendor/bin/typo3 vault:rotate-master-key --dry-run
# Execute rotation
vendor/bin/typo3 vault:rotate-master-key \
--old-key=/path/to/old.key \
--new-key=/path/to/new.key \
--confirm
The rotation process:
- Inventory this extension's secrets and every registered consumer's sealed envelopes (ADR-033)
- Verify old key can decrypt existing secrets
- Re-encrypt all DEKs with new master key (transactional)
- Re-wrap every registered consumer's envelopes, in the same transaction
- Re-key the audit chain, then commit
- Dispatch
MasterKey Rotated Event - Update configuration to use new key
Note
Steps 1, 4 and 6 landed with
ADR-033. Before that, rotation
covered tx_ only and step 6 was never actually reached —
Master existed and was documented but was not dispatched
from anywhere, so consumer-owned envelopes were left wrapped under the retired
key.
Consequences
Positive
- Zero-config default: Works immediately with TYPO3 installation
- Deployment flexibility: File/env for containers, external for enterprise
- Key separation: HKDF ensures vault key is distinct from TYPO3 key
- Atomic rotation: Database transaction ensures consistency
- Extensibility: Custom providers via interface implementation
Negative
- Configuration complexity: Multiple options to understand
- Key synchronization: Multi-server deployments need key distribution
Risks
- TYPO3 provider: Changing
encryptionbreaks vault accessKey - File provider: Key file backup and distribution challenges
- All providers: Master key loss = permanent data loss
Mitigation
- Document backup procedures prominently
- Provide key export command for disaster recovery
- Log warnings when using derived keys in production