nr-vault 

Extension key

nr_vault

Package name

netresearch/nr-vault

Version

0.3

Language

en

Author

Netresearch DTT GmbH

License

This document is published under the GPL-2.0-or-later license.

Rendered

Mon, 26 Jan 2026 18:26:22 +0000


Secure secrets management for TYPO3 with envelope encryption, access control, and audit logging.


📖 Introduction 

Learn what nr-vault provides and why you need it for secure secrets management in TYPO3.

📥 Installation 

Install the extension via Composer and set up your master key for encryption.

⚙️ Configuration 

Configure storage adapters, master key providers, access control, and extension settings.

💻 Usage 

Manage secrets through the backend module, CLI commands, and PHP API.

🔒 Security 

Understand the encryption architecture, audit logging, and security best practices.

👨‍💻 Developer 

API reference, TCA integration, extending nr-vault with custom adapters, and events.

Introduction 

The secret problem 

Your TYPO3 site integrates with Stripe, SendGrid, Google Maps, and a dozen other services. Where are those API keys right now?

Probably in one of these places:

  • Plain text in LocalConfiguration.php (committed to git?)
  • Unencrypted in a database field (visible in backups, exports, SQL injection)
  • Hardcoded in extension configuration (accessible to every backend user)

If your database leaks, your secrets leak. If an intern gets backend access, they can see your payment credentials. If you need to rotate a compromised key, you're editing config files and redeploying.

There has to be a better way.

How secrets are typically stored 

Let's compare common approaches, from most to least secure:

Method Security Operational Reality
External Services (HashiCorp Vault, AWS Secrets Manager) ⭐⭐⭐⭐⭐ Requires dedicated infrastructure, network connectivity, and authentication to the service itself. Enterprise-grade, enterprise-priced.
Environment Variables ⭐⭐⭐ Requires deployment pipeline or host access to set. Container restart needed to change values. No rotation UI, no audit trail, hard to manage.
Files outside webroot ⭐⭐⭐ Requires deployment or server access. Proper file permissions a must. No management interface, rotation means redeployment.
nr-vault (encrypted in database) ⭐⭐⭐⭐ Runtime manageable via TYPO3 backend. Rotate anytime. Full audit trail. No external infrastructure required.
Plain text in config/database ❌ No protection. Secrets visible to anyone with database or file access.

The trade-off nobody talks about 

Notice something? All the "more secure" methods share the same operational pain:

  • External services: Infrastructure cost, complexity, another system to maintain
  • Environment variables: Need DevOps to change a value. Restart containers. No audit trail. "Who changed the Stripe key last Tuesday?" Good luck.
  • Files outside webroot: Same deployment dance. No UI. No history.

And then there's plain text - which is what most TYPO3 extensions actually use.

Why nr-vault? 

nr-vault is the sweet spot between "no security" and "enterprise complexity":

Challenge Env Vars / Files nr-vault
Rotate a compromised API key Call DevOps, update config, redeploy, restart Click in backend, done
See who accessed a secret Check deploy logs (if they exist) Full audit log with timestamps
Emergency credential revocation Wait for deployment pipeline Immediate via backend module
Non-technical editor needs to update SMTP password Create support ticket Self-service in backend
Compliance audit: prove access history Manually correlate logs Export tamper-evident audit trail

The pitch: Enterprise-grade secret management without enterprise-grade complexity.

What is nr-vault? 

nr-vault is a TYPO3 extension providing:

Encryption at rest
Every secret is encrypted with its own key (envelope encryption - the same pattern used by AWS KMS and Google Cloud KMS). Even if your database leaks, secrets remain protected.
Runtime management
Create, update, rotate, and revoke secrets through the TYPO3 backend. No deployments. No config file editing. No container restarts.
Access control
Fine-grained permissions based on backend user groups. The marketing team can manage their Mailchimp key without seeing payment credentials.
Audit logging
Tamper-evident logs with hash chain verification. Know exactly who accessed what, when - for compliance and incident response.
TYPO3-native integration
TCA field type, site configuration support, TypoScript integration, CLI commands. Works the way TYPO3 developers expect.
nr-vault backend module showing vault overview with statistics and quick start guide

The vault backend module provides an intuitive interface for managing secrets

Use cases 

  • Payment gateway credentials - Stripe, PayPal, Adyen API keys
  • Email service authentication - SMTP passwords, Mailchimp, SendGrid tokens
  • Third-party API keys - Google Maps, analytics, CRM integrations
  • OAuth client secrets - with automatic token refresh via Vault HTTP Client
  • Database credentials - connection strings for external systems
  • Per-record credentials - different API keys per client in TCA records
  • Multi-site secrets - site-specific configuration in multi-domain setups

Requirements 

  • TYPO3 v14.0 or higher
  • PHP 8.5 or higher
  • PHP sodium extension (bundled with PHP 8.5)
  • Composer-based TYPO3 installation

Installation 

Requirements 

Before installing nr-vault, ensure your system meets these requirements:

  • TYPO3 v14.0 or higher.
  • PHP 8.5 or higher.
  • PHP sodium extension (usually included in PHP 8.5).
  • Composer-based TYPO3 installation.

Installation via Composer 

Install the extension using Composer:

Install via Composer
composer require netresearch/nr-vault
Copied!

Activate the extension 

After installation, activate the extension in the TYPO3 backend:

  1. Go to Admin Tools > Extensions.
  2. Find "nr-vault" in the list.
  3. Click the activation icon.

Or use the command line:

Activate extension via CLI
vendor/bin/typo3 extension:activate nr_vault
Copied!

Database schema 

Update the database schema to create the required tables:

Update database schema
vendor/bin/typo3 database:updateschema
Copied!

This creates the following tables:

  • tx_nrvault_secret - Stores encrypted secrets with metadata.
  • tx_nrvault_audit_log - Stores audit log entries with hash chain.

Master key setup 

nr-vault requires a master encryption key to protect your secrets. There are three options, from simplest to most configurable:

Option 1: TYPO3 encryption key (default, zero configuration) 

This is the recommended default. nr-vault automatically derives a master key from TYPO3's built-in encryption key ( $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ).

No configuration required - nr-vault works immediately after installation.

Benefits:

  • Zero setup - works out of the box
  • Unique per TYPO3 installation
  • Already secured by TYPO3's configuration protection

Option 2: Environment variable 

For containerized deployments or when you need explicit control:

  1. Generate a master key:

    Generate master key
    openssl rand -base64 32
    Copied!
  2. Set the environment variable:

    Set environment variable
    export NR_VAULT_MASTER_KEY="your-generated-key"
    Copied!
  3. Configure the extension in Admin Tools > Settings > Extension Configuration:

Option 3: Key file 

For maximum security, store the key in a file outside the web root:

Create secure key file
openssl rand -base64 32 > /secure/path/vault.key
chmod 0400 /secure/path/vault.key
Copied!

Configure the extension:

See Master key providers for detailed information on each provider.

Verify installation 

Verify the installation by listing secrets (should return empty if newly installed):

List vault secrets
vendor/bin/typo3 vault:list
Copied!

If the command executes without errors, the extension is properly configured.

You can also test by storing and retrieving a test secret:

Test vault functionality
# Store a test secret
vendor/bin/typo3 vault:store test_secret --value="test-value"

# Retrieve it
vendor/bin/typo3 vault:retrieve test_secret

# Clean up
vendor/bin/typo3 vault:delete test_secret --force
Copied!

Configuration 

Extension configuration 

Configure nr-vault in Admin Tools > Settings > Extension Configuration.

storageAdapter

storageAdapter
Type
string
Default
local
Options
local

Where secrets are stored.

local
Store secrets in the TYPO3 database (default). Secrets are encrypted with envelope encryption before storage.

masterKeyProvider

masterKeyProvider
Type
string
Default
typo3
Options
typo3, file, env

How to retrieve the master encryption key.

typo3
Derive from TYPO3's encryption key. This is the recommended default as it requires no additional configuration and works out of the box.
file
Read from a file on the filesystem.
env
Read from an environment variable.

masterKeySource

masterKeySource
Type
string
Default
NR_VAULT_MASTER_KEY

