From c01aa9a5d964eae9312db9e8fdcaae3b7cfb6fc2 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 3 Mar 2026 11:30:10 -0800 Subject: [PATCH 1/5] feat(totp): encrypt TOTP secrets at rest using AES-256-GCM (#455) Add encryption-at-rest for TOTP secrets stored in wp_usermeta using AES-256-GCM via sodium_compat, controlled by TWO_FACTOR_TOTP_ENCRYPTION_KEY and TWO_FACTOR_TOTP_ENCRYPTION_KEY_PREVIOUS constants. Fully backward- compatible: without constants defined, behavior is unchanged. Co-Authored-By: Claude Opus 4.6 --- providers/class-two-factor-totp-secret.php | 264 ++++++++++++++ providers/class-two-factor-totp.php | 8 +- .../class-two-factor-totp-secret.php | 345 ++++++++++++++++++ 3 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 providers/class-two-factor-totp-secret.php create mode 100644 tests/providers/class-two-factor-totp-secret.php diff --git a/providers/class-two-factor-totp-secret.php b/providers/class-two-factor-totp-secret.php new file mode 100644 index 00000000..651374d4 --- /dev/null +++ b/providers/class-two-factor-totp-secret.php @@ -0,0 +1,264 @@ + $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 ); + } + } + return $stored_value; + } + + // Encrypted value — attempt decryption. + $result = self::decrypt( $stored_value, $user_id ); + if ( false === $result ) { + return ''; + } + + // 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 ); + } + } + + 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 ) { + return $encrypted; + } + } + + return $plaintext; + } +} diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 9726fef3..1fcd1296 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 * @@ -516,7 +518,8 @@ 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 ); + return Two_Factor_Totp_Secret::resolve( $stored_value, $user_id ); } /** @@ -530,7 +533,8 @@ 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 ); + $value = Two_Factor_Totp_Secret::prepare_for_storage( $key, $user_id ); + return update_user_meta( $user_id, self::SECRET_META_KEY, $value ); } /** diff --git a/tests/providers/class-two-factor-totp-secret.php b/tests/providers/class-two-factor-totp-secret.php new file mode 100644 index 00000000..f1e6029b --- /dev/null +++ b/tests/providers/class-two-factor-totp-secret.php @@ -0,0 +1,345 @@ +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 ); + } +} From 08dbe6af4f0e072df46305e06459bc48b3baf2ea Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 3 Mar 2026 11:32:07 -0800 Subject: [PATCH 2/5] refactor(totp): use WordPress hooks for encryption integration Replace direct static method calls with apply_filters for secret resolve and prepare_for_storage, registered as default callbacks in the constructor. Add do_action calls for encryption lifecycle events: encrypted, decrypted, rotated, and decrypt_failed. Co-Authored-By: Claude Opus 4.6 --- providers/class-two-factor-totp-secret.php | 44 ++++++++++++++++++++++ providers/class-two-factor-totp.php | 31 ++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/providers/class-two-factor-totp-secret.php b/providers/class-two-factor-totp-secret.php index 651374d4..c32995e1 100644 --- a/providers/class-two-factor-totp-secret.php +++ b/providers/class-two-factor-totp-secret.php @@ -219,6 +219,15 @@ public static function resolve( $stored_value, $user_id ) { $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; @@ -227,14 +236,47 @@ public static function resolve( $stored_value, $user_id ) { // 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 ); } } @@ -255,6 +297,8 @@ 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; } } diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 1fcd1296..1331436a 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -54,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(); } @@ -519,7 +522,19 @@ public function user_two_factor_options( $user ) { */ public function get_user_totp_key( $user_id ) { $stored_value = (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); - return Two_Factor_Totp_Secret::resolve( $stored_value, $user_id ); + + /** + * 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 ); } /** @@ -533,7 +548,19 @@ 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 ) { - $value = Two_Factor_Totp_Secret::prepare_for_storage( $key, $user_id ); + /** + * 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 ); } From 45638693cdc182734771cfd9d8ead835f6ec0d86 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 3 Mar 2026 12:53:05 -0800 Subject: [PATCH 3/5] feat(cli): add WP-CLI command to bulk-encrypt TOTP secrets Add `wp two-factor totp encrypt-secrets` command that encrypts all plaintext TOTP secrets in the database. Supports --dry-run flag to preview changes. Addresses the gap where users who never log in would retain plaintext secrets indefinitely after enabling encryption. Co-Authored-By: Claude Opus 4.6 --- includes/class-two-factor-totp-cli.php | 117 +++++++++ .../class-two-factor-totp-cli-mocks.php | 57 +++++ tests/includes/class-two-factor-totp-cli.php | 226 ++++++++++++++++++ tests/includes/wp-cli-utils-mock.php | 21 ++ two-factor.php | 6 + 5 files changed, 427 insertions(+) create mode 100644 includes/class-two-factor-totp-cli.php create mode 100644 tests/includes/class-two-factor-totp-cli-mocks.php create mode 100644 tests/includes/class-two-factor-totp-cli.php create mode 100644 tests/includes/wp-cli-utils-mock.php 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/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 @@ + Date: Tue, 3 Mar 2026 12:58:40 -0800 Subject: [PATCH 4/5] fix(ci): add WP-CLI stubs for PHPStan analysis Add stub file for WP_CLI class and WP_CLI\Utils\get_flag_value so PHPStan can analyse the CLI command file without errors. Co-Authored-By: Claude Opus 4.6 --- phpstan.dist.neon | 2 ++ tests/stubs/wp-cli.php | 58 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/stubs/wp-cli.php 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/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 @@ + Date: Tue, 3 Mar 2026 13:14:53 -0800 Subject: [PATCH 5/5] docs: add encryption hooks and WP-CLI command to readme Document the new filters (two_factor_totp_secret_resolve, two_factor_totp_secret_prepare), actions (encrypted, decrypted, rotated, decrypt_failed), and the wp two-factor totp encrypt-secrets WP-CLI command including key rotation instructions. Co-Authored-By: Claude Opus 4.6 --- readme.txt | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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 ==