* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ class SymmetricCrypto { /** * Cache for secret keys derived from the password * indexed by the used salt and limits */ protected array $secretKeysByOptions = []; /** * Initializes the keys used for crypto, both optional * * @param string|null $password Password to be derived into a `$secretKey` * @param string|null $secretKey 256-bit key, alternatively a `$password` can be used */ public function __construct( #[SensitiveParameter] protected string|null $password = null, #[SensitiveParameter] protected string|null $secretKey = null, ) { if ($password !== null && $secretKey !== null) { throw new InvalidArgumentException('Passing both a secret key and a password is not supported'); } if ($secretKey !== null && strlen($secretKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { throw new InvalidArgumentException('Invalid secret key length, expected ' . SODIUM_CRYPTO_SECRETBOX_KEYBYTES . ' bytes'); } } /** * Hide values of secrets when printing the object */ public function __debugInfo(): array { return [ 'hasPassword' => isset($this->password), 'hasSecretKey' => isset($this->secretKey), ]; } /** * Wipes the secrets from memory when they are no longer needed */ public function __destruct() { $this->memzero($this->password); $this->memzero($this->secretKey); foreach ($this->secretKeysByOptions as $key => &$value) { $this->memzero($value); unset($this->secretKeysByOptions[$key]); } } /** * Decrypts JSON data encrypted by `SymmetricCrypto::encrypt()` using the secret key or password * * * // decryption with a password * $crypto = new SymmetricCrypto(password: 'super secure'); * $plaintext = $crypto->decrypt('a very confidential string'); * * // decryption with a previously generated key * $crypto = new SymmetricCrypto(secretKey: $secretKey); * $plaintext = $crypto->decrypt('{"mode":"secretbox"...}'); * */ public function decrypt(string $json): string { $props = Json::decode($json); if (($props['mode'] ?? null) !== 'secretbox') { throw new InvalidArgumentException('Unsupported encryption mode "' . ($props['mode'] ?? '') . '"'); } if ( isset($props['data']) !== true || isset($props['nonce']) !== true || isset($props['salt']) !== true || isset($props['limits']) !== true ) { throw new InvalidArgumentException('Input data does not contain all required props'); } $data = base64_decode($props['data']); $nonce = base64_decode($props['nonce']); $salt = base64_decode($props['salt']); $limits = $props['limits']; $plaintext = sodium_crypto_secretbox_open($data, $nonce, $this->secretKey($salt, $limits)); if (is_string($plaintext) !== true) { throw new LogicException('Encrypted string was tampered with'); } return $plaintext; } /** * Encrypts a string using the secret key or password * * * // encryption with a password * $crypto = new SymmetricCrypto(password: 'super secure'); * $ciphertext = $crypto->encrypt('a very confidential string'); * * // encryption with a random key * $crypto = new SymmetricCrypto(); * $ciphertext = $crypto->encrypt('a very confidential string'); * $secretKey = $crypto->secretKey(); * */ public function encrypt( #[SensitiveParameter] string $string ): string { $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES); $limits = [SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE]; $key = $this->secretKey($salt, $limits); $ciphertext = sodium_crypto_secretbox($string, $nonce, $key); // bundle all necessary information in a JSON object; // always include the salt and limits to hide whether a key or password was used return Json::encode([ 'mode' => 'secretbox', 'data' => base64_encode($ciphertext), 'nonce' => base64_encode($nonce), 'salt' => base64_encode($salt), 'limits' => $limits, ]); } /** * Checks if the required PHP `sodium` extension is available */ public static function isAvailable(): bool { return defined('SODIUM_LIBRARY_MAJOR_VERSION') === true && SODIUM_LIBRARY_MAJOR_VERSION >= 10; } /** * Returns the binary secret key, optionally derived from the password * or randomly generated * * @param string|null $salt Salt for password-based key derivation * @param array|null $limits Processing limits for password-based key derivation */ public function secretKey( #[SensitiveParameter] string|null $salt = null, array|null $limits = null ): string { if (isset($this->secretKey) === true) { return $this->secretKey; } // derive from password if (isset($this->password) === true) { if ($salt === null || $limits === null) { throw new InvalidArgumentException('Salt and limits are required when deriving a secret key from a password'); } // access from cache $options = $salt . ':' . implode(',', $limits); if (isset($this->secretKeysByOptions[$options]) === true) { return $this->secretKeysByOptions[$options]; } return $this->secretKeysByOptions[$options] = sodium_crypto_pwhash( SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $this->password, $salt, $limits[0], $limits[1], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13 ); } // generate a random key return $this->secretKey = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); } /** * Wipes a variable from memory if it is a string */ protected function memzero(mixed &$value): void { if (is_string($value) === true) { sodium_memzero($value); $value = ''; } } }