Skip to content

Commit

Permalink
Encrypt TOTP secrets.
Browse files Browse the repository at this point in the history
This exposes a versioned authenticated encryption interface (powered by libsodium, which is polyfilled in WordPress via sodium_compat).

Version 1 of the under-the-hood protocol uses the HMAC-SHA256 hash of a constant string and SECURE_AUTH_SALT to generate a key. This can be changed safely in version 2 without breaking old users.

Version 1 uses XChaCha20-Poly1305 to encrypt the TOTP secrets. The authentication tag on the ciphertext is also validated over the user's database ID and the version prefix.

Threat model:

* Protects against read-only SQLi due to encryption.
* Protects against copy-and-paste attacks (replacing TOTP secrets from one account with another's), since the ciphertext+tag are bound to the user's database ID.
* Protects against chosen-ciphertext attacks (IND-CCA2).
* Does not protect against replay attacks.
* Does not protect against attackers capable of reading the salt from the filesystem.
  • Loading branch information
soatok committed Oct 19, 2020
1 parent 736473e commit 6c380c8
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 2 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
}
},
"require": {
"paragonie/sodium_compat": "^1.13",
"php": ">=5.6"
},
"require-dev": {
Expand Down
165 changes: 163 additions & 2 deletions providers/class-two-factor-totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ class Two_Factor_Totp extends Two_Factor_Provider {
const DEFAULT_TIME_STEP_SEC = 30;
const DEFAULT_TIME_STEP_ALLOWANCE = 4;

/**
* Prefix for encrypted TOTP secrets. Contains a version identifier.
*
* $t1$ -> TOTP v1 (RFC 6238, encrypted with XChaCha20-Poly1305, with a key derived from HMAC-SHA256
* of SECURE_AUTH_SAL.)
*
* @var string
*/
const ENCRYPTED_TOTP_PREFIX = '$t1$';

/**
* Current "version" of the TOTP encryption protocol.
*
* 1 -> $t1$nonce|ciphertext|tag
*/
const ENCRYPTED_TOTP_VERSION = 1;

/**
* Chracters used in base32 encoding.
*
Expand Down Expand Up @@ -206,7 +223,12 @@ public function user_two_factor_options_update( $user_id ) {
* @return string
*/
public function get_user_totp_key( $user_id ) {
return (string) get_user_meta( $user_id, self::SECRET_META_KEY, true );
$user_meta_value = get_user_meta( $user_id, self::SECRET_META_KEY, true );
if ( ! self::is_encrypted( $user_meta_value ) ) {
$user_meta_value = self::encrypt( $user_meta_value, $user_id );
update_user_meta( $user_id, self::SECRET_META_KEY, $user_meta_value );
}
return self::decrypt( $user_meta_value, $user_id );
}

/**
Expand All @@ -218,7 +240,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 );
$encrypted = self::encrypt( $key, $user_id );
return update_user_meta( $user_id, self::SECRET_META_KEY, $encrypted );
}

/**
Expand Down Expand Up @@ -555,4 +578,142 @@ private static function abssort( $a, $b ) {
}
return ( $a < $b ) ? -1 : 1;
}

/**
* Is this string an encrypted TOTP secret?
*
* @param string $secret Stored TOTP secret.
* @return bool
*/
public static function is_encrypted( $secret ) {
if ( strlen( $secret ) < 40 ) {
return false;
}
if ( strpos( $secret, self::ENCRYPTED_TOTP_PREFIX ) !== 0 ) {
return false;
}
return true;
}

/**
* Encrypt a TOTP secret.
*
* @param string $secret TOTP secret.
* @param int $user_id User ID.
* @param int $version (Optional) Version ID.
* @return string
* @throws SodiumException From sodium_compat or ext/sodium.
*/
public static function encrypt( $secret, $user_id, $version = self::ENCRYPTED_TOTP_VERSION ) {
$prefix = self::get_version_header( $version );
$nonce = random_bytes( 24 );
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
$secret,
self::serialize_aad( $prefix, $nonce, $user_id ),
$nonce,
self::get_key( $version )
);
// @codingStandardsIgnoreStart
return self::ENCRYPTED_TOTP_PREFIX . base64_encode( $nonce . $ciphertext );
// @codingStandardsIgnoreEnd
}

/**
* Decrypt a TOTP secret.
*
* Version information is encoded with the ciphertext and thus omitted from this function.
*
* @param string $encrypted Encrypted TOTP secret.
* @param int $user_id User ID.
* @return string
* @throws RuntimeException Decryption failed.
*/
public static function decrypt( $encrypted, $user_id ) {
if ( strlen( $encrypted ) < 4 ) {
throw new RuntimeException( 'Message is too short to be encrypted' );
}
$prefix = substr( $encrypted, 0, 4 );
$version = self::get_version_id( $prefix );
if ( 1 === $version ) {
// @codingStandardsIgnoreStart
$decoded = base64_decode( substr( $encrypted, 4 ) );
// @codingStandardsIgnoreEnd
$nonce = RandomCompat_substr( $decoded, 0, 24 );
$ciphertext = RandomCompat_substr( $decoded, 24 );
try {
$decrypted = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$ciphertext,
self::serialize_aad( $prefix, $nonce, $user_id ),
$nonce,
self::get_key( $version )
);
} catch ( SodiumException $ex ) {
throw new RuntimeException( 'Decryption failed', 0, $ex );
}
} else {
throw new RuntimeException( 'Unknown version: ' . $version );
}

// If we don't have a string, throw an exception because decryption failed.
if ( ! is_string( $decrypted ) ) {
throw new RuntimeException( 'Could not decrypt TOTP secret' );
}
return $decrypted;
}

