This commit is contained in:
Javier Casares 2026-08-15 15:37:04 +00:00
commit 69c8e9eb97
5 changed files with 191 additions and 19 deletions

View file

@ -1,6 +1,6 @@
<?php
/**
* AES-256-CBC encryption helper for the account-level API key.
* AES-256-CBC + HMAC encryption helper for sensitive plugin options.
*
* @package Robotstxt_Manager
*/
@ -12,9 +12,14 @@ if ( ! defined( 'ABSPATH' ) ) {
/**
* Class Robotstxt_Manager_Encryption
*
* Same symmetric AES-256-CBC pattern Plugins Core uses for its Forgejo
* token. Encrypted values are base64-encoded strings with the IV prepended
* to the ciphertext, safe for wp_options storage.
* 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 {
@ -26,25 +31,52 @@ class Robotstxt_Manager_Encryption {
private const CIPHER = 'aes-256-cbc';
/**
* Context string used to derive a plugin-specific key.
* 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';
/**
* Encrypts a plaintext string and returns a base64-encoded payload.
* 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 Base64-encoded ciphertext, or empty string on failure.
* @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 ) {
@ -58,13 +90,20 @@ class Robotstxt_Manager_Encryption {
return '';
}
return base64_encode( $iv . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$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 base64-encoded payload previously produced by encrypt().
* Decrypts a payload previously produced by encrypt().
*
* @param string $encoded The base64-encoded ciphertext.
* 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.
*/
@ -73,28 +112,85 @@ class Robotstxt_Manager_Encryption {
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 || strlen( $decoded ) <= $iv_length ) {
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( $decoded, 0, $iv_length );
$ciphertext = substr( $decoded, $iv_length );
$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 a 32-byte encryption key from WordPress secret constants.
* 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.
*/
@ -108,4 +204,23 @@ class Robotstxt_Manager_Encryption {
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
);
}
}