Correctly implementing Multi-Factor Authentication with encrypted storage

Code examples here are for PHP specifically, used in a Web Application running in a LAMP environment.

Multi-Factor Authentication

«insert bumf here, something around https://en.wikipedia.org/wiki/Strong_customer_authentication»

Encryption (at rest) during the Registration phase

Why encrypt at rest?

Assuming the back-end storage (along with it's backups) is "safe" and "secure" and not vulnerable to exfiltration by SQL injection and that if it DID fall into evil hands there would be MUCH bigger problems... is it really necessary to bother to secure credentials?

None of the outcomes above account for staff with legitimate access to the database reading and using the unencrypted data to commit fraud, acting as a client.

I would suggest that following the principles of defensive programming at very least the local fraud case should be motivation enough for authentication data and sensitive payment data (and any GDPR special category data) to be encrypted at rest including in backups.

Password / Knowledge Factor

Most languages have a one-way cryptographic function for storing passwords, I use password_hash

$hashedPasswd = password_hash($_SAFE['pass1'], PASSWORD_DEFAULT);

produces an output that looks something like:

$2y$10$LcWSslcI1fs4Ae.Z0ly/ruRhdTfI9SwCqXdZDcwKJPv2WGYMpc5P2

following the PHP doc guidance I store that in a VARCHAR(255). This can not be decoded, but it can be verified thusly:

if (password_verify($_SAFE['pass'], $userRow['passwd'])) {

TOTP Secret / Posession Factor

Secrets are set and codes verified using Michael Kliewe's GoogleAuthenticator library according to RFC 6238

An app such as Google Authenticator, FreeTOTP on Android / iPhone or WinAuth on a Windows PC is necessary to obtain a code from a secret.

A plaintext secret is necessary to verify a TOTP code, so this time symmetric encryption is necessary, specifically I'm using AES-256-CBC, encryption is thusly:

$encryptedSecret = openssl_encrypt($_SESSION['secret'], $enc_cipher, $_SAFE['pass1'], OPENSSL_RAW_DATA, $initVector);

which, when base64'd produces an output that looks something like:

bgrnHu11oapeFMtcEzSFlL+5H+FPR3v4E5Bkn8SlIF+ZtkuAllL42n9Eh1GbaSn7

This I store in a char(100), lets looks at each of those more closely:

$_SESSION['secret']

Keeping the secret safe is paramount with TOTP. I generate a secret during a registration process thusly:

$ga = new PHPGangsta_GoogleAuthenticator();
$_SESSION['secret'] = $ga->createSecret();

but it's not saved, other than temporarily in a SESSION variable. A GUID is generated and sent via email and/or WhatsApp, also saved into the SESSION. To complete the registration process the GUID must be returned into the same browser window that has the secret in it's session and must match the GUID in the session. The secret is only persisted into the database when the final stage of the registration is complete - this includes the setting of a password.

$enc_cipher

$enc_cipher = 'aes-256-cbc';

$_SAFE['pass1']

When the final phase of registration is completed I have a secret in a session variable (that I need to save) and a password (send via POST). I use the password as the $key since this is unknown to me, but it known to the user, with out it I cannot decrypt (i.e. leak) the secret. When the user wished to authenticate I ask for a user, password and a TOTP at the same time - I have the password long enough to decrypt the secret and verify the TOTP code.

OPENSSL_RAW_DATA

If you've got this far you'll realise that I need not have included OPENSSL_RAW_DATA since I want base64. I need to store the (random) $initVector to allow me to decrypt the secret. It is currently binary, but of a known length, so I catenate the $initVector with the encrypted secret and then manually base64 the result for storage:

base64_encode($initVector . $encryptedSecret)

$initVector

$initVector = openssl_random_pseudo_bytes(openssl_cipher_iv_length($enc_cipher));

The authentication / login process

An authentication/login attempt requests 3 things:

The email allows me to fetch $userRow and check things thusly:

if (password_verify($_SAFE['pass'], $userRow['passwd'])) {
  $tmp = base64_decode($userRow['secret']);        $initVectorLen = openssl_cipher_iv_length($enc_cipher); 
  $initVector = substr($tmp, 0, $initVectorLen);   $encSecret = substr($tmp, $initVectorLen);
  $secret = openssl_decrypt($encSecret, $enc_cipher, $_SAFE['pass'], OPENSSL_RAW_DATA, $initVector);
  require_once ($_SERVER['DOCUMENT_ROOT'] . '/../lib/GoogleAuthenticator-master/PHPGangsta/GoogleAuthenticator.php');
  $ga = new PHPGangsta_GoogleAuthenticator();
  if ($ga->verifyCode($secret, $_SAFE['totp'], 2)) {

That's it!!!