Skip to content

Commit

Permalink
First draft of adding recrypt functionality to #389
Browse files Browse the repository at this point in the history
  • Loading branch information
georgestephanis committed Sep 12, 2022
1 parent e3022fd commit b26a606
Show file tree
Hide file tree
Showing 2 changed files with 193 additions and 12 deletions.
119 changes: 115 additions & 4 deletions providers/class-two-factor-provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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' );
}
Expand All @@ -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 );
Expand All @@ -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.
*
Expand Down Expand Up @@ -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 );
}
}
}
86 changes: 78 additions & 8 deletions providers/class-two-factor-totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 );

?>
<div id="two-factor-totp-options">
<?php
if ( empty( $key ) ) :
if ( $decryption_issue ) :
// Possibly display a prompt here to enable entering the prior decryption key, if site admin?
printf( '<p>%s</p>', 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 );
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 );
}
Expand All @@ -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( '<p>%s</p>', esc_html__( 'Error: Code-based authentication is temporarily unavailable.', 'two-factor' ) );
return;
}

require_once ABSPATH . '/wp-admin/includes/template.php';
?>
<p>
Expand Down Expand Up @@ -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?
}
}
}
}
}

0 comments on commit b26a606

Please sign in to comment.