diff --git a/Build/phpstan.bootstrap-dashboard.php b/Build/phpstan.bootstrap-typo3stubs.php similarity index 58% rename from Build/phpstan.bootstrap-dashboard.php rename to Build/phpstan.bootstrap-typo3stubs.php index 4593bf12..992fc987 100644 --- a/Build/phpstan.bootstrap-dashboard.php +++ b/Build/phpstan.bootstrap-typo3stubs.php @@ -17,6 +17,22 @@ }); } +// Register autoloader for typo3/cms-install (not in the main vendor). +// Needed for UpgradeWizardInterface and the UpgradeWizard attribute. +$installClassesDir = __DIR__ . '/../.Build/dummy-typo3/vendor/typo3/cms-install/Classes/'; +if (is_dir($installClassesDir)) { + spl_autoload_register(static function (string $class) use ($installClassesDir): void { + $prefix = 'TYPO3\\CMS\\Install\\'; + if (!str_starts_with($class, $prefix)) { + return; + } + $file = $installClassesDir . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + if (is_file($file)) { + require_once $file; + } + }); +} + // v13 fallback stubs — declared when cms-dashboard is not available (e.g. CI). require_once __DIR__ . '/phpstan.bootstrap-v13stubs.php'; diff --git a/Build/phpstan.bootstrap-v14stubs.php b/Build/phpstan.bootstrap-v14stubs.php index 7a9bb365..bbf6b0ed 100644 --- a/Build/phpstan.bootstrap-v14stubs.php +++ b/Build/phpstan.bootstrap-v14stubs.php @@ -1,7 +1,8 @@ deriveSharedKeyFromEncryptionKey(self::KEY_SEED); - // CipherValue::fromSerialized() reconstructs the value object from the serialized form + // Values written under v13 use "ciphertext" as JSON key — route them to the sodium + // fallback path until InstanceSecretEncryptionMigrationWizard has re-encrypted them. + if ($this->isLegacyFormat($encrypted)) { + return $this->decryptWithSodium($encrypted); + } + $cipherValueClass = 'TYPO3\\CMS\\Core\\Crypto\\Cipher\\CipherValue'; $cipherValue = $cipherValueClass::fromSerialized($encrypted); @@ -109,7 +117,7 @@ private function encryptWithSodium(string $plaintext): string return sodium_bin2base64( (string)json_encode([ 'nonce' => sodium_bin2base64($nonce, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), - 'ciphertext' => sodium_bin2base64($ciphertext, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + 'cipher' => sodium_bin2base64($ciphertext, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), ]), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); @@ -123,24 +131,26 @@ private function decryptWithSodium(string $encrypted): string { $key = $this->deriveKey(); - $payload = json_decode( - sodium_base642bin($encrypted, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), - true, - 512, - JSON_THROW_ON_ERROR - ); - - $nonce = sodium_base642bin((string) $payload['nonce'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); - $ciphertext = sodium_base642bin((string) $payload['ciphertext'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); - - $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( - $ciphertext, - '', - $nonce, - $key - ); - - sodium_memzero($key); + try { + $payload = json_decode( + sodium_base642bin($encrypted, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + true, + 512, + JSON_THROW_ON_ERROR + ); + + $nonce = sodium_base642bin((string) $payload['nonce'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + $ciphertext = sodium_base642bin((string) ($payload['cipher'] ?? $payload['ciphertext'] ?? ''), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + + $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + $ciphertext, + '', + $nonce, + $key + ); + } finally { + sodium_memzero($key); + } if ($plaintext === false) { throw new \RuntimeException('Cipher decryption failed: authentication tag mismatch.', 1744800000); @@ -149,6 +159,21 @@ private function decryptWithSodium(string $encrypted): string return $plaintext; } + public function isLegacyFormat(string $encrypted): bool + { + try { + $decoded = json_decode( + sodium_base642bin($encrypted, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + true, + 512, + JSON_THROW_ON_ERROR + ); + return isset($decoded['ciphertext']) && !isset($decoded['cipher']); + } catch (\Throwable) { + return false; + } + } + /** Shared helpers */ private function isCoreServiceAvailable(): bool diff --git a/Classes/Service/CipherServiceInterface.php b/Classes/Service/CipherServiceInterface.php index 41e885ff..c97ceedf 100644 --- a/Classes/Service/CipherServiceInterface.php +++ b/Classes/Service/CipherServiceInterface.php @@ -9,4 +9,6 @@ interface CipherServiceInterface public function encrypt(string $plaintext): string; public function decrypt(string $encrypted): string; + + public function isLegacyFormat(string $encrypted): bool; } diff --git a/Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php b/Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php new file mode 100644 index 00000000..2c1e1878 --- /dev/null +++ b/Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php @@ -0,0 +1,88 @@ +siteFinder->getAllSites() as $site) { + try { + $secret = $site->getSettings()->get('instanceSecret', ''); + if ($secret !== '' && $this->cipherService->isLegacyFormat($secret)) { + return true; + } + } catch (\Throwable) { + continue; + } + } + return false; + } + + public function executeUpdate(): bool + { + $success = true; + foreach ($this->siteFinder->getAllSites() as $site) { + $identifier = $site->getIdentifier(); + try { + $encrypted = $site->getSettings()->get('instanceSecret', ''); + } catch (\Throwable) { + continue; + } + if ($encrypted === '' || !$this->cipherService->isLegacyFormat($encrypted)) { + continue; + } + try { + $plaintext = $this->cipherService->decrypt($encrypted); + $reEncrypted = $this->cipherService->encrypt($plaintext); + $existing = $this->siteSettingsFactory->loadLocalSettings($identifier) ?? []; + $this->siteSettingsService->writeSettings($site, array_merge($existing, ['instanceSecret' => $reEncrypted])); + } catch (\Throwable $e) { + $this->logger->error('InstanceSecretEncryptionMigrationWizard: migration failed.', [ + 'siteIdentifier' => $identifier, + 'exception' => $e->getMessage(), + ]); + $success = false; + } + } + return $success; + } + + + public function getPrerequisites(): array + { + return []; + } +} diff --git a/Tests/Functional/Service/CipherServiceTest.php b/Tests/Functional/Service/CipherServiceTest.php index cbe8c097..6a58a240 100644 --- a/Tests/Functional/Service/CipherServiceTest.php +++ b/Tests/Functional/Service/CipherServiceTest.php @@ -71,4 +71,32 @@ public function emptyStringRoundtrip(): void $encrypted = $this->subject->encrypt(''); self::assertSame('', $this->subject->decrypt($encrypted)); } + + #[Test] + public function encryptProducesCipherKey(): void + { + $encrypted = $this->subject->encrypt('test'); + $payload = json_decode( + sodium_base642bin($encrypted, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + true, + 512, + JSON_THROW_ON_ERROR + ); + + self::assertArrayHasKey('cipher', $payload); + self::assertArrayNotHasKey('ciphertext', $payload); + } + + #[Test] + public function decryptHandlesLegacyCiphertextKey(): void + { + $fixture = json_decode( + (string)file_get_contents(__DIR__ . '/Fixtures/legacy_cipher_value.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + + self::assertSame($fixture['plaintext'], $this->subject->decrypt($fixture['encrypted'])); + } } diff --git a/Tests/Functional/Service/Fixtures/legacy_cipher_value.json b/Tests/Functional/Service/Fixtures/legacy_cipher_value.json new file mode 100644 index 00000000..8b0a030a --- /dev/null +++ b/Tests/Functional/Service/Fixtures/legacy_cipher_value.json @@ -0,0 +1,6 @@ +{ + "description": "instanceSecret encrypted with the v13 'ciphertext' key format using the test encryptionKey", + "encryptionKey": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "plaintext": "legacy-test-secret", + "encrypted": "eyJub25jZSI6InhoZTJqR1JRMGptZ3Y4TGktMmtoS1BQYmVDSGZjcHI1IiwiY2lwaGVydGV4dCI6IkRWV1VlajlSdWVzT3R0aThnQU1OX2U4Q1RJb1AtbkJmeGlhZzRNbFdjZF92clEifQ" +}