From cc1f3b9bc101ab29dac401d9eedd406ae49c545a Mon Sep 17 00:00:00 2001 From: Simon Schmidt Date: Tue, 1 Sep 2026 14:35:56 +0200 Subject: [PATCH 1/2] [BUGFIX] add upgrade wizard to migrate v13 to v14 --- Build/phpstan.neon | 3 + Classes/Service/CipherService.php | 33 +++++-- Classes/Service/CipherServiceInterface.php | 2 + ...nstanceSecretEncryptionMigrationWizard.php | 88 +++++++++++++++++++ .../Functional/Service/CipherServiceTest.php | 28 ++++++ .../Service/Fixtures/legacy_cipher_value.json | 6 ++ 6 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php create mode 100644 Tests/Functional/Service/Fixtures/legacy_cipher_value.json diff --git a/Build/phpstan.neon b/Build/phpstan.neon index ef32d05b..0165fd56 100644 --- a/Build/phpstan.neon +++ b/Build/phpstan.neon @@ -9,5 +9,8 @@ parameters: paths: - %currentWorkingDirectory%/Classes/ + excludePaths: + - %currentWorkingDirectory%/Classes/Upgrades/ + bootstrapFiles: - phpstan.bootstrap-dashboard.php diff --git a/Classes/Service/CipherService.php b/Classes/Service/CipherService.php index 958cc11b..170c0631 100644 --- a/Classes/Service/CipherService.php +++ b/Classes/Service/CipherService.php @@ -12,8 +12,11 @@ * * On TYPO3 v14+ the built-in TYPO3\CMS\Core\Crypto\Cipher\CipherService is used * automatically (Feature-108002). On v13 an equivalent implementation based on - * the same algorithm and serialization format is used as fallback, so encrypted - * values written under v13 remain decryptable after upgrading to v14. + * the same algorithm and serialization format is used as fallback. + * + * Values written under v13 use the same "cipher" JSON key as v14 and are therefore + * directly decryptable after upgrading. Older values written with the legacy + * "ciphertext" key are handled by the InstanceSecretEncryptionMigrationWizard. * * Key derivation mirrors KeyFactory::deriveSharedKeyFromEncryptionKey(): * BLAKE2b keyed hash over a domain-specific seed derived from @@ -78,7 +81,12 @@ private function decryptWithCoreService(string $encrypted): string // @phpstan-ignore class.notFound (TYPO3\CMS\Core\Crypto\Cipher\KeyFactory does not exist in v13) $key = $keyFactory->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 ); @@ -131,7 +139,7 @@ private function decryptWithSodium(string $encrypted): string ); $nonce = sodium_base642bin((string) $payload['nonce'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); - $ciphertext = sodium_base642bin((string) $payload['ciphertext'], 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, @@ -149,6 +157,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..02376541 --- /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->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" +} From c82f64456e060015af2c9b5b7accf70b3f2800f8 Mon Sep 17 00:00:00 2001 From: Simon Schmidt Date: Fri, 18 Sep 2026 14:00:31 +0200 Subject: [PATCH 2/2] [BUGFIX] fix upgrade wizard and extend PHPStan coverage to Upgrades/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix isLegacyFormat() call in InstanceSecretEncryptionMigrationWizard (was $this->isLegacyFormat(), must be $this->cipherService->isLegacyFormat()) - Wrap decryptWithSodium key derivation in try/finally so sodium_memzero() is always called even if a SodiumException or JsonException is thrown - Rename phpstan.bootstrap-dashboard.php → phpstan.bootstrap-typo3stubs.php and add a cms-install autoloader shim so Classes/Upgrades/ can be included in PHPStan analysis; add CI fallback stubs for UpgradeWizardInterface and the UpgradeWizard attribute - Remove excludePaths for Classes/Upgrades/ from phpstan.neon --- ...d.php => phpstan.bootstrap-typo3stubs.php} | 16 +++++++++ Build/phpstan.bootstrap-v14stubs.php | 29 ++++++++++++++-- Build/phpstan.neon | 5 +-- Classes/Service/CipherService.php | 34 ++++++++++--------- ...nstanceSecretEncryptionMigrationWizard.php | 2 +- 5 files changed, 63 insertions(+), 23 deletions(-) rename Build/{phpstan.bootstrap-dashboard.php => phpstan.bootstrap-typo3stubs.php} (58%) 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 @@ deriveKey(); - $payload = json_decode( - sodium_base642bin($encrypted, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), - true, - 512, - JSON_THROW_ON_ERROR - ); + 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); + $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 - ); - - sodium_memzero($key); + $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); diff --git a/Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php b/Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php index 02376541..2c1e1878 100644 --- a/Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php +++ b/Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php @@ -61,7 +61,7 @@ public function executeUpdate(): bool } catch (\Throwable) { continue; } - if ($encrypted === '' || !$this->isLegacyFormat($encrypted)) { + if ($encrypted === '' || !$this->cipherService->isLegacyFormat($encrypted)) { continue; } try {