Skip to content

Commit ebf1f3c

Browse files
committed
[TASK] Add api key encryption
1 parent 1fbc093 commit ebf1f3c

16 files changed

Lines changed: 862 additions & 12 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of TYPO3 CMS-based extension "aim" by b13.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*/
12+
13+
namespace B13\Aim\Backend\FormDataProvider;
14+
15+
use B13\Aim\Crypto\ApiKeyEncryption;
16+
use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
17+
18+
/**
19+
* Decrypts the api_key value before it is rendered in the backend form.
20+
*
21+
* Admins editing a tx_aim_configuration record see the plaintext key and
22+
* can verify or replace it. On submit, the DataHandler hook encrypts the
23+
* value again. If the admin re-saves the record without touching api_key,
24+
* the form re-sends plaintext, which the hook re-encrypts — the value on
25+
* disk stays encrypted throughout.
26+
*/
27+
final class DecryptApiKey implements FormDataProviderInterface
28+
{
29+
public function __construct(private readonly ApiKeyEncryption $encryption) {}
30+
31+
public function addData(array $result): array
32+
{
33+
if (($result['tableName'] ?? '') !== 'tx_aim_configuration') {
34+
return $result;
35+
}
36+
37+
$value = (string)($result['databaseRow']['api_key'] ?? '');
38+
if ($value === '' || !$this->encryption->isEncrypted($value)) {
39+
return $result;
40+
}
41+
42+
$result['databaseRow']['api_key'] = $this->encryption->decrypt($value);
43+
return $result;
44+
}
45+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of TYPO3 CMS-based extension "aim" by b13.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*/
12+
13+
namespace B13\Aim\Crypto;
14+
15+
use B13\Aim\Exception\ApiKeyEncryptionException;
16+
use TYPO3\CMS\Core\Crypto\Cipher\CipherDecryptionFailedException;
17+
use TYPO3\CMS\Core\Crypto\Cipher\CipherException;
18+
use TYPO3\CMS\Core\Crypto\Cipher\CipherService;
19+
use TYPO3\CMS\Core\Crypto\Cipher\CipherValue;
20+
use TYPO3\CMS\Core\Crypto\Cipher\KeyFactory;
21+
use TYPO3\CMS\Core\Utility\GeneralUtility;
22+
23+
/**
24+
* Encrypts and decrypts AiM provider API keys stored in the database.
25+
*
26+
* On TYPO3 v14+ this delegates to the core CipherService (XChaCha20-Poly1305
27+
* AEAD, key derived via sodium_crypto_kdf_derive_from_key from SYS/encryptionKey).
28+
* On v12/v13 — where CipherService does not exist yet — we use a libsodium
29+
* secretbox implementation (XSalsa20-Poly1305) with the same SYS/encryptionKey
30+
* as input, derived through sodium_crypto_generichash.
31+
*
32+
* Wire format selects the path on decrypt:
33+
* aim:enc:v1: ... — local secretbox payload (used on v12/v13)
34+
* aim:enc:v2: ... — TYPO3 CipherService payload (used on v14+)
35+
*
36+
* Values without either prefix are treated as legacy plaintext and returned
37+
* unchanged, so the upgrade wizard can migrate them in-place.
38+
*/
39+
final class ApiKeyEncryption
40+
{
41+
public const PREFIX_V1 = 'aim:enc:v1:';
42+
public const PREFIX_V2 = 'aim:enc:v2:';
43+
public const PREFIX_ANY = 'aim:enc:';
44+
45+
private const KEY_DOMAIN = 'aim:apikey:v1';
46+
47+
public function encrypt(string $plaintext): string
48+
{
49+
if ($plaintext === '' || $this->isEncrypted($plaintext) || $this->isEndpointUrl($plaintext)) {
50+
return $plaintext;
51+
}
52+
53+
if ($this->coreCipherAvailable()) {
54+
return self::PREFIX_V2 . $this->encryptViaCore($plaintext);
55+
}
56+
return self::PREFIX_V1 . $this->encryptViaSecretbox($plaintext);
57+
}
58+
59+
/**
60+
* The api_key column doubles as an endpoint URL for providers that
61+
* expose a local HTTP service (Ollama, LM Studio, OpenAI-compatible
62+
* proxies). Those values aren't secrets and shouldn't be encrypted.
63+
*/
64+
public function isEndpointUrl(string $value): bool
65+
{
66+
return str_starts_with($value, 'http://') || str_starts_with($value, 'https://');
67+
}
68+
69+
public function decrypt(string $value): string
70+
{
71+
if ($value === '') {
72+
return $value;
73+
}
74+
if (str_starts_with($value, self::PREFIX_V2)) {
75+
return $this->decryptViaCore(substr($value, strlen(self::PREFIX_V2)));
76+
}
77+
if (str_starts_with($value, self::PREFIX_V1)) {
78+
return $this->decryptViaSecretbox(substr($value, strlen(self::PREFIX_V1)));
79+
}
80+
return $value;
81+
}
82+
83+
public function isEncrypted(string $value): bool
84+
{
85+
return str_starts_with($value, self::PREFIX_ANY);
86+
}
87+
88+
private function coreCipherAvailable(): bool
89+
{
90+
return class_exists(CipherService::class) && class_exists(KeyFactory::class);
91+
}
92+
93+
private function encryptViaCore(string $plaintext): string
94+
{
95+
$keyFactory = GeneralUtility::makeInstance(KeyFactory::class);
96+
$cipher = GeneralUtility::makeInstance(CipherService::class);
97+
$sharedKey = $keyFactory->deriveSharedKeyFromEncryptionKey(self::class);
98+
return $cipher->encrypt($plaintext, $sharedKey)->encode();
99+
}
100+
101+
private function decryptViaCore(string $payload): string
102+
{
103+
try {
104+
$keyFactory = GeneralUtility::makeInstance(KeyFactory::class);
105+
$cipher = GeneralUtility::makeInstance(CipherService::class);
106+
$sharedKey = $keyFactory->deriveSharedKeyFromEncryptionKey(self::class);
107+
return $cipher->decrypt(CipherValue::fromSerialized($payload), $sharedKey);
108+
} catch (CipherDecryptionFailedException | CipherException $e) {
109+
throw new ApiKeyEncryptionException(
110+
'AiM API key could not be decrypted via core CipherService: ' . $e->getMessage(),
111+
1773874410,
112+
$e,
113+
);
114+
}
115+
}
116+
117+
private function encryptViaSecretbox(string $plaintext): string
118+
{
119+
$key = $this->deriveSecretboxKey();
120+
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
121+
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);
122+
sodium_memzero($key);
123+
124+
return base64_encode($nonce . $ciphertext);
125+
}
126+
127+
private function decryptViaSecretbox(string $payload): string
128+
{
129+
$bytes = base64_decode($payload, true);
130+
if ($bytes === false || strlen($bytes) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
131+
throw new ApiKeyEncryptionException('AiM API key ciphertext is malformed.', 1773874400);
132+
}
133+
134+
$nonce = substr($bytes, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
135+
$ciphertext = substr($bytes, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
136+
$key = $this->deriveSecretboxKey();
137+
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
138+
sodium_memzero($key);
139+
140+
if ($plaintext === false) {
141+
throw new ApiKeyEncryptionException(
142+
'AiM API key could not be decrypted. The system encryption key may have changed.',
143+
1773874401,
144+
);
145+
}
146+
147+
return $plaintext;
148+
}
149+
150+
private function deriveSecretboxKey(): string
151+
{
152+
$systemKey = (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? '');
153+
if ($systemKey === '') {
154+
throw new ApiKeyEncryptionException(
155+
'AiM API key encryption requires $GLOBALS[\'TYPO3_CONF_VARS\'][\'SYS\'][\'encryptionKey\'] to be set.',
156+
1773874402,
157+
);
158+
}
159+
160+
return sodium_crypto_generichash(
161+
self::KEY_DOMAIN . "\0" . $systemKey,
162+
'',
163+
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
164+
);
165+
}
166+
}

Classes/DependencyInjection/SymfonyAiCompilerPass.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ private function resolveNamespace(string $packageName): ?string
195195
private function buildBridgeDefinition(array $package): ?array
196196
{
197197
$namespace = $package['namespace'];
198-
$factoryClass = $namespace . '\\PlatformFactory';
198+
$factoryClass = $namespace . '\\Factory';
199199

200200
if (!class_exists($factoryClass)) {
201201
return null;
@@ -397,15 +397,15 @@ private function deriveName(string $packageName): string
397397
}
398398

399399
/**
400-
* Detect the primary authentication parameter of a PlatformFactory::create() method.
400+
* Detect the primary authentication parameter of a Factory::createProvider() method.
401401
*
402402
* Most bridges use `apiKey`. Some (Ollama, LM Studio) use `endpoint`.
403403
* We check the first parameter name via reflection.
404404
*/
405405
private function detectFactoryParam(string $factoryClass): string
406406
{
407407
try {
408-
$method = new \ReflectionMethod($factoryClass, 'create');
408+
$method = new \ReflectionMethod($factoryClass, 'createProvider');
409409
foreach ($method->getParameters() as $param) {
410410
$name = $param->getName();
411411
if ($name === 'endpoint' || $name === 'hostUrl' || $name === 'baseUrl') {

Classes/Domain/Repository/ProviderConfigurationRepository.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
namespace B13\Aim\Domain\Repository;
1414

15+
use B13\Aim\Crypto\ApiKeyEncryption;
1516
use B13\Aim\Domain\Model\ProviderConfiguration;
1617
use B13\Aim\Domain\Model\ProviderConfigurationFactory;
1718
use TYPO3\CMS\Core\Database\Connection;
@@ -26,6 +27,7 @@ class ProviderConfigurationRepository
2627

2728
public function __construct(
2829
private readonly ConnectionPool $connectionPool,
30+
private readonly ApiKeyEncryption $encryption,
2931
) {}
3032

3133
public function findByUid(int $uid): ?ProviderConfiguration
@@ -188,6 +190,9 @@ protected function map(array $rows): array
188190

189191
protected function mapSingleRow(array $row): ProviderConfiguration
190192
{
193+
if (isset($row['api_key']) && $row['api_key'] !== '') {
194+
$row['api_key'] = $this->encryption->decrypt((string)$row['api_key']);
195+
}
191196
return ProviderConfigurationFactory::fromRow($row);
192197
}
193198

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of TYPO3 CMS-based extension "aim" by b13.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*/
12+
13+
namespace B13\Aim\Exception;
14+
15+
/**
16+
* Thrown when an AiM API key cannot be encrypted or decrypted.
17+
*/
18+
final class ApiKeyEncryptionException extends \RuntimeException {}

Classes/Hooks/EncryptApiKey.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of TYPO3 CMS-based extension "aim" by b13.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*/
12+
13+
namespace B13\Aim\Hooks;
14+
15+
use B13\Aim\Crypto\ApiKeyEncryption;
16+
use TYPO3\CMS\Core\DataHandling\DataHandler;
17+
18+
/**
19+
* Encrypts AiM provider API keys before they are written to the database.
20+
*
21+
* Runs in processDatamap_postProcessFieldArray so the encrypted value is
22+
* what DataHandler persists. Idempotent: already-encrypted values pass
23+
* through unchanged, which means re-saving an unchanged row does not
24+
* double-encrypt the value.
25+
*/
26+
final class EncryptApiKey
27+
{
28+
private const TABLE = 'tx_aim_configuration';
29+
30+
public function __construct(private readonly ApiKeyEncryption $encryption) {}
31+
32+
public function processDatamap_postProcessFieldArray(
33+
string $status,
34+
string $table,
35+
$id,
36+
array &$fieldArray,
37+
DataHandler $dataHandler,
38+
): void {
39+
if ($table !== self::TABLE || !array_key_exists('api_key', $fieldArray)) {
40+
return;
41+
}
42+
43+
$value = (string)$fieldArray['api_key'];
44+
if ($value === '') {
45+
return;
46+
}
47+
48+
$fieldArray['api_key'] = $this->encryption->encrypt($value);
49+
}
50+
}

Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
use Symfony\AI\Platform\Message\Content\Image;
3838
use Symfony\AI\Platform\Message\Message;
3939
use Symfony\AI\Platform\Message\MessageBag;
40-
use Symfony\AI\Platform\PlatformInterface;
40+
use Symfony\AI\Platform\ProviderInterface;
4141
use Symfony\AI\Platform\TokenUsage\TokenUsageInterface;
4242

4343
/**
@@ -61,13 +61,13 @@ class SymfonyAiPlatformAdapter implements
6161
ToolCallingCapableInterface,
6262
EmbeddingCapableInterface
6363
{
64-
/** @var array<string, PlatformInterface> Platforms cached by configuration key */
64+
/** @var array<string, ProviderInterface> Providers cached by configuration key */
6565
private array $platforms = [];
6666

6767
private readonly string $maxTokensKey;
6868

6969
/**
70-
* @param string $factoryClass Fully-qualified class name of the bridge's PlatformFactory
70+
* @param string $factoryClass Fully-qualified class name of the bridge's Factory
7171
* @param string $factoryParam Name of the factory parameter to pass the config value to ('apiKey' or 'endpoint')
7272
*/
7373
public function __construct(
@@ -237,16 +237,16 @@ public function processEmbeddingRequest(EmbeddingRequest $request): EmbeddingRes
237237
}
238238

239239
/**
240-
* Lazily create and cache a Platform instance per provider configuration.
240+
* Lazily create and cache a Provider instance per provider configuration.
241241
*/
242-
private function getPlatform(ProviderConfiguration $config): PlatformInterface
242+
private function getPlatform(ProviderConfiguration $config): ProviderInterface
243243
{
244244
$cacheKey = $config->uid > 0 ? (string)$config->uid : md5($config->apiKey . $config->model);
245245
if (!isset($this->platforms[$cacheKey])) {
246246
$factoryClass = $this->factoryClass;
247247
$this->platforms[$cacheKey] = match ($this->factoryParam) {
248-
'endpoint' => $factoryClass::create(endpoint: $config->apiKey),
249-
default => $factoryClass::create(apiKey: $config->apiKey),
248+
'endpoint' => $factoryClass::createProvider(endpoint: $config->apiKey),
249+
default => $factoryClass::createProvider(apiKey: $config->apiKey),
250250
};
251251
}
252252
return $this->platforms[$cacheKey];

0 commit comments

Comments
 (0)