226 lines
6.3 KiB
PHP
226 lines
6.3 KiB
PHP
<?php
|
|
/**
|
|
* AES-256-CBC + HMAC encryption helper for sensitive plugin options.
|
|
*
|
|
* @package Robotstxt_Manager
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class Robotstxt_Manager_Encryption
|
|
*
|
|
* Provides symmetric authenticated encryption (encrypt-then-MAC) using a
|
|
* key derived from WordPress secret keys. Since 1.6.0 new ciphertexts are
|
|
* authenticated with HMAC-SHA256: tampered or truncated payloads fail
|
|
* closed. Legacy payloads (unauthenticated AES-256-CBC, format
|
|
* base64(iv . ciphertext)) remain decryptable; values re-encrypt to the
|
|
* authenticated format the next time they are saved.
|
|
*
|
|
* Requires the PHP openssl extension (bundled with PHP 8.0+).
|
|
*/
|
|
class Robotstxt_Manager_Encryption {
|
|
|
|
/**
|
|
* OpenSSL cipher method.
|
|
*
|
|
* @var string
|
|
*/
|
|
private const CIPHER = 'aes-256-cbc';
|
|
|
|
/**
|
|
* Context string used to derive the encryption key.
|
|
*
|
|
* Must never change: existing ciphertexts (including legacy ones)
|
|
* depend on it.
|
|
*
|
|
* @var string
|
|
*/
|
|
private const CONTEXT = 'robotstxt_manager_encryption_v1';
|
|
|
|
/**
|
|
* Context string used to derive the MAC key (separate from the
|
|
* encryption key by design).
|
|
*
|
|
* @var string
|
|
*/
|
|
private const MAC_CONTEXT = 'robotstxt_manager_encryption_v2_mac';
|
|
|
|
/**
|
|
* Prefix marking authenticated (v2) payloads.
|
|
*
|
|
* @var string
|
|
*/
|
|
private const PREFIX_V2 = 'v2:';
|
|
|
|
/**
|
|
* Encrypts a plaintext string and returns an authenticated payload.
|
|
*
|
|
* Format: 'v2:' . base64( iv . ciphertext . hmac_sha256( iv . ciphertext ) ).
|
|
*
|
|
* @param string $plaintext The value to encrypt.
|
|
*
|
|
* @return string Payload string, or empty string on failure (including
|
|
* missing WordPress secret keys — encryption without the
|
|
* real salts would be recoverable by anyone).
|
|
*/
|
|
public static function encrypt( string $plaintext ): string {
|
|
if ( '' === $plaintext ) {
|
|
return '';
|
|
}
|
|
|
|
if ( ! defined( 'AUTH_KEY' ) || ! defined( 'AUTH_SALT' ) ) {
|
|
return ''; // Fail closed — never encrypt under fallback keys.
|
|
}
|
|
|
|
$key = self::derive_key();
|
|
$mac_key = self::derive_mac_key();
|
|
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
|
|
|
if ( false === $iv_length ) {
|
|
return '';
|
|
}
|
|
|
|
$iv = openssl_random_pseudo_bytes( $iv_length );
|
|
$ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
|
|
|
|
if ( false === $ciphertext ) {
|
|
return '';
|
|
}
|
|
|
|
$mac = hash_hmac( 'sha256', $iv . $ciphertext, $mac_key, true );
|
|
|
|
return self::PREFIX_V2 . base64_encode( $iv . $ciphertext . $mac ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
|
}
|
|
|
|
/**
|
|
* Decrypts a payload previously produced by encrypt().
|
|
*
|
|
* Authenticated (v2) payloads are verified with a constant-time MAC
|
|
* comparison before decryption; tampered payloads return an empty
|
|
* string. Legacy unauthenticated payloads are decrypted as before so
|
|
* existing stored values keep working until re-saved.
|
|
*
|
|
* @param string $encoded The stored payload.
|
|
*
|
|
* @return string The original plaintext, or empty string on failure.
|
|
*/
|
|
public static function decrypt( string $encoded ): string {
|
|
if ( '' === $encoded ) {
|
|
return '';
|
|
}
|
|
|
|
if ( 0 === strpos( $encoded, self::PREFIX_V2 ) ) {
|
|
return self::decrypt_v2( substr( $encoded, strlen( self::PREFIX_V2 ) ) );
|
|
}
|
|
|
|
$decoded = base64_decode( $encoded, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
|
|
|
if ( false === $decoded ) {
|
|
return '';
|
|
}
|
|
|
|
return self::decrypt_cbc( $decoded );
|
|
}
|
|
|
|
/**
|
|
* Decrypts an authenticated v2 payload.
|
|
*
|
|
* @param string $b64 Base64 portion after the 'v2:' prefix.
|
|
*
|
|
* @return string Plaintext, or empty string on any failure.
|
|
*/
|
|
private static function decrypt_v2( string $b64 ): string {
|
|
$decoded = base64_decode( $b64, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
|
|
|
if ( false === $decoded ) {
|
|
return '';
|
|
}
|
|
|
|
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
|
|
|
if ( false === $iv_length ) {
|
|
return '';
|
|
}
|
|
|
|
$mac_length = 32; // sha256.
|
|
|
|
if ( strlen( $decoded ) <= $iv_length + $mac_length ) {
|
|
return '';
|
|
}
|
|
|
|
$iv = substr( $decoded, 0, $iv_length );
|
|
$ciphertext = substr( $decoded, $iv_length, strlen( $decoded ) - $iv_length - $mac_length );
|
|
$mac = substr( $decoded, -1 * $mac_length );
|
|
|
|
$expected = hash_hmac( 'sha256', $iv . $ciphertext, self::derive_mac_key(), true );
|
|
|
|
if ( ! hash_equals( $expected, $mac ) ) {
|
|
return ''; // Tampered or corrupted — fail closed.
|
|
}
|
|
|
|
return self::decrypt_cbc( $iv . $ciphertext );
|
|
}
|
|
|
|
/**
|
|
* Performs the raw CBC decryption over iv . ciphertext bytes.
|
|
*
|
|
* @param string $bytes Raw bytes: iv followed by ciphertext.
|
|
*
|
|
* @return string Plaintext, or empty string on failure.
|
|
*/
|
|
private static function decrypt_cbc( string $bytes ): string {
|
|
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
|
|
|
if ( false === $iv_length || strlen( $bytes ) <= $iv_length ) {
|
|
return '';
|
|
}
|
|
|
|
$key = self::derive_key();
|
|
$iv = substr( $bytes, 0, $iv_length );
|
|
$ciphertext = substr( $bytes, $iv_length );
|
|
$plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
|
|
|
|
return false !== $plaintext ? $plaintext : '';
|
|
}
|
|
|
|
/**
|
|
* Derives the 32-byte encryption key from WordPress secret constants.
|
|
*
|
|
* The deterministic fallback only applies while WordPress constants are
|
|
* undefined (early install context); encrypt() refuses to run there.
|
|
*
|
|
* @return string 32-byte raw key.
|
|
*/
|
|
private static function derive_key(): string {
|
|
$auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined';
|
|
$auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined';
|
|
|
|
return substr(
|
|
hash_hmac( 'sha256', self::CONTEXT, $auth_key . $auth_salt, true ),
|
|
0,
|
|
32
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derives the 32-byte MAC key from WordPress secret constants.
|
|
*
|
|
* Derived with a different context string than the encryption key, so
|
|
* knowing one reveals nothing about the other.
|
|
*
|
|
* @return string 32-byte raw key.
|
|
*/
|
|
private static function derive_mac_key(): string {
|
|
$auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined';
|
|
$auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined';
|
|
|
|
return substr(
|
|
hash_hmac( 'sha256', self::MAC_CONTEXT, $auth_key . $auth_salt, true ),
|
|
0,
|
|
32
|
|
);
|
|
}
|
|
}
|