| 1 |
<?php |
| 2 |
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. |
| 3 |
|
| 4 |
namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto; |
| 5 |
|
| 6 |
use Exception; |
| 7 |
use SensitiveParameter; |
| 8 |
use SodiumException; |
| 9 |
|
| 10 |
/** |
| 11 |
* Provides symmetric encryption and decryption using libsodium. |
| 12 |
* |
| 13 |
* Derives per-purpose 256-bit encryption keys from the WordPress AUTH_KEY |
| 14 |
* constant via HKDF-SHA256. The encryption key is never stored — it is |
| 15 |
* derived on every call. |
| 16 |
*/ |
| 17 |
class Encryption { |
| 18 |
|
| 19 |
/** |
| 20 |
* Encrypts a plaintext string using sodium_crypto_secretbox (XSalsa20-Poly1305). |
| 21 |
* |
| 22 |
* @param string $plaintext The data to encrypt. |
| 23 |
* @param string $context A unique context string for key derivation (e.g. "yoast-myyoast-dpop-key"). |
| 24 |
* |
| 25 |
* @return string Base64-encoded nonce + ciphertext. |
| 26 |
* |
| 27 |
* @throws Encryption_Exception If encryption fails. |
| 28 |
*/ |
| 29 |
public function encrypt( |
| 30 |
// phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+. |
| 31 |
#[SensitiveParameter] |
| 32 |
string $plaintext, |
| 33 |
string $context |
| 34 |
): string { |
| 35 |
$key = $this->derive_key( $context ); |
| 36 |
|
| 37 |
try { |
| 38 |
$nonce = \random_bytes( \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 39 |
$ciphertext = \sodium_crypto_secretbox( $plaintext, $nonce, $key ); |
| 40 |
|
| 41 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Binary ciphertext needs safe encoding. |
| 42 |
return \base64_encode( $nonce . $ciphertext ); |
| 43 |
} catch ( Exception $e ) { |
| 44 |
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| 45 |
throw new Encryption_Exception( 'Encryption failed: ' . $e->getMessage(), 0, $e ); |
| 46 |
} |
| 47 |
finally { |
| 48 |
// Securely wipe the derived key from memory to prevent leakage via memory dumps or core files. |
| 49 |
try { |
| 50 |
\sodium_memzero( $key ); |
| 51 |
} catch ( SodiumException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Best-effort cleanup. |
| 52 |
// Best-effort: key will be freed when $key goes out of scope. |
| 53 |
} |
| 54 |
} |
| 55 |
} |
| 56 |
|
| 57 |
/** |
| 58 |
* Decrypts a previously encrypted string. |
| 59 |
* |
| 60 |
* @param string $encrypted Base64-encoded nonce + ciphertext from encrypt(). |
| 61 |
* @param string $context The same context string used during encryption. |
| 62 |
* |
| 63 |
* @return string The decrypted plaintext. |
| 64 |
* |
| 65 |
* @throws Encryption_Exception If decryption fails (corrupt data, wrong key, tampered ciphertext). |
| 66 |
*/ |
| 67 |
public function decrypt( string $encrypted, string $context ): string { |
| 68 |
$key = $this->derive_key( $context ); |
| 69 |
|
| 70 |
try { |
| 71 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding our own base64-encoded ciphertext. |
| 72 |
$decoded = \base64_decode( $encrypted, true ); |
| 73 |
if ( $decoded === false ) { |
| 74 |
throw new Encryption_Exception( 'Decryption failed: invalid base64 encoding.' ); |
| 75 |
} |
| 76 |
|
| 77 |
if ( \strlen( $decoded ) < ( \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + \SODIUM_CRYPTO_SECRETBOX_MACBYTES ) ) { |
| 78 |
throw new Encryption_Exception( 'Decryption failed: ciphertext too short.' ); |
| 79 |
} |
| 80 |
|
| 81 |
$nonce = \substr( $decoded, 0, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 82 |
$ciphertext = \substr( $decoded, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 83 |
$plaintext = \sodium_crypto_secretbox_open( $ciphertext, $nonce, $key ); |
| 84 |
|
| 85 |
if ( $plaintext === false ) { |
| 86 |
throw new Encryption_Exception( 'Decryption failed: authentication tag verification failed.' ); |
| 87 |
} |
| 88 |
|
| 89 |
return $plaintext; |
| 90 |
} |
| 91 |
finally { |
| 92 |
// Securely wipe the derived key from memory to prevent leakage via memory dumps or core files. |
| 93 |
try { |
| 94 |
\sodium_memzero( $key ); |
| 95 |
} |
| 96 |
catch ( SodiumException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Best-effort cleanup. |
| 97 |
// Best-effort: key will be freed when $key goes out of scope. |
| 98 |
} |
| 99 |
} |
| 100 |
} |
| 101 |
|
| 102 |
/** |
| 103 |
* Derives a 256-bit encryption key from AUTH_KEY using HKDF-SHA256. |
| 104 |
* |
| 105 |
* @param string $context A unique context string for key derivation. |
| 106 |
* |
| 107 |
* @return string A 32-byte derived key. |
| 108 |
* |
| 109 |
* @throws Encryption_Exception If AUTH_KEY is not defined or sodium is unavailable. |
| 110 |
*/ |
| 111 |
private function derive_key( string $context ): string { |
| 112 |
if ( ! \function_exists( 'sodium_crypto_secretbox' ) ) { |
| 113 |
throw new Encryption_Exception( 'The sodium PHP extension is required but not available.' ); |
| 114 |
} |
| 115 |
|
| 116 |
if ( ! \defined( 'AUTH_KEY' ) || ! \is_string( \AUTH_KEY ) || \AUTH_KEY === '' || \AUTH_KEY === 'put your unique phrase here' ) { |
| 117 |
throw new Encryption_Exception( 'AUTH_KEY is not configured. Please set a unique AUTH_KEY in wp-config.php.' ); |
| 118 |
} |
| 119 |
|
| 120 |
return \hash_hkdf( 'sha256', \AUTH_KEY, \SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $context ); |
| 121 |
} |
| 122 |
} |
| 123 |
|