Encrypt and decrypt sensitive data with the cipher service 

An extension sometimes has to store a value that it needs again in plain text, for example an API token of a third-party service. The \TYPO3\CMS\Core\Crypto\Cipher\CipherService encrypts and decrypts such a value. It uses the XChaCha20-Poly1305 cipher of the PHP extension sodium. The cipher authenticates the data as well: the service rejects a value that somebody changed after the encryption.

Do not use the cipher service for a password of a website user. A password is verified, not decrypted. Use password hashing for it.

Derive a key from the TYPO3 encryption key 

The cipher service needs a key. The \TYPO3\CMS\Core\Crypto\Cipher\KeyFactory derives that key from the encryption key of the installation. Pass a seed to the method KeyFactory::deriveSharedKeyFromEncryptionKey() . The seed names the purpose of the key, for example the class that uses it. Each seed produces a different key.

The factory also creates a key without the encryption key. The method KeyFactory::createSharedKeyFromString() takes a key of your own, for example from an environment variable. The method KeyFactory::generateSharedKey() returns a new random key. Store such a key yourself, otherwise the data stays encrypted forever.

Encrypt and decrypt a value with the cipher service 

The method CipherService::encrypt() returns a \TYPO3\CMS\Core\Crypto\Cipher\CipherValue object. Cast that object to a string to store it. Each call returns a different string, because the service uses a new random nonce every time.

The method CipherService::decrypt() takes the cipher value back. Build it from the stored string with CipherValue::fromSerialized() . The method throws a \TYPO3\CMS\Core\Crypto\Cipher\CipherDecryptionFailedException when the key is wrong or when somebody changed the stored value. Catch that exception.

packages/my_extension/Classes/Service/TokenEncryptionService.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Service;

use TYPO3\CMS\Core\Crypto\Cipher\CipherDecryptionFailedException;
use TYPO3\CMS\Core\Crypto\Cipher\CipherService;
use TYPO3\CMS\Core\Crypto\Cipher\CipherValue;
use TYPO3\CMS\Core\Crypto\Cipher\KeyFactory;

final readonly class TokenEncryptionService
{
  public function __construct(
    private CipherService $cipherService,
    private KeyFactory $keyFactory,
  ) {}

  public function encryptToken(string $token): string
  {
    $key = $this->keyFactory->deriveSharedKeyFromEncryptionKey(self::class);
    return (string)$this->cipherService->encrypt($token, $key);
  }

  public function decryptToken(string $storedToken): string
  {
    $key = $this->keyFactory->deriveSharedKeyFromEncryptionKey(self::class);
    try {
      $cipherValue = CipherValue::fromSerialized($storedToken);
      return $this->cipherService->decrypt($cipherValue, $key);
    } catch (CipherDecryptionFailedException $exception) {
      throw new \RuntimeException(
        'The stored token cannot be decrypted',
        1758700800,
        $exception,
      );
    }
  }
}
Copied!

Bind encrypted data to a context with additional authenticated data 

Both methods take additional authenticated data as a third argument. The service does not encrypt this data. It includes the data in the integrity check instead. The decryption therefore only succeeds when the caller passes the same data again.

Use this argument to bind a value to the record that it belongs to. The example stores an API token in an account record. It passes the table name and the uid of the record as additional data.

packages/my_extension/Classes/Service/AccountTokenEncryptionService.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Service;

use TYPO3\CMS\Core\Crypto\Cipher\CipherService;
use TYPO3\CMS\Core\Crypto\Cipher\CipherValue;
use TYPO3\CMS\Core\Crypto\Cipher\KeyFactory;

final readonly class AccountTokenEncryptionService
{
  private const TABLE = 'tx_myextension_domain_model_account';

  public function __construct(
    private CipherService $cipherService,
    private KeyFactory $keyFactory,
  ) {}

  public function encryptToken(string $token, int $accountUid): string
  {
    $key = $this->keyFactory->deriveSharedKeyFromEncryptionKey(self::class);
    $cipherValue = $this->cipherService->encrypt(
      $token,
      $key,
      $this->buildContext($accountUid),
    );
    return (string)$cipherValue;
  }

  public function decryptToken(string $storedToken, int $accountUid): string
  {
    $key = $this->keyFactory->deriveSharedKeyFromEncryptionKey(self::class);
    // A token of another account record throws a
    // CipherDecryptionFailedException here
    return $this->cipherService->decrypt(
      CipherValue::fromSerialized($storedToken),
      $key,
      $this->buildContext($accountUid),
    );
  }

  private function buildContext(int $accountUid): string
  {
    return self::TABLE . ':' . $accountUid;
  }
}
Copied!

Somebody who copies the stored token of account 5 into account 7 gains nothing. The service builds the additional data from the uid of account 7 and refuses to decrypt the token.