Source location for the master key. Interpretation depends on the provider:

  • file: Path to the key file (e.g., /secure/path/vault.key).
  • env: Environment variable name (e.g., NR_VAULT_MASTER_KEY).
  • typo3: Not used (key derived from TYPO3's encryption key).

allowCliAccess

allowCliAccess
Type
boolean
Default
false

Allow CLI commands to access secrets without a backend user session.

cliAccessGroups

cliAccessGroups
Type
string
Default
empty

Comma-separated list of backend user group UIDs that CLI can access. Empty means all secrets are accessible when CLI access is enabled.

auditLogRetention

auditLogRetention
Type
integer
Default
365

Number of days to retain audit log entries. Set to 0 for unlimited retention.

preferXChaCha20

preferXChaCha20
Type
boolean
Default
false

Prefer XChaCha20-Poly1305 over AES-256-GCM. XChaCha20 is recommended when hardware AES acceleration is not available.

Master key providers 

TYPO3 provider (default) 

Uses TYPO3's built-in encryption key to derive the master key. This is the recommended default because:

  • Zero configuration: Works immediately after installation.
  • No server access required: Ideal for users without shell access.
  • Unique per installation: Each TYPO3 instance has its own key.
  • Already secured: TYPO3's encryption key is already protected.

The master key is derived from the encryption key using HKDF-SHA256 with a nr-vault-specific context, ensuring it cannot be used to compromise other TYPO3 functionality.

Master key derivation (internal)
// How it works internally
$masterKey = hash_hkdf(
    'sha256',
    $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'],
    32,
    'nr-vault-master-key'
);
Copied!

File provider 

Store the master key in a file with restrictive permissions:

Create master key file
# Generate a new key
openssl rand -base64 32 > /secure/path/vault-master.key
chmod 0400 /secure/path/vault-master.key
Copied!

Configure in extension settings:

Environment provider 

Store the master key in an environment variable:

Set master key via environment
export NR_VAULT_MASTER_KEY="base64-encoded-key"
Copied!

Configure in extension settings:

This is ideal for containerized deployments where secrets are injected via environment variables.

Access control 

Access to secrets is controlled by:

  1. Ownership: The user who created the secret has full access.
  2. Group membership: Secrets can be shared with backend user groups.
  3. Admin access: Backend administrators have access to all secrets.
  4. CLI access: Configurable via allowCliAccess.

Context-based scoping 

Organize secrets by context for easier management:

  • payment - Payment gateway credentials.
  • email - Email service API keys.
  • api - Third-party API tokens.
  • database - External database credentials.

Contexts are user-defined strings that help organize and filter secrets.

Site configuration integration 

Use the %vault(identifier)% syntax in site configuration files:

config/sites/main/config.yaml
settings:
  payment:
    stripeSecretKey: '%vault(stripe_api_key)%'
  email:
    mailchimpKey: '%vault(mailchimp_key)%'
Copied!

Secrets are resolved when the site configuration is loaded. This keeps sensitive values out of version control while allowing configuration through the standard TYPO3 site settings.

Frontend-accessible secrets 

By default, secrets cannot be resolved in frontend context (TypoScript). To allow a secret to be used in TypoScript:

  1. Create the secret with frontend_accessible metadata.
  2. Use the %vault(identifier)% syntax in TypoScript.
Store frontend-accessible secret
$this->vaultService->store(
    'google_maps_key',
    $apiKey,
    [
        'metadata' => [
            'frontend_accessible' => true,
        ],
    ],
);
Copied!

Usage 

Backend module 

Access the vault through the TYPO3 backend:

  1. Go to Admin Tools > Vault.
  2. The overview shows statistics and quick-start examples.
  3. Navigate to Secrets to manage your secrets.
Vault module overview showing statistics and quick start guide

The vault overview displays key metrics and provides quick-start code examples

Creating secrets 

  1. Click Create Secret (+ button).
  2. Fill in the form:

    Identifier
    Unique identifier for the secret (e.g., stripe_api_key).
    Value
    The secret value to encrypt.
    Description
    Optional description for documentation.
    Context
    Optional context for organization (e.g., payment).
    Allowed groups
    Backend user groups that can access this secret.
    Expiration
    Optional expiration date after which the secret becomes inaccessible.
  3. Click Save.

Viewing and editing secrets 

Secrets are displayed with their metadata but not their values. Click Reveal to temporarily show a secret value.

Secrets list view showing secret identifiers, contexts, and metadata

The secrets list provides filtering, bulk actions, and quick access to secret operations

Site configuration 

Reference secrets in your site configuration files using the %vault(identifier)% syntax:

config/sites/mysite/config.yaml
settings:
  payment:
    stripePublicKey: 'pk_live_...'
    stripeSecretKey: '%vault(stripe_secret_key)%'
  email:
    mailchimpApiKey: '%vault(mailchimp_api_key)%'
    sendgridToken: '%vault(sendgrid_token)%'
Copied!

Secrets are resolved at runtime when the site configuration is loaded. This keeps sensitive values out of your version control while still allowing you to configure them through the familiar site settings.

TypoScript integration 

Use vault references in TypoScript for frontend-accessible secrets:

TypoScript vault reference
lib.googleMapsKey = TEXT
lib.googleMapsKey.value = %vault(google_maps_api_key)%

page.headerData.10 = TEXT
page.headerData.10.value = <script>var API_KEY = '%vault(public_api_key)%';</script>
Copied!

Example with caching disabled:

Disable caching for secrets
lib.apiKey = TEXT
lib.apiKey {
  value = %vault(my_api_key)%
  stdWrap.cache.disable = 1
}
Copied!

CLI commands 

vault:init 

Initialize the vault and generate a master key:

Initialize vault
vendor/bin/typo3 vault:init

# Output as environment variable format
vendor/bin/typo3 vault:init --env

# Specify custom output location
vendor/bin/typo3 vault:init --output=/secure/path/vault.key
Copied!

vault:store 

Create or update a secret:

Store a secret
# Interactive (prompts for value)
vendor/bin/typo3 vault:store stripe_api_key

# With all options
vendor/bin/typo3 vault:store payment_key \
  --value="sk_live_..." \
  --description="Stripe production key" \
  --context="payment" \
  --expires="+90 days" \
  --groups="1,2"
Copied!

vault:retrieve 

Retrieve a secret value:

Retrieve a secret
vendor/bin/typo3 vault:retrieve stripe_api_key

# Quiet mode for scripting
API_KEY=$(vendor/bin/typo3 vault:retrieve -q stripe_api_key)
Copied!

vault:list 

List all accessible secrets:

List secrets
vendor/bin/typo3 vault:list

# Filter by pattern
vendor/bin/typo3 vault:list --pattern="payment_*"

# JSON output for automation
vendor/bin/typo3 vault:list --format=json
Copied!

vault:rotate 

Rotate a secret with a new value:

Rotate a secret
vendor/bin/typo3 vault:rotate stripe_api_key \
  --reason="Scheduled quarterly rotation"
Copied!

vault:delete 

Delete a secret:

Delete a secret
vendor/bin/typo3 vault:delete old_api_key \
  --reason="Service deprecated" \
  --force
Copied!

vault:audit 

View the audit log:

View audit log
# View recent entries
vendor/bin/typo3 vault:audit --days=7

# Filter by secret
vendor/bin/typo3 vault:audit --identifier=stripe_api_key

# Export to JSON
vendor/bin/typo3 vault:audit --format=json > audit.json
Copied!
Audit log showing secret access history with timestamps, actors, and IP addresses

The audit log tracks all secret operations with tamper-evident hash chains

vault:rotate-master-key 

Rotate the master encryption key (re-encrypts all DEKs):

Rotate master key
# Using old key from file, new key from current config
vendor/bin/typo3 vault:rotate-master-key \
  --old-key=/path/to/old.key \
  --confirm

# Dry run to simulate
vendor/bin/typo3 vault:rotate-master-key \
  --old-key=/path/to/old.key \
  --dry-run
Copied!

vault:scan 

Scan for potential plaintext secrets in database:

Scan for plaintext secrets
vendor/bin/typo3 vault:scan

# Only critical issues
vendor/bin/typo3 vault:scan --severity=critical

# JSON for CI/CD
vendor/bin/typo3 vault:scan --format=json
Copied!

vault:migrate-field 

Migrate existing plaintext field values to vault:

Migrate field to vault
# Preview
vendor/bin/typo3 vault:migrate-field tx_myext_settings api_key --dry-run

# Execute
vendor/bin/typo3 vault:migrate-field tx_myext_settings api_key
Copied!

vault:cleanup-orphans 

Remove orphaned secrets from deleted records:

Clean up orphaned secrets
vendor/bin/typo3 vault:cleanup-orphans --dry-run
vendor/bin/typo3 vault:cleanup-orphans --retention-days=30
Copied!

PHP API 

VaultService 

Inject the VaultService to access secrets programmatically:

Inject and use VaultService
use Netresearch\NrVault\Service\VaultServiceInterface;

final class PaymentService
{
    public function __construct(
        private readonly VaultServiceInterface $vaultService,
    ) {}

    public function getApiKey(): ?string
    {
        return $this->vaultService->retrieve('stripe_api_key');
    }
}
Copied!

Storing secrets 

Store secret with options
$this->vaultService->store(
    identifier: 'payment_api_key',
    secret: 'sk_live_...',
    options: [
        'description' => 'Stripe production API key',
        'context' => 'payment',
        'groups' => [1, 2], // Backend user group UIDs
        'expiresAt' => time() + (86400 * 90), // 90 days
    ],
);
Copied!

Checking existence 

Check if secret exists
if ($this->vaultService->exists('stripe_api_key')) {
    $value = $this->vaultService->retrieve('stripe_api_key');
}
Copied!

Listing secrets 

List secrets programmatically
// Get all accessible secrets
$secrets = $this->vaultService->list();

// Filter by pattern
$paymentSecrets = $this->vaultService->list(pattern: 'payment_*');
Copied!

Vault HTTP client 

Make authenticated API calls without exposing secrets to your code. The HTTP client is PSR-18 compatible. Configure authentication with withAuthentication(), then use standard sendRequest().

Inject VaultHttpClientInterface directly:

HTTP client with vault authentication
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Http\VaultHttpClientInterface;

final class ExternalApiService
{
    public function __construct(
        private readonly VaultHttpClientInterface $httpClient,
    ) {}

    public function fetchData(): array
    {
        // Configure authentication, then use PSR-18
        $client = $this->httpClient->withAuthentication(
            'api_token',
            SecretPlacement::Bearer,
        );

        $request = new Request('GET', 'https://api.example.com/data');
        $response = $client->sendRequest($request);

        return json_decode($response->getBody()->getContents(), true);
    }
}
Copied!

Or access via VaultService:

HTTP client via VaultService
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

$client = $this->vaultService->http()
    ->withAuthentication('stripe_api_key', SecretPlacement::Bearer);

$request = new Request(
    'POST',
    'https://api.stripe.com/v1/charges',
    ['Content-Type' => 'application/json'],
    json_encode($payload),
);

$response = $client->sendRequest($request);
Copied!

Authentication options 

Authentication placement examples
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

// Bearer token
$client = $vault->http()
    ->withAuthentication('api_token', SecretPlacement::Bearer);
$response = $client->sendRequest(new Request('GET', $url));

// API key header (X-API-Key)
$client = $vault->http()
    ->withAuthentication('api_key', SecretPlacement::ApiKey);
$response = $client->sendRequest(new Request('GET', $url));

// Custom header
$client = $vault->http()
    ->withAuthentication('api_key', SecretPlacement::Header, [
        'headerName' => 'X-Custom-Auth',
    ]);
$response = $client->sendRequest(new Request('GET', $url));

// Basic authentication with separate secrets
$client = $vault->http()
    ->withAuthentication('service_password', SecretPlacement::BasicAuth, [
        'usernameSecret' => 'service_user',
    ]);
$response = $client->sendRequest(new Request('GET', $url));

// Query parameter
$client = $vault->http()
    ->withAuthentication('api_key', SecretPlacement::QueryParam, [
        'queryParam' => 'key',
    ]);
$response = $client->sendRequest(new Request('GET', $url));
Copied!

For a complete real-world example combining TCA vault fields with the HTTP client, see Example: API endpoint management.

Example: API endpoint management 

A common pattern is storing API endpoints with their credentials in a database table. This example shows how to combine TCA vault fields with the HTTP client.

Step 1: Define the TCA table 

EXT:my_extension/Configuration/TCA/tx_myext_apiendpoint.php
<?php

return [
    'ctrl' => [
        'title' => 'API Endpoints',
        'label' => 'name',
    ],
    'columns' => [
        'name' => [
            'label' => 'Name',
            'config' => ['type' => 'input', 'required' => true],
        ],
        'url' => [
            'label' => 'API Base URL',
            'config' => ['type' => 'input', 'required' => true],
        ],
        'token' => [
            'label' => 'API Token',
            'config' => [
                'type' => 'input',
                'renderType' => 'vaultSecret',
            ],
        ],
    ],
];
Copied!

Creating an API endpoint record (backend):

  1. Go to List module and select your storage folder
  2. Click + Create new record and select API Endpoint
  3. Fill in the form:

    • Name: Stripe
    • API Base URL: https://api.stripe.com/v1
    • API Token: Paste your actual API key (stored securely in vault)
  4. Click Save

The token field uses renderType: 'vaultSecret' which:

  • Shows a masked password field with reveal/copy buttons
  • Automatically stores the secret in the vault on save
  • Stores only a UUID v7 reference in the database

What gets stored in the database:

Database content (token is UUID, not the secret)
SELECT uid, name, url, token FROM tx_myext_apiendpoint;
-- | uid | name   | url                       | token                                |
-- |-----|--------|---------------------------|--------------------------------------|
-- | 1   | Stripe | https://api.stripe.com/v1 | 01937b6e-4b6c-7abc-8def-0123456789ab |
Copied!

Step 2: Create a DTO for type safety 

EXT:my_extension/Classes/Domain/Dto/ApiEndpoint.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Dto;

final readonly class ApiEndpoint
{
    public function __construct(
        public int $uid,
        public string $name,
        public string $url,
        public string $token,  // Contains vault UUID, not the secret
    ) {}

    /**
     * @param array<string, mixed> $row
     */
    public static function fromDatabaseRow(array $row): self
    {
        return new self(
            uid: (int) $row['uid'],
            name: (string) $row['name'],
            url: (string) $row['url'],
            token: (string) $row['token'],
        );
    }
}
Copied!

Step 3: Create a service for authenticated requests 

EXT:my_extension/Classes/Service/ApiClientService.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Service;

use MyVendor\MyExtension\Domain\Dto\ApiEndpoint;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Http\RequestFactory;

final class ApiClientService
{
    public function __construct(
        private readonly VaultServiceInterface $vault,
        private readonly RequestFactory $requestFactory,
    ) {}

    /**
     * Call an API endpoint with vault-managed authentication.
     *
     * The token is retrieved from vault and injected at request time.
     * It never appears in this code and is wiped from memory immediately.
     */
    public function call(
        ApiEndpoint $endpoint,
        string $method,
        string $path,
        array $data = [],
    ): ResponseInterface {
        // Create PSR-7 request using TYPO3's RequestFactory
        $request = $this->requestFactory->createRequest(
            $method,
            rtrim($endpoint->url, '/') . '/' . ltrim($path, '/'),
        );

        if ($data !== [] && \in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
            $request = $request
                ->withHeader('Content-Type', 'application/json')
                ->withBody(
                    \GuzzleHttp\Psr7\Utils::streamFor(json_encode($data))
                );
        }

        // Send via VaultHttpClient - token never exposed to application
        return $this->vault->http()
            ->withAuthentication($endpoint->token, SecretPlacement::Bearer)
            ->withReason('API call to ' . $endpoint->name . ': ' . $path)
            ->sendRequest($request);
    }

    /**
     * Convenience method for GET requests.
     */
    public function get(ApiEndpoint $endpoint, string $path): array
    {
        $response = $this->call($endpoint, 'GET', $path);

        return json_decode(
            $response->getBody()->getContents(),
            true,
            512,
            JSON_THROW_ON_ERROR,
        );
    }
}
Copied!

Step 4: Use the service 

Example controller or command
<?php

use MyVendor\MyExtension\Domain\Dto\ApiEndpoint;
use MyVendor\MyExtension\Service\ApiClientService;

// Load endpoint from database
$row = $connection->select(['*'], 'tx_myext_apiendpoint', ['uid' => 1])
    ->fetchAssociative();
$endpoint = ApiEndpoint::fromDatabaseRow($row);

// Make authenticated API call
$customers = $this->apiClientService->get($endpoint, '/customers');
Copied!

What happens under the hood 

  1. The token field contains a UUID v7 like 01937b6e-4b6c-...
  2. VaultHttpClient::sendRequest() retrieves the actual token from vault
  3. Token is injected into the Authorization: Bearer ... header
  4. sodium_memzero() immediately wipes the token from memory
  5. The HTTP call is logged to the audit trail (without the secret)

Security benefits 

This pattern provides several security advantages:

No secret exposure
Application code never sees the actual API token. The DTO contains only the vault UUID, which is useless without vault access.
Memory safety
Secrets are cleared from memory immediately after injection using sodium_memzero().
Audit trail
Every HTTP call is logged with the endpoint name, HTTP method, URL, and status code - but never the secret itself.
Separation of concerns
Credential management is handled by the vault. Application code focuses on business logic.
No CLI or file access required
Editors and admins can manage API endpoints entirely through the TYPO3 backend. The vault secret field provides a secure password input with reveal and copy functionality - no command line needed.

Example: SaaS API keys in extension settings 

Many TYPO3 extensions integrate with SaaS services (DeepL, Personio, Stepstone, Stripe, etc.) and store API keys in extension settings. This page shows how to secure these credentials with nr-vault.

The challenge 

Extension settings defined in ext_conf_template.txt are stored in LocalConfiguration.php - not in TCA tables. This means:

  • The renderType: 'vaultSecret' approach doesn't work directly
  • API keys are stored as plaintext in the filesystem
  • Keys may end up in version control or backups

Approaches 

There are three approaches to secure extension settings with vault:

Approach 2: Environment variable reference 

For containerized deployments, reference environment variables.

Extension settings template:

EXT:my_deepl_extension/ext_conf_template.txt
# cat=api; type=string; label=DeepL API Key (env var): Environment variable name containing the API key
deeplApiKeyEnvVar = DEEPL_API_KEY
Copied!

Service implementation:

EXT:my_deepl_extension/Classes/Service/DeepLService.php
<?php

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Service;

use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\RequestFactory;

final class DeepLService
{
    private const API_URL = 'https://api-free.deepl.com/v2';

    private string $apiKey;

    public function __construct(
        private readonly RequestFactory $requestFactory,
        ExtensionConfiguration $extensionConfiguration,
    ) {
        $config = $extensionConfiguration->get('my_deepl_extension');
        $envVar = (string) ($config['deeplApiKeyEnvVar'] ?? 'DEEPL_API_KEY');
        $this->apiKey = getenv($envVar) ?: '';
    }

    public function translate(string $text, string $targetLang): string
    {
        // Use TYPO3's RequestFactory directly with env-provided key
        $response = $this->requestFactory->request(
            self::API_URL . '/translate',
            'POST',
            [
                'headers' => [
                    'Authorization' => 'DeepL-Auth-Key ' . $this->apiKey,
                    'Content-Type' => 'application/json',
                ],
                'body' => json_encode([
                    'text' => [$text],
                    'target_lang' => $targetLang,
                ]),
            ]
        );

        $data = json_decode($response->getBody()->getContents(), true);
        return $data['translations'][0]['text'] ?? '';
    }
}
Copied!

Approach 3: Configuration record with TCA vault field 

For maximum security and a proper backend UI, create a configuration record.

TCA definition:

EXT:my_deepl_extension/Configuration/TCA/tx_mydeeplext_config.php
<?php

return [
    'ctrl' => [
        'title' => 'DeepL Configuration',
        'label' => 'name',
        'rootLevel' => 1,
        'security' => [
            'ignorePageTypeRestriction' => true,
        ],
    ],
    'columns' => [
        'name' => [
            'label' => 'Configuration Name',
            'config' => [
                'type' => 'input',
                'default' => 'Default',
            ],
        ],
        'api_key' => [
            'label' => 'DeepL API Key',
            'config' => [
                'type' => 'input',
                'renderType' => 'vaultSecret',
            ],
        ],
        'api_url' => [
            'label' => 'API URL',
            'config' => [
                'type' => 'input',
                'default' => 'https://api-free.deepl.com/v2',
            ],
        ],
    ],
    'types' => [
        '0' => ['showitem' => 'name, api_key, api_url'],
    ],
];
Copied!

Repository to load configuration:

EXT:my_deepl_extension/Classes/Domain/Repository/ConfigRepository.php
<?php

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Domain\Repository;

use MyVendor\MyDeeplExtension\Domain\Dto\DeepLConfig;
use TYPO3\CMS\Core\Database\ConnectionPool;

final class ConfigRepository
{
    public function __construct(
        private readonly ConnectionPool $connectionPool,
    ) {}

    public function findDefault(): ?DeepLConfig
    {
        $row = $this->connectionPool
            ->getConnectionForTable('tx_mydeeplext_config')
            ->select(['*'], 'tx_mydeeplext_config', ['deleted' => 0])
            ->fetchAssociative();

        return $row ? DeepLConfig::fromDatabaseRow($row) : null;
    }
}
Copied!

DTO:

EXT:my_deepl_extension/Classes/Domain/Dto/DeepLConfig.php
<?php

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Domain\Dto;

final readonly class DeepLConfig
{
    public function __construct(
        public int $uid,
        public string $name,
        public string $apiKey,  // Vault UUID
        public string $apiUrl,
    ) {}

    public static function fromDatabaseRow(array $row): self
    {
        return new self(
            uid: (int) $row['uid'],
            name: (string) $row['name'],
            apiKey: (string) $row['api_key'],
            apiUrl: (string) $row['api_url'],
        );
    }
}
Copied!

Service using config record:

EXT:my_deepl_extension/Classes/Service/DeepLService.php
<?php

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Service;

use MyVendor\MyDeeplExtension\Domain\Repository\ConfigRepository;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
use TYPO3\CMS\Core\Http\RequestFactory;

final class DeepLService
{
    public function __construct(
        private readonly VaultServiceInterface $vault,
        private readonly RequestFactory $requestFactory,
        private readonly ConfigRepository $configRepository,
    ) {}

    public function translate(string $text, string $targetLang): string
    {
        $config = $this->configRepository->findDefault();
        if ($config === null) {
            throw new \RuntimeException('DeepL not configured', 1735900001);
        }

        $request = $this->requestFactory
            ->createRequest('POST', $config->apiUrl . '/translate')
            ->withHeader('Content-Type', 'application/json')
            ->withBody(\GuzzleHttp\Psr7\Utils::streamFor(json_encode([
                'text' => [$text],
                'target_lang' => $targetLang,
            ])));

        $response = $this->vault->http()
            ->withAuthentication($config->apiKey, SecretPlacement::Bearer)
            ->withReason('DeepL translation: ' . $targetLang)
            ->sendRequest($request);

        $data = json_decode($response->getBody()->getContents(), true);
        return $data['translations'][0]['text'] ?? '';
    }
}
Copied!

Advantages of Approach 3:

  • Full vault UI with masked input, reveal, copy buttons
  • Proper access control (owner, groups)
  • Audit logging of configuration access
  • Multiple configurations possible (e.g., per site)

Comparison 

Aspect Approach 1 (Vault ID) Approach 2 (Env var) Approach 3 (TCA record)
Setup complexity Low Low Medium
Security High Medium High
Memory safety Yes (sodium_memzero) No Yes (sodium_memzero)
Audit trail Yes No Yes
Backend UI Text field Text field Vault secret field
Multi-config Manual Manual Native

Recommendation: Use Approach 1 for simple single-key integrations, Approach 3 for complex integrations requiring multiple configurations or strict access control.

Security 

Encryption architecture 

nr-vault uses envelope encryption, an industry-standard pattern for protecting sensitive data.

Secret 1Secret 2Secret 3DEK₁ (encrypted)Value (encrypted)DEK₂ (encrypted)Value (encrypted)DEK₃ (encrypted)Value (encrypted)Master Keyencryptsencryptsencryptsencryptsencryptsencrypts
Envelope encryption: Each secret has its own DEK encrypted by the master key.

How it works 

  1. Data Encryption Key (DEK): Each secret gets a unique 256-bit key generated using cryptographically secure random bytes.
  2. Value encryption: The secret value is encrypted with its DEK using AES-256-GCM (or XChaCha20-Poly1305).
  3. DEK encryption: The DEK is encrypted with the Master Key and stored alongside the encrypted value.
  4. Decryption: To read a secret, first decrypt the DEK with the Master Key, then use the DEK to decrypt the value.

Benefits 

  • Key rotation: Rotating the master key only requires re-encrypting DEKs, not the actual secret values.
  • Blast radius: If a DEK is compromised, only one secret is affected.
  • Performance: Bulk operations on secrets don't require the master key for each operation.

Algorithms 

AES-256-GCM (default)
Advanced Encryption Standard with 256-bit keys in Galois/Counter Mode. Provides authenticated encryption with hardware acceleration on modern CPUs.
XChaCha20-Poly1305 (optional)
ChaCha20 stream cipher with extended nonce and Poly1305 MAC. Recommended when hardware AES is not available.

Both algorithms provide:

  • 256-bit key strength.
  • Authenticated encryption (AEAD).
  • Protection against tampering.

Master key security 

The master key is the root of trust for all secrets.

Provider security comparison 

TYPO3 provider (default, recommended for most users)
Security depends on TYPO3's encryption key protection. Suitable for environments where the encryption key is properly secured in settings.php. No additional configuration required.
File provider (recommended for high-security environments)
Allows storing the key outside the database and web root with strict permissions. Requires server access to configure.
Environment provider (recommended for containers)
Ideal for containerized deployments where secrets are injected at runtime. Follows 12-factor app methodology.

File storage recommendations 

When using the file provider:

  1. Outside web root: Never store in publicly accessible directories.
  2. Restrictive permissions: Use 0400 (read-only by owner).
  3. Separate backup: Back up the master key separately from the database.
  4. Access logging: Monitor access to the key file.
  5. Key rotation: Rotate the master key periodically.

Audit logging 

All secret operations are logged with:

  • Timestamp.
  • Action (create, read, update, delete).
  • Actor (user ID, username, type).
  • Secret identifier.
  • IP address.
  • Result (success/failure).

Hash chain integrity 

Audit log entries form a hash chain where each entry includes a hash of the previous entry. This provides:

  • Tamper detection: Any modification to log entries breaks the chain.
  • Completeness: Deleted entries are detectable.
  • Non-repudiation: Actions cannot be denied after logging.

Access control 

Secret access is controlled at multiple levels:

  1. Authentication: Backend user must be logged in.
  2. Ownership: Creator has full access.
  3. Group membership: Shared access via backend groups.
  4. Admin override: Administrators can access all secrets.

Security best practices 

  1. Regular key rotation: Rotate the master key annually or after security incidents.
  2. Audit log review: Regularly review audit logs for suspicious access.
  3. Minimal permissions: Grant access only to users who need it.
  4. Secret rotation: Rotate secrets when personnel changes occur.
  5. Monitoring: Set up alerts for access_denied events.
  6. Backup security: Encrypt backups and store them securely.

Reporting vulnerabilities 

If you discover a security vulnerability, please report it responsibly:

DO NOT create a public GitHub issue.

Use GitHub's private security reporting feature: Report a vulnerability

See SECURITY.md for the full security policy.

Developer 

Architecture overview 

nr-vault follows clean architecture principles with these main components:

Service layer
VaultService - Main facade for all vault operations.
Crypto layer
EncryptionService - Envelope encryption implementation. MasterKeyProvider - Master key retrieval abstraction.
Storage layer
SecretRepository - Database persistence. VaultAdapterInterface - Storage backend abstraction.
Security layer
AccessControlService - Permission checks. AuditLogService - Operation logging.

Extending nr-vault 

Custom storage adapters 

Implement VaultAdapterInterface to add new storage backends:

EXT:my_extension/Classes/Adapter/CustomAdapter.php
namespace MyVendor\MyExtension\Adapter;

use Netresearch\NrVault\Adapter\VaultAdapterInterface;
use Netresearch\NrVault\Domain\Model\Secret;

final class CustomAdapter implements VaultAdapterInterface
{
    public function getIdentifier(): string
    {
        return 'custom';
    }

    public function isAvailable(): bool
    {
        // Check if your backend is configured and reachable
    }

    public function store(Secret $secret): void
    {
        // Store secret in your backend
    }

    public function retrieve(string $identifier): ?Secret
    {
        // Retrieve secret from your backend
    }

    public function delete(string $identifier): void
    {
        // Delete from your backend
    }

    public function exists(string $identifier): bool
    {
        // Check if secret exists
    }

    public function list(array $filters = []): array
    {
        // List secret identifiers
    }

    public function getMetadata(string $identifier): ?array
    {
        // Get secret metadata
    }

    public function updateMetadata(string $identifier, array $metadata): void
    {
        // Update metadata
    }
}
Copied!

Register in Services.yaml:

EXT:my_extension/Configuration/Services.yaml
MyVendor\MyExtension\Adapter\CustomAdapter:
  tags:
    - name: nr_vault.adapter
      identifier: custom
Copied!

Custom master key providers 

Implement MasterKeyProviderInterface for custom key sources:

EXT:my_extension/Classes/Crypto/VaultKeyProvider.php
namespace MyVendor\MyExtension\Crypto;

use Netresearch\NrVault\Crypto\MasterKeyProviderInterface;

final class VaultKeyProvider implements MasterKeyProviderInterface
{
    public function getIdentifier(): string
    {
        return 'hashicorp';
    }

    public function isAvailable(): bool
    {
        // Check if HashiCorp Vault is accessible
    }

    public function getMasterKey(): string
    {
        // Retrieve key from HashiCorp Vault
    }

    public function storeMasterKey(string $key): void
    {
        // Store key in HashiCorp Vault
    }

    public function generateMasterKey(): string
    {
        return random_bytes(32);
    }
}
Copied!

Events 

nr-vault dispatches PSR-14 events for extensibility:

SecretAccessedEvent
Dispatched when a secret is read.
SecretCreatedEvent
Dispatched when a new secret is created.
SecretRotatedEvent
Dispatched when a secret is rotated with a new value.
SecretUpdatedEvent
Dispatched when a secret value is updated (without rotation).
SecretDeletedEvent
Dispatched when a secret is deleted.
MasterKeyRotatedEvent
Dispatched after master key rotation completes.

Example listener:

EXT:my_extension/Classes/EventListener/SecretAccessLogger.php
namespace MyVendor\MyExtension\EventListener;

use Netresearch\NrVault\Event\SecretAccessedEvent;

final class SecretAccessLogger
{
    public function __invoke(SecretAccessedEvent $event): void
    {
        // Custom logging or alerting
        $identifier = $event->getIdentifier();
        $actorUid = $event->getActorUid();
    }
}
Copied!

Testing 

Development setup 

Use DDEV for local development:

Start DDEV environment
ddev start
ddev install-v14
ddev vault-init
Copied!

Running tests 

Run test suites
# All tests
ddev exec composer test

# Unit tests only
ddev exec .Build/bin/phpunit -c Tests/Build/phpunit.xml --testsuite Unit

# With coverage
ddev exec .Build/bin/phpunit -c Tests/Build/phpunit.xml --coverage-html .Build/coverage
Copied!

Code quality 

Run code quality tools
# PHP-CS-Fixer
ddev exec composer fix

# PHPStan
ddev exec composer stan
Copied!

Contributing 

See CONTRIBUTING.md for contribution guidelines.

  1. Fork the repository.
  2. Create a feature branch.
  3. Write tests for your changes.
  4. Ensure all tests pass.
  5. Submit a pull request.

API reference 

VaultService 

class VaultService
Fully qualified name
\Netresearch\\NrVault\\Service\VaultService

Main facade for vault operations.

retrieve ( string $identifier)

Retrieve a decrypted secret value.

returntype

string|null

store ( string $identifier, string $secret, array $options = []) : void

Create or update a secret.

exists ( string $identifier) : bool

Check if a secret exists and is accessible.

delete ( string $identifier, string $reason = '') : void

Delete a secret.

rotate ( string $identifier, string $newSecret, string $reason = '') : void

Rotate a secret with a new value.

list ( string $pattern = null) : array

List accessible secrets.

param string|null $pattern

Optional filter pattern.

getMetadata ( string $identifier) : array

Get metadata for a secret.

http ( ) : VaultHttpClientInterface

Get the Vault HTTP Client.

EncryptionService 

class EncryptionService
Fully qualified name
\Netresearch\\NrVault\\Crypto\EncryptionService

Envelope encryption implementation.

encrypt ( string $plaintext, string $identifier) : array

Encrypt a value using envelope encryption.

decrypt ( string $encryptedValue, string $encryptedDek, string $dekNonce, string $valueNonce, string $identifier) : string

Decrypt an envelope-encrypted value.

generateDek ( ) : string

Generate a new Data Encryption Key.

calculateChecksum ( string $plaintext) : string

Calculate value checksum for change detection.

reEncryptDek ( string $encryptedDek, string $dekNonce, string $identifier, string $oldMasterKey, string $newMasterKey) : array

Re-encrypt a DEK with a new master key (for master key rotation).

API 

This chapter documents the public API of the nr-vault extension.

VaultService 

The main service for interacting with the vault.

interface VaultServiceInterface
Fully qualified name
\Netresearch\NrVault\Service\VaultServiceInterface

Main interface for vault operations.

store ( string $identifier, string $secret, array $options = []) : void

Store a secret in the vault.

param string $identifier

Unique identifier for the secret.

param string $secret

The secret value to store.

param array $options

Optional configuration (owner, groups, context, expiresAt, metadata).

retrieve ( string $identifier)

Retrieve a secret from the vault.

param string $identifier

The secret identifier.

returntype

string|null

throws AccessDeniedException

If user lacks read permission.

throws SecretExpiredException

If the secret has expired.

Returns

The decrypted secret value or null if not found.

exists ( string $identifier) : bool

Check if a secret exists.

param string $identifier

The secret identifier.

Returns

True if the secret exists.

delete ( string $identifier, string $reason = '') : void

Delete a secret from the vault.

param string $identifier

The secret identifier.

param string $reason

Optional reason for deletion (logged).

throws SecretNotFoundException

If secret doesn't exist.

throws AccessDeniedException

If user lacks delete permission.

rotate ( string $identifier, string $newSecret, string $reason = '') : void

Rotate a secret with a new value.

param string $identifier

The secret identifier.

param string $newSecret

The new secret value.

param string $reason

Optional reason for rotation (logged).

list ( string $pattern = null) : array

List accessible secrets.

param string|null $pattern

Optional pattern to filter identifiers.

Returns

Array of secret metadata.

getMetadata ( string $identifier) : array

Get metadata for a secret without retrieving its value.

param string $identifier

The secret identifier.

Returns

Array with identifier, description, owner, groups, version, etc.

http ( ) : VaultHttpClientInterface

Get an HTTP client that can inject secrets into requests.

Returns

A PSR-18 compatible vault-aware HTTP client.

Usage examples 

Storing a secret 

Store a secret with VaultService
use Netresearch\NrVault\Service\VaultServiceInterface;

class MyService
{
    public function __construct(
        private readonly VaultServiceInterface $vault,
    ) {}

    public function storeApiKey(string $apiKey): void
    {
        $this->vault->store(
            'my_extension_api_key',
            $apiKey,
            [
                'description' => 'API key for external service',
                'groups' => [1, 2], // Admin, Editor groups
                'context' => 'payment',
                'expiresAt' => time() + 86400 * 90, // 90 days
            ]
        );
    }
}
Copied!

Retrieving a secret 

Retrieve a secret value
public function getApiKey(): ?string
{
    return $this->vault->retrieve('my_extension_api_key');
}
Copied!

Vault HTTP client 

The vault provides a PSR-18 compatible HTTP client that can inject secrets into requests without exposing them to your code. Configure authentication with withAuthentication(), then use standard sendRequest().

Via VaultService 

HTTP client via VaultService
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

$client = $this->vaultService->http()
    ->withAuthentication('stripe_api_key', SecretPlacement::Bearer);

$request = new Request(
    'POST',
    'https://api.stripe.com/v1/charges',
    ['Content-Type' => 'application/json'],
    json_encode($payload),
);

$response = $client->sendRequest($request);
Copied!
interface VaultHttpClientInterface
Fully qualified name
\Netresearch\NrVault\Service\VaultHttpClientInterface

PSR-18 compatible HTTP client with vault-based authentication. Extends \Psr\Http\Client\ClientInterface .

withAuthentication ( string $secretIdentifier, SecretPlacement $placement = SecretPlacement::Bearer, array $options = []) : static

Create a new client instance configured with authentication. Returns an immutable instance - the original is unchanged.

param string $secretIdentifier

Vault identifier for the secret.

param SecretPlacement $placement

How to inject the secret.

param array $options

Additional options (headerName, queryParam, usernameSecret, reason).

Returns

New client instance with authentication configured.

withOAuth ( OAuthConfig $config, string $reason = 'OAuth2 API call') : static

Create a new client instance configured with OAuth 2.0 authentication.

param OAuthConfig $config

OAuth configuration.

param string $reason

Audit log reason.

Returns

New client instance with OAuth configured.

withReason ( string $reason) : static

Create a new client instance with a custom audit reason.

param string $reason

Audit log reason for requests.

Returns

New client instance with reason configured.

sendRequest ( RequestInterface $request) : ResponseInterface

Send an HTTP request (PSR-18 method).

param RequestInterface $request

PSR-7 request.

throws ClientExceptionInterface

If request fails.

Returns

PSR-7 response.

Authentication options 

The withAuthentication() method accepts these options:

headerName
Custom header name (for SecretPlacement::Header, default: X-API-Key).
queryParam
Query parameter name (for SecretPlacement::QueryParam, default: api_key).
bodyField
Body field name (for SecretPlacement::BodyField, default: api_key).
usernameSecret
Separate username secret identifier (for SecretPlacement::BasicAuth).
reason
Reason for access (logged in audit).

SecretPlacement enum 

placement

Authentication placement using SecretPlacement enum:

  • SecretPlacement::Bearer - Bearer token in Authorization header.
  • SecretPlacement::BasicAuth - HTTP Basic Authentication.
  • SecretPlacement::Header - Custom header value.
  • SecretPlacement::QueryParam - Query parameter.
  • SecretPlacement::BodyField - Field in request body.
  • SecretPlacement::OAuth2 - OAuth 2.0 with automatic token refresh.
  • SecretPlacement::ApiKey - X-API-Key header (shorthand).
Authentication examples
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

// Bearer authentication
$client = $this->vault->http()
    ->withAuthentication('stripe_api_key', SecretPlacement::Bearer);
$response = $client->sendRequest(
    new Request('POST', 'https://api.stripe.com/v1/charges', [], $body)
);

// Custom header
$client = $this->vault->http()
    ->withAuthentication('api_token', SecretPlacement::Header, [
        'headerName' => 'X-API-Key',
    ]);
$response = $client->sendRequest(
    new Request('GET', 'https://api.example.com/data')
);

// Basic authentication with separate credentials
$client = $this->vault->http()
    ->withAuthentication('service_password', SecretPlacement::BasicAuth, [
        'usernameSecret' => 'service_username',
        'reason' => 'Fetching secure data',
    ]);
$response = $client->sendRequest(
    new Request('GET', 'https://api.example.com/secure')
);

// Query parameter
$client = $this->vault->http()
    ->withAuthentication('api_key', SecretPlacement::QueryParam, [
        'queryParam' => 'key',
    ]);
$response = $client->sendRequest(
    new Request('GET', 'https://maps.example.com/geocode')
);
Copied!

PSR-14 events 

The vault dispatches events during secret operations.

class SecretCreatedEvent
Fully qualified name
\Netresearch\NrVault\Service\SecretCreatedEvent

Dispatched when a new secret is created.

  • getIdentifier(): The secret identifier.
  • getSecret(): The Secret entity.
  • getActorUid(): User ID who created it.
class SecretAccessedEvent
Fully qualified name
\Netresearch\NrVault\Service\SecretAccessedEvent

Dispatched when a secret is read.

  • getIdentifier(): The secret identifier.
  • getActorUid(): User ID who accessed it.
  • getContext(): The secret's context.
class SecretRotatedEvent
Fully qualified name
\Netresearch\NrVault\Service\SecretRotatedEvent

Dispatched when a secret is rotated.

  • getIdentifier(): The secret identifier.
  • getNewVersion(): The new version number.
  • getActorUid(): User ID who rotated it.
  • getReason(): The rotation reason.
class SecretDeletedEvent
Fully qualified name
\Netresearch\NrVault\Service\SecretDeletedEvent

Dispatched when a secret is deleted.

  • getIdentifier(): The secret identifier.
  • getActorUid(): User ID who deleted it.
  • getReason(): The deletion reason.
class SecretUpdatedEvent
Fully qualified name
\Netresearch\NrVault\Service\SecretUpdatedEvent

Dispatched when a secret value is updated (without rotation).

  • getIdentifier(): The secret identifier.
  • getNewVersion(): The new version number.
  • getActorUid(): User ID who updated it.
class MasterKeyRotatedEvent
Fully qualified name
\Netresearch\NrVault\Service\MasterKeyRotatedEvent

Dispatched after master key rotation completes.

  • getSecretsReEncrypted(): Number of secrets re-encrypted.
  • getActorUid(): User ID who performed the rotation.
  • getRotatedAt(): DateTimeImmutable of when rotation completed.

CLI commands 

nr-vault provides several CLI commands for DevOps automation and management.

vault:init 

Initialize the vault by creating a master key.

Command syntax
vendor/bin/typo3 vault:init [options]
Copied!

Options 

--output, -o
Path to store the master key file (default: configured path or var/vault/master.key).
--force, -f
Overwrite existing master key (dangerous - existing secrets become unrecoverable!).
--env, -e
Output key as environment variable format instead of file.

Example 

vault:init examples
# Initialize with default location
vendor/bin/typo3 vault:init

# Specify custom key file location
vendor/bin/typo3 vault:init --output=/secure/path/vault.key

# Output as environment variable
vendor/bin/typo3 vault:init --env
Copied!

vault:store 

Store a secret in the vault.

Command syntax
vendor/bin/typo3 vault:store <identifier> [options]
Copied!

Arguments 

identifier
Unique identifier for the secret.

Options 

--value=SECRET
The secret value (will prompt if not provided).
--description=TEXT
Optional description.
--context=CONTEXT
Optional context for permission scoping.
--expires=TIMESTAMP
Expiration timestamp or relative time (e.g., +90 days).
--groups=GROUPS
Comma-separated list of allowed backend user group IDs.

Example 

vault:store examples
# Interactive (prompts for secret)
vendor/bin/typo3 vault:store stripe_api_key

# With options
vendor/bin/typo3 vault:store payment_key \
  --value="sk_live_..." \
  --description="Stripe production key" \
  --context="payment" \
  --expires="+90 days" \
  --groups="1,2"
Copied!

vault:retrieve 

Retrieve a secret from the vault.

Command syntax
vendor/bin/typo3 vault:retrieve <identifier> [options]
Copied!

Options 

--quiet, -q
Output only the secret value (for scripting).

Example 

vault:retrieve examples
# Display with metadata
vendor/bin/typo3 vault:retrieve stripe_api_key

# For use in scripts
API_KEY=$(vendor/bin/typo3 vault:retrieve -q stripe_api_key)
Copied!

vault:list 

List all accessible secrets.

Command syntax
vendor/bin/typo3 vault:list [options]
Copied!

Options 

--pattern=PATTERN
Filter by identifier pattern (supports * wildcard).
--format=FORMAT
Output format: table (default), json, csv.

Example 

vault:list examples
# List all secrets
vendor/bin/typo3 vault:list

# Filter by pattern
vendor/bin/typo3 vault:list --pattern="payment_*"

# JSON output for automation
vendor/bin/typo3 vault:list --format=json
Copied!

vault:rotate 

Rotate a secret with a new value.

Command syntax
vendor/bin/typo3 vault:rotate <identifier> [options]
Copied!

Options 

--value=SECRET
The new secret value (will prompt if not provided).
--reason=TEXT
Reason for rotation (logged in audit).

Example 

vault:rotate example
vendor/bin/typo3 vault:rotate stripe_api_key \
  --reason="Scheduled quarterly rotation"
Copied!

vault:delete 

Delete a secret from the vault.

Command syntax
vendor/bin/typo3 vault:delete <identifier> [options]
Copied!

Options 

--reason=TEXT
Reason for deletion (logged in audit).
--force, -f
Skip confirmation prompt.

Example 

vault:delete example
vendor/bin/typo3 vault:delete old_api_key \
  --reason="Service deprecated" \
  --force
Copied!

vault:audit 

View the audit log.

Command syntax
vendor/bin/typo3 vault:audit [options]
Copied!

Options 

--identifier=ID
Filter by secret identifier.
--action=ACTION
Filter by action (create, read, update, delete, rotate).
--days=N
Show entries from last N days (default: 30).
--limit=N
Maximum entries to show (default: 100).
--format=FORMAT
Output format: table (default), json.

Example 

vault:audit examples
# View recent audit log
vendor/bin/typo3 vault:audit --days=7

# Filter by secret
vendor/bin/typo3 vault:audit --identifier=stripe_api_key

# Export to JSON
vendor/bin/typo3 vault:audit --format=json > audit.json
Copied!

vault:rotate-master-key 

Rotate the master encryption key. Re-encrypts all DEKs with a new master key.

Command syntax
vendor/bin/typo3 vault:rotate-master-key [options]
Copied!

Options 

--old-key=PATH
Path to file containing the old master key (defaults to current configured key).
--new-key=PATH
Path to file containing the new master key (defaults to current configured key).
--dry-run
Simulate the rotation without making changes.
--confirm
Required for actual execution (safety measure).

Example 

vault:rotate-master-key examples
# Old key from file, new key from current config
vendor/bin/typo3 vault:rotate-master-key \
  --old-key=/secure/path/old-master.key \
  --confirm

# Both keys from files
vendor/bin/typo3 vault:rotate-master-key \
  --old-key=/path/to/old.key \
  --new-key=/path/to/new.key \
  --confirm

# Dry run to verify before actual rotation
vendor/bin/typo3 vault:rotate-master-key \
  --old-key=/path/to/old.key \
  --dry-run
Copied!

vault:scan 

Scan for potential plaintext secrets in database and configuration.

Command syntax
vendor/bin/typo3 vault:scan [options]
Copied!

Options 

--format, -f
Output format: table (default), json, or summary.
--exclude, -e
Comma-separated list of tables to exclude (supports wildcards).
--severity, -s
Minimum severity to report: critical, high, medium, low (default: low).
--database-only
Only scan database tables.
--config-only
Only scan configuration files.

The command detects:

  • Database columns with secret-like names (password, api_key, token, etc.).
  • Known API key patterns (Stripe, AWS, GitHub, Slack, etc.).
  • Extension configuration secrets.
  • LocalConfiguration secrets (SMTP password, etc.).

Severity levels 

critical
Known API key pattern detected (Stripe, AWS, etc.).
high
Password or private key column with non-empty value.
medium
Token or API key column with suspicious value.
low
Secret-like column name detected.

Example 

vault:scan examples
# Scan all sources
vendor/bin/typo3 vault:scan

# Output as JSON for CI/CD
vendor/bin/typo3 vault:scan --format=json

# Exclude cache tables
vendor/bin/typo3 vault:scan --exclude=cache_*,cf_*

# Only show critical issues
vendor/bin/typo3 vault:scan --severity=critical
Copied!

vault:migrate-field 

Migrate existing plaintext database field values to vault storage.

Command syntax
vendor/bin/typo3 vault:migrate-field <table> <field> [options]
Copied!

Arguments 

table
Database table name (e.g., tx_myext_settings).
field
Field name containing plaintext values to migrate.

Options 

--dry-run
Show what would be migrated without making changes.
--batch-size, -b
Number of records to process per batch (default: 100).
--where, -w
Additional WHERE clause to filter records (e.g., pid=1).
--force, -f
Migrate even if field already contains vault identifiers.
--clear-source
Clear the source field after migration (set to empty string).
--uid-field
Name of the UID field (default: uid).

Example 

vault:migrate-field examples
# Preview migration
vendor/bin/typo3 vault:migrate-field tx_myext_settings api_key --dry-run

# Migrate with specific records
vendor/bin/typo3 vault:migrate-field tx_myext_settings api_key --where="pid=1"

# Migrate and clear source field
vendor/bin/typo3 vault:migrate-field tx_myext_settings api_key --clear-source
Copied!

vault:cleanup-orphans 

Clean up orphaned vault secrets from deleted TCA records.

When records with vault-backed fields are deleted, the corresponding vault secrets may become orphaned. This command identifies and removes such orphaned secrets.

Command syntax
vendor/bin/typo3 vault:cleanup-orphans [options]
Copied!

Options 

--dry-run
Show what would be deleted without making changes.
--retention-days, -r
Only delete orphans older than this many days (default: 0).
--table, -t
Only check secrets for this specific table.
--batch-size, -b
Number of secrets to check per batch (default: 100).

Example 

vault:cleanup-orphans examples
# Preview orphan cleanup
vendor/bin/typo3 vault:cleanup-orphans --dry-run

# Only clean up orphans older than 30 days
vendor/bin/typo3 vault:cleanup-orphans --retention-days=30

# Clean up orphans for specific table only
vendor/bin/typo3 vault:cleanup-orphans --table=tx_myext_settings
Copied!

TCA integration 

nr-vault provides a custom TCA field type that allows any TYPO3 extension to store sensitive data (API keys, credentials, tokens) securely in the vault instead of plaintext in the database.

Quick start 

Step 1: Add dependency 

Add nr-vault as a dependency in your extension's composer.json:

EXT:my_extension/composer.json
{
    "require": {
        "netresearch/nr-vault": "^1.0"
    }
}
Copied!

Step 2: Configure TCA field 

Use the vaultSecret renderType in your TCA configuration:

Configuration/TCA/tx_myext_settings.php
<?php
return [
    'ctrl' => [
        'title' => 'My Extension Settings',
        // ... other ctrl settings
    ],
    'columns' => [
        'api_key' => [
            'label' => 'API Key',
            'config' => [
                'type' => 'input',
                'renderType' => 'vaultSecret',
                'size' => 30,
            ],
        ],
    ],
];
Copied!

Step 3: Add database column 

Add the column to your extension's ext_tables.sql:

EXT:my_extension/ext_tables.sql
CREATE TABLE tx_myext_settings (
    api_key varchar(255) DEFAULT '' NOT NULL
);
Copied!

The column stores the vault identifier, not the actual secret.

Step 4: Retrieve secrets in code 

Use the VaultFieldResolver utility to retrieve actual secret values:

Resolve vault fields in code
use Netresearch\NrVault\Utility\VaultFieldResolver;

class MyService
{
    public function callExternalApi(array $settings): void
    {
        // Resolve vault identifiers to actual values
        $resolved = VaultFieldResolver::resolveFields(
            $settings,
            ['api_key', 'api_secret']
        );

        // Now $resolved['api_key'] contains the actual secret
        $client->authenticate($resolved['api_key']);
    }
}
Copied!

Using the TCA helper 

For cleaner TCA configuration, use the VaultFieldHelper:

Configuration/TCA/tx_myext_settings.php
<?php
use Netresearch\NrVault\TCA\VaultFieldHelper;

return [
    'columns' => [
        'api_key' => VaultFieldHelper::getFieldConfig([
            'label' => 'API Key',
            'description' => 'Your API authentication key',
            'size' => 30,
        ]),

        // Secure field with common defaults (exclude: true, l10n_mode: exclude)
        'api_secret' => VaultFieldHelper::getSecureFieldConfig(
            'API Secret',
            ['required' => true]
        ),
    ],
];
Copied!

Available options 

Option Type Description
label string Field label.
description string Field description/help text.
size int Input field size (default: 30).
required bool Whether field is required (default: false).
placeholder string Placeholder text.
displayCond string TCA display condition.
l10n_mode string Localization mode.
exclude bool Exclude from non-admin access.

FlexForm integration 

Vault secrets also work in FlexForm fields:

Configuration/FlexForms/Settings.xml
<T3DataStructure>
    <sheets>
        <settings>
            <ROOT>
                <el>
                    <apiKey>
                        <label>API Key</label>
                        <config>
                            <type>input</type>
                            <renderType>vaultSecret</renderType>
                            <size>30</size>
                        </config>
                    </apiKey>
                </el>
            </ROOT>
        </settings>
    </sheets>
</T3DataStructure>
Copied!

Resolve FlexForm secrets using FlexFormVaultResolver:

Resolve FlexForm vault fields
use Netresearch\NrVault\Utility\FlexFormVaultResolver;
use TYPO3\CMS\Core\Service\FlexFormService;

class MyPlugin
{
    public function __construct(
        private readonly FlexFormService $flexFormService,
    ) {}

    public function processSettings(array $contentElement): array
    {
        $settings = $this->flexFormService->convertFlexFormContentToArray(
            $contentElement['pi_flexform']
        );

        // Resolve specific fields
        return FlexFormVaultResolver::resolveSettings(
            $settings,
            ['apiKey', 'apiSecret']
        );

        // Or resolve all vault identifiers automatically
        return FlexFormVaultResolver::resolveAll($settings);
    }
}
Copied!

VaultFieldResolver API 

The VaultFieldResolver class provides utilities for working with vault-backed TCA fields.

resolveFields() 

Resolve specific fields in a data array:

VaultFieldResolver::resolveFields()
$resolved = VaultFieldResolver::resolveFields(
    $data,           // Array with potential vault identifiers
    ['field1'],      // Fields to resolve
    false            // Throw on error (default: false)
);
Copied!

resolve() 

Resolve a single vault identifier (UUID v7 format):

VaultFieldResolver::resolve()
// TCA field identifiers use UUID v7 format
$secret = VaultFieldResolver::resolve('01937b6e-4b6c-7abc-8def-0123456789ab');
Copied!

resolveRecord() 

Automatically resolve all vault fields in a record based on TCA:

VaultFieldResolver::resolveRecord()
$resolved = VaultFieldResolver::resolveRecord('tx_myext_settings', $record);
Copied!

isVaultIdentifier() 

Check if a value is a vault identifier:

VaultFieldResolver::isVaultIdentifier()
if (VaultFieldResolver::isVaultIdentifier($value)) {
    // This is a vault identifier
}
Copied!

getVaultFieldsForTable() 

Get list of vault field names for a table:

VaultFieldResolver::getVaultFieldsForTable()
$fields = VaultFieldResolver::getVaultFieldsForTable('tx_myext_settings');
// Returns: ['api_key', 'api_secret']
Copied!

How it works 

Data flow 

  1. Form display: The VaultSecretElement renders an obfuscated password field with reveal/copy buttons.
  2. Form submit: The DataHandlerHook intercepts the form data:

    • Extracts the secret value from the form.
    • Generates a UUID v7 identifier (time-ordered, unique).
    • Stores the secret in the vault with metadata (table, field, uid).
    • Saves only the UUID identifier to the database.
  3. Runtime retrieval: Your code uses VaultFieldResolver to look up the actual secret from the vault using the UUID.

Identifier format 

TCA and FlexForm fields use UUID v7 identifiers:

01937b6e-4b6c-7abc-8def-0123456789ab
Copied!

UUID v7 provides:

  • Time-ordering: Better B-tree index performance in databases.
  • Uniqueness: Collision-free without central coordination.
  • Security: Does not expose table/field names in the identifier.

The source context (table, field, uid) is stored as metadata in the vault, not in the identifier itself.

Record operations 

  • Create: New vault secret is stored automatically.
  • Update: Secret is rotated (maintains audit trail).
  • Delete: Vault secret is removed when record is deleted.
  • Copy: Vault secret is copied to new record.

Security considerations 

Access control 

Vault secrets inherit the access control of the record they belong to. If a backend user can edit the record, they can update the vault secret.

The reveal button requires explicit user action and is logged.

Audit trail 

All vault operations are logged:

  • Secret creation.
  • Secret reads (via reveal button).
  • Secret updates.
  • Secret deletion.

Review the audit log in the backend module under Admin Tools > Vault > Audit Log.

No plaintext in database 

Only vault identifiers are stored in your extension's database tables. The actual secrets are encrypted with AES-256-GCM in the vault.

Migration 

To migrate existing plaintext credentials to vault storage:

  1. Add the renderType to your existing TCA field configuration.
  2. Run the migration command:

    Migrate existing field to vault
    vendor/bin/typo3 vault:migrate-field tx_myext_settings api_key
    Copied!

This will:

  • Read existing plaintext values.
  • Store them securely in the vault.
  • Update records with vault identifiers.

Secure Outbound 

Secure Outbound extends nr-vault into a governed outbound integration platform for TYPO3. It provides centralized credential management, policy enforcement, and audit logging for all external API calls.

Overview 

TYPO3 projects increasingly depend on external APIs (LLMs, shipping, payments, CRM, marketing, internal platforms). Today, most integrations are implemented per extension:

  • endpoints are configured in multiple places
  • credentials get passed around in PHP memory
  • every integration re-implements auth/retry/timeouts/logging
  • no centralized policy enforcement (SSRF hardening, allowed hosts/paths)
  • no consistent audit trail per outbound API call

Secure Outbound addresses these issues with three core components:

Service Registry
Central definition of service endpoints with security policies.
Credential Sets
Typed bundles of secrets (OAuth2, API key, Basic auth) managed as one unit.
SecureHttpClient
Stable PHP API for extensions to call services by serviceId.

Core concepts 

Service Registry 

Services are centrally configured with:

  • serviceId: stable identifier used by consuming code
  • base URLs: allowed endpoint URLs
  • security policy: allowed hosts, methods, path patterns, timeout caps
  • credential binding: link to a Credential Set

Extensions never hardcode endpoints. They reference services by serviceId.

Credential Sets 

Credential Sets are typed wrappers over nr-vault secrets. They store encrypted JSON payloads containing all fields for a credential type:

Bearer Token:

{"token": "sk-abc123..."}
Copied!

OAuth2 Client Credentials:

{
  "client_id": "my-client",
  "client_secret": "secret123",
  "token_url": "https://oauth.example.com/token",
  "scopes": ["read", "write"]
}
Copied!

Supported credential types (MVP):

  • Bearer Token
  • API Key Header
  • Basic Authentication
  • OAuth2 Client Credentials

SecureHttpClient API 

Extensions call external services using a simple PHP API:

interface SecureHttpClientInterface
{
    public function request(
        string $serviceId,
        string $method,
        string $path,
        array $options = []
    ): SecureHttpResponse;
}
Copied!

Request options:

  • query: Query parameters
  • headers: Additional headers (non-secret)
  • json: JSON body
  • body: Raw body
  • timeout: Timeout override (clamped by policy)
  • idempotencyKey: Optional idempotency key

Response:

$response->statusCode();  // int
$response->headers();     // array
$response->body();        // string
$response->json();        // array (throws on invalid JSON)
Copied!

Security features 

Policy enforcement 

All requests are validated against the service's security policy:

  • Allowed hosts/base URLs: Requests can only go to configured endpoints
  • Allowed methods: Restrict to GET, POST, etc.
  • Allowed path patterns: Limit which paths can be called
  • Private range blocking: Block access to private IPs, link-local, metadata endpoints
  • Timeout caps: Maximum request duration
  • Max body sizes: Prevent resource exhaustion

Audit logging 

Every outbound call is logged with metadata:

  • serviceId, caller identity, timestamp
  • method, path template, status code
  • duration, bytes in/out
  • error classification, correlation ID

Request/response bodies and secrets are never logged.

Secret protection 

Credentials are:

  • stored encrypted at rest
  • never exposed in PHP variables when using Rust transport
  • redacted from all logs and debug output
  • rotated centrally without code changes

Transport backends 

Secure Outbound supports multiple transport backends:

PhpTransport (default) 

Uses PSR-18 or Symfony HttpClient. Works everywhere, no special requirements.

RustFfiTransport (optional) 

Rust-based transport that:

  • decrypts credentials inside the Rust runtime
  • makes HTTP requests without exposing secrets to PHP
  • supports HTTP/2 and optional HTTP/3

Requires:

  • Rust library installed separately
  • PHP ffi.enable=preload configuration

See ADR-013: Rust FFI preload-only mode for security considerations.

SidecarTransport (future) 

For highest security requirements, a separate daemon process can provide stronger isolation. See ADR-016: Sidecar daemon option.

Usage example 

Calling an external API
use Netresearch\NrVault\Service\SecureHttpClientInterface;

final class MyApiService
{
    public function __construct(
        private readonly SecureHttpClientInterface $httpClient,
    ) {}

    public function fetchData(string $resourceId): array
    {
        $response = $this->httpClient->request(
            serviceId: 'my-api',
            method: 'GET',
            path: '/resources/' . $resourceId,
            options: [
                'query' => ['include' => 'metadata'],
            ]
        );

        return $response->json();
    }
}
Copied!

The my-api service and its credentials are configured in the backend module. The extension code never handles credentials directly.

Architecture decision records 

This section documents significant architectural decisions made during the development of nr-vault, along with the context and consequences of each decision.

Architecture Decision Records (ADRs) capture important decisions along with their context and consequences. They provide a historical record of why certain decisions were made, helping future maintainers understand the codebase.

Table of contents

Overview 

ADR-001: UUID v7 for secret identifiers 

Status 

Accepted

Date 

2026-01-03

Context 

The nr-vault extension needs a reliable, collision-free identifier format for secrets stored in the vault. These identifiers are:

  • Stored in the database column of the TCA/FlexForm field
  • Used to look up the actual secret value from the vault
  • Part of audit logs and metadata
  • Potentially used in B-tree indexed database columns

Initially, a human-readable format was considered ({table}__{field}__{uid}), but this approach has drawbacks:

  • Exposes internal database structure in identifiers
  • Requires parsing logic to extract components
  • Not suitable for secrets without direct TCA record association
  • Long identifiers for FlexForm fields

Problem statement 

What identifier format should be used for vault secrets that:

  1. Is guaranteed unique across all installations
  2. Performs well in database indexes
  3. Does not leak internal structure information
  4. Supports both TCA-managed and manually created secrets

Decision drivers 

  • Uniqueness: Must be collision-free without central coordination
  • Performance: Should be efficient for B-tree database indexes
  • Security: Should not expose internal database structure
  • Simplicity: Easy to generate and validate
  • Debuggability: Helpful for troubleshooting when possible

Considered options 

Option 1: Human-readable format 

Format: {table}__{field}__{uid} (e.g., tx_myext__api_key__42)

Pros:

  • Human-readable, easy to understand
  • Contains context about the secret's source

Cons:

  • Exposes internal database structure
  • Complex format for FlexForm fields
  • Requires parsing logic
  • Not suitable for non-TCA secrets

Option 2: UUID v4 (random) 

Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx

Pros:

  • Simple to generate
  • Widely supported
  • No information leakage

Cons:

  • Random distribution causes poor B-tree index performance
  • No time-ordering (debugging harder)
  • Index fragmentation over time

Option 3: UUID v7 (time-ordered) 

Format: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx

Pros:

  • Time-ordered (48-bit millisecond timestamp)
  • Excellent B-tree index performance
  • Timestamp aids debugging
  • Collision-free with randomness
  • RFC 9562 standardized

Cons:

  • Slightly more complex generation
  • Timestamp visible in identifier (minor information leak)

Option 4: GUID (Microsoft format) 

Similar to UUID v4 but with different byte ordering.

Pros:

  • Familiar to Windows developers

Cons:

  • Non-standard in Unix/Linux environments
  • Same performance issues as UUID v4

Decision 

We chose UUID v7 because:

  1. Index performance: Time-ordering ensures new secrets append to B-tree indexes rather than causing random inserts and page splits.
  2. Debuggability: The embedded timestamp helps identify when secrets were created, useful for audit and troubleshooting.
  3. Simplicity: Standard format, easy to validate with regex.
  4. Future-proof: RFC 9562 standardized, replacing deprecated UUID versions.

Implementation 

UUID v7 generation 

UUID v7 generation in DataHandlerHook
private function generateUuid(): string
{
    // 48-bit timestamp in milliseconds
    $time = (int) (microtime(true) * 1000);
    $random = random_bytes(10);

    return sprintf(
        '%08x-%04x-7%03x-%04x-%012x',
        ($time >> 16) & 0xFFFFFFFF,
        $time & 0xFFFF,
        ord($random[0]) << 4 | ord($random[1]) >> 4 & 0x0FFF,
        (ord($random[1]) & 0x0F) << 8 | ord($random[2]) & 0x3FFF | 0x8000,
        (ord($random[3]) << 40) | (ord($random[4]) << 32)
            | (ord($random[5]) << 24) | (ord($random[6]) << 16)
            | (ord($random[7]) << 8) | ord($random[8]),
    );
}
Copied!

UUID v7 validation 

Pattern used to validate UUID v7 identifiers:

UUID v7 validation pattern
private const string UUID_PATTERN =
    '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i';

public static function isVaultIdentifier(mixed $value): bool
{
    if (!is_string($value) || $value === '') {
        return false;
    }

    return preg_match(self::UUID_PATTERN, $value) === 1;
}
Copied!

Example identifiers 

Valid UUID v7 examples:

01937b6e-4b6c-7abc-8def-0123456789ab
01937b6f-0000-7000-8000-000000000000
01937b6f-ffff-7fff-bfff-ffffffffffff
Copied!

The format components:

  • Positions 1-8: Timestamp (high bits)
  • Positions 10-13: Timestamp (low bits)
  • Position 15: Version (always 7)
  • Positions 16-18: Random data
  • Position 20: Variant (8, 9, a, or b)
  • Positions 21-23: Random data
  • Positions 25-36: Random data

Consequences 

Positive 

  • Excellent index performance: Time-ordered UUIDs append to indexes, avoiding random inserts and page splits.
  • No structure leakage: Identifiers don't reveal table/field names.
  • Unified format: Same identifier format for TCA fields, FlexForm fields, and manually managed secrets.
  • Debuggable timestamps: Creation time can be extracted for diagnostics.
  • RFC standardized: Future-proof, widely supported format.

Negative 

  • No context in identifier: Cannot determine source table/field from identifier alone (use metadata instead).
  • Timestamp visible: Minor information leak about creation time.

Risks 

  • Clock skew on distributed systems could affect ordering (mitigated by random component).
  • Migration from old format required for existing installations.

References 

ADR-002: Envelope encryption 

Status 

Accepted

Date 

2026-01-03

Context 

The nr-vault extension needs to encrypt secrets at rest in the database. The encryption approach must:

  • Protect secrets even if the database is compromised
  • Allow efficient key rotation without re-encrypting all secret values
  • Use well-audited, modern cryptographic primitives
  • Integrate with PHP's native cryptography libraries

Problem statement 

How should secrets be encrypted to provide strong security while enabling efficient operations like key rotation?

Decision drivers 

  • Security: Must use authenticated encryption (AEAD)
  • Key rotation: Master key changes should not require re-encrypting values
  • Performance: Encryption/decryption must be fast
  • Simplicity: Use PHP's built-in libsodium, no external dependencies
  • Memory safety: Sensitive data must be cleared from memory

Considered options 

Option 1: Direct encryption with master key 

Encrypt each secret directly with the master key.

Pros:

  • Simple implementation
  • Single key to manage

Cons:

  • Master key rotation requires re-encrypting ALL secrets
  • Same key used for all secrets (higher exposure risk)

Option 2: Envelope encryption (DEK/KEK) 

Two-layer encryption: unique Data Encryption Key (DEK) per secret, encrypted with Master Key (KEK).

Pros:

  • Master key rotation only re-encrypts DEKs (fast)
  • Each secret has unique encryption key
  • Industry-standard pattern (AWS KMS, Google Cloud KMS)

Cons:

  • Slightly more complex implementation
  • More data to store (encrypted DEK + nonces)

Decision 

We chose envelope encryption with AES-256-GCM (primary) or XChaCha20-Poly1305 (fallback) because:

  1. Efficient key rotation: Only DEKs need re-encryption, not secret values
  2. Defense in depth: Unique key per secret limits blast radius
  3. Industry standard: Proven pattern used by major cloud providers
  4. Modern algorithms: Both are AEAD with strong security properties

Implementation 

Encryption flow 

Envelope encryption process
1. Generate unique DEK (32 bytes) for the secret
2. Generate two random nonces (12 or 24 bytes each)
3. Encrypt DEK with master key: encryptedDek = AEAD(DEK, masterKey, dekNonce)
4. Encrypt secret with DEK: encryptedValue = AEAD(secret, DEK, valueNonce)
5. Calculate SHA-256 checksum for change detection
6. Clear sensitive data from memory (sodium_memzero)
7. Store: encryptedValue, encryptedDek, dekNonce, valueNonce, checksum
Copied!

Decryption flow 

Envelope decryption process
1. Retrieve master key from provider
2. Decrypt DEK: DEK = AEAD_decrypt(encryptedDek, masterKey, dekNonce)
3. Decrypt secret: secret = AEAD_decrypt(encryptedValue, DEK, valueNonce)
4. Clear DEK and master key from memory
5. Return plaintext secret
Copied!

Algorithm selection 

Classes/Crypto/EncryptionService.php
private function useAes256Gcm(): bool
{
    // Use AES-256-GCM if hardware acceleration available
    // Otherwise fall back to XChaCha20-Poly1305
    if (!sodium_crypto_aead_aes256gcm_is_available()) {
        return false;
    }

    return !$this->configuration->preferXChaCha20();
}

private function getNonceLength(): int
{
    return $this->useAes256Gcm()
        ? SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES      // 12 bytes
        : SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;  // 24 bytes
}
Copied!

Memory safety 

Secure memory handling
try {
    $dek = $this->generateDek();
    $encryptedValue = $this->encryptWithKey($plaintext, $dek, $valueNonce);
    // ... store encrypted data
} finally {
    sodium_memzero($dek);
    sodium_memzero($masterKey);
    sodium_memzero($plaintext);
}
Copied!

Master key rotation 

With envelope encryption, rotating the master key is efficient:

Re-encrypting DEKs only
public function reEncryptDek(
    string $encryptedDek,
    string $dekNonce,
    string $identifier,
    string $oldMasterKey,
    string $newMasterKey,
): array {
    // Decrypt DEK with old master key
    $dek = $this->decryptDek($encryptedDek, $dekNonce, $identifier, $oldMasterKey);

    // Re-encrypt DEK with new master key
    $newNonce = random_bytes($this->getNonceLength());
    $newEncryptedDek = $this->encryptWithKey($dek, $newMasterKey, $newNonce);

    sodium_memzero($dek);
    return ['encrypted_dek' => $newEncryptedDek, 'dek_nonce' => $newNonce];
}
Copied!

Database storage 

Encrypted data columns
encrypted_value mediumblob,           -- AEAD ciphertext + auth tag
encrypted_dek text,                   -- Base64-encoded encrypted DEK
dek_nonce varchar(24) NOT NULL,       -- Base64-encoded DEK nonce
value_nonce varchar(24) NOT NULL,     -- Base64-encoded value nonce
encryption_version int unsigned,      -- For algorithm migrations
value_checksum char(64) NOT NULL,     -- SHA-256 for change detection
Copied!

Consequences 

Positive 

  • Fast key rotation: Only DEKs re-encrypted, O(n) simple operations
  • Unique keys per secret: Compromise of one DEK doesn't expose others
  • Hardware acceleration: AES-256-GCM uses AES-NI when available
  • Authenticated encryption: Tampering is detected and rejected
  • Memory safety: Sensitive data cleared immediately after use

Negative 

  • More storage: Each secret requires DEK + two nonces
  • Complexity: Two-layer encryption requires careful implementation
  • Algorithm migration: Changing algorithms requires re-encryption

Risks 

  • Master key loss = all secrets unrecoverable (mitigate with secure backups)
  • Memory-based attacks could capture keys during brief window of use

References 

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 three 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

This provides zero-config operation while enabling enterprise deployments.

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 getAvailableProvider(): MasterKeyProviderInterface
{
    // 1. Try explicitly configured provider
    $configured = $this->configuration->getMasterKeyProvider();
    if ($configured && $this->providers[$configured]->isAvailable()) {
        return $this->providers[$configured];
    }

    // 2. Fallback chain: typo3 -> env -> file
    foreach (['typo3', 'env', 'file'] as $id) {
        if ($this->providers[$id]->isAvailable()) {
            return $this->providers[$id];
        }
    }

    // 3. Return TYPO3 provider (will fail with clear error)
    return $this->providers['typo3'];
}
Copied!

Configuration 

Extension configuration options
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['nr_vault'] = [
    'masterKeyProvider' => 'typo3',  // typo3, file, or env
    '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. Verify old key can decrypt existing secrets
  2. Re-encrypt all DEKs with new master key (transactional)
  3. Dispatch MasterKeyRotatedEvent
  4. 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 

ADR-004: TCA integration 

Status 

Accepted

Date 

2026-01-03

Context 

TYPO3 extensions commonly store sensitive data (API keys, credentials, tokens) in database fields configured via TCA. The nr-vault extension needs to provide a seamless way to store these values securely without requiring extensions to rewrite their data handling.

The integration must:

  • Work with existing TCA field configurations
  • Handle record operations (create, update, delete, copy)
  • Support both regular TCA fields and FlexForm fields
  • Maintain the TYPO3 backend user experience

Problem statement 

How should nr-vault integrate with TYPO3's TCA system to transparently encrypt sensitive fields while maintaining standard TYPO3 workflows?

Decision drivers 

  • Transparency: Extensions should need minimal code changes
  • Compatibility: Must work with standard TYPO3 record operations
  • User experience: Backend users should see familiar interfaces
  • Flexibility: Support various field types and configurations
  • Auditability: All operations must be trackable

Considered options 

Option 1: Custom field type 

Create a completely new TCA field type.

Pros:

  • Full control over behavior

Cons:

  • Requires TCA rewrite for existing extensions
  • Different behavior from standard fields

Option 2: FormEngine override 

Override the default input field rendering globally.

Pros:

  • No TCA changes needed

Cons:

  • Affects all input fields
  • Difficult to target specific fields
  • Potential conflicts

Option 3: Custom renderType with DataHandler hooks 

Provide a renderType for FormEngine and intercept saves via hooks.

Pros:

  • Opt-in per field (add renderType: 'vaultSecret')
  • Uses standard TYPO3 hook system
  • Familiar pattern for TYPO3 developers

Cons:

  • Requires TCA modification (but minimal)
  • Two components to maintain (element + hook)

Decision 

We chose custom renderType with DataHandler hooks because:

  1. Explicit opt-in: Only fields marked with renderType: 'vaultSecret' are encrypted
  2. Standard patterns: Uses FormEngine elements and DataHandler hooks
  3. Minimal changes: One line added to existing TCA configurations
  4. Full lifecycle: Hooks handle create, update, delete, and copy operations

Implementation 

FormEngine element 

Classes/Form/Element/VaultSecretElement.php
final class VaultSecretElement extends AbstractFormElement
{
    public function render(): array
    {
        // Render password field with:
        // - Masked display (dots)
        // - Reveal button (permission-based)
        // - Copy button (permission-based)
        // - Hidden field for vault identifier
    }
}
Copied!

Registration in ext_localconf.php:

ext_localconf.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1735400000] = [
    'nodeName' => 'vaultSecret',
    'priority' => 40,
    'class' => VaultSecretElement::class,
];
Copied!

DataHandler hook 

Classes/Hook/DataHandlerHook.php
final class DataHandlerHook
{
    // Before save: Extract secret, generate UUID, queue for storage
    public function processDatamap_preProcessFieldArray(...): void
    {
        foreach ($this->getVaultFields($table) as $field) {
            if ($this->hasSecretValue($fieldArray, $field)) {
                $uuid = $this->generateUuid();
                $this->pendingSecrets[$table][$id][$field] = [
                    'uuid' => $uuid,
                    'value' => $fieldArray[$field]['value'],
                ];
                $fieldArray[$field] = $uuid;  // Store UUID in database
            }
        }
    }

    // After save: Store secrets with correct UID
    public function processDatamap_afterDatabaseOperations(...): void
    {
        foreach ($this->pendingSecrets[$table][$id] as $field => $data) {
            $this->vaultService->store($data['uuid'], $data['value'], [
                'metadata' => [
                    'table' => $table,
                    'field' => $field,
                    'uid' => $recordUid,
                    'source' => 'tca_field',
                ],
            ]);
        }
    }

    // Before delete: Remove associated secrets
    public function processCmdmap_preProcess(...): void;

    // After copy: Create new secrets for copied record
    public function processCmdmap_postProcess(...): void;
}
Copied!

FlexForm hook 

Separate hook for FlexForm fields due to different data structure:

Classes/Hook/FlexFormVaultHook.php
final class FlexFormVaultHook
{
    public function processDatamap_preProcessFieldArray(...): void
    {
        // Recursively scan FlexForm XML for vaultSecret fields
        // Same UUID-based approach as TCA fields
        // Store metadata: flexField, sheet, fieldPath
    }
}
Copied!

TCA configuration 

Extensions add vault support with one line:

Configuration/TCA/tx_myext_settings.php
'api_key' => [
    'label' => 'API Key',
    'config' => [
        'type' => 'input',
        'renderType' => 'vaultSecret',  // This one line
        'size' => 30,
    ],
],
Copied!

Helper for common patterns:

Using VaultFieldHelper
use Netresearch\NrVault\TCA\VaultFieldHelper;

'api_key' => VaultFieldHelper::getSecureFieldConfig('API Key'),
Copied!

Data flow 

TCA vault field data flow
Form Display:
1. VaultSecretElement renders password field
2. If UUID exists, shows masked value with reveal option
3. JavaScript handles reveal/copy interactions

Form Submit:
1. DataHandlerHook.preProcess extracts secret value
2. Generates UUID v7 identifier (see ADR-001)
3. Sets field value to UUID (for database)
4. DataHandlerHook.afterDatabaseOperations stores secret in vault

Record Delete:
1. DataHandlerHook.processCmdmap_preProcess finds vault fields
2. Retrieves UUIDs from record
3. Deletes corresponding vault secrets

Record Copy:
1. DataHandlerHook.processCmdmap_postProcess detects copy
2. Retrieves source secrets by UUID
3. Creates new secrets with new UUIDs for copied record
Copied!

Runtime resolution 

Resolving secrets in application code
use Netresearch\NrVault\Utility\VaultFieldResolver;

// Resolve specific fields
$resolved = VaultFieldResolver::resolveFields($record, ['api_key']);

// Auto-detect vault fields from TCA
$resolved = VaultFieldResolver::resolveRecord('tx_myext_settings', $record);
Copied!

Consequences 

Positive 

  • Minimal migration: Add renderType to existing fields
  • Familiar patterns: Standard FormEngine and DataHandler usage
  • Full lifecycle: Handles all record operations automatically
  • Audit trail: All operations logged with context metadata
  • UUID portability: Secrets not tied to table structure

Negative 

  • Two hooks required: Separate handling for TCA and FlexForm
  • Runtime resolution: Application code must resolve UUIDs to values
  • Learning curve: Developers must understand vault resolution

Risks 

  • Hook execution order conflicts with other extensions
  • FlexForm structure changes could break field detection

Mitigation 

  • Use high priority for hooks
  • Comprehensive test coverage for FlexForm parsing
  • Clear documentation for resolution patterns

References 

ADR-005: Access control 

Status 

Accepted

Date 

2026-01-03

Context 

Secrets in the vault may contain highly sensitive data (API keys, passwords, certificates). Access to these secrets must be controlled to:

  • Prevent unauthorized access to sensitive data
  • Support collaborative workflows (teams, departments)
  • Integrate with TYPO3's existing permission system
  • Enable audit trails for compliance

Problem statement 

How should access to vault secrets be controlled in a way that integrates naturally with TYPO3's backend user system?

Decision drivers 

  • TYPO3 integration: Use existing backend users and groups
  • Granularity: Per-secret permissions, not just global
  • Simplicity: Familiar model for TYPO3 administrators
  • Flexibility: Support owner, group, and admin access patterns
  • Auditability: All access attempts must be logged

Considered options 

Option 1: TYPO3 page-based permissions 

Inherit permissions from the page tree where secrets are stored.

Pros:

  • Familiar TYPO3 pattern
  • Works with existing mount points

Cons:

  • Secrets aren't naturally page-based
  • Complex for cross-page secrets
  • Inflexible for API-created secrets

Option 2: Custom ACL system 

Build a separate permission system specific to vault.

Pros:

  • Maximum flexibility
  • Could model complex scenarios

Cons:

  • Learning curve for administrators
  • Doesn't leverage existing TYPO3 knowledge
  • More code to maintain

Option 3: Owner/Group model with TYPO3 integration 

Each secret has an owner (backend user) and allowed groups (backend groups).

Pros:

  • Maps to TYPO3 concepts (users, groups)
  • Simple mental model: "who owns it, who can access it"
  • Familiar to Unix-style permissions

Cons:

  • Less granular than full ACL
  • No per-operation permissions (read vs write)

Decision 

We chose Owner/Group model with TYPO3 integration because:

  1. Familiarity: TYPO3 administrators understand users and groups
  2. Simplicity: Easy to reason about access decisions
  3. Sufficient granularity: Owner + groups covers most use cases
  4. Admin override: TYPO3 admins can access all secrets (expected behavior)

Implementation 

Permission model 

Access decision tree
Access Decision Tree:

1. Is user a TYPO3 admin or system maintainer?
   → YES: ALLOW (full access)

2. Is user the secret's owner (owner_uid)?
   → YES: ALLOW (full access)

3. Is user a member of any allowed_groups?
   → YES: ALLOW (read/write access)

4. Is this a CLI/scheduler context with CLI access enabled?
   → YES: Check CLI access groups
   → Group matches: ALLOW

5. Is this frontend context with frontend_accessible=true?
   → YES: ALLOW (read only)

6. Default: DENY
Copied!

Database schema 

Access control columns
-- Single owner
owner_uid int(11) unsigned DEFAULT 0 NOT NULL,

-- Multiple groups (many-to-many)
allowed_groups text,

-- Frontend access flag
frontend_accessible tinyint(1) unsigned DEFAULT 0 NOT NULL,

-- Permission scoping
context varchar(50) DEFAULT '' NOT NULL,
scope_pid int(11) unsigned DEFAULT 0 NOT NULL,

-- Many-to-many relation table
CREATE TABLE tx_nrvault_secret_begroups_mm (
    uid_local int(11) unsigned,    -- Secret UID
    uid_foreign int(11) unsigned,  -- Backend group UID
);
Copied!

AccessControlService 

Classes/Security/AccessControlService.php
final readonly class AccessControlService implements AccessControlServiceInterface
{
    public function canRead(Secret $secret): bool
    {
        return $this->checkAccess($secret);
    }

    public function canWrite(Secret $secret): bool
    {
        return $this->checkAccess($secret);
    }

    public function canDelete(Secret $secret): bool
    {
        return $this->checkAccess($secret);
    }

    private function checkAccess(Secret $secret): bool
    {
        $backendUser = $GLOBALS['BE_USER'] ?? null;

        if ($backendUser === null) {
            return $this->checkCliAccess($secret);
        }

        // Admins and system maintainers have full access
        if ($backendUser->isAdmin() || $backendUser->isSystemMaintainer()) {
            return true;
        }

        // Owner has full access
        $userUid = (int) ($backendUser->user['uid'] ?? 0);
        if ($userUid === $secret->getOwnerUid()) {
            return true;
        }

        // Check group membership
        $userGroups = $backendUser->userGroupsUID ?? [];
        $allowedGroups = $secret->getAllowedGroups();

        return count(array_intersect($userGroups, $allowedGroups)) > 0;
    }
}
Copied!

Enforcement points 

Access checks are enforced in VaultService:

Classes/Service/VaultService.php
public function retrieve(string $identifier): ?string
{
    $secret = $this->repository->findByIdentifier($identifier);

    if (!$this->accessControl->canRead($secret)) {
        $this->auditLog->log($identifier, 'access_denied', false);
        throw new AccessDeniedException('Access denied');
    }

    // ... decrypt and return
}

public function delete(string $identifier, string $reason = ''): void
{
    $secret = $this->repository->findByIdentifier($identifier);

    if (!$this->accessControl->canDelete($secret)) {
        throw new AccessDeniedException('Delete access denied');
    }

    // ... delete secret
}
Copied!

TCA configuration 

Configuration/TCA/tx_nrvault_secret.php
'owner_uid' => [
    'label' => 'Owner',
    'config' => [
        'type' => 'group',
        'allowed' => 'be_users',
        'maxitems' => 1,
    ],
],

'allowed_groups' => [
    'label' => 'Allowed Groups',
    'config' => [
        'type' => 'group',
        'allowed' => 'be_groups',
        'MM' => 'tx_nrvault_secret_begroups_mm',
        'maxitems' => 20,
    ],
],
Copied!

Actor context 

Getting current actor information
public function getCurrentActorUid(): int
{
    return (int) ($GLOBALS['BE_USER']->user['uid'] ?? 0);
}

public function getCurrentActorType(): string
{
    if (Environment::isCli()) {
        return 'cli';
    }
    if ($GLOBALS['BE_USER'] ?? null) {
        return 'backend';
    }
    return 'api';
}
Copied!

Field-level permissions (TSconfig) 

Additional UI-level control via TSconfig:

TSconfig for field permissions
vault.permissions {
    default {
        reveal = 1
        copy = 1
        edit = 1
        readOnly = 0
    }

    tx_myext_settings.api_key {
        reveal = 0
        copy = 0
    }
}
Copied!

Consequences 

Positive 

  • Familiar model: Uses TYPO3 users and groups
  • Simple reasoning: Owner and group membership are clear concepts
  • Admin override: Expected TYPO3 behavior preserved
  • Audit integration: All access attempts logged with actor info
  • Flexible scoping: Context and scope_pid for additional filtering

Negative 

  • No per-operation ACL: Read/write/delete not separately controlled
  • Group proliferation: May need many groups for fine-grained control
  • No inheritance: Secrets don't inherit from parent pages

Risks 

  • Orphaned secrets if owner is deleted
  • Group changes affect access immediately (no caching)

Mitigation 

  • Default to admin ownership for orphaned secrets
  • Document group membership implications
  • Provide cleanup commands for orphaned secrets

References 

ADR-006: Audit logging 

Status 

Accepted

Date 

2026-01-03

Context 

Secret management systems require comprehensive audit trails for:

  • Security incident investigation
  • Compliance requirements (SOC 2, ISO 27001, GDPR)
  • Debugging access issues
  • Detecting unauthorized access attempts

The audit system must capture who accessed what, when, and from where, while being tamper-evident to ensure log integrity.

Problem statement 

How should vault operations be logged to provide complete auditability while preventing log tampering?

Decision drivers 

  • Completeness: All operations must be logged
  • Tamper evidence: Modifications to logs must be detectable
  • Performance: Logging should not significantly impact operations
  • Queryability: Logs must be filterable and searchable
  • Extensibility: External systems should be able to react to events

Considered options 

Option 1: TYPO3 sys_log 

Use TYPO3's built-in logging system.

Pros:

  • Already integrated
  • Familiar to TYPO3 administrators

Cons:

  • No tamper detection
  • Limited structure for vault-specific data
  • Mixed with other system logs

Option 2: External logging service 

Send logs to external SIEM (Splunk, ELK, etc.).

Pros:

  • Enterprise-grade features
  • Centralized logging

Cons:

  • Requires external infrastructure
  • Network dependency
  • Complex configuration

Option 3: Dedicated audit table with hash chain 

Custom table with tamper-evident hash chain linking entries.

Pros:

  • Self-contained, no external dependencies
  • Cryptographic tamper evidence
  • Structured for vault operations
  • Combined with PSR-14 events for extensibility

Cons:

  • Additional storage
  • Hash chain verification overhead

Decision 

We chose dedicated audit table with hash chain combined with PSR-14 events because:

  1. Self-contained: No external dependencies required
  2. Tamper-evident: SHA-256 hash chain detects modifications
  3. Extensible: PSR-14 events allow external system integration
  4. Structured: Purpose-built schema for vault operations

Implementation 

Audit log entry structure 

Classes/Audit/AuditLogEntry.php
final readonly class AuditLogEntry implements JsonSerializable
{
    public function __construct(
        public ?int $uid,
        public string $secretIdentifier,
        public string $action,              // create, read, update, delete, rotate
        public bool $success,
        public ?string $errorMessage,
        public ?string $reason,
        public int $actorUid,
        public string $actorType,           // backend, cli, api, scheduler
        public string $actorUsername,
        public string $actorRole,
        public string $ipAddress,
        public string $userAgent,
        public string $requestId,
        public string $previousHash,        // Links to prior entry
        public string $entryHash,           // SHA-256 of this entry
        public string $hashBefore,          // Value checksum before
        public string $hashAfter,           // Value checksum after
        public int $crdate,
        public array $context,              // Structured JSON metadata
    ) {}
}
Copied!

Hash chain algorithm 

Each entry's hash includes the previous entry's hash, creating an unbroken chain:

Hash chain calculation
private function calculateEntryHash(AuditLogEntry $entry): string
{
    $data = implode('|', [
        $entry->uid,
        $entry->secretIdentifier,
        $entry->action,
        $entry->actorUid,
        $entry->crdate,
        $entry->previousHash,
    ]);

    return hash('sha256', $data);
}

public function verifyHashChain(?int $fromUid = null, ?int $toUid = null): array
{
    $entries = $this->getEntriesInRange($fromUid, $toUid);
    $errors = [];

    foreach ($entries as $i => $entry) {
        // Verify entry hash
        $expectedHash = $this->calculateEntryHash($entry);
        if ($entry->entryHash !== $expectedHash) {
            $errors[$entry->uid] = 'Hash mismatch';
        }

        // Verify chain link
        if ($i > 0 && $entry->previousHash !== $entries[$i - 1]->entryHash) {
            $errors[$entry->uid] = 'Chain break';
        }
    }

    return ['valid' => empty($errors), 'errors' => $errors];
}
Copied!

Database schema 

Audit log table
CREATE TABLE tx_nrvault_audit_log (
    uid int(11) unsigned NOT NULL auto_increment,

    -- What happened
    secret_identifier varchar(255) NOT NULL,
    action varchar(50) NOT NULL,
    success tinyint(1) unsigned DEFAULT 1 NOT NULL,
    error_message text,
    reason text,

    -- Who did it
    actor_uid int(11) unsigned DEFAULT 0 NOT NULL,
    actor_type varchar(50) NOT NULL,
    actor_username varchar(255) NOT NULL,
    actor_role varchar(100) NOT NULL,

    -- Context
    ip_address varchar(45) NOT NULL,
    user_agent varchar(500) NOT NULL,
    request_id varchar(100) NOT NULL,

    -- Tamper detection
    previous_hash varchar(64) NOT NULL,
    entry_hash varchar(64) NOT NULL,

    -- Change tracking
    hash_before char(64) NOT NULL,
    hash_after char(64) NOT NULL,

    -- Metadata
    crdate int(11) unsigned NOT NULL,
    context text,

    PRIMARY KEY (uid),
    KEY secret_identifier (secret_identifier),
    KEY action (action),
    KEY actor_uid (actor_uid),
    KEY crdate (crdate)
);
Copied!

Logged operations 

Operations logged
// All vault operations:
'create'        // New secret stored
'read'          // Secret retrieved/decrypted
'update'        // Secret value changed
'delete'        // Secret removed
'rotate'        // Secret rotated with new value
'access_denied' // Permission check failed
'http_call'     // VaultHttpClient API call
Copied!

AuditLogService 

Classes/Audit/AuditLogService.php
final readonly class AuditLogService implements AuditLogServiceInterface
{
    public function log(
        string $identifier,
        string $action,
        bool $success,
        ?string $errorMessage = null,
        ?string $reason = null,
        ?string $hashBefore = null,
        ?string $hashAfter = null,
        ?AuditContextInterface $context = null,
    ): void;

    public function query(
        ?AuditLogFilter $filter = null,
        int $limit = 100,
        int $offset = 0,
    ): array;

    public function count(?AuditLogFilter $filter = null): int;

    public function verifyHashChain(?int $fromUid = null, ?int $toUid = null): array;

    public function export(?AuditLogFilter $filter = null): array;
}
Copied!

Filtering and querying 

Classes/Audit/AuditLogFilter.php
$filter = AuditLogFilter::forSecret('my_api_key')
    ->withAction('read')
    ->withDateRange($startTime, $endTime)
    ->withSuccess(true);

$entries = $auditService->query($filter, limit: 50);
Copied!

PSR-14 events 

Events dispatched after logging for external integration:

Classes/Event/
SecretCreatedEvent    // identifier, secret, actorUid
SecretAccessedEvent   // identifier, actorUid, context
SecretUpdatedEvent    // identifier, version, actorUid
SecretDeletedEvent    // identifier, actorUid, reason
SecretRotatedEvent    // identifier, newVersion, actorUid, reason
MasterKeyRotatedEvent // secretsReEncrypted, actorUid, rotatedAt
Copied!

Example listener:

Custom event listener
final class SlackNotifier
{
    public function __invoke(SecretAccessedEvent $event): void
    {
        if ($event->getContext() === 'production') {
            $this->slack->notify("Secret accessed: {$event->getIdentifier()}");
        }
    }
}
Copied!

Context objects 

Type-safe context for structured metadata:

Classes/Audit/HttpCallContext.php
final readonly class HttpCallContext implements AuditContextInterface
{
    public function __construct(
        public string $method,
        public string $host,
        public string $path,
        public int $statusCode,
    ) {}

    public static function fromRequest(
        string $method,
        string $url,
        int $statusCode,
    ): self;
}
Copied!

Consequences 

Positive 

  • Tamper-evident: Hash chain detects any modifications
  • Complete trail: All operations logged with full context
  • Queryable: Efficient filtering by secret, action, actor, time
  • Extensible: PSR-14 events enable SIEM integration
  • Self-contained: No external dependencies required
  • Verifiable: Chain integrity can be validated on demand

Negative 

  • Storage growth: Each operation creates a log entry
  • Chain dependency: Corrupted entry affects chain verification
  • No real-time alerts: Events are post-hoc (listeners can add alerts)

Risks 

  • Log table growth in high-volume environments
  • Database access required for verification

Mitigation 

  • Provide log rotation/archival commands
  • Index optimization for common queries
  • Background verification jobs

References 

ADR-007: Secret metadata 

Status 

Accepted

Date 

2026-01-03

Context 

Vault secrets need associated metadata for:

  • Access control decisions (owner, groups)
  • Lifecycle management (expiration, versioning)
  • Operational insights (read counts, last access)
  • Application context (source, purpose)

This metadata must be queryable without decrypting secrets.

Problem statement 

How should secret metadata be stored and structured to enable efficient queries and management without exposing encrypted values?

Decision drivers 

  • Query efficiency: Filter secrets without decryption
  • Access control: Check permissions before decryption attempt
  • Lifecycle management: Expiration, versioning, rotation tracking
  • Flexibility: Support custom application metadata
  • Performance: Metadata operations should be fast

Considered options 

Option 1: Metadata in encrypted payload 

Store metadata inside the encrypted blob.

Pros:

  • Single encrypted unit
  • Metadata protected

Cons:

  • Must decrypt to query anything
  • Cannot check permissions without decryption
  • Expiration checks require decryption

Option 2: Separate metadata table 

Store metadata in a separate linked table.

Pros:

  • Clean separation
  • Different access patterns possible

Cons:

  • Join overhead
  • Potential inconsistency
  • More complex queries

Option 3: Metadata columns alongside encrypted value 

Store metadata as plaintext columns in the same table as encrypted data.

Pros:

  • Single table, atomic operations
  • Efficient queries on metadata
  • No joins required
  • Access control before decryption

Cons:

  • Metadata not encrypted (acceptable for non-sensitive fields)
  • Wider table

Decision 

We chose metadata columns alongside encrypted value because:

  1. Query efficiency: Filter by owner, context, expiration without decryption
  2. Access control: Check permissions before attempting decryption
  3. Atomic operations: Single table ensures consistency
  4. Practical security: Metadata (owner, groups) is not sensitive

Implementation 

Secret entity structure 

Classes/Domain/Model/Secret.php
final class Secret
{
    // Identification
    private ?int $uid = null;
    private string $identifier = '';
    private string $description = '';

    // Encrypted data (only sensitive part)
    private ?string $encryptedValue = null;
    private string $encryptedDek = '';
    private string $dekNonce = '';
    private string $valueNonce = '';
    private string $valueChecksum = '';
    private int $encryptionVersion = 1;

    // Access control (plaintext, needed for permission checks)
    private int $ownerUid = 0;
    private array $allowedGroups = [];
    private string $context = '';
    private bool $frontendAccessible = false;

    // Lifecycle (plaintext, needed for queries)
    private int $version = 1;
    private int $expiresAt = 0;
    private int $lastRotatedAt = 0;
    private int $readCount = 0;
    private int $lastReadAt = 0;

    // Storage
    private string $adapter = 'local';
    private string $externalReference = '';
    private int $scopePid = 0;

    // TYPO3 standard fields
    private int $pid = 0;
    private int $crdate = 0;
    private int $tstamp = 0;
    private int $cruserId = 0;
    private bool $deleted = false;
    private bool $hidden = false;

    // Custom metadata (JSON)
    private array $metadata = [];
}
Copied!

Database schema 

Metadata columns
CREATE TABLE tx_nrvault_secret (
    -- Primary key
    uid int(11) unsigned NOT NULL auto_increment,

    -- Identification (queryable)
    identifier varchar(255) NOT NULL,
    description text,

    -- Encrypted data (protected)
    encrypted_value mediumblob,
    encrypted_dek text,
    dek_nonce varchar(24) NOT NULL,
    value_nonce varchar(24) NOT NULL,
    encryption_version int(11) unsigned DEFAULT 1,
    value_checksum char(64) NOT NULL,

    -- Access control (queryable, not sensitive)
    owner_uid int(11) unsigned DEFAULT 0,
    allowed_groups text,
    context varchar(50) DEFAULT '',
    frontend_accessible tinyint(1) unsigned DEFAULT 0,

    -- Lifecycle (queryable)
    version int(11) unsigned DEFAULT 1,
    expires_at int(11) unsigned DEFAULT 0,
    last_rotated_at int(11) unsigned DEFAULT 0,
    read_count int(11) unsigned DEFAULT 0,
    last_read_at int(11) unsigned DEFAULT 0,

    -- Storage adapter
    adapter varchar(50) DEFAULT 'local',
    external_reference varchar(500) DEFAULT '',
    scope_pid int(11) unsigned DEFAULT 0,

    -- Custom metadata (JSON)
    metadata text,

    -- TYPO3 standard
    pid int(11) DEFAULT 0,
    tstamp int(11) unsigned DEFAULT 0,
    crdate int(11) unsigned DEFAULT 0,
    cruser_id int(11) unsigned DEFAULT 0,
    deleted tinyint(1) unsigned DEFAULT 0,
    hidden tinyint(1) unsigned DEFAULT 0,

    PRIMARY KEY (uid),
    UNIQUE KEY identifier (identifier, deleted),
    KEY owner_uid (owner_uid),
    KEY context (context),
    KEY expires_at (expires_at),
    KEY adapter (adapter)
);
Copied!

Metadata categories 

Identification:

  • identifier - Unique secret name (queryable)
  • description - Human-readable description

Access Control:

  • owner_uid - Backend user who owns the secret
  • allowed_groups - Backend groups with access
  • context - Permission scoping context (e.g., "payment", "reporting")
  • frontend_accessible - Allow frontend access

Lifecycle:

  • version - Incremented on rotation
  • expires_at - Unix timestamp for expiration (0 = never)
  • last_rotated_at - Last rotation timestamp
  • read_count - Total access count
  • last_read_at - Last access timestamp

Storage:

  • adapter - Storage backend (currently: local; planned: hashicorp, aws, azure)
  • external_reference - Reference for external adapters (reserved for future use)
  • scope_pid - TYPO3 page for hierarchical scoping

Custom:

  • metadata - JSON object for application-specific data

Metadata-only access 

Classes/Service/VaultService.php
public function getMetadata(string $identifier): array
{
    $secret = $this->repository->findByIdentifier($identifier);

    // No decryption needed - metadata is plaintext
    return [
        'uid' => $secret->getUid(),
        'identifier' => $secret->getIdentifier(),
        'description' => $secret->getDescription(),
        'owner' => $secret->getOwnerUid(),
        'groups' => $secret->getAllowedGroups(),
        'context' => $secret->getContext(),
        'version' => $secret->getVersion(),
        'createdAt' => $secret->getCrdate(),
        'updatedAt' => $secret->getTstamp(),
        'expiresAt' => $secret->getExpiresAt(),
        'lastRotatedAt' => $secret->getLastRotatedAt(),
        'metadata' => $secret->getMetadata(),
        'scopePid' => $secret->getScopePid(),
    ];
}

public function updateMetadata(string $identifier, array $metadata): void
{
    // Update metadata without touching encrypted value
    $secret = $this->repository->findByIdentifier($identifier);

    if (isset($metadata['description'])) {
        $secret->setDescription($metadata['description']);
    }
    if (isset($metadata['context'])) {
        $secret->setContext($metadata['context']);
    }
    // ... other metadata fields

    $this->repository->save($secret);
}
Copied!

Expiration handling 

Expiration check without decryption
public function retrieve(string $identifier): ?string
{
    $secret = $this->repository->findByIdentifier($identifier);

    // Check expiration from metadata (no decryption)
    if ($secret->isExpired()) {
        throw new SecretExpiredException($identifier);
    }

    // Check access from metadata (no decryption)
    if (!$this->accessControl->canRead($secret)) {
        throw new AccessDeniedException();
    }

    // Only now decrypt
    return $this->decrypt($secret);
}
Copied!

Custom metadata 

Using custom metadata
$vault->store('api_key', $value, [
    'metadata' => [
        'source' => 'tca_field',
        'table' => 'tx_myext_settings',
        'field' => 'api_key',
        'uid' => 42,
        'environment' => 'production',
    ],
]);

// Query by custom metadata
$secrets = $vault->list();
$tcaSecrets = array_filter($secrets, fn($s) =>
    ($s['metadata']['source'] ?? '') === 'tca_field'
);
Copied!

Consequences 

Positive 

  • Fast queries: Filter secrets without decryption
  • Access control first: Permissions checked before crypto operations
  • Expiration enforcement: Check timestamps without decryption
  • Flexible metadata: JSON field for application-specific data
  • Atomic updates: Single table ensures consistency
  • Efficient lifecycle: Version, rotation, read stats always available

Negative 

  • Metadata exposure: Plaintext metadata visible in database
  • Schema rigidity: Adding new metadata may require migrations
  • JSON querying: Custom metadata requires application-level filtering

Risks 

  • Sensitive data accidentally stored in metadata
  • Metadata inconsistency with encrypted value

Mitigation 

  • Document which fields are encrypted vs plaintext
  • Validate metadata doesn't contain secrets
  • Use database transactions for consistency

References 

ADR-008: HTTP client 

Status 

Accepted

Date 

2026-01-03

Context 

Applications often need to make authenticated HTTP requests to external APIs using secrets stored in the vault. The typical pattern exposes secrets to application code:

Typical insecure pattern
$apiKey = $vault->retrieve('stripe_api_key');
$client->request('POST', '/charges', [
    'headers' => ['Authorization' => 'Bearer ' . $apiKey],
]);
// $apiKey remains in memory, possibly logged
Copied!

This approach:

  • Exposes secrets to application code
  • Risks logging secrets in debug output
  • Requires manual memory cleanup
  • Duplicates authentication logic across services

Problem statement 

How can applications make authenticated HTTP requests using vault secrets without exposing the secret values to application code?

Decision drivers 

  • Secret isolation: Application code should never see raw secrets
  • Memory safety: Secrets cleared from memory immediately after use
  • Standards compliance: Use PSR-18 for HTTP client interoperability
  • Flexibility: Support various authentication methods
  • Auditability: Log API calls without exposing credentials
  • Simplicity: Fluent API for common use cases

Considered options 

Option 1: Helper methods returning configured clients 

Factory methods that return pre-configured HTTP clients.

Pros:

  • Simple API

Cons:

  • Secrets exposed during client creation
  • Limited flexibility
  • Hard to audit

Option 2: Request middleware/interceptor 

Middleware that injects credentials into requests.

Pros:

  • Transparent injection

Cons:

  • Framework-specific (Guzzle middleware vs PSR-18)
  • Complex configuration

Option 3: PSR-18 wrapper with fluent authentication API 

Immutable wrapper implementing PSR-18 with authentication configuration.

Pros:

  • Standards-compliant (PSR-18)
  • Immutable (thread-safe, predictable)
  • Fluent API for configuration
  • Secret injection at request time

Cons:

  • Wrapper overhead
  • Must implement all PSR-18 methods

Decision 

We chose PSR-18 wrapper with fluent authentication API because:

  1. Standards compliance: Works with any PSR-18 compatible code
  2. Immutability: Each with* call returns new instance, preventing state issues
  3. Late binding: Secrets retrieved only when request is sent
  4. Memory safety: sodium_memzero() clears secrets after injection
  5. Audit integration: Logs API calls with secret identifiers (not values)

Implementation 

Interface design 

Classes/Http/VaultHttpClientInterface.php
interface VaultHttpClientInterface extends ClientInterface
{
    public function withAuthentication(
        string $secretIdentifier,
        SecretPlacement $placement = SecretPlacement::Bearer,
        array $options = [],
    ): static;

    public function withOAuth(OAuthConfig $config, string $reason = ''): static;

    public function withReason(string $reason): static;
}
Copied!

SecretPlacement enum 

Type-safe authentication placement options:

Classes/Http/SecretPlacement.php
enum SecretPlacement: string
{
    case Bearer = 'bearer';         // Authorization: Bearer {secret}
    case BasicAuth = 'basic';       // Authorization: Basic {base64}
    case Header = 'header';         // Custom header
    case QueryParam = 'query';      // URL query parameter
    case BodyField = 'body_field';  // Request body field
    case OAuth2 = 'oauth2';         // OAuth 2.0 with token refresh
    case ApiKey = 'api_key';        // X-API-Key header
}
Copied!

Fluent API usage 

Using VaultHttpClient
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

// Bearer authentication
$response = $this->httpClient
    ->withAuthentication('stripe_api_key', SecretPlacement::Bearer)
    ->sendRequest(new Request('POST', 'https://api.stripe.com/v1/charges'));

// Custom header
$response = $this->httpClient
    ->withAuthentication('api_token', SecretPlacement::Header, [
        'headerName' => 'X-API-Key',
    ])
    ->sendRequest(new Request('GET', 'https://api.example.com/data'));

// Basic authentication with two secrets
$response = $this->httpClient
    ->withAuthentication('service_password', SecretPlacement::BasicAuth, [
        'usernameSecret' => 'service_username',
        'reason' => 'Fetching secure data',
    ])
    ->sendRequest(new Request('GET', 'https://api.example.com/secure'));
Copied!

Immutable implementation 

Classes/Http/VaultHttpClient.php
final readonly class VaultHttpClient implements VaultHttpClientInterface
{
    public function __construct(
        private VaultServiceInterface $vaultService,
        private AuditLogServiceInterface $auditLogService,
        private ?ClientInterface $innerClient = null,
        private ?string $secretIdentifier = null,
        private ?SecretPlacement $placement = null,
        // ... other configuration
    ) {}

    public function withAuthentication(
        string $secretIdentifier,
        SecretPlacement $placement = SecretPlacement::Bearer,
        array $options = [],
    ): static {
        // Return NEW instance with updated configuration
        return new self(
            $this->vaultService,
            $this->auditLogService,
            $this->innerClient,
            $secretIdentifier,
            $placement,
            // ... merge options
        );
    }

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        // Inject authentication into request
        $request = $this->injectAuthentication($request);

        // Send request
        $response = $this->getInnerClient()->sendRequest($request);

        // Audit log (secret identifier, not value)
        $this->logHttpCall($request, $response);

        return $response;
    }
}
Copied!

