Files
2meet-data-optimizer/includes/class-tmdo-crypto.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

397 lines
12 KiB
PHP

<?php
/**
* TMDO_Crypto — AES-256-GCM authenticated encryption for sensitive option values.
*
* Derives a site-specific key from AUTH_KEY + SECURE_AUTH_SALT so ciphertext
* is useless outside this WordPress installation.
*
* Storage formats:
* - "enc:v2:<base64(iv12 . tag16 . ciphertext)>" (current — AES-256-GCM, AEAD)
* - "enc:v1:<base64(iv16 . ciphertext)>" (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 TMDO
* @since 2.6.4 (v1 CBC)
* @since 2.15.0 (v2 GCM, AEAD authentication)
*/
declare(strict_types=1);
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.
*
* Returns empty string when both AUTH_KEY and SECURE_AUTH_SALT are absent —
* callers treat '' as "encryption unavailable" and store plaintext instead.
*
* @return string 32 raw bytes.
*/
private static function derived_key(): string {
$has_auth_key = defined( 'AUTH_KEY' ) && AUTH_KEY !== '';
$has_auth_salt = defined( 'SECURE_AUTH_SALT' ) && SECURE_AUTH_SALT !== '';
if ( ! $has_auth_key && ! $has_auth_salt ) {
// Both salts missing — key would be derived from predictable ABSPATH. Refuse.
return '';
}
$salt = $has_auth_key ? AUTH_KEY : '';
$salt .= $has_auth_salt ? SECURE_AUTH_SALT : '';
return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
}
/**
* Whether the site has valid WP auth constants for key derivation.
*
* @return bool False when both AUTH_KEY and SECURE_AUTH_SALT are absent/empty.
*/
public static function is_key_derivable(): bool {
return self::derived_key() !== '';
}
/**
* 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;
}
$key = self::derived_key();
if ( '' === $key ) {
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,
$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 ( '' === self::derived_key() ) {
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<string,string>
* }
* @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;
}
}