/**
* Serialize the Additional Authenticated Data for TOTP secret encryption.
*
* @param string $prefix Version prefix.
* @param string $nonce Encryption nonce.
* @param int $user_id User ID.
* @return string
*/
public static function serialize_aad( $prefix, $nonce, $user_id ) {
return $prefix . $nonce . pack( 'N', $user_id );
}

/**
* Get the version prefix from a given version number.
*
* @param int $number Version number.
* @return string
* @throws RuntimeException For incorrect versions.
*/
final private static function get_version_header( $number = self::ENCRYPTED_TOTP_VERSION ) {
switch ( $number ) {
case 1:
return '$t1$';
}
throw new RuntimeException( 'Incorrect version number: ' . $number );
}

/**
* Get the version prefix from a given version number.
*
* @param string $prefix Version prefix.
* @return int
* @throws RuntimeException For incorrect versions.
*/
final private static function get_version_id( $prefix = self::ENCRYPTED_TOTP_PREFIX ) {
switch ( $prefix ) {
case '$t1$':
return 1;
}
throw new RuntimeException( 'Incorrect version identifier: ' . $prefix );
}

/**
* Get the encryption key for encrypting TOTP secrets.
*
* @param int $version Key derivation strategy.
* @return string
* @throws RuntimeException For incorrect versions.
*/
final private static function get_key( $version = self::ENCRYPTED_TOTP_VERSION ) {
if ( 1 === $version ) {
return hash_hmac( 'sha256', SECURE_AUTH_SALT, 'totp-encryption', true );
}
throw new RuntimeException( 'Incorrect version number: ' . $version );
}
}
28 changes: 28 additions & 0 deletions tests/providers/class-two-factor-totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,32 @@ public function test_user_can_delete_secret() {
);
}

/**
* Verify the encryption and decryption functions behave correctly
*
* @throws SodiumException Libsodium can fail.
*/
public function test_encrypt_decrypt() {
$user = new WP_User( $this->factory->user->create() );
$key = $this->provider->generate_key();

if ( ! defined( 'SECURE_AUTH_SALT' ) ) {
define( 'SECURE_AUTH_SALT', random_bytes( 32 ) );
}

$encrypted = Two_Factor_Totp::encrypt( $key, $user->ID );
$this->assertEquals(
Two_Factor_Totp::ENCRYPTED_TOTP_PREFIX,
substr( $encrypted, 0, 4 ),
'Encryption defaults to the latest version.'
);

$decrypted = Two_Factor_Totp::decrypt( $encrypted, $user->ID );
$this->assertSame(
$key,
$decrypted,
'Decrypted secret must be identical to plaintext'
);
}

}

0 comments on commit 6c380c8

Please sign in to comment.