Memory-safe secret injection 

Secure injection with immediate cleanup
private function injectBearer(RequestInterface $request): RequestInterface
{
    $secret = $this->vaultService->retrieve($this->secretIdentifier);

    try {
        return $request->withHeader('Authorization', 'Bearer ' . $secret);
    } finally {
        sodium_memzero($secret);  // Clear from memory immediately
    }
}

private function injectBasicAuth(RequestInterface $request): RequestInterface
{
    $password = $this->vaultService->retrieve($this->secretIdentifier);
    $username = $this->usernameSecretIdentifier
        ? $this->vaultService->retrieve($this->usernameSecretIdentifier)
        : '';

    try {
        $credentials = base64_encode($username . ':' . $password);
        return $request->withHeader('Authorization', 'Basic ' . $credentials);
    } finally {
        sodium_memzero($password);
        if ($username !== '') {
            sodium_memzero($username);
        }
    }
}
Copied!

OAuth 2.0 support 

OAuth configuration
$config = OAuthConfig::clientCredentials(
    tokenUrl: 'https://oauth.example.com/token',
    clientIdSecret: 'oauth_client_id',
    clientSecretSecret: 'oauth_client_secret',
    scopes: ['read', 'write'],
);

$response = $this->httpClient
    ->withOAuth($config, 'API access')
    ->sendRequest($request);
