|
| 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 | +} |
0 commit comments