" (current — AES-256-GCM, AEAD) * - "enc:v1:" (legacy — AES-256-CBC; read-only) * * Backward compatibility: * - encrypt() always writes v2 GCM * - decrypt() reads BOTH v1 and v2 (transparent migration) * - Values without any "enc:" prefix are returned as-is (existing plaintext * options continue to work until re-saved or migrated explicitly) * * The v1→v2 upgrade can be triggered via: * - WP-CLI: `wp wpdo crypto-migrate` * - Auto-migration during install/upgrade (idempotent best-effort) * * Usage (unchanged from v2.6.4 contract): * TMDO_Crypto::set_option( 'wpdo_slack_webhook', $url ); // writes v2 * $url = TMDO_Crypto::get_option( 'wpdo_slack_webhook' ); // reads v1 or v2 * * @package WP_Data_Optimizer * @since 2.6.4 (v1 CBC) * @since 2.15.0 (v2 GCM, AEAD authentication) */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Symmetric authenticated encryption helper for sensitive wp_options entries. */ class TMDO_Crypto { /** Ciphertext prefix v2 (AES-256-GCM, current). */ public const PREFIX_V2 = 'enc:v2:'; /** Ciphertext prefix v1 (AES-256-CBC, legacy read-only). */ public const PREFIX_V1 = 'enc:v1:'; /** Cipher suite v2 (AEAD — authenticated, tamper-detectable). */ private const CIPHER_V2 = 'aes-256-gcm'; /** Cipher suite v1 (legacy — no authentication). */ private const CIPHER_V1 = 'AES-256-CBC'; /** GCM IV length (12 bytes is the GCM standard / NIST SP 800-38D recommended). */ private const IV_LEN_V2 = 12; /** CBC IV length (legacy). */ private const IV_LEN_V1 = 16; /** GCM authentication tag length (16 bytes = 128 bits, the strongest standard). */ private const TAG_LEN = 16; /** * Derive a 32-byte site-specific key from WordPress auth constants. * * Stable per site → ciphertext written on this site cannot be decrypted * elsewhere. Both v1 and v2 use the same derived key (same secret material, * different cipher) so v1 ciphertext can be read after the v2 upgrade. * * Falls back to a sha256 of ABSPATH when constants are not defined * (unit-test environments). The fallback must be stable per request. * * @return string 32 raw bytes. */ private static function derived_key(): string { $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : ''; $salt .= defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : ABSPATH; return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 ); } /** * Encrypt a plaintext string with AES-256-GCM (v2 format). * * @param string $plaintext Value to encrypt. * @return string Encrypted value with "enc:v2:" prefix, or original on failure. */ public static function encrypt( string $plaintext ): string { if ( '' === $plaintext ) { return ''; } if ( ! function_exists( 'openssl_encrypt' ) ) { return $plaintext; } $iv = random_bytes( self::IV_LEN_V2 ); $tag = ''; // phpcs:ignore -- $tag is reference output for GCM auth tag. $ciphertext = openssl_encrypt( $plaintext, self::CIPHER_V2, self::derived_key(), OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LEN ); if ( false === $ciphertext ) { return $plaintext; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- intentional binary encoding. return self::PREFIX_V2 . base64_encode( $iv . $tag . $ciphertext ); } /** * Decrypt an encrypted value (v2 GCM or v1 CBC). * * Dispatches by prefix: * - "enc:v2:..." → AES-256-GCM (auth-tag verified) * - "enc:v1:..." → AES-256-CBC (legacy, no auth) * - other → returned as-is (legacy plaintext) * * @param string $stored Stored option value. * @return string Plaintext, or original value on failure. */ public static function decrypt( string $stored ): string { if ( '' === $stored ) { return $stored; } if ( ! function_exists( 'openssl_decrypt' ) ) { return $stored; } if ( str_starts_with( $stored, self::PREFIX_V2 ) ) { return self::decrypt_v2( $stored ); } if ( str_starts_with( $stored, self::PREFIX_V1 ) ) { return self::decrypt_v1( $stored ); } return $stored; // plaintext (legacy). } /** * Decrypt v2 GCM blob (private — dispatched by decrypt()). * * @param string $stored "enc:v2:..." string. * @return string Plaintext or original on auth failure / parse error. */ private static function decrypt_v2( string $stored ): string { $encoded = substr( $stored, strlen( self::PREFIX_V2 ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data. $raw = base64_decode( $encoded, true ); if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V2 + self::TAG_LEN ) { return $stored; } $iv = substr( $raw, 0, self::IV_LEN_V2 ); $tag = substr( $raw, self::IV_LEN_V2, self::TAG_LEN ); $ciphertext = substr( $raw, self::IV_LEN_V2 + self::TAG_LEN ); $plaintext = openssl_decrypt( $ciphertext, self::CIPHER_V2, self::derived_key(), OPENSSL_RAW_DATA, $iv, $tag ); return false === $plaintext ? $stored : $plaintext; } /** * Decrypt v1 CBC blob (private — backward compatibility path). * * NOTE: CBC has no authentication. A successful decrypt does NOT prove the * ciphertext is intact. Callers should treat v1 plaintext as "trusted as * much as the surrounding wp_options column was trusted at write time". * The v1→v2 migration upgrades these values to authenticated GCM. * * @param string $stored "enc:v1:..." string. * @return string Plaintext or original on parse error. */ private static function decrypt_v1( string $stored ): string { $encoded = substr( $stored, strlen( self::PREFIX_V1 ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data. $raw = base64_decode( $encoded, true ); if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V1 ) { return $stored; } $iv = substr( $raw, 0, self::IV_LEN_V1 ); $ciphertext = substr( $raw, self::IV_LEN_V1 ); $plaintext = openssl_decrypt( $ciphertext, self::CIPHER_V1, self::derived_key(), OPENSSL_RAW_DATA, $iv ); return false === $plaintext ? $stored : $plaintext; } /** * Encrypt a value and save it to wp_options. * * @param string $option_name Option name. * @param string $plaintext Value to encrypt and store. * @param bool $autoload Whether to autoload (default false — secrets should never autoload). * @return bool Whether the option was updated. */ public static function set_option( string $option_name, string $plaintext, bool $autoload = false ): bool { $encrypted = self::encrypt( $plaintext ); return update_option( $option_name, $encrypted, $autoload ); } /** * Read a wp_option and decrypt it. * * @param string $option_name Option name. * @param string $fallback Value returned when option is empty. * @return string Decrypted plaintext (or legacy plaintext, or fallback). */ public static function get_option( string $option_name, string $fallback = '' ): string { $stored = (string) get_option( $option_name, '' ); if ( '' === $stored ) { return $fallback; } return self::decrypt( $stored ); } /** * Whether a stored option is encrypted (v1 or v2). * * @param string $option_name Option name. * @return bool */ public static function is_encrypted( string $option_name ): bool { $stored = get_option( $option_name, '' ); if ( ! is_string( $stored ) ) { return false; } return str_starts_with( $stored, self::PREFIX_V2 ) || str_starts_with( $stored, self::PREFIX_V1 ); } /** * Format version of a stored option ("v2", "v1", "plaintext", or "empty"). * * Non-string option values (arrays / objects) are classified as * "plaintext" since they are not in any encrypted format. This avoids the * "Array to string conversion" warning when wp_options stores serialized * arrays (e.g. `wpdo_features`, `wpdo_bridge_modes`). * * @param string $option_name Option name. * @return string One of: "v2", "v1", "plaintext", "empty". * @since 2.15.0 */ public static function format_version( string $option_name ): string { $stored = get_option( $option_name, '' ); if ( '' === $stored || null === $stored ) { return 'empty'; } if ( ! is_string( $stored ) ) { // Arrays, objects, ints, etc. — never encrypted. return 'plaintext'; } if ( str_starts_with( $stored, self::PREFIX_V2 ) ) { return 'v2'; } if ( str_starts_with( $stored, self::PREFIX_V1 ) ) { return 'v1'; } return 'plaintext'; } /** * Migrate a single option from v1 (CBC) to v2 (GCM). * * Idempotent — already-v2 options are skipped. Plaintext options are NOT * touched (the caller decides whether to encrypt; this method only handles * format upgrade of already-encrypted values). * * @param string $option_name Option name. * @return string One of: "migrated", "already_v2", "plaintext_skipped", * "empty", "decrypt_failed", "encrypt_failed", "no_op". * @since 2.15.0 */ public static function migrate_option_v1_to_v2( string $option_name ): string { $stored = get_option( $option_name, '' ); if ( '' === $stored || null === $stored ) { return 'empty'; } if ( ! is_string( $stored ) ) { // Arrays / objects are never v1 ciphertext. return 'plaintext_skipped'; } if ( str_starts_with( $stored, self::PREFIX_V2 ) ) { return 'already_v2'; } if ( ! str_starts_with( $stored, self::PREFIX_V1 ) ) { return 'plaintext_skipped'; } // Decrypt v1. $plaintext = self::decrypt_v1( $stored ); if ( $plaintext === $stored ) { // decrypt_v1() returns original on failure. return 'decrypt_failed'; } // Re-encrypt as v2. $reencrypted = self::encrypt( $plaintext ); if ( ! str_starts_with( $reencrypted, self::PREFIX_V2 ) ) { return 'encrypt_failed'; } // Preserve current autoload flag (don't accidentally flip). $autoload = wp_cache_get( 'notoptions', 'options' ); // not used; kept here as reference for the option API contract. $ok = update_option( $option_name, $reencrypted, false ); return $ok ? 'migrated' : 'no_op'; } /** * Bulk-migrate every wp_option whose name starts with `wpdo_` (or the * supplied prefix) from v1 CBC to v2 GCM. * * Idempotent: already-v2 / plaintext / empty options are skipped without * error. Returns counts so the caller can log / display progress. * * @param string $option_prefix Prefix to scan (default 'wpdo_'). * @return array{ * scanned:int, migrated:int, already_v2:int, plaintext:int, empty:int, * failed:int, errors:array * } * @since 2.15.0 */ public static function migrate_v1_to_v2( string $option_prefix = 'wpdo_' ): array { global $wpdb; $counts = array( 'scanned' => 0, 'migrated' => 0, 'already_v2' => 0, 'plaintext' => 0, 'empty' => 0, 'failed' => 0, 'errors' => array(), ); $option_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( $option_prefix ) . '%' ) ); foreach ( (array) $option_names as $option_name ) { ++$counts['scanned']; $result = self::migrate_option_v1_to_v2( $option_name ); switch ( $result ) { case 'migrated': ++$counts['migrated']; break; case 'already_v2': ++$counts['already_v2']; break; case 'plaintext_skipped': ++$counts['plaintext']; break; case 'empty': ++$counts['empty']; break; default: ++$counts['failed']; $counts['errors'][ $option_name ] = $result; } } return $counts; } }