Copied!

The OAuthTokenManager handles:

  • Token caching (in-memory)
  • Automatic refresh before expiry
  • Secure credential handling

Secure client factory 

Classes/Http/SecureHttpClientFactory.php
final class SecureHttpClientFactory
{
    public function create(): ClientInterface
    {
        return new Client([
            'debug' => false,  // Never log request/response bodies
            'http_errors' => false,  // Handle errors in vault client
            // Respect TYPO3 HTTP settings
            'proxy' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['proxy'] ?? null,
            'verify' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['verify'] ?? true,
            'timeout' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout'] ?? 30,
        ]);
    }
}
Copied!

Audit logging 

Logging without exposing secrets
private function logHttpCall(RequestInterface $request, ResponseInterface $response): void
{
    $this->auditLogService->log(
        $this->secretIdentifier,          // Which secret was used
        'http_call',
        $response->getStatusCode() < 400,  // Success flag
        null,
        $this->reason,
        context: HttpCallContext::fromRequest(
            $request->getMethod(),
            (string) $request->getUri(),
            $response->getStatusCode(),
        ),
    );
}
Copied!

Consequences 

Positive 

  • Secret isolation: Application code never sees raw secret values
  • Memory safety: sodium_memzero() clears secrets immediately
  • Standards compliance: PSR-18 compatible, works with any framework
  • Immutable design: Thread-safe, predictable behavior
  • Audit trail: API calls logged with context, not credentials
  • TYPO3 integration: Respects proxy, SSL, timeout settings
  • No debug leaks: debug: false prevents request/response logging

