chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
<?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 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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user