ADR-003: Master key management 

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:

  1. typo3 (default): Derives key from TYPO3's encryption key using HKDF
  2. file: Reads key from filesystem with strict permissions
  3. env: Reads key from environment variable
  4. 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 

Classes/Crypto/MasterKeyProviderInterface.php
interface MasterKeyProviderInterface
{
    public function getIdentifier(): string;
    public function isAvailable(): bool;
    public function getMasterKey(): string;
    public function storeMasterKey(string $key): void;
    public function generateMasterKey(): string;
}
Copied!

TYPO3 provider (default) 

Uses HKDF-SHA256 to derive a vault-specific key from TYPO3's encryption key:

Classes/Crypto/Typo3MasterKeyProvider.php
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,
        );
    }
}
Copied!

The HKDF context string nr-vault-master-key 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:

Classes/Crypto/FileMasterKeyProvider.php
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
}
Copied!

Environment provider 

Reads key from environment variable (default: NR_VAULT_MASTER_KEY):

Classes/Crypto/EnvironmentMasterKeyProvider.php
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;
}
Copied!

Factory with auto-detection 

Classes/Crypto/MasterKeyProviderFactory.php
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;
}
Copied!

Provider registry 

The set of providers is not a closed list inside the factory. Every service tagged nr_vault.master_key_provider is collected by MasterKeyProviderRegistry and indexed under the identifier it returns from getIdentifier() ; masterKeyProvider 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 name file; 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 whose getIdentifier() returns an empty string names nothing an operator could configure, and indexing it under '' would make it the provider an explicitly emptied masterKeyProvider resolves to. An absent setting never reaches that case — ExtensionConfiguration::getMasterKeyProvider() answers typo3 until 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 ConfigurationException to survive an unconfigured install. Without that ordering, auto-detection would answer the custody question by load order after all.

The hardened profile's deny list names typo3 and nothing else. Its demand is that the key lives outside config/system/settings.php, 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 

Extension configuration options
$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
];
Copied!

Key rotation command 

Rotate master key
# 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
Copied!

The rotation process:

  1. Inventory this extension's secrets and every registered consumer's sealed envelopes (ADR-033)
  2. Verify old key can decrypt existing secrets
  3. Re-encrypt all DEKs with new master key (transactional)
  4. Re-wrap every registered consumer's envelopes, in the same transaction
  5. Re-key the audit chain, then commit
  6. Dispatch MasterKeyRotatedEvent
  7. Update configuration to use new 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 encryptionKey breaks vault access
  • 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

References