diff --git a/includes/class-two-factor-totp-cli.php b/includes/class-two-factor-totp-cli.php new file mode 100644 index 00000000..e1819dff --- /dev/null +++ b/includes/class-two-factor-totp-cli.php @@ -0,0 +1,117 @@ +secret_class; + + if ( ! $secret_class::is_encryption_available() ) { + WP_CLI::error( 'Encryption is not available. Ensure TWO_FACTOR_TOTP_ENCRYPTION_KEY is defined in wp-config.php and AES-256-GCM hardware support is present.' ); + } + + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value != ''", + '_two_factor_totp_key' + ) + ); + + if ( empty( $results ) ) { + WP_CLI::success( 'No TOTP secrets found in the database.' ); + return; + } + + $encrypted_count = 0; + $skipped_count = 0; + $error_count = 0; + + foreach ( $results as $row ) { + $user_id = (int) $row->user_id; + $value = $row->meta_value; + + if ( $secret_class::is_encrypted( $value ) ) { + $skipped_count++; + continue; + } + + if ( $dry_run ) { + WP_CLI::log( sprintf( 'Would encrypt secret for user %d.', $user_id ) ); + $encrypted_count++; + continue; + } + + $encrypted = $secret_class::encrypt( $value, $user_id ); + if ( false === $encrypted ) { + WP_CLI::warning( sprintf( 'Failed to encrypt secret for user %d.', $user_id ) ); + $error_count++; + continue; + } + + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + $encrypted_count++; + } + + if ( $dry_run ) { + WP_CLI::success( + sprintf( + 'Dry run complete. %d secret(s) would be encrypted, %d already encrypted.', + $encrypted_count, + $skipped_count + ) + ); + } else { + WP_CLI::success( + sprintf( + 'Done. %d secret(s) encrypted, %d already encrypted, %d error(s).', + $encrypted_count, + $skipped_count, + $error_count + ) + ); + } + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index fc02e7c0..aba358e2 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,6 +1,8 @@ includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon parameters: + bootstrapFiles: + - tests/stubs/wp-cli.php level: 0 paths: - includes diff --git a/providers/class-two-factor-totp-secret.php b/providers/class-two-factor-totp-secret.php new file mode 100644 index 00000000..c32995e1 --- /dev/null +++ b/providers/class-two-factor-totp-secret.php @@ -0,0 +1,308 @@ + $plaintext, + 'needs_reencrypt' => false, + ); + } + } + + // Try previous key for rotation. + $previous_key = static::get_previous_key(); + if ( false !== $previous_key ) { + $plaintext = self::try_decrypt( $ciphertext, $ad, $nonce, $previous_key ); + if ( false !== $plaintext ) { + return array( + 'plaintext' => $plaintext, + 'needs_reencrypt' => true, + ); + } + } + + return false; + } + + /** + * Attempt to decrypt with a specific key. + * + * @param string $ciphertext The ciphertext to decrypt. + * @param string $ad Additional authenticated data. + * @param string $nonce The nonce used during encryption. + * @param string $key The encryption key. + * + * @return string|false The plaintext or false on failure. + */ + private static function try_decrypt( $ciphertext, $ad, $nonce, $key ) { + try { + $result = sodium_crypto_aead_aes256gcm_decrypt( $ciphertext, $ad, $nonce, $key ); + return ( false === $result ) ? false : $result; + } catch ( SodiumException $e ) { + return false; + } + } + + /** + * Resolve a stored value to plaintext, handling encryption/decryption transparently. + * + * This is the main entry point for reading TOTP secrets from the database. + * + * @param string $stored_value The raw value from the database. + * @param int $user_id The user ID. + * + * @return string The plaintext TOTP secret, or empty string on failure. + */ + public static function resolve( $stored_value, $user_id ) { + // Empty value. + if ( '' === $stored_value || false === $stored_value ) { + return ''; + } + + // Not encrypted. + if ( ! self::is_encrypted( $stored_value ) ) { + // If encryption is available, opportunistically encrypt. + if ( self::is_encryption_available() ) { + $encrypted = self::encrypt( $stored_value, $user_id ); + if ( false !== $encrypted ) { + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + /** + * Fires after a plaintext TOTP secret is opportunistically encrypted on read. + * + * @since 0.10.0 + * + * @param int $user_id The user ID whose secret was encrypted. + */ + do_action( 'two_factor_totp_secret_encrypted', $user_id ); + } + } + return $stored_value; + } + + // Encrypted value — attempt decryption. + $result = self::decrypt( $stored_value, $user_id ); + if ( false === $result ) { + /** + * Fires when an encrypted TOTP secret cannot be decrypted. + * + * This may indicate a missing or incorrect encryption key, or data corruption. + * Useful for security audit logging and monitoring. + * + * @since 0.10.0 + * + * @param int $user_id The user ID whose secret failed to decrypt. + */ + do_action( 'two_factor_totp_secret_decrypt_failed', $user_id ); + return ''; + } + + /** + * Fires after a TOTP secret is successfully decrypted. + * + * @since 0.10.0 + * + * @param int $user_id The user ID whose secret was decrypted. + * @param bool $needs_reencrypt Whether the secret needs re-encryption due to key rotation. + */ + do_action( 'two_factor_totp_secret_decrypted', $user_id, $result['needs_reencrypt'] ); + + // Re-encrypt with current key if needed (key rotation). + if ( $result['needs_reencrypt'] ) { + $encrypted = self::encrypt( $result['plaintext'], $user_id ); + if ( false !== $encrypted ) { + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + /** + * Fires after a TOTP secret is re-encrypted during key rotation. + * + * The secret was decrypted with the previous key and re-encrypted + * with the current key. + * + * @since 0.10.0 + * + * @param int $user_id The user ID whose secret was rotated. + */ + do_action( 'two_factor_totp_secret_rotated', $user_id ); + } + } + + return $result['plaintext']; + } + + /** + * Prepare a plaintext TOTP secret for storage. + * + * Encrypts if a key is available, otherwise returns plaintext. + * + * @param string $plaintext The plaintext TOTP secret. + * @param int $user_id The user ID. + * + * @return string The value to store in the database. + */ + public static function prepare_for_storage( $plaintext, $user_id ) { + if ( self::is_encryption_available() ) { + $encrypted = self::encrypt( $plaintext, $user_id ); + if ( false !== $encrypted ) { + /** This action is documented in providers/class-two-factor-totp-secret.php */ + do_action( 'two_factor_totp_secret_encrypted', $user_id ); + return $encrypted; + } + } + + return $plaintext; + } +} diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 9726fef3..1331436a 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -5,6 +5,8 @@ * @package Two_Factor */ +require_once __DIR__ . '/class-two-factor-totp-secret.php'; + /** * Class Two_Factor_Totp * @@ -52,6 +54,9 @@ protected function __construct() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_two_factor_options' ) ); + add_filter( 'two_factor_totp_secret_resolve', array( 'Two_Factor_Totp_Secret', 'resolve' ), 10, 2 ); + add_filter( 'two_factor_totp_secret_prepare', array( 'Two_Factor_Totp_Secret', 'prepare_for_storage' ), 10, 2 ); + parent::__construct(); } @@ -516,7 +521,20 @@ public function user_two_factor_options( $user ) { * @return string */ public function get_user_totp_key( $user_id ) { - return (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); + $stored_value = (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); + + /** + * Filters the TOTP secret after reading it from the database. + * + * The default callback (Two_Factor_Totp_Secret::resolve) handles decryption, + * opportunistic encryption, and key rotation transparently. + * + * @since 0.10.0 + * + * @param string $stored_value The raw value from the database. + * @param int $user_id The user ID. + */ + return apply_filters( 'two_factor_totp_secret_resolve', $stored_value, $user_id ); } /** @@ -530,7 +548,20 @@ public function get_user_totp_key( $user_id ) { * @return boolean If the key was stored successfully. */ public function set_user_totp_key( $user_id, $key ) { - return update_user_meta( $user_id, self::SECRET_META_KEY, $key ); + /** + * Filters the TOTP secret before writing it to the database. + * + * The default callback (Two_Factor_Totp_Secret::prepare_for_storage) handles + * encryption when an encryption key is configured. + * + * @since 0.10.0 + * + * @param string $key The plaintext TOTP secret. + * @param int $user_id The user ID. + */ + $value = apply_filters( 'two_factor_totp_secret_prepare', $key, $user_id ); + + return update_user_meta( $user_id, self::SECRET_META_KEY, $value ); } /** diff --git a/readme.txt b/readme.txt index 4093abcf..fa4bc355 100644 --- a/readme.txt +++ b/readme.txt @@ -102,6 +102,41 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_after_authentication_prompt` action which receives the provider object and fires after the prompt shown on the authentication input form. - `two_factor_after_authentication_input`action which receives the provider object and fires after the input shown on the authentication input form (if form contains no input, action fires immediately after `two_factor_after_authentication_prompt`). - `two_factor_login_backup_links` filters the backup links displayed on the two-factor login form. +- `two_factor_totp_secret_resolve` filter processes a TOTP secret after reading it from the database. The default callback handles decryption, opportunistic encryption of plaintext secrets, and key rotation. Receives the raw stored value as the first argument and the user ID as the second argument. +- `two_factor_totp_secret_prepare` filter processes a TOTP secret before writing it to the database. The default callback encrypts the secret when an encryption key is configured. Receives the plaintext secret as the first argument and the user ID as the second argument. +- `two_factor_totp_secret_encrypted` action fires after a TOTP secret is encrypted, whether during an explicit write, an opportunistic encryption on read, or a bulk migration via WP-CLI. Receives the user ID as its argument. +- `two_factor_totp_secret_decrypted` action fires after a TOTP secret is successfully decrypted. Receives the user ID as the first argument and a boolean indicating whether re-encryption is needed (due to key rotation) as the second argument. +- `two_factor_totp_secret_rotated` action fires after a TOTP secret is re-encrypted with the current key during key rotation. Receives the user ID as its argument. +- `two_factor_totp_secret_decrypt_failed` action fires when an encrypted TOTP secret cannot be decrypted, which may indicate a missing or incorrect encryption key or data corruption. Receives the user ID as its argument. Useful for security audit logging. + += WP-CLI Commands = + +The plugin provides WP-CLI commands for managing TOTP secrets. + +== Encrypting TOTP Secrets at Rest == + +TOTP secrets can be encrypted at rest using AES-256-GCM. This requires defining an encryption key constant in `wp-config.php`: + +`define( 'TWO_FACTOR_TOTP_ENCRYPTION_KEY', '<64 hex characters>' );` + +Generate a key with: `php -r "echo bin2hex(random_bytes(32));"` + +Once configured, new secrets are encrypted automatically and existing secrets are encrypted opportunistically when users log in. To encrypt all remaining plaintext secrets immediately, use: + +`wp two-factor totp encrypt-secrets` + +Use the `--dry-run` flag to preview what would be encrypted without making changes: + +`wp two-factor totp encrypt-secrets --dry-run` + +== Key Rotation == + +To rotate your encryption key, move the current key to `TWO_FACTOR_TOTP_ENCRYPTION_KEY_PREVIOUS` and set a new `TWO_FACTOR_TOTP_ENCRYPTION_KEY`: + +`define( 'TWO_FACTOR_TOTP_ENCRYPTION_KEY', '' );` +`define( 'TWO_FACTOR_TOTP_ENCRYPTION_KEY_PREVIOUS', '' );` + +Secrets encrypted with the previous key are automatically re-encrypted with the new key when read. Run `wp two-factor totp encrypt-secrets` to re-encrypt all secrets immediately. == Frequently Asked Questions == diff --git a/tests/includes/class-two-factor-totp-cli-mocks.php b/tests/includes/class-two-factor-totp-cli-mocks.php new file mode 100644 index 00000000..8f91a57d --- /dev/null +++ b/tests/includes/class-two-factor-totp-cli-mocks.php @@ -0,0 +1,57 @@ +result_type = 'error'; + self::$test_instance->result_message = $message; + } + throw new Exception( 'WP_CLI::error: ' . $message ); + } + + /** + * @param string $message Success message. + */ + public static function success( $message ) { + if ( self::$test_instance ) { + self::$test_instance->result_type = 'success'; + self::$test_instance->result_message = $message; + } + } + + /** + * @param string $message Log message. + */ + public static function log( $message ) { + if ( self::$test_instance ) { + self::$test_instance->logs[] = $message; + } + } + + /** + * @param string $message Warning message. + */ + public static function warning( $message ) { + if ( self::$test_instance ) { + self::$test_instance->warnings[] = $message; + } + } +} diff --git a/tests/includes/class-two-factor-totp-cli.php b/tests/includes/class-two-factor-totp-cli.php new file mode 100644 index 00000000..26ca2701 --- /dev/null +++ b/tests/includes/class-two-factor-totp-cli.php @@ -0,0 +1,226 @@ +markTestSkipped( 'AES-256-GCM is not available on this system.' ); + } + + $this->test_key_hex = bin2hex( random_bytes( 32 ) ); + + Two_Factor_Totp_Secret_Testable::$test_current_key = false; + Two_Factor_Totp_Secret_Testable::$test_previous_key = false; + + $this->cli = new Two_Factor_Totp_Cli_Testable(); + WP_CLI::$test_instance = $this->cli; + } + + public function tear_down() { + Two_Factor_Totp_Secret_Testable::$test_current_key = false; + Two_Factor_Totp_Secret_Testable::$test_previous_key = false; + WP_CLI::$test_instance = null; + + parent::tear_down(); + } + + /** + * @covers Two_Factor_Totp_Cli::encrypt_secrets + */ + public function test_encrypt_secrets_without_key_errors() { + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'WP_CLI::error' ); + + $this->cli->encrypt_secrets( array(), array() ); + } + + /** + * @covers Two_Factor_Totp_Cli::encrypt_secrets + */ + public function test_encrypt_secrets_no_secrets_in_db() { + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $this->cli->encrypt_secrets( array(), array() ); + + $this->assertSame( 'success', $this->cli->result_type ); + $this->assertStringContainsString( 'No TOTP secrets found', $this->cli->result_message ); + } + + /** + * @covers Two_Factor_Totp_Cli::encrypt_secrets + */ + public function test_encrypt_secrets_encrypts_plaintext() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + update_user_meta( $user_id, '_two_factor_totp_key', $plaintext ); + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $this->cli->encrypt_secrets( array(), array() ); + + $this->assertSame( 'success', $this->cli->result_type ); + $this->assertStringContainsString( '1 secret(s) encrypted', $this->cli->result_message ); + + $db_value = get_user_meta( $user_id, '_two_factor_totp_key', true ); + $this->assertTrue( Two_Factor_Totp_Secret_Testable::is_encrypted( $db_value ) ); + } + + /** + * @covers Two_Factor_Totp_Cli::encrypt_secrets + */ + public function test_encrypt_secrets_skips_already_encrypted() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $encrypted = Two_Factor_Totp_Secret_Testable::encrypt( $plaintext, $user_id ); + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + $this->cli->encrypt_secrets( array(), array() ); + + $this->assertSame( 'success', $this->cli->result_type ); + $this->assertStringContainsString( '0 secret(s) encrypted', $this->cli->result_message ); + $this->assertStringContainsString( '1 already encrypted', $this->cli->result_message ); + } + + /** + * @covers Two_Factor_Totp_Cli::encrypt_secrets + */ + public function test_encrypt_secrets_dry_run() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + update_user_meta( $user_id, '_two_factor_totp_key', $plaintext ); + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $this->cli->encrypt_secrets( array(), array( 'dry-run' => true ) ); + + $this->assertSame( 'success', $this->cli->result_type ); + $this->assertStringContainsString( '1 secret(s) would be encrypted', $this->cli->result_message ); + + // Verify DB was NOT changed. + $db_value = get_user_meta( $user_id, '_two_factor_totp_key', true ); + $this->assertSame( $plaintext, $db_value ); + } + + /** + * @covers Two_Factor_Totp_Cli::encrypt_secrets + */ + public function test_encrypt_secrets_multiple_users() { + $user1 = self::factory()->user->create(); + $user2 = self::factory()->user->create(); + $user3 = self::factory()->user->create(); + + update_user_meta( $user1, '_two_factor_totp_key', 'JBSWY3DPEHPK3PXP' ); + update_user_meta( $user2, '_two_factor_totp_key', 'KRSXG5CTMVRXEZLU' ); + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + // Pre-encrypt user3's key. + $encrypted = Two_Factor_Totp_Secret_Testable::encrypt( 'MFZWIZLTOQ6Q', $user3 ); + update_user_meta( $user3, '_two_factor_totp_key', $encrypted ); + + $this->cli->encrypt_secrets( array(), array() ); + + $this->assertSame( 'success', $this->cli->result_type ); + $this->assertStringContainsString( '2 secret(s) encrypted', $this->cli->result_message ); + $this->assertStringContainsString( '1 already encrypted', $this->cli->result_message ); + + // All three should now be encrypted. + $this->assertTrue( Two_Factor_Totp_Secret_Testable::is_encrypted( get_user_meta( $user1, '_two_factor_totp_key', true ) ) ); + $this->assertTrue( Two_Factor_Totp_Secret_Testable::is_encrypted( get_user_meta( $user2, '_two_factor_totp_key', true ) ) ); + $this->assertTrue( Two_Factor_Totp_Secret_Testable::is_encrypted( get_user_meta( $user3, '_two_factor_totp_key', true ) ) ); + } + + /** + * @covers Two_Factor_Totp_Cli::encrypt_secrets + */ + public function test_encrypted_secrets_remain_decryptable() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + update_user_meta( $user_id, '_two_factor_totp_key', $plaintext ); + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $this->cli->encrypt_secrets( array(), array() ); + + $db_value = get_user_meta( $user_id, '_two_factor_totp_key', true ); + $result = Two_Factor_Totp_Secret_Testable::decrypt( $db_value, $user_id ); + + $this->assertNotFalse( $result ); + $this->assertSame( $plaintext, $result['plaintext'] ); + } +} diff --git a/tests/includes/wp-cli-utils-mock.php b/tests/includes/wp-cli-utils-mock.php new file mode 100644 index 00000000..1ed9469e --- /dev/null +++ b/tests/includes/wp-cli-utils-mock.php @@ -0,0 +1,21 @@ +markTestSkipped( 'AES-256-GCM is not available on this system.' ); + } + + // Generate deterministic test keys. + $this->test_key_hex = bin2hex( random_bytes( 32 ) ); + $this->test_key_hex_2 = bin2hex( random_bytes( 32 ) ); + + Two_Factor_Totp_Secret_Testable::$test_current_key = false; + Two_Factor_Totp_Secret_Testable::$test_previous_key = false; + + $this->provider = Two_Factor_Totp::get_instance(); + } + + /** + * Clean up after tests. + */ + public function tear_down() { + Two_Factor_Totp_Secret_Testable::$test_current_key = false; + Two_Factor_Totp_Secret_Testable::$test_previous_key = false; + + parent::tear_down(); + } + + /** + * @covers Two_Factor_Totp_Secret::resolve + */ + public function test_plaintext_passthrough_without_encryption_key() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + + update_user_meta( $user_id, '_two_factor_totp_key', $plaintext ); + + $result = Two_Factor_Totp_Secret_Testable::resolve( $plaintext, $user_id ); + + $this->assertSame( $plaintext, $result ); + $this->assertSame( $plaintext, get_user_meta( $user_id, '_two_factor_totp_key', true ) ); + } + + /** + * @covers Two_Factor_Totp_Secret::resolve + * @covers Two_Factor_Totp_Secret::encrypt + */ + public function test_opportunistic_encryption_on_read() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + + update_user_meta( $user_id, '_two_factor_totp_key', $plaintext ); + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $result = Two_Factor_Totp_Secret_Testable::resolve( $plaintext, $user_id ); + + $this->assertSame( $plaintext, $result ); + + $db_value = get_user_meta( $user_id, '_two_factor_totp_key', true ); + $this->assertTrue( Two_Factor_Totp_Secret_Testable::is_encrypted( $db_value ) ); + } + + /** + * @covers Two_Factor_Totp_Secret::encrypt + * @covers Two_Factor_Totp_Secret::decrypt + * @covers Two_Factor_Totp_Secret::resolve + */ + public function test_encrypted_secret_decrypts_correctly() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $encrypted = Two_Factor_Totp_Secret_Testable::encrypt( $plaintext, $user_id ); + $this->assertNotFalse( $encrypted ); + + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + $result = Two_Factor_Totp_Secret_Testable::resolve( $encrypted, $user_id ); + $this->assertSame( $plaintext, $result ); + } + + /** + * @covers Two_Factor_Totp_Secret::decrypt + * @covers Two_Factor_Totp_Secret::resolve + */ + public function test_key_rotation_reencrypts() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + + // Encrypt with the "old" key. + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + $encrypted_old = Two_Factor_Totp_Secret_Testable::encrypt( $plaintext, $user_id ); + $this->assertNotFalse( $encrypted_old ); + + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted_old ); + + // Rotate: new key is current, old key is previous. + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex_2 ); + Two_Factor_Totp_Secret_Testable::$test_previous_key = hex2bin( $this->test_key_hex ); + + $result = Two_Factor_Totp_Secret_Testable::resolve( $encrypted_old, $user_id ); + $this->assertSame( $plaintext, $result ); + + // Verify DB was re-encrypted (different from old encrypted value). + $db_value = get_user_meta( $user_id, '_two_factor_totp_key', true ); + $this->assertTrue( Two_Factor_Totp_Secret_Testable::is_encrypted( $db_value ) ); + $this->assertNotSame( $encrypted_old, $db_value ); + + // Verify new encrypted value decrypts with current key. + $decrypt_result = Two_Factor_Totp_Secret_Testable::decrypt( $db_value, $user_id ); + $this->assertSame( $plaintext, $decrypt_result['plaintext'] ); + $this->assertFalse( $decrypt_result['needs_reencrypt'] ); + } + + /** + * @covers Two_Factor_Totp_Secret::resolve + */ + public function test_decryption_failure_returns_empty() { + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + + // Encrypt with one key. + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + $encrypted = Two_Factor_Totp_Secret_Testable::encrypt( $plaintext, $user_id ); + + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + // Try to resolve with a completely different key (no previous key). + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex_2 ); + Two_Factor_Totp_Secret_Testable::$test_previous_key = false; + + $result = Two_Factor_Totp_Secret_Testable::resolve( $encrypted, $user_id ); + $this->assertSame( '', $result ); + } + + /** + * @covers Two_Factor_Totp_Secret::is_encrypted + */ + public function test_is_encrypted_detection() { + $this->assertTrue( Two_Factor_Totp_Secret::is_encrypted( '1::aabbccdd:eeff0011' ) ); + $this->assertFalse( Two_Factor_Totp_Secret::is_encrypted( 'JBSWY3DPEHPK3PXP' ) ); + $this->assertFalse( Two_Factor_Totp_Secret::is_encrypted( '' ) ); + $this->assertFalse( Two_Factor_Totp_Secret::is_encrypted( '2::aabbccdd:eeff0011' ) ); + $this->assertFalse( Two_Factor_Totp_Secret::is_encrypted( false ) ); + $this->assertFalse( Two_Factor_Totp_Secret::is_encrypted( null ) ); + } + + /** + * @covers Two_Factor_Totp_Secret::encrypt + */ + public function test_encrypt_format() { + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $encrypted = Two_Factor_Totp_Secret_Testable::encrypt( 'JBSWY3DPEHPK3PXP', 1 ); + $this->assertNotFalse( $encrypted ); + + // Format: 1::[24 hex chars for 12-byte nonce]:[hex chars for ciphertext] + $this->assertMatchesRegularExpression( '/^1::[0-9a-f]{24}:[0-9a-f]+$/', $encrypted ); + } + + /** + * @covers Two_Factor_Totp_Secret::encrypt + */ + public function test_unique_nonces() { + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $encrypted1 = Two_Factor_Totp_Secret_Testable::encrypt( 'JBSWY3DPEHPK3PXP', 1 ); + $encrypted2 = Two_Factor_Totp_Secret_Testable::encrypt( 'JBSWY3DPEHPK3PXP', 1 ); + + $this->assertNotSame( $encrypted1, $encrypted2 ); + } + + /** + * @covers Two_Factor_Totp_Secret::decrypt + */ + public function test_encryption_bound_to_user_id() { + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $encrypted = Two_Factor_Totp_Secret_Testable::encrypt( 'JBSWY3DPEHPK3PXP', 1 ); + + // Decrypting with a different user_id should fail (AD mismatch). + $result = Two_Factor_Totp_Secret_Testable::decrypt( $encrypted, 2 ); + $this->assertFalse( $result ); + } + + /** + * Integration test: full set/get roundtrip through the TOTP provider with encryption. + * + * @covers Two_Factor_Totp::set_user_totp_key + * @covers Two_Factor_Totp::get_user_totp_key + */ + public function test_set_get_roundtrip_with_encryption() { + // This test requires the actual constants to be defined, which we cannot do + // in a single process. Instead, we test at the Secret class level. + $user_id = self::factory()->user->create(); + $plaintext = 'JBSWY3DPEHPK3PXP'; + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + // Simulate what set_user_totp_key does. + $value = Two_Factor_Totp_Secret_Testable::prepare_for_storage( $plaintext, $user_id ); + $this->assertTrue( Two_Factor_Totp_Secret_Testable::is_encrypted( $value ) ); + + update_user_meta( $user_id, '_two_factor_totp_key', $value ); + + // Simulate what get_user_totp_key does. + $stored = (string) get_user_meta( $user_id, '_two_factor_totp_key', true ); + $result = Two_Factor_Totp_Secret_Testable::resolve( $stored, $user_id ); + + $this->assertSame( $plaintext, $result ); + } + + /** + * Integration test: encrypt a secret, generate a TOTP code, and validate it. + * + * @covers Two_Factor_Totp_Secret::resolve + * @covers Two_Factor_Totp::calc_totp + */ + public function test_validate_authentication_with_encrypted_secret() { + $user_id = self::factory()->user->create(); + $plaintext = Two_Factor_Totp::generate_key(); + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + // Store encrypted. + $encrypted = Two_Factor_Totp_Secret_Testable::prepare_for_storage( $plaintext, $user_id ); + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + // Generate a valid TOTP code from the plaintext key. + $code = Two_Factor_Totp::calc_totp( $plaintext ); + + // Resolve should return plaintext, which can be used to validate. + $stored = (string) get_user_meta( $user_id, '_two_factor_totp_key', true ); + $resolved = Two_Factor_Totp_Secret_Testable::resolve( $stored, $user_id ); + + $this->assertTrue( Two_Factor_Totp::is_valid_authcode( $resolved, $code ) ); + } + + /** + * @covers Two_Factor_Totp_Secret::resolve + */ + public function test_is_available_for_user_with_encrypted_secret() { + $user_id = self::factory()->user->create(); + $plaintext = Two_Factor_Totp::generate_key(); + + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + + $encrypted = Two_Factor_Totp_Secret_Testable::prepare_for_storage( $plaintext, $user_id ); + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + // Resolve returns non-empty plaintext when decryption succeeds. + $stored = (string) get_user_meta( $user_id, '_two_factor_totp_key', true ); + $resolved = Two_Factor_Totp_Secret_Testable::resolve( $stored, $user_id ); + + $this->assertNotEmpty( $resolved ); + } + + /** + * @covers Two_Factor_Totp_Secret::resolve + */ + public function test_is_available_for_user_decryption_failure() { + $user_id = self::factory()->user->create(); + $plaintext = Two_Factor_Totp::generate_key(); + + // Encrypt with one key. + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex ); + $encrypted = Two_Factor_Totp_Secret_Testable::prepare_for_storage( $plaintext, $user_id ); + update_user_meta( $user_id, '_two_factor_totp_key', $encrypted ); + + // Switch to unknown key, no previous. + Two_Factor_Totp_Secret_Testable::$test_current_key = hex2bin( $this->test_key_hex_2 ); + Two_Factor_Totp_Secret_Testable::$test_previous_key = false; + + $stored = (string) get_user_meta( $user_id, '_two_factor_totp_key', true ); + $resolved = Two_Factor_Totp_Secret_Testable::resolve( $stored, $user_id ); + + $this->assertSame( '', $resolved ); + } +} diff --git a/tests/stubs/wp-cli.php b/tests/stubs/wp-cli.php new file mode 100644 index 00000000..ff7d5aeb --- /dev/null +++ b/tests/stubs/wp-cli.php @@ -0,0 +1,58 @@ +