Negative 

  • Wrapper overhead: Additional object creation per request
  • PSR-18 limitation: Async requests not supported by PSR-18
  • Memory pressure: Brief window where secret exists in memory

Risks 

  • Exception handlers might capture request objects with injected secrets
  • Memory dumps could expose secrets during injection window

Mitigation 

  • Use try/finally to ensure cleanup even on exceptions
  • Avoid storing configured requests in variables
  • Document memory safety requirements

References 

ADR-009: Extension configuration secrets 

Status 

Accepted

Date 

2026-01-04

Context 

TYPO3 extensions commonly store API keys and credentials in extension settings (defined in ext_conf_template.txt, managed via Admin Tools > Settings > Extension Configuration).

These settings are stored in the database ( sys_registry table in v12+) and loaded into $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] at runtime.

Challenges 

  1. No PSR-14 events: TYPO3 provides no events for extension configuration save/load operations. The old afterExtensionConfigurationWrite signal was removed in v9.
  2. Memory persistence: Values loaded into $GLOBALS persist for the entire request lifecycle. Storing actual secrets there defeats vault's security model (immediate memory cleanup via sodium_memzero).
  3. No custom field types: While type=user[...] allows custom rendering, there's no hook into the save/load lifecycle to intercept values.

Decision 

Store vault identifiers (not secrets) in extension settings. The identifier is resolved to the actual secret only at use time via VaultHttpClient.

