Files
wpdev d36bb954d1 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
2026-07-31 05:06:36 +08:00

198 lines
8.6 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit test: WPDO_Crypto AES-256-GCM v2 + AES-256-CBC v1 backward compat (v2.15.0).
*
* Verifies:
* - v2 GCM round-trip (encrypt/decrypt)
* - v2 GCM tamper detection (auth tag verification)
* - v1 CBC backward compat read
* - Plaintext passthrough
* - Empty input handling
* - Invalid input safe failure
*/
class CryptoV2Test extends TestCase {
public static function setUpBeforeClass(): void {
// Define WP auth constants for stable key derivation in tests.
if ( ! defined( 'AUTH_KEY' ) ) {
define( 'AUTH_KEY', 'test_auth_key_for_phpunit_long_enough_string_xxxxxxxxxxxxxxxx' );
}
if ( ! defined( 'SECURE_AUTH_SALT' ) ) {
define( 'SECURE_AUTH_SALT', 'test_secure_auth_salt_for_phpunit_xxxxxxxxxxxxxxxxxxxxxxx' );
}
}
// ── v2 GCM happy paths ───────────────────────────────────────────────────
public function test_v2_round_trip_simple_string(): void {
$plain = 'https://hooks.slack.com/services/T00000000/B00000000/abc123';
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertStringStartsWith( WPDO_Crypto::PREFIX_V2, $encrypted );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_round_trip_unicode(): void {
$plain = '中文密碼 + emoji 🔐 + special chars !@#$%^&*()';
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_round_trip_long_string(): void {
$plain = str_repeat( 'A', 4096 );
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_each_encryption_produces_unique_ciphertext(): void {
// Random IV → repeated encrypts of the same plaintext yield different blobs.
$plain = 'identical plaintext';
$ct1 = WPDO_Crypto::encrypt( $plain );
$ct2 = WPDO_Crypto::encrypt( $plain );
$this->assertNotSame( $ct1, $ct2, 'IV randomness should produce unique ciphertexts' );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $ct1 ) );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $ct2 ) );
}
// ── v2 GCM tamper detection ──────────────────────────────────────────────
public function test_v2_tampered_ciphertext_returns_original(): void {
$plain = 'sensitive webhook url';
$encrypted = WPDO_Crypto::encrypt( $plain );
// Decode the base64 payload, flip the FIRST byte of the GCM auth tag
// (which lives at offset 12 right after the IV), re-encode. This
// guarantees a real ciphertext modification regardless of base64
// alphabet (vs str_replace which can be a no-op for some random IVs).
$prefix_len = strlen( WPDO_Crypto::PREFIX_V2 );
$encoded = substr( $encrypted, $prefix_len );
$raw = base64_decode( $encoded, true );
$this->assertNotFalse( $raw, 'Setup precondition: ciphertext must be valid base64' );
$raw[12] = chr( ord( $raw[12] ) ^ 0x55 ); // flip 4 bits of the auth tag.
$tampered = WPDO_Crypto::PREFIX_V2 . base64_encode( $raw );
$result = WPDO_Crypto::decrypt( $tampered );
$this->assertNotSame( $plain, $result, 'Tampered GCM ciphertext must NOT decrypt to original plaintext' );
$this->assertSame( $tampered, $result, 'On auth failure decrypt() must return original blob' );
}
public function test_v2_truncated_blob_safe_failure(): void {
$encrypted = WPDO_Crypto::encrypt( 'some value' );
// Truncate to less than min size (12 IV + 16 tag + 1 byte ciphertext).
$truncated = substr( $encrypted, 0, strlen( WPDO_Crypto::PREFIX_V2 ) + 5 );
// Should not throw; should return original.
$result = WPDO_Crypto::decrypt( $truncated );
$this->assertSame( $truncated, $result );
}
// ── v1 CBC backward compat ───────────────────────────────────────────────
public function test_v1_legacy_blob_decrypts_successfully(): void {
// Hand-craft a v1 CBC blob using the same key derivation.
$plain = 'legacy webhook url from pre-v2.15';
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $blob ) );
}
public function test_v1_blob_with_garbage_returns_original(): void {
$bad = WPDO_Crypto::PREFIX_V1 . 'not_valid_base64!!!';
$this->assertSame( $bad, WPDO_Crypto::decrypt( $bad ) );
}
// ── Plaintext passthrough ────────────────────────────────────────────────
public function test_plaintext_passthrough(): void {
$plain = 'https://example.com/raw';
$this->assertSame( $plain, WPDO_Crypto::decrypt( $plain ) );
}
public function test_empty_input(): void {
$this->assertSame( '', WPDO_Crypto::encrypt( '' ) );
$this->assertSame( '', WPDO_Crypto::decrypt( '' ) );
}
// ── format_version ───────────────────────────────────────────────────────
public function test_format_version_classification(): void {
// Use option-API stubs from bootstrap.
$GLOBALS['_wp_options']['test_v2_opt'] = WPDO_Crypto::encrypt( 'foo' );
$GLOBALS['_wp_options']['test_plain_opt'] = 'plaintext_value';
$GLOBALS['_wp_options']['test_empty_opt'] = '';
// Hand-craft a v1 blob.
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( 'bar', 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$GLOBALS['_wp_options']['test_v1_opt'] = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'test_v2_opt' ) );
$this->assertSame( 'v1', WPDO_Crypto::format_version( 'test_v1_opt' ) );
$this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'test_plain_opt' ) );
$this->assertSame( 'empty', WPDO_Crypto::format_version( 'test_empty_opt' ) );
$this->assertSame( 'empty', WPDO_Crypto::format_version( 'nonexistent_opt' ) );
}
// ── migrate_option_v1_to_v2 ──────────────────────────────────────────────
public function test_migrate_option_v1_to_v2_round_trip(): void {
$plain = 'webhook to migrate';
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$GLOBALS['_wp_options']['migrate_test'] = $blob;
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'migrate_test' );
$this->assertSame( 'migrated', $result );
// After migration: v2 blob, decrypts to original plaintext.
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'migrate_test' ) );
$this->assertSame( $plain, WPDO_Crypto::get_option( 'migrate_test' ) );
}
public function test_migrate_option_already_v2_is_noop(): void {
$GLOBALS['_wp_options']['already_v2'] = WPDO_Crypto::encrypt( 'foo' );
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'already_v2' );
$this->assertSame( 'already_v2', $result );
}
public function test_migrate_option_plaintext_skipped(): void {
$GLOBALS['_wp_options']['plain_opt'] = 'just plaintext';
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'plain_opt' );
$this->assertSame( 'plaintext_skipped', $result );
// Original value preserved.
$this->assertSame( 'just plaintext', $GLOBALS['_wp_options']['plain_opt'] );
}
public function test_migrate_option_empty_returns_empty(): void {
$GLOBALS['_wp_options']['empty_opt'] = '';
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'empty_opt' );
$this->assertSame( 'empty', $result );
}
// ── Helper ───────────────────────────────────────────────────────────────
/**
* Replicates WPDO_Crypto::derived_key() to craft test fixtures.
*
* @return string 32 raw bytes.
*/
private function derive_key(): string {
$salt = AUTH_KEY . SECURE_AUTH_SALT;
return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
}
}