76c01e44df
對齊 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
397 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|