Two patterns are supported depending on use case:

Pattern B: Prefixed reference (optional) 

For mixed settings, explicit documentation, or migration from plaintext to vault:

Extension setting value
vault:my_translation_api_key
Copied!

Parsed with VaultReference helper:

Prefixed usage
<?php

// Pattern B: Prefixed reference usage
// Extension setting value: vault:my_translation_api_key

$ref = VaultReference::tryParse($config['apiKey']);
if ($ref !== null) {
    $vault->http()
        ->withAuthentication($ref->identifier, SecretPlacement::Bearer)
        ->sendRequest($request);
}
Copied!

Advantages:

  • Self-documenting in settings UI
  • Distinguishes vault refs from plain values
  • Explicit validation

Implementation 

Example: Translation service integration 

Extension settings template:

EXT:acme_translate/ext_conf_template.txt
# cat=api; type=string; label=API Key (Vault Identifier): Enter your vault secret identifier
apiKey =

# cat=api; type=string; label=API Endpoint
apiEndpoint = https://api.translate.example.com/v1
Copied!

Service implementation:

EXT:acme_translate/Classes/Service/TranslationService.php
<?php

declare(strict_types=1);

namespace Acme\AcmeTranslate\Service;

use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\RequestFactory;

final class TranslationService
{
    private string $apiKey;
    private string $apiEndpoint;

