From b26a606a6f242ec2514054072c246cc4e45dfc68 Mon Sep 17 00:00:00 2001 From: George Stephanis Date: Mon, 12 Sep 2022 13:42:34 -0400 Subject: [PATCH] First draft of adding recrypt functionality to #389 --- providers/class-two-factor-provider.php | 119 +++++++++++++++++++++++- providers/class-two-factor-totp.php | 86 +++++++++++++++-- 2 files changed, 193 insertions(+), 12 deletions(-) diff --git a/providers/class-two-factor-provider.php b/providers/class-two-factor-provider.php index 89d1d688..9a923e3f 100644 --- a/providers/class-two-factor-provider.php +++ b/providers/class-two-factor-provider.php @@ -31,6 +31,16 @@ abstract class Two_Factor_Provider { */ const ENCRYPTED_VERSION = 1; + /** + * String used to confirm whether the encryption key has not changed. + */ + const ENCRYPTION_TEST_STRING = 'Code is Poetry'; + + /** + * String used to confirm whether the encryption key has not changed. + */ + const ENCRYPTION_TEST_OPTION = 'two_factor_encryption_test'; + /** * Class constructor. * @@ -164,10 +174,11 @@ public static function encrypt( $secret, $user_id, $version = self::ENCRYPTED_VE * * @param string $encrypted Encrypted secret. * @param int $user_id User ID. + * @param string $salt (Optional) The salt to derive the encryption key from. * @return string * @throws RuntimeException Decryption failed. */ - public static function decrypt( $encrypted, $user_id ) { + public static function decrypt( $encrypted, $user_id, $salt = null ) { if ( strlen( $encrypted ) < 4 ) { throw new RuntimeException( 'Message is too short to be encrypted' ); } @@ -184,7 +195,7 @@ public static function decrypt( $encrypted, $user_id ) { $ciphertext, self::serialize_aad( $prefix, $nonce, $user_id ), $nonce, - self::get_encryption_key( $version ) + self::get_encryption_key( $version, $salt ) ); } catch ( SodiumException $ex ) { throw new RuntimeException( 'Decryption failed', 0, $ex ); @@ -200,6 +211,29 @@ public static function decrypt( $encrypted, $user_id ) { return $decrypted; } + /** + * Recrypt a secret. + * + * This will use an old encryption key to decrypt a secret, and then re-encrypt + * it with the current key. + * + * The bulk of this function is duplicating ::decrypt() so we can use a different key. + * + * @param string $old_salt The old salt to derive the key from. + * @param string $secret The encrypted secret. + * @param int $user_id User ID. + * @return string The encrypted data. + */ + public static function recrypt( $old_salt, $encrypted, $user_id ) { + $decrypted = self::decrypt( $encrypted, $user_id, $old_salt ); + + // We'll just use the same version that was on the previously encrypted value. + $prefix = substr( $encrypted, 0, 4 ); + $version = self::get_version_id( $prefix ); + + return self::encrypt( $decrypted, $user_id, $version ); + } + /** * Serialize the Additional Authenticated Data for secret encryption. * @@ -248,14 +282,91 @@ final private static function get_version_id( $prefix = self::ENCRYPTED_PREFIX ) * If we want to change the salt that we're using to encrypt/decrypt, * this is where we change it. * + * The Salt can be overridden in the arguments, for instances when we need + * to use a prior value after rotating salts in wp-config. + * * @param int $version Key derivation strategy. + * @param string $salt (Optional) The raw salt we're deriving the key from. * @return string * @throws RuntimeException For incorrect versions. */ - final private static function get_encryption_key( $version = self::ENCRYPTED_VERSION ) { + final private static function get_encryption_key( $version = self::ENCRYPTED_VERSION, $salt = null ) { + if ( empty( $salt ) ) { + $salt = SECURE_AUTH_SALT; + } if ( 1 === $version ) { - return hash_hmac( 'sha256', SECURE_AUTH_SALT, 'two-factor-encryption', true ); + return hash_hmac( 'sha256', $salt, 'two-factor-encryption', true ); } throw new RuntimeException( 'Incorrect version number: ' . $version ); } + + /** + * Check to see if the encryption key has changed. + * + * Worth noting that this is written specifically to be multisite-compatible, + * which does mean that the options being used, if in a multisite environment, + * will not autoload as they would if in a single site environment. + * + * @param string $salt (Optiona) The string from which we will derive our encryption key. + * @return boolean Whether all seems right with the world. (false = data may need recrypted) + */ + final public static function test_encryption_key( $salt = null ) { + $user_id = 0; // We are doing this user-agnostic. + $encrypted = get_site_option( self::ENCRYPTION_TEST_OPTION ); + + if ( ! $encrypted ) { + // If it hasn't been set yet, set it without overriding the salt. + $encrypted = self::encrypt( self::ENCRYPTION_TEST_STRING, $user_id ); + update_site_option( self::ENCRYPTION_TEST_OPTION, $encrypted ); + // We've just set it, so there's no need to test it. + return true; + } + + try { + $raw = self::decrypt( $encrypted, $user_id, $salt ); + } catch ( RuntimeException $ex ) { + // If it doesn't decrypt at all, something went wrong. + return false; + } + + if ( self::ENCRYPTION_TEST_STRING !== $raw ) { + // If it doesn't decrypt to our constant test string, + // something must have changed. + return false; + } + + return true; + } + + /** + * Runner function to iterate through recrypting data. Uses a static + * variable to avoid recursion. + * + * @param string $old_salt The old salt that had been used to derive the key. + */ + public static function recrypt_data( $old_salt ) { + static $once = false; + if ( ! $once ) { + $once = true; + + $user_id = 0; + $option = get_site_option( self::ENCRYPTION_TEST_OPTION ); + + try { + $new_value = self::recrypt( $old_salt, $option, $user_id ); + update_site_option( self::ENCRYPTION_TEST_OPTION, $new_value ); + } catch ( RuntimeException $ex ) { + return new WP_Error( + 'recrypt-failed', + __( 'The recrypt could not complete due to a runtime error.' ), + array( + 'error' => $ex, + ) + ); + } + + // Now is the action we kick off to handle any other providers that may need to update their data. + do_action( 'two_factor_recrypt_data', $old_salt ); + } + } } diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index bcf54844..8116256e 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -52,6 +52,7 @@ protected function __construct() { add_action( 'personal_options_update', array( $this, 'user_two_factor_options_update' ) ); add_action( 'edit_user_profile_update', array( $this, 'user_two_factor_options_update' ) ); add_action( 'two_factor_user_settings_action', array( $this, 'user_settings_action' ), 10, 2 ); + add_action( 'two_factor_recrypt_data', array( __CLASS__, 'recrypt_data' ) ); return parent::__construct(); } @@ -112,13 +113,23 @@ public function user_two_factor_options( $user ) { wp_nonce_field( 'user_two_factor_totp_options', '_nonce_user_two_factor_totp_options', false ); - $key = $this->get_user_totp_key( $user->ID ); + $key = null; + $decryption_issue = false; + try { + $key = $this->get_user_totp_key( $user->ID ); + } catch ( RuntimeException $ex ) { + $decryption_issue = true; + } + $this->admin_notices( $user->ID ); ?>
%s

