d36bb954d1
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
198 lines
8.6 KiB
PHP
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 );
|
|
}
|
|
}
|