    public function __construct(
        private readonly VaultServiceInterface $vault,
        private readonly RequestFactory $requestFactory,
        ExtensionConfiguration $extensionConfiguration,
    ) {
        $config = $extensionConfiguration->get('acme_translate');
        $this->apiKey = (string) ($config['apiKey'] ?? '');
        $this->apiEndpoint = (string) ($config['apiEndpoint'] ?? '');
    }

    public function translate(string $text, string $targetLang): string
    {
        if ($this->apiKey === '') {
            throw new \RuntimeException(
                'Translation API key not configured in extension settings.',
                1735990000
            );
        }

        $request = $this->requestFactory
            ->createRequest('POST', $this->apiEndpoint . '/translate')
            ->withHeader('Content-Type', 'application/json')
            ->withBody(\GuzzleHttp\Psr7\Utils::streamFor(json_encode([
                'text' => $text,
                'target' => $targetLang,
            ])));

        // $this->apiKey contains vault identifier, resolved at request time
        $response = $this->vault->http()
            ->withAuthentication($this->apiKey, SecretPlacement::Bearer)
            ->withReason('Translation request: ' . $targetLang)
            ->sendRequest($request);

        $data = json_decode($response->getBody()->getContents(), true);
        return $data['translation'] ?? '';
    }
}
Copied!