', esc_html__( 'Error: Code-based authentication is temporarily unavailable.', 'two-factor' ) ); + elseif ( empty( $key ) ) : $key = $this->generate_key(); $site_name = get_bloginfo( 'name', 'display' ); $totp_title = apply_filters( 'two_factor_totp_title', $site_name . ':' . $user->user_login, $user ); @@ -295,10 +306,14 @@ public function admin_notices( $user_id ) { */ public function validate_authentication( $user ) { if ( ! empty( $_REQUEST['authcode'] ) ) { - return $this->is_valid_authcode( - $this->get_user_totp_key( $user->ID ), - sanitize_text_field( $_REQUEST['authcode'] ) - ); + try { + return $this->is_valid_authcode( + $this->get_user_totp_key( $user->ID ), + sanitize_text_field( $_REQUEST['authcode'] ) + ); + } catch ( RuntimeException $ex ) { + return false; + } } return false; @@ -446,8 +461,13 @@ public static function get_google_qr_code( $name, $key, $title = null ) { * @return boolean */ public function is_available_for_user( $user ) { - // Only available if the secret key has been saved for the user. - $key = $this->get_user_totp_key( $user->ID ); + try { + // Only available if the secret key has been saved for the user. + $key = $this->get_user_totp_key( $user->ID ); + } catch ( RuntimeException $ex ) { + // If the decryption failed and generated an exception -- return true as we don't want to disable two-factor accidentally? + return true; + } return ! empty( $key ); } @@ -458,6 +478,15 @@ public function is_available_for_user( $user ) { * @param WP_User $user WP_User object of the logged-in user. */ public function authentication_page( $user ) { + try { + $this->get_user_totp_key( $user->ID ); + } catch ( RuntimeException $ex ) { + // The totp key decryption caused an error: call for an admin. + // Possibly display a prompt here to enable entering the prior decryption key, if site admin? + printf( '

%s

', esc_html__( 'Error: Code-based authentication is temporarily unavailable.', 'two-factor' ) ); + return; + } + require_once ABSPATH . '/wp-admin/includes/template.php'; ?>

@@ -561,4 +590,45 @@ private static function abssort( $a, $b ) { } return ( $a < $b ) ? -1 : 1; } + + /** + * Runner function to iterate through recrypting data. Uses a static + * variable to avoid recursion. + * + * @param string $old_salt The old salt that had been used to derive the key. + */ + public static function recrypt_data( $old_salt ) { + global $wpdb; + + static $once = false; + if ( ! $once ) { + $once = true; + + // Handle upstream recrypt, and trigger action for other providers. + parent::recrypt_data( $old_salt ); + + // Do + $sql = $wpdb->prepare( "SELECT `user_id`, `meta_value` FROM {$wpdb->usermeta} WHERE `meta_key` = %s", self::SECRET_META_KEY ); + $data_to_recrypt = $wpdb->get_results( $sql ); + + if ( ! $data_to_recrypt ) { + return; + } + + foreach ( $data_to_recrypt as $row ) { + try { + // The decrypt called by recrypt should throw a RuntimeException if the old salt doesn't work. + $new_encrypted = self::recrypt( $old_salt, $row->meta_value, $row->user_id ); + update_user_meta( + $row->user_id, + self::SECRET_META_KEY, + $new_encrypted, + $row->meta_value + ); + } catch ( RuntimeException $ex ) { + // oops? maybe error_log it? + } + } + } + } }