diff --git a/Classes/Backend/FormDataProvider/DecryptApiKey.php b/Classes/Backend/FormDataProvider/DecryptApiKey.php new file mode 100644 index 0000000..b7229fd --- /dev/null +++ b/Classes/Backend/FormDataProvider/DecryptApiKey.php @@ -0,0 +1,45 @@ +encryption->isEncrypted($value)) { + return $result; + } + + $result['databaseRow']['api_key'] = $this->encryption->decrypt($value); + return $result; + } +} diff --git a/Classes/Command/RotateApiKeys.php b/Classes/Command/RotateApiKeys.php new file mode 100644 index 0000000..9c00f65 --- /dev/null +++ b/Classes/Command/RotateApiKeys.php @@ -0,0 +1,194 @@ +setHelp( + 'When $TYPO3_CONF_VARS[SYS][encryptionKey] has been rotated, existing encrypted ' + . 'API keys can no longer be read. This command takes the previous value of the ' + . 'system key, decrypts each stored API key with it, and re-encrypts using the ' + . 'current system key.' + ) + ->addOption( + 'old-key', + null, + InputOption::VALUE_REQUIRED, + 'The previous value of $TYPO3_CONF_VARS[SYS][encryptionKey].', + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Report what would change without writing.', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $oldKey = (string)$input->getOption('old-key'); + if ($oldKey === '') { + $output->writeln('--old-key is required.'); + return Command::FAILURE; + } + $dryRun = (bool)$input->getOption('dry-run'); + + [$toRotate, $unrecoverable, $alreadyCurrent, $unencrypted] = $this->classifyRows($oldKey); + + if ($unrecoverable !== []) { + $output->writeln('The following rows cannot be decrypted with the supplied old key:'); + foreach ($unrecoverable as $uid => $message) { + $output->writeln(sprintf(' - uid=%d: %s', $uid, $message)); + } + $output->writeln('Aborting without writes. Verify the --old-key value.'); + return Command::FAILURE; + } + + if ($toRotate === []) { + $output->writeln('Nothing to rotate.'); + $this->writeSkipBreakdown($output, $alreadyCurrent, $unencrypted); + return Command::SUCCESS; + } + + if ($dryRun) { + $output->writeln(sprintf( + '[dry-run] Would re-encrypt %d row(s).', + count($toRotate), + )); + $this->writeSkipBreakdown($output, $alreadyCurrent, $unencrypted); + return Command::SUCCESS; + } + + $connection = $this->connectionPool->getConnectionForTable(self::TABLE); + foreach ($toRotate as $uid => $plaintext) { + $connection->update( + self::TABLE, + ['api_key' => $this->encryption->encrypt($plaintext)], + ['uid' => $uid], + ['api_key' => Connection::PARAM_STR], + ); + } + + $output->writeln(sprintf( + 'Re-encrypted %d API key(s).', + count($toRotate), + )); + $this->writeSkipBreakdown($output, $alreadyCurrent, $unencrypted); + return Command::SUCCESS; + } + + private function writeSkipBreakdown(OutputInterface $output, int $alreadyCurrent, int $unencrypted): void + { + if ($alreadyCurrent > 0) { + $output->writeln(sprintf( + ' - %d row(s) already use the current system key', + $alreadyCurrent, + )); + } + if ($unencrypted > 0) { + $output->writeln(sprintf( + ' - %d row(s) are not encrypted (endpoint URLs or empty values)', + $unencrypted, + )); + } + } + + /** + * @return array{0: array, 1: array, 2: int, 3: int} + * [uid => plaintext to re-encrypt], [uid => failure message], already-current count, unencrypted count + */ + private function classifyRows(string $oldKey): array + { + $toRotate = []; + $unrecoverable = []; + $alreadyCurrent = 0; + $unencrypted = 0; + + foreach ($this->fetchAllRows() as $row) { + $uid = (int)$row['uid']; + $value = (string)$row['api_key']; + + if ($value === '' || $this->encryption->isEndpointUrl($value) || !$this->encryption->isEncrypted($value)) { + $unencrypted++; + continue; + } + + try { + $this->encryption->decrypt($value); + $alreadyCurrent++; + continue; + } catch (ApiKeyEncryptionException) { + // Fall through to old-key attempt + } + + try { + $toRotate[$uid] = $this->encryption->decryptWithSystemKey($value, $oldKey); + } catch (ApiKeyEncryptionException $e) { + $unrecoverable[$uid] = $e->getMessage(); + } + } + + return [$toRotate, $unrecoverable, $alreadyCurrent, $unencrypted]; + } + + /** + * @return list + */ + private function fetchAllRows(): array + { + $qb = $this->connectionPool->getQueryBuilderForTable(self::TABLE); + $qb->getRestrictions()->removeAll(); + return $qb->select('uid', 'api_key') + ->from(self::TABLE) + ->executeQuery() + ->fetchAllAssociative(); + } +} diff --git a/Classes/Crypto/ApiKeyEncryption.php b/Classes/Crypto/ApiKeyEncryption.php new file mode 100644 index 0000000..b64fe66 --- /dev/null +++ b/Classes/Crypto/ApiKeyEncryption.php @@ -0,0 +1,191 @@ +isEncrypted($plaintext) || $this->isEndpointUrl($plaintext)) { + return $plaintext; + } + + if ($this->coreCipherAvailable()) { + return self::PREFIX_V2 . $this->encryptViaCore($plaintext); + } + return self::PREFIX_V1 . $this->encryptViaSecretbox($plaintext); + } + + /** + * The api_key column doubles as an endpoint URL for providers that + * expose a local HTTP service (Ollama, LM Studio, OpenAI-compatible + * proxies). Those values aren't secrets and shouldn't be encrypted. + */ + public function isEndpointUrl(string $value): bool + { + return str_starts_with($value, 'http://') || str_starts_with($value, 'https://'); + } + + public function decrypt(string $value): string + { + if ($value === '') { + return $value; + } + if (str_starts_with($value, self::PREFIX_V2)) { + return $this->decryptViaCore(substr($value, strlen(self::PREFIX_V2))); + } + if (str_starts_with($value, self::PREFIX_V1)) { + return $this->decryptViaSecretbox(substr($value, strlen(self::PREFIX_V1))); + } + return $value; + } + + /** + * Decrypts a value using a system encryption key other than the one + * currently in $TYPO3_CONF_VARS — needed by the rotation command after + * SYS/encryptionKey has changed but stored ciphertexts still use the old + * derivation. + * + * Both decryption paths (v1 secretbox and v2 CipherService) read the + * system key from globals, so we swap it temporarily and restore it via + * try/finally. The swap is local to this call. + */ + public function decryptWithSystemKey(string $value, string $systemKey): string + { + if ($value === '' || !$this->isEncrypted($value)) { + return $value; + } + + $previous = $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? null; + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = $systemKey; + try { + return $this->decrypt($value); + } finally { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = $previous; + } + } + + public function isEncrypted(string $value): bool + { + return str_starts_with($value, self::PREFIX_ANY); + } + + private function coreCipherAvailable(): bool + { + return class_exists(CipherService::class) && class_exists(KeyFactory::class); + } + + private function encryptViaCore(string $plaintext): string + { + $keyFactory = GeneralUtility::makeInstance(KeyFactory::class); + $cipher = GeneralUtility::makeInstance(CipherService::class); + $sharedKey = $keyFactory->deriveSharedKeyFromEncryptionKey(self::class); + return $cipher->encrypt($plaintext, $sharedKey)->encode(); + } + + private function decryptViaCore(string $payload): string + { + try { + $keyFactory = GeneralUtility::makeInstance(KeyFactory::class); + $cipher = GeneralUtility::makeInstance(CipherService::class); + $sharedKey = $keyFactory->deriveSharedKeyFromEncryptionKey(self::class); + return $cipher->decrypt(CipherValue::fromSerialized($payload), $sharedKey); + } catch (CipherDecryptionFailedException | CipherException $e) { + throw new ApiKeyEncryptionException( + 'AiM API key could not be decrypted via core CipherService: ' . $e->getMessage(), + 1773874410, + $e, + ); + } + } + + private function encryptViaSecretbox(string $plaintext): string + { + $key = $this->deriveSecretboxKey(); + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key); + sodium_memzero($key); + + return base64_encode($nonce . $ciphertext); + } + + private function decryptViaSecretbox(string $payload): string + { + $bytes = base64_decode($payload, true); + if ($bytes === false || strlen($bytes) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) { + throw new ApiKeyEncryptionException('AiM API key ciphertext is malformed.', 1773874400); + } + + $nonce = substr($bytes, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = substr($bytes, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $key = $this->deriveSecretboxKey(); + $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); + sodium_memzero($key); + + if ($plaintext === false) { + throw new ApiKeyEncryptionException( + 'AiM API key could not be decrypted. The system encryption key may have changed.', + 1773874401, + ); + } + + return $plaintext; + } + + private function deriveSecretboxKey(): string + { + $systemKey = (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? ''); + if ($systemKey === '') { + throw new ApiKeyEncryptionException( + 'AiM API key encryption requires $GLOBALS[\'TYPO3_CONF_VARS\'][\'SYS\'][\'encryptionKey\'] to be set.', + 1773874402, + ); + } + + return sodium_crypto_generichash( + self::KEY_DOMAIN . "\0" . $systemKey, + '', + SODIUM_CRYPTO_SECRETBOX_KEYBYTES, + ); + } +} diff --git a/Classes/Domain/Repository/ProviderConfigurationRepository.php b/Classes/Domain/Repository/ProviderConfigurationRepository.php index b0b554b..d0f8982 100644 --- a/Classes/Domain/Repository/ProviderConfigurationRepository.php +++ b/Classes/Domain/Repository/ProviderConfigurationRepository.php @@ -12,6 +12,7 @@ namespace B13\Aim\Domain\Repository; +use B13\Aim\Crypto\ApiKeyEncryption; use B13\Aim\Domain\Model\ProviderConfiguration; use B13\Aim\Domain\Model\ProviderConfigurationFactory; use TYPO3\CMS\Core\Database\Connection; @@ -26,6 +27,7 @@ class ProviderConfigurationRepository public function __construct( private readonly ConnectionPool $connectionPool, + private readonly ApiKeyEncryption $encryption, ) {} public function findByUid(int $uid): ?ProviderConfiguration @@ -188,6 +190,9 @@ protected function map(array $rows): array protected function mapSingleRow(array $row): ProviderConfiguration { + if (isset($row['api_key']) && $row['api_key'] !== '') { + $row['api_key'] = $this->encryption->decrypt((string)$row['api_key']); + } return ProviderConfigurationFactory::fromRow($row); } diff --git a/Classes/Exception/ApiKeyEncryptionException.php b/Classes/Exception/ApiKeyEncryptionException.php new file mode 100644 index 0000000..ee99154 --- /dev/null +++ b/Classes/Exception/ApiKeyEncryptionException.php @@ -0,0 +1,18 @@ +encryption->encrypt($value); + } +} diff --git a/Classes/Updates/EncryptApiKeysUpgrade.php b/Classes/Updates/EncryptApiKeysUpgrade.php new file mode 100644 index 0000000..390a0f8 --- /dev/null +++ b/Classes/Updates/EncryptApiKeysUpgrade.php @@ -0,0 +1,108 @@ +findRowsToEncrypt() !== []; + } + + public function executeUpdate(): bool + { + $connection = $this->connectionPool->getConnectionForTable(self::TABLE); + + foreach ($this->findRowsToEncrypt() as $row) { + $connection->update( + self::TABLE, + ['api_key' => $this->encryption->encrypt((string)$row['api_key'])], + ['uid' => (int)$row['uid']], + ['api_key' => Connection::PARAM_STR], + ); + } + + return true; + } + + /** + * Returns plaintext rows that hold a real API key (not an endpoint URL). + * + * @return list + */ + private function findRowsToEncrypt(): array + { + $qb = $this->connectionPool->getQueryBuilderForTable(self::TABLE); + $qb->getRestrictions()->removeAll(); + + $rows = $qb->select('uid', 'api_key') + ->from(self::TABLE) + ->where( + $qb->expr()->neq('api_key', $qb->createNamedParameter('')), + $qb->expr()->notLike( + 'api_key', + $qb->createNamedParameter(ApiKeyEncryption::PREFIX_ANY . '%'), + ), + $qb->expr()->notLike('api_key', $qb->createNamedParameter('http://%')), + $qb->expr()->notLike('api_key', $qb->createNamedParameter('https://%')), + ) + ->executeQuery() + ->fetchAllAssociative(); + + return array_map( + static fn(array $row): array => ['uid' => (int)$row['uid'], 'api_key' => (string)$row['api_key']], + $rows, + ); + } +} diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index bf02247..e313a70 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -12,3 +12,12 @@ services: B13\Aim\Domain\Repository\ProviderConfigurationRepository: public: true + + B13\Aim\Crypto\ApiKeyEncryption: + public: true + + B13\Aim\Hooks\EncryptApiKey: + public: true + + B13\Aim\Backend\FormDataProvider\DecryptApiKey: + public: true diff --git a/README.md b/README.md index 52d2a2e..a14f9d5 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,29 @@ Install a bridge, flush caches. The provider appears automatically in the backen AiM provides a complete governance system for AI usage, built on native TYPO3 mechanisms. +### API key encryption + +Provider API keys stored in `tx_aim_configuration.api_key` are encrypted using a key derived from `$GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey']`. + +| TYPO3 version | Cipher | Implementation | +|---|---|---| +| v14+ | XChaCha20-Poly1305 AEAD | Core `\TYPO3\CMS\Core\Crypto\Cipher\CipherService` | +| v12 / v13 | XSalsa20-Poly1305 secretbox | Local libsodium implementation (CipherService not yet available) | + +Stored values carry a version prefix (`aim:enc:v1:` for the v12/v13 path, `aim:enc:v2:` for the v14 path) so decryption auto-selects the right routine even after an upgrade. Encryption is transparent: a DataHandler hook encrypts on save, a FormDataProvider decrypts for the backend edit form, and the repository decrypts on read. Legacy plaintext rows from earlier AiM versions are migrated via the **"[AiM] Encrypt stored provider API keys"** upgrade wizard in the Install Tool. + +For providers that put an **endpoint URL** in the `api_key` field instead of a real secret (Ollama, LM Studio, self-hosted OpenAI-compatible proxies), AiM detects the `http://` / `https://` prefix and skips encryption — the URL stays plaintext both in the column and in DB exports. + +If `SYS/encryptionKey` is rotated, existing API keys can no longer be decrypted with the new key. Run the rotation command *before* the rotation takes effect, or right after with the old value still in hand: + +```bash +vendor/bin/typo3 aim:rotateApiKeys --old-key='' +``` + +The command decrypts each stored key with the supplied old value, re-encrypts with the current one, and reports the result. It is idempotent (re-running with the same old key is a no-op) and aborts without writes if any row cannot be decrypted with the supplied value. Add `--dry-run` to preview. + +Without the previous key value, encrypted API keys cannot be recovered. This is by design. Save the old `SYS/encryptionKey` somewhere safe before rotating. + ### Provider restrictions Restrict provider configurations to specific backend user groups via the `be_groups` field on each configuration record. Only members of the listed groups (or admins) can use that configuration. diff --git a/Tests/Functional/Command/RotateApiKeysTest.php b/Tests/Functional/Command/RotateApiKeysTest.php new file mode 100644 index 0000000..a5f51d3 --- /dev/null +++ b/Tests/Functional/Command/RotateApiKeysTest.php @@ -0,0 +1,197 @@ +get(CommandRegistry::class); + self::assertTrue($registry->has('aim:rotateApiKeys')); + self::assertInstanceOf(RotateApiKeys::class, $registry->get('aim:rotateApiKeys')); + } + + #[Test] + public function rotatesEncryptedRowsFromOldKeyToCurrent(): void + { + $encryption = $this->get(ApiKeyEncryption::class); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::OLD_KEY; + $uid = $this->insertRow($encryption->encrypt('sk-real-secret')); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::NEW_KEY; + // The old ciphertext must NOT decrypt with the new key + $this->assertThrows(fn() => $encryption->decrypt($this->fetchRawApiKey($uid))); + + $exit = $this->runCommand(['--old-key' => self::OLD_KEY]); + self::assertSame(0, $exit); + + // After rotation, the value decrypts with the current key + self::assertSame('sk-real-secret', $encryption->decrypt($this->fetchRawApiKey($uid))); + } + + #[Test] + public function isIdempotentOnSecondRun(): void + { + $encryption = $this->get(ApiKeyEncryption::class); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::OLD_KEY; + $uid = $this->insertRow($encryption->encrypt('sk-real-secret')); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::NEW_KEY; + + self::assertSame(0, $this->runCommand(['--old-key' => self::OLD_KEY])); + $afterFirst = $this->fetchRawApiKey($uid); + + $tester = $this->runCommandAndReturnTester(['--old-key' => self::OLD_KEY]); + self::assertSame(0, $tester->getStatusCode()); + $display = $tester->getDisplay(); + self::assertStringContainsString('Nothing to rotate', $display); + self::assertStringContainsString('1 row(s) already use the current system key', $display); + + // Same ciphertext (no double re-encryption) + self::assertSame($afterFirst, $this->fetchRawApiKey($uid)); + self::assertSame('sk-real-secret', $encryption->decrypt($afterFirst)); + } + + #[Test] + public function endpointUrlsAreSkippedAndReportedDistinctly(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::NEW_KEY; + $uid = $this->insertRow('http://host.docker.internal:11434'); + + $tester = $this->runCommandAndReturnTester(['--old-key' => self::OLD_KEY]); + self::assertSame(0, $tester->getStatusCode()); + + $display = $tester->getDisplay(); + self::assertStringContainsString('Nothing to rotate', $display); + self::assertStringContainsString('1 row(s) are not encrypted', $display); + self::assertStringNotContainsString('already use the current system key', $display); + self::assertSame('http://host.docker.internal:11434', $this->fetchRawApiKey($uid)); + } + + #[Test] + public function abortsAndReportsWhenOldKeyIsWrong(): void + { + $encryption = $this->get(ApiKeyEncryption::class); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::OLD_KEY; + $uid = $this->insertRow($encryption->encrypt('sk-real-secret')); + $beforeRotation = $this->fetchRawApiKey($uid); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::NEW_KEY; + + $tester = $this->runCommandAndReturnTester(['--old-key' => 'definitely-not-the-old-key']); + self::assertSame(1, $tester->getStatusCode()); + self::assertStringContainsString('cannot be decrypted', $tester->getDisplay()); + self::assertStringContainsString('Aborting without writes', $tester->getDisplay()); + + // Row was not touched + self::assertSame($beforeRotation, $this->fetchRawApiKey($uid)); + } + + #[Test] + public function failsWithoutOldKey(): void + { + $tester = $this->runCommandAndReturnTester([]); + self::assertSame(1, $tester->getStatusCode()); + self::assertStringContainsString('--old-key is required', $tester->getDisplay()); + } + + #[Test] + public function dryRunDoesNotWrite(): void + { + $encryption = $this->get(ApiKeyEncryption::class); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::OLD_KEY; + $uid = $this->insertRow($encryption->encrypt('sk-real-secret')); + $beforeRotation = $this->fetchRawApiKey($uid); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = self::NEW_KEY; + + $tester = $this->runCommandAndReturnTester([ + '--old-key' => self::OLD_KEY, + '--dry-run' => true, + ]); + self::assertSame(0, $tester->getStatusCode()); + self::assertStringContainsString('[dry-run]', $tester->getDisplay()); + self::assertSame($beforeRotation, $this->fetchRawApiKey($uid)); + } + + private function runCommand(array $options): int + { + return $this->runCommandAndReturnTester($options)->getStatusCode(); + } + + private function runCommandAndReturnTester(array $options): CommandTester + { + $tester = new CommandTester($this->get(RotateApiKeys::class)); + $tester->execute($options); + return $tester; + } + + private function insertRow(string $apiKey): int + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable(self::TABLE); + $connection->insert(self::TABLE, [ + 'pid' => 0, + 'ai_provider' => 'test', + 'title' => 'Test', + 'api_key' => $apiKey, + 'model' => 'test-model', + ]); + return (int)$connection->lastInsertId(); + } + + private function fetchRawApiKey(int $uid): string + { + $qb = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable(self::TABLE); + $qb->getRestrictions()->removeAll(); + return (string)$qb->select('api_key') + ->from(self::TABLE) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchOne(); + } + + private function assertThrows(callable $callable): void + { + try { + $callable(); + } catch (\Throwable) { + return; + } + self::fail('Expected an exception but none was thrown.'); + } +} diff --git a/Tests/Functional/Crypto/ApiKeyEncryptionPersistenceTest.php b/Tests/Functional/Crypto/ApiKeyEncryptionPersistenceTest.php new file mode 100644 index 0000000..6c23ac7 --- /dev/null +++ b/Tests/Functional/Crypto/ApiKeyEncryptionPersistenceTest.php @@ -0,0 +1,146 @@ +get(ApiKeyEncryption::class); + $uid = $this->insertRow($encryption->encrypt('sk-plaintext-secret')); + + $config = $this->get(ProviderConfigurationRepository::class)->findByUid($uid); + + self::assertNotNull($config); + self::assertSame('sk-plaintext-secret', $config->apiKey); + } + + #[Test] + public function repositoryReturnsLegacyPlaintextUnchanged(): void + { + $uid = $this->insertRow('sk-legacy-plaintext'); + + $config = $this->get(ProviderConfigurationRepository::class)->findByUid($uid); + + self::assertNotNull($config); + self::assertSame('sk-legacy-plaintext', $config->apiKey); + } + + #[Test] + public function upgradeWizardIsRegisteredInTheInstallToolRegistry(): void + { + $registry = $this->get(UpgradeWizardRegistry::class); + self::assertTrue($registry->hasUpgradeWizard('aimEncryptApiKeys')); + self::assertInstanceOf(EncryptApiKeysUpgrade::class, $registry->getUpgradeWizard('aimEncryptApiKeys')); + } + + #[Test] + public function upgradeWizardEncryptsLegacyPlaintextRows(): void + { + $uid = $this->insertRow('sk-legacy-from-old-version'); + + $wizard = $this->get(UpgradeWizardRegistry::class)->getUpgradeWizard('aimEncryptApiKeys'); + self::assertTrue($wizard->updateNecessary()); + self::assertTrue($wizard->executeUpdate()); + self::assertFalse($wizard->updateNecessary()); + + self::assertStringStartsWith(ApiKeyEncryption::PREFIX_ANY, $this->fetchRawApiKey($uid)); + + $config = $this->get(ProviderConfigurationRepository::class)->findByUid($uid); + self::assertNotNull($config); + self::assertSame('sk-legacy-from-old-version', $config->apiKey); + } + + #[Test] + public function upgradeWizardLeavesEndpointUrlsAsPlaintext(): void + { + $uid = $this->insertRow('http://host.docker.internal:11434'); + + $wizard = $this->get(UpgradeWizardRegistry::class)->getUpgradeWizard('aimEncryptApiKeys'); + self::assertFalse($wizard->updateNecessary()); + self::assertTrue($wizard->executeUpdate()); + + self::assertSame('http://host.docker.internal:11434', $this->fetchRawApiKey($uid)); + + $config = $this->get(ProviderConfigurationRepository::class)->findByUid($uid); + self::assertNotNull($config); + self::assertSame('http://host.docker.internal:11434', $config->apiKey); + } + + #[Test] + public function upgradeWizardLeavesAlreadyEncryptedRowsAlone(): void + { + $encryption = $this->get(ApiKeyEncryption::class); + $uid = $this->insertRow($encryption->encrypt('sk-already-encrypted')); + $before = $this->fetchRawApiKey($uid); + + $wizard = $this->get(UpgradeWizardRegistry::class)->getUpgradeWizard('aimEncryptApiKeys'); + self::assertFalse($wizard->updateNecessary()); + self::assertTrue($wizard->executeUpdate()); + + self::assertSame($before, $this->fetchRawApiKey($uid)); + } + + private function insertRow(string $apiKey): int + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable(self::TABLE); + $connection->insert(self::TABLE, [ + 'pid' => 0, + 'ai_provider' => 'test', + 'title' => 'Test', + 'api_key' => $apiKey, + 'model' => 'test-model', + ]); + return (int)$connection->lastInsertId(); + } + + private function fetchRawApiKey(int $uid): string + { + $qb = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable(self::TABLE); + $qb->getRestrictions()->removeAll(); + return (string)$qb->select('api_key') + ->from(self::TABLE) + ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchOne(); + } +} diff --git a/Tests/Unit/Crypto/ApiKeyEncryptionTest.php b/Tests/Unit/Crypto/ApiKeyEncryptionTest.php new file mode 100644 index 0000000..2d7f454 --- /dev/null +++ b/Tests/Unit/Crypto/ApiKeyEncryptionTest.php @@ -0,0 +1,177 @@ +originalKey = (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? ''); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = str_repeat('a', 96); + } + + protected function tearDown(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = $this->originalKey; + parent::tearDown(); + } + + #[Test] + public function encryptDecryptRoundTrip(): void + { + $service = new ApiKeyEncryption(); + $plaintext = 'sk-proj-abc123-very-secret'; + + $encrypted = $service->encrypt($plaintext); + + self::assertNotSame($plaintext, $encrypted); + self::assertTrue($service->isEncrypted($encrypted)); + self::assertSame($plaintext, $service->decrypt($encrypted)); + } + + #[Test] + public function encryptPicksV2OnTypo3V14(): void + { + if (!class_exists(CipherService::class)) { + self::markTestSkipped('TYPO3 CipherService not available — only present on v14+.'); + } + $encrypted = (new ApiKeyEncryption())->encrypt('sk-test'); + self::assertStringStartsWith(ApiKeyEncryption::PREFIX_V2, $encrypted); + } + + #[Test] + public function decryptOfV1PayloadStillWorksOnV14(): void + { + // A value persisted by v12/v13 must remain readable after upgrade. + // We build a v1 payload directly so the test is independent of which + // path encrypt() currently picks. + $v1 = $this->makeV1Payload('sk-from-old-typo3'); + self::assertSame('sk-from-old-typo3', (new ApiKeyEncryption())->decrypt($v1)); + } + + #[Test] + public function encryptLeavesEndpointUrlsUntouched(): void + { + $service = new ApiKeyEncryption(); + $endpoint = 'http://host.docker.internal:11434'; + + $result = $service->encrypt($endpoint); + + self::assertSame($endpoint, $result); + self::assertFalse($service->isEncrypted($result)); + } + + #[Test] + public function encryptLeavesHttpsEndpointUrlsUntouched(): void + { + $service = new ApiKeyEncryption(); + $endpoint = 'https://my-self-hosted-llm.example.com:8443'; + + self::assertSame($endpoint, $service->encrypt($endpoint)); + } + + #[Test] + public function encryptIsIdempotent(): void + { + $service = new ApiKeyEncryption(); + $encrypted = $service->encrypt('sk-test'); + + self::assertSame($encrypted, $service->encrypt($encrypted)); + } + + #[Test] + public function encryptOfEmptyStringStaysEmpty(): void + { + $service = new ApiKeyEncryption(); + self::assertSame('', $service->encrypt('')); + } + + #[Test] + public function decryptOfLegacyPlaintextReturnsInputUnchanged(): void + { + $service = new ApiKeyEncryption(); + self::assertSame('sk-legacy-plaintext', $service->decrypt('sk-legacy-plaintext')); + } + + #[Test] + public function encryptUsesFreshNonceEachTime(): void + { + $service = new ApiKeyEncryption(); + $first = $service->encrypt('sk-same-input'); + $second = $service->encrypt('sk-same-input'); + + self::assertNotSame($first, $second); + self::assertSame('sk-same-input', $service->decrypt($first)); + self::assertSame('sk-same-input', $service->decrypt($second)); + } + + #[Test] + public function decryptFailsWhenSystemKeyChanged(): void + { + $service = new ApiKeyEncryption(); + $encrypted = $service->encrypt('sk-test'); + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = str_repeat('b', 96); + + $this->expectException(ApiKeyEncryptionException::class); + $service->decrypt($encrypted); + } + + #[Test] + public function isEncryptedDetectsBothPrefixes(): void + { + $service = new ApiKeyEncryption(); + self::assertFalse($service->isEncrypted('sk-plaintext')); + self::assertTrue($service->isEncrypted(ApiKeyEncryption::PREFIX_V1 . 'whatever')); + self::assertTrue($service->isEncrypted(ApiKeyEncryption::PREFIX_V2 . 'whatever')); + } + + #[Test] + public function encryptFailsWithoutSystemEncryptionKeyOnV1Path(): void + { + if (class_exists(CipherService::class)) { + self::markTestSkipped('On v14 the missing-key error is raised by core, not by us.'); + } + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = ''; + + $this->expectException(ApiKeyEncryptionException::class); + $this->expectExceptionCode(1773874402); + (new ApiKeyEncryption())->encrypt('sk-test'); + } + + /** + * Builds a v1 payload by replicating the libsodium secretbox path, + * so the test does not depend on which encryption path the service + * picks at runtime. + */ + private function makeV1Payload(string $plaintext): string + { + $key = sodium_crypto_generichash( + "aim:apikey:v1\0" . $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'], + '', + SODIUM_CRYPTO_SECRETBOX_KEYBYTES, + ); + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key); + return ApiKeyEncryption::PREFIX_V1 . base64_encode($nonce . $ciphertext); + } +} diff --git a/Tests/Unit/Hooks/EncryptApiKeyTest.php b/Tests/Unit/Hooks/EncryptApiKeyTest.php new file mode 100644 index 0000000..be7fdf1 --- /dev/null +++ b/Tests/Unit/Hooks/EncryptApiKeyTest.php @@ -0,0 +1,100 @@ +originalKey = (string)($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] ?? ''); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = str_repeat('a', 96); + } + + protected function tearDown(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = $this->originalKey; + parent::tearDown(); + } + + #[Test] + public function encryptsPlaintextApiKeyOnSave(): void + { + $hook = new EncryptApiKey(new ApiKeyEncryption()); + $fieldArray = ['api_key' => 'sk-secret', 'title' => 'My Provider']; + + $hook->processDatamap_postProcessFieldArray('new', 'tx_aim_configuration', 'NEW1', $fieldArray, $this->createDataHandlerMock()); + + self::assertTrue((new ApiKeyEncryption())->isEncrypted($fieldArray['api_key'])); + self::assertSame('My Provider', $fieldArray['title']); + } + + #[Test] + public function leavesAlreadyEncryptedValueUntouched(): void + { + $encryption = new ApiKeyEncryption(); + $hook = new EncryptApiKey($encryption); + $alreadyEncrypted = $encryption->encrypt('sk-secret'); + $fieldArray = ['api_key' => $alreadyEncrypted]; + + $hook->processDatamap_postProcessFieldArray('update', 'tx_aim_configuration', 1, $fieldArray, $this->createDataHandlerMock()); + + self::assertSame($alreadyEncrypted, $fieldArray['api_key']); + } + + #[Test] + public function ignoresOtherTables(): void + { + $hook = new EncryptApiKey(new ApiKeyEncryption()); + $fieldArray = ['api_key' => 'sk-secret']; + + $hook->processDatamap_postProcessFieldArray('new', 'tt_content', 'NEW1', $fieldArray, $this->createDataHandlerMock()); + + self::assertSame('sk-secret', $fieldArray['api_key']); + } + + #[Test] + public function ignoresUpdatesThatDoNotTouchApiKey(): void + { + $hook = new EncryptApiKey(new ApiKeyEncryption()); + $fieldArray = ['title' => 'Renamed']; + + $hook->processDatamap_postProcessFieldArray('update', 'tx_aim_configuration', 1, $fieldArray, $this->createDataHandlerMock()); + + self::assertSame(['title' => 'Renamed'], $fieldArray); + } + + #[Test] + public function ignoresEmptyApiKey(): void + { + $hook = new EncryptApiKey(new ApiKeyEncryption()); + $fieldArray = ['api_key' => '']; + + $hook->processDatamap_postProcessFieldArray('update', 'tx_aim_configuration', 1, $fieldArray, $this->createDataHandlerMock()); + + self::assertSame('', $fieldArray['api_key']); + } + + private function createDataHandlerMock(): DataHandler + { + return $this->createMock(DataHandler::class); + } +} diff --git a/composer.json b/composer.json index a48ff5b..04e60f1 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "license": "GPL-2.0-or-later", "require": { "php": "^8.1", + "ext-sodium": "*", "typo3/cms-core": "^12.4 || ^13.4 || ^14.0", "typo3/cms-backend": "^12.4 || ^13.4 || ^14.0" }, diff --git a/ext_localconf.php b/ext_localconf.php index b83b7ca..9f0037d 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -2,9 +2,16 @@ declare(strict_types=1); +use B13\Aim\Backend\FormDataProvider\DecryptApiKey; use B13\Aim\Hooks\DefaultProviderHook; +use B13\Aim\Hooks\EncryptApiKey; $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][DefaultProviderHook::class] = DefaultProviderHook::class; +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][EncryptApiKey::class] = EncryptApiKey::class; + +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord'][DecryptApiKey::class] = [ + 'depends' => [\TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEditRow::class], +]; // Register AI capability permissions for backend user groups $GLOBALS['TYPO3_CONF_VARS']['BE']['customPermOptions']['aim'] = [ diff --git a/ext_tables.sql b/ext_tables.sql index 027d60b..7167b24 100644 --- a/ext_tables.sql +++ b/ext_tables.sql @@ -3,7 +3,7 @@ CREATE TABLE tx_aim_configuration ( title varchar(255) DEFAULT '' NOT NULL, description text, `default` tinyint(4) unsigned DEFAULT '0' NOT NULL, - api_key varchar(255) DEFAULT '' NOT NULL, + api_key text, model varchar(255) DEFAULT '' NOT NULL, total_cost double(10,6) DEFAULT '0.000000' NOT NULL, cost_currency varchar(10) DEFAULT 'USD' NOT NULL,