---
title: "ADR-003: Master key management"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:adr-003-master-key-management@1.0"
source: "Developer/Adr/ADR-003-MasterKeyManagement.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# ADR-003: Master key management {#adr-003-master-key-management-1}

**Table of contents**

-   [Status](https://docs.typo3.org/permalink/netresearch/nr-vault:status@1.0)
-   [Date](https://docs.typo3.org/permalink/netresearch/nr-vault:date@1.0)
-   [Context](https://docs.typo3.org/permalink/netresearch/nr-vault:context@1.0)
-   [Problem statement](https://docs.typo3.org/permalink/netresearch/nr-vault:problem-statement@1.0)
-   [Decision drivers](https://docs.typo3.org/permalink/netresearch/nr-vault:decision-drivers@1.0)
-   [Considered options](https://docs.typo3.org/permalink/netresearch/nr-vault:considered-options@1.0)
-   [Decision](https://docs.typo3.org/permalink/netresearch/nr-vault:decision@1.0)
-   [Implementation](https://docs.typo3.org/permalink/netresearch/nr-vault:implementation@1.0)
-   [Consequences](https://docs.typo3.org/permalink/netresearch/nr-vault:consequences@1.0)
-   [Related decisions](https://docs.typo3.org/permalink/netresearch/nr-vault:related-decisions@1.0)
-   [References](https://docs.typo3.org/permalink/netresearch/nr-vault:references@1.0)

## Status {#status}

Accepted

## Date {#date}

2026-01-03

## Context {#context}

The envelope encryption system (see [ADR-002: Envelope encryption](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-002-envelope-encryption@1.0)) 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 {#problem-statement}

How should the master key be stored, retrieved, and rotated across different
deployment scenarios?

## Decision drivers {#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 {#considered-options}

### Option 1: Single hardcoded source {#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 {#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 {#decision}

We chose a **pluggable provider system** with four built-in providers:

1.  **typo3** (default): Derives key from TYPO3's encryption key using HKDF
1.  **file**: Reads key from filesystem with strict permissions
1.  **env**: Reads key from environment variable
1.  **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](https://docs.typo3.org/permalink/netresearch/nr-vault:provider-registry@1.0) below.

## Implementation {#implementation}

### Provider interface {#provider-interface}

**Classes/Crypto/MasterKeyProviderInterface.php**

```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;
}
```

### TYPO3 provider (default) {#typo3-provider-default}

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

**Classes/Crypto/Typo3MasterKeyProvider.php**

```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,
        );
    }
}
```

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 {#file-provider}

Reads a 32-byte key from a file with strict permission requirements:

**Classes/Crypto/FileMasterKeyProvider.php**

```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
}
```

### Environment provider {#environment-provider}

Reads key from environment variable (default: `NR_VAULT_MASTER_KEY`):

**Classes/Crypto/EnvironmentMasterKeyProvider.php**

```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;
}
```

### Factory with auto-detection {#factory-with-auto-detection}

**Classes/Crypto/MasterKeyProviderFactory.php**

```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;
}
```

### Provider registry {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-custom-key-providers@1.0)).

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 {#configuration}

**Extension configuration options**

```php
$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 {#key-rotation-command}

**Rotate master key**

```bash
# 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:

1.  Inventory this extension's secrets and every registered consumer's sealed
    envelopes ([ADR-033](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-foreign-envelope-rotation@1.0))
1.  Verify old key can decrypt existing secrets
1.  Re-encrypt all DEKs with new master key (transactional)
1.  Re-wrap every registered consumer's envelopes, in the same transaction
1.  Re-key the audit chain, then commit
1.  Dispatch `MasterKeyRotatedEvent`
1.  Update configuration to use new key

> [!NOTE]
> Steps 1, 4 and 6 landed with
> [ADR-033](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-foreign-envelope-rotation@1.0). Before that, rotation
> covered `tx_nrvault_secret` only and step 6 was never actually reached —
> `MasterKeyRotatedEvent` existed and was documented but was not dispatched
> from anywhere, so consumer-owned envelopes were left wrapped under the retired
> key.

## Consequences {#consequences}

### Positive {#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 {#negative}

-   **Configuration complexity**: Multiple options to understand
-   **Key synchronization**: Multi-server deployments need key distribution

### Risks {#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 {#mitigation}

-   Document backup procedures prominently
-   Provide key export command for disaster recovery
-   Log warnings when using derived keys in production

## Related decisions {#related-decisions}

-   [ADR-002: Envelope encryption](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-002-envelope-encryption@1.0) \- Uses master key for DEK encryption

## References {#references}

-   [HKDF RFC 5869](https://tools.ietf.org/html/rfc5869)
-   [HashiCorp Vault Transit Engine](https://developer.hashicorp.com/vault/docs/secrets/transit)