Setup via backend:

  1. Create secret in vault:

    1. Go to Admin Tools > Vault > Secrets
    2. Click + Create new
    3. Enter identifier: acme_translate_api_key
    4. Paste your API key
    5. Click Save
  2. Configure extension:

    1. Go to Admin Tools > Settings > Extension Configuration
    2. Find acme_translate
    3. Enter API Key: acme_translate_api_key
    4. Click Save

Via CLI (alternative):

Store secret via CLI
./vendor/bin/typo3 vault:store acme_translate_api_key "your-actual-api-key"
Copied!

Why this is safe 

The extension setting stores only the identifier, never the secret:

What gets stored where
sys_registry (extension config):
  apiKey = "acme_translate_api_key"    ← Just the identifier

tx_nrvault_secret (vault):
  identifier = "acme_translate_api_key"
  encrypted_value = [AES-256-GCM encrypted actual key]
Copied!

Even if someone accidentally enters the actual API key in extension settings:

  1. withAuthentication('sk_live_abc123...') tries vault lookup
  2. Vault returns "secret not found"
  3. Request fails safely (secret never sent)

Alternatives considered 

Store actual secrets in extension config 

Rejected because:

  • Secrets persist in $GLOBALS for entire request
  • No sodium_memzero() cleanup possible
  • Secrets visible in database (sys_registry)
  • May leak to logs, backups, version control

Custom user field type with vault UI 

Hypothetical
# type=user[Netresearch\NrVault\Configuration\VaultSecretField->render]
apiKey =
Copied!

Rejected because:

  • No save/load lifecycle hooks in TYPO3
  • Would need to store secret in config (defeats purpose)
  • Complex JavaScript for vault API interaction

Consequences 

Positive 

  • Memory safety preserved: Secrets resolved only at use time
  • Simple pattern: Direct identifier works with VaultHttpClient
  • Safe failure: Wrong values cause "not found", not exposure
  • No core changes: Works with standard extension configuration
  • Backend-friendly: Admins manage via TYPO3 backend, no CLI needed

Negative 

  • Two-step setup: Create secret in vault, then reference in settings
  • No UI validation: Extension settings show plain text field
  • Convention-based: Developers must document which fields are vault refs

References 

ADR-010: Secure Outbound inside nr-vault 

Status 

Accepted

Date 

2026-01-12

Context 

We need a TYPO3-wide solution for outbound service calls that centralizes:

  • credential handling,
  • policy enforcement,
  • audit logging,
  • and (optionally) a Rust transport backend.

There are two organizational packaging options:

  1. Build a new TYPO3 extension (e.g. t3x-nr-secure-http) and let nr-vault consume it, or
  2. Enhance t3x-nr-vault directly with Service Registry + SecureHttpClient.

Decision 

We will implement Secure Outbound inside nr-vault as a first-class feature:

  • ServiceRegistryService
  • CredentialSetService
  • SecureHttpClientInterface + default transport
  • Backend module extensions
  • Audit logging for outbound calls

Other extensions (nr-llm, shipping integrations, custom extensions) will depend on nr-vault for outbound calls.

Consequences 

Positive 

  • Single source of truth for secrets, ACL, and audit
  • Fewer dependency/coupling issues (no cyclic dependencies)
  • Unified backend UI for secrets + services + credential sets
  • Clear product story: nr-vault becomes the governance platform for outbound calls

Negative 

  • nr-vault grows in scope and responsibility
  • Teams that want "HTTP only" will still pull nr-vault (acceptable given the primary value is governance + secrets)

Alternatives considered 

Separate extension with nr-vault dependency 

Create t3x-nr-secure-http with nr-vault as a dependency.

Rejected because it complicates adoption, creates unclear ownership boundaries, and risks cycles later.

Separate extension without nr-vault 

Create a standalone extension with no dependency on nr-vault.

Rejected because it duplicates encryption/audit/ACL capabilities and becomes "yet another secret store".

Notes 

If a future scenario demands it, we can still split packages later, but the MVP should optimize for clarity and adoption.

ADR-011: Credential Sets data model 

Status 

Accepted

Date 

2026-01-12

Context 

nr-vault currently stores atomic secrets (single values). Real integrations often require a set of related fields (e.g. OAuth2 client credentials: client_id, client_secret, token_url, scopes). Managing these as separate secrets is error-prone and lacks semantic validation.

We need a "Credential Set" concept while preserving the existing tx_nrvault_secret primitive and its encryption/audit semantics.

Decision 

We will introduce a new table/concept tx_nrvault_credential_set and define:

  • tx_nrvault_secret remains the primitive encrypted storage unit (atomic, type-agnostic)
  • tx_nrvault_credential_set becomes a typed wrapper that references exactly one secret row via secret_uid
  • The referenced secret contains an encrypted JSON payload holding all credential fields for the set

Credential sets do not replace tx_nrvault_secret. They build on top of it.

Example decrypted payloads 

Bearer token:

{"token": "sk-abc123..."}
Copied!

OAuth2 Client Credentials:

{
  "client_id": "my-client",
  "client_secret": "secret123",
  "token_url": "https://oauth.example.com/token",
  "scopes": ["read", "write"]
}
Copied!

Consequences 

Positive 

  • Reuses nr-vault's encryption, ACL, and audit model without duplication
  • A credential set becomes the stable reference target for the Service Registry
  • Rotation becomes straightforward: update one credential set = update one encrypted payload
  • Simplifies Rust transport integration: pass one ciphertext payload instead of N

Negative 

  • Fine-grained per-field ACL inside one credential set is not supported (acceptable: if you can use the credential set, you can use its fields)
  • Requires a migration/import story for existing scattered secrets

Alternatives considered 

Parent-child secrets model 

Credential set as parent, multiple child secrets.

Rejected for MVP: more joins and complexity; awkward for FFI integration (multiple blobs); unclear audit semantics.

Store encrypted blob directly in credential_set 

No secret FK, store encryption directly in tx_nrvault_credential_set.

Rejected: duplicates encryption/audit logic and creates two competing secret stores.

Metadata-only linking (tags) 

Link secrets via tags or metadata.

Rejected: weak referential integrity; too easy to break.

ADR-012: SecureHttpClient API and transports 

Status 

Accepted

Date 

2026-01-12

Context 

Multiple extensions need to call external HTTP services with centralized credentials, policies, and audit. We need:

  • a stable, minimal public PHP API,
  • a clean boundary between "product logic" (registry/policy/audit) and "transport engine".

We also want optional Rust (FFI or later sidecar) without forcing it on everyone.

Decision 

We define a public SecureHttpClientInterface and a transport abstraction:

SecureHttpClient (product logic) 

  • Resolves service by serviceId
  • Loads credential set
  • Enforces policy (deny-by-default)
  • Executes request via a configured transport backend
  • Records audit metadata
  • Returns a response wrapper

TransportInterface (engine) 

interface TransportInterface
{
    public function send(RequestSpec $request): ResponseSpec;
}
Copied!

Backends:

  • PhpTransport (default; PSR-18 or Symfony HttpClient)
  • RustFfiTransport (optional)
  • (future) SidecarTransport

Consumers never talk to transports directly; they only use SecureHttpClientInterface.

Consequences 

Positive 

  • Decouples governance logic from transport implementation
  • Allows fallback and progressive rollout of Rust
  • Keeps API surface small and stable for consumers
  • Avoids "typed DTO in Rust" trap: response is raw/json in PHP

Negative 

  • Slight abstraction overhead
  • Requires careful policy enforcement placement (must not be bypassable)

Alternatives considered 

Let consumers pick HTTP client directly 

Allow consumers to use PSR-18 clients directly.

Rejected: loses central policy enforcement and audit guarantees.

Make Rust mandatory 

Require Rust transport for all installations.

Rejected: adoption killer; too many environments can't/won't run native code.

ADR-013: Rust FFI preload-only mode 

Status 

Accepted

Date 

2026-01-12

Context 

PHP FFI is powerful but increases attack surface if enabled broadly. Dynamic FFI::cdef() at runtime allows binding arbitrary native symbols, which is risky in web contexts.

We need a production-safe operational model that reduces risk and keeps behavior predictable.

Decision 

If we ship/use a Rust FFI transport, we require:

  • Production deployments run with ffi.enable=preload (or an equivalent hardened configuration)
  • FFI bindings are created in preload (e.g. opcache.preload) and not dynamically in request handling
  • The PHP layer exposes only a limited wrapper API (no arbitrary symbol access)
  • We provide a non-FFI fallback transport and keep it as the default

Consequences 

Positive 

  • Smaller attack surface vs full runtime FFI
  • More predictable behavior and better operability
  • Easier to audit what native code is actually callable

Negative 

  • Requires ops work (preload configuration)
  • Some hosting environments will still refuse FFI entirely → fallback must work

Alternatives considered 

Enable full FFI at runtime 

Use ffi.enable=true to allow dynamic FFI calls.

Rejected: unacceptable risk in typical web hosting setups.

Use ext-php-rs or custom PHP extension 

Build a native PHP extension in Rust.

Deferred: could be considered later, but increases maintenance and build complexity.

ADR-014: Packaging native artifacts 

Status 

Accepted

Date 

2026-01-12

Context 

Shipping native binaries inside extension packages:

  • complicates security review and supply-chain trust,
  • complicates updates (CVE patching),
  • complicates platform support (x86_64, aarch64, glibc vs musl),
  • and often triggers "no executables in extensions" policies in security-conscious environments.

We still want Rust as an optional performance/security feature where it makes sense.

Decision 

  • The default nr-vault distribution remains PHP-only
  • Rust transport artifacts are distributed as separate platform-specific artifacts, e.g.:

    • OS packages (deb/rpm)
    • Container images / sidecar
    • A dedicated "engine package" download with checksums/signing
  • "Bundled binary inside extension" is allowed only for controlled managed environments and is not the default path

Consequences 

Positive 

  • Better adoption in security-conscious TYPO3 environments
  • Clear update and patching model for native components
  • Cleaner separation of responsibilities and reduced TER friction

Negative 

  • Additional installation steps for Rust mode
  • Requires CI/CD pipeline for multi-arch artifacts and release management

Alternatives considered 

Bundle libvault.so directly in the extension 

Ship the native library inside the TYPO3 extension package.

Rejected as default; allowed only in managed/special cases.

ADR-015: HTTP/3 feature flag 

Status 

Accepted

Date 

2026-01-12

Context 

HTTP/3 support is uneven across ecosystems and can be experimental in client libraries. For our target installations, correctness and operability matter more than "having HTTP/3".

We want to benefit from HTTP/3 where it works, without destabilizing the platform.

Decision 

  • MVP requires stable HTTP/1.1 and HTTP/2 support
  • HTTP/3 is optional and controlled by:

    • feature flag per service or global
    • runtime capability detection
    • mandatory fallback to HTTP/2/1.1
  • No business-critical functionality depends on HTTP/3 availability

Consequences 

Positive 

  • Avoids shipping unstable transport as a dependency
  • Keeps rollout safe; reduces support burden

Negative 

  • Some expected performance gains will not be guaranteed everywhere

Alternatives considered 

Make HTTP/3 the default transport 

Use HTTP/3 as the default transport mode.

Rejected: too risky, too unstable, too environment-dependent.

ADR-016: Sidecar daemon option 

Status 

Accepted

Date 

2026-01-12

Context 

FFI does not provide true isolation. If the threat model includes "PHP process compromise", a separate process (sidecar/daemon) running under different OS permissions can provide stronger separation:

  • Master key not readable by PHP process user
  • Narrower filesystem and network capabilities
  • Independent hardening and observability

We don't want to block MVP with sidecar complexity, but we must not paint ourselves into a corner.

Decision 

  • The transport abstraction (ADR-012: SecureHttpClient API and transports) remains compatible with a future SidecarTransport
  • Request/response specs are designed to be serializable (e.g., JSON or binary framing) so that FFI and sidecar can share the same protocol shape
  • Sidecar mode is explicitly a Phase 3 candidate, not MVP scope

Consequences 

Positive 

  • Preserves an upgrade path to stronger isolation without breaking consumers
  • Allows security-conscious customers to adopt a more robust deployment model later

Negative 

  • Some design choices (spec framing, error taxonomy) must be slightly more disciplined early on

Alternatives considered 

Commit only to FFI and ignore sidecar 

Do not support a sidecar mode at all.

Rejected: too limiting for serious security requirements.

Start with sidecar immediately 

Build sidecar mode from the start.

Rejected: slows down MVP and increases operational burden prematurely.

ADR-017: Audit metadata retention 

Status 

Accepted

Date 

2026-01-12

Context 

We want auditability ("who called what, when, and with what outcome") without:

  • leaking secrets into logs,
  • storing sensitive request/response bodies,
  • or exploding database size and causing operational pain.

Audit is necessary, but must be safe and bounded.

Decision 

  • The outbound audit log stores metadata only:

    • serviceId
    • caller identity
    • method
    • path template
    • status code
    • duration
    • bytes in/out
    • error classification
    • correlation id
  • It does not store:

    • request/response bodies
    • Authorization headers
    • any secret material
  • Retention and/or sampling is supported and should have safe defaults

Consequences 

Positive 

  • Useful for compliance, debugging, and incident response
  • Low risk of secret leakage via audit
  • Bounded storage growth

Negative 

  • Deep forensic analysis may still require separate application-level tracing in exceptional cases

Alternatives considered 

Store request/response bodies by default 

Log full request and response bodies for maximum detail.

Rejected: high leakage risk and storage blow-up.

No audit logs 

Do not log outbound requests at all.

Rejected: undermines the core governance value proposition.