ADR-012: API key encryption at application level
- Status
-
Superseded
- Date
-
2024-12-27
- Superseded
-
2025-01 by nr-vault integration
- Authors
-
Netresearch DTT GmbH
Note
This ADR documents the original encryption approach which has been replaced. API keys are now stored using the netresearch/nr-vault extension which provides enterprise-grade secrets management with envelope encryption, audit logging, and access control.
Context
The nr_llm extension stores API keys for various LLM providers (OpenAI, Anthropic, etc.) in the database. These credentials are sensitive and require protection.
Problem statement
TYPO3's TCA type=password field has two modes:
- Hashed mode (default): Uses bcrypt/argon2 - irreversible, suitable for user passwords
- Unhashed mode (hashed => false): Stores plaintext - required for API keys that must be retrieved
API keys must be retrievable to authenticate with external services, so hashing is not an option. However, storing them in plaintext exposes them to:
- Database dumps/backups
- SQL injection attacks
- Unauthorized database access
- Accidental exposure in logs
Requirements
- API keys must be retrievable (not hashed).
- Keys must be encrypted at rest in the database.
- Encryption must be transparent to the application.
- Solution must work without external dependencies (self-contained).
- Must support key rotation.
- Backwards compatible with existing plaintext values.
Decision
Implement application-level encryption using sodium_crypto_secretbox (XSalsa20-Poly1305) with key derivation from TYPO3's encryptionKey.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Backend Form │
│ (user enters API key) │
└─────────────────────────────┬───────────────────────────────────┘
│ plaintext
▼
┌─────────────────────────────────────────────────────────────────┐
│ Provider::setApiKey() │
│ ProviderEncryptionService::encrypt() │
│ │
│ 1. Generate random nonce (24 bytes) │
│ 2. Derive key from TYPO3 encryptionKey via SHA-256 │
│ 3. Encrypt with XSalsa20-Poly1305 │
│ 4. Prefix with "enc:" marker │
│ 5. Base64 encode for storage │
└─────────────────────────────┬───────────────────────────────────┘
│ "enc:base64(nonce+ciphertext+tag)"
▼
┌─────────────────────────────────────────────────────────────────┐
│ Database │
│ tx_nrllm_provider.api_key │
└─────────────────────────────────────────────────────────────────┘
Key derivation
// Domain-separated key derivation
$key = hash('sha256', $typo3EncryptionKey . ':nr_llm_provider_encryption', true);
The domain separator :nr_llm_provider_encryption ensures:
- Keys are unique to this use case.
- Same encryptionKey produces different keys for different purposes.
- No collision with other extensions using similar patterns.
Encryption format
enc:{base64(nonce || ciphertext || auth_tag)}
Where:
- "enc:" = 4-byte prefix marker
- nonce = 24 bytes (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)
- ciphertext = variable length
- auth_tag = 16 bytes (Poly1305 MAC, included by sodium)
Implementation
Files created/modified
| File | Purpose |
|---|---|
Classes/ | Interface definition |
Classes/ | Encryption implementation |
Classes/ | Updated setApiKey/getDecryptedApiKey |
Configuration/ | Added hashed => false |
Configuration/ | Service registration |
Key methods
// ProviderEncryptionService
public function encrypt(string $plaintext): string;
public function decrypt(string $ciphertext): string;
public function isEncrypted(string $value): bool;
// Provider Model
public function setApiKey(string $apiKey): void; // Encrypts before storage
public function getApiKey(): string; // Returns raw (encrypted)
public function getDecryptedApiKey(): string; // Returns decrypted
public function toAdapterConfig(): array; // Uses decrypted key
Consequences
Positive
◐ Encryption at rest: Database dumps no longer expose plaintext credentials.
◐ Transparent operation: Encryption/decryption handled automatically.
◐ No external dependencies: Uses PHP's built-in sodium extension.
◐ Authenticated encryption: Tampering is detected (Poly1305 MAC).
◐ Backwards compatible: Unencrypted values work without migration.
◐ Industry standard: XSalsa20-Poly1305 is used by NaCl/libsodium.
Negative
◑ Single point of failure: If encryptionKey is compromised, all keys are exposed.
◑ No key rotation: Changing encryptionKey requires re-encryption of all keys.
◑ In-memory exposure: Decrypted keys exist briefly in memory.
◑ Performance overhead: Encryption/decryption on every save/load (minimal).
Net Score: +4 (Strong positive)
Alternatives considered
- TYPO3 Core password type with custom transformer. Rejected: TCA doesn't support custom encryption transformers for password fields.
- Defuse PHP Encryption library. Rejected: Adds external dependency. Sodium is built into PHP 7.2+.
- OpenSSL AES-256-GCM. Rejected: Sodium's API is simpler and less prone to misuse.
- Database-level encryption (TDE). Rejected: Requires database configuration, not portable across environments.
- External vault (HashiCorp, AWS KMS). Deferred: Planned for nr-vault extension. Current solution works standalone.