Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
29 changes: 27 additions & 2 deletions Build/phpstan.bootstrap-v14stubs.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?php

// Stubs for TYPO3 v14-only dashboard types. No strict_types so conditional
// interface/class declarations are valid. Guards prevent redeclaration on v14.
// Stubs for TYPO3 v14-only types. No strict_types so conditional
// interface/class declarations are valid. Guards prevent redeclaration when
// the real packages are available (local dummy install or CI with cms-install).

namespace TYPO3\CMS\Dashboard\Widgets;

Expand Down Expand Up @@ -37,3 +38,27 @@ public function __construct(
) {}
}
}

namespace TYPO3\CMS\Install\Updates;

if (!interface_exists(UpgradeWizardInterface::class)) {
interface UpgradeWizardInterface
{
public function getTitle(): string;
public function getDescription(): string;
public function executeUpdate(): bool;
public function updateNecessary(): bool;
/** @return string[] */
public function getPrerequisites(): array;
}
}

namespace TYPO3\CMS\Install\Attribute;

if (!class_exists(UpgradeWizard::class)) {
#[\Attribute(\Attribute::TARGET_CLASS)]
class UpgradeWizard
{
public function __construct(public string $identifier) {}
}
}
2 changes: 1 addition & 1 deletion Build/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ parameters:
- %currentWorkingDirectory%/Classes/

bootstrapFiles:
- phpstan.bootstrap-dashboard.php
- phpstan.bootstrap-typo3stubs.php
69 changes: 47 additions & 22 deletions Classes/Service/CipherService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
);
Expand All @@ -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);
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Classes/Service/CipherServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ interface CipherServiceInterface
public function encrypt(string $plaintext): string;

public function decrypt(string $encrypted): string;

public function isLegacyFormat(string $encrypted): bool;
}
88 changes: 88 additions & 0 deletions Classes/Upgrades/InstanceSecretEncryptionMigrationWizard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace T3G\Analytics\Upgrades;

use Psr\Log\LoggerInterface;
use T3G\Analytics\Service\CipherServiceInterface;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Site\SiteSettingsFactory;
use TYPO3\CMS\Core\Site\SiteSettingsService;
use TYPO3\CMS\Install\Attribute\UpgradeWizard;
use TYPO3\CMS\Install\Updates\UpgradeWizardInterface;

#[UpgradeWizard('analytics_instanceSecretEncryptionMigration')]
final class InstanceSecretEncryptionMigrationWizard implements UpgradeWizardInterface
{
public function __construct(
private readonly SiteFinder $siteFinder,
private readonly CipherServiceInterface $cipherService,
private readonly SiteSettingsService $siteSettingsService,
private readonly SiteSettingsFactory $siteSettingsFactory,
private readonly LoggerInterface $logger,
) {
}

public function getTitle(): string
{
return 'Analytics: Migrate instance secret encryption (v13 → v14)';
}

public function getDescription(): string
{
return 'Re-encrypts instanceSecret values stored in site settings from the TYPO3 v13 '
. 'format ("ciphertext" key) to the TYPO3 v14 core cipher format ("cipher" key). '
. 'Required after upgrading from TYPO3 v13 to v14.';
}

public function updateNecessary(): bool
{
foreach ($this->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 [];
}
}
28 changes: 28 additions & 0 deletions Tests/Functional/Service/CipherServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']));
}
}
6 changes: 6 additions & 0 deletions Tests/Functional/Service/Fixtures/legacy_cipher_value.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading