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 ); } }