A Libsodium-based secure remote password scheme for PHP

Let's geek out on my favorite form of authentication - not sending passwords over the wire in the first place!

I’ve been talking about secure remote passwords (SRP) for a long time. I’ve written magazine articles, presented at conferences, and published code snippet examples. It is still, hands-down, my favorite way to authenticate users because it prevents the server from ever seeing a user’s password.

There are various other steps required to make SRP truly secure and safe, but understanding the basic data flows and cryptographic primitives is critical.

Rather than storing a hash of a user’s password, the server stores a way to verify the user has the password. This “verifier” is a public key derived from the password itself that is leveraged as part of a challenge-response authentication workflow. The user keeps their password safe, and the server can trust the user is actually who they say they are.

SRP illustrated

Rather than registering with a username/email and a password, clients keep the password entirely on the client-side and generate a separate cryptographic proof that is sent to the server. Clients create a random salt and blend it with their password to create a unique public/private keypair. Clients then register by sending their username/email, the public component of their keypair, and the random salt they generated to the server.

The server never sees the client’s password. Again, a database breach will never reveal plaintext of passwords or even hashes of passwords because the server has neither. Even if an attacker sees the code on the server-side, there is no useful information there to leak.

Authentication then becomes a multi-step handshake between the client and the server.

From a high level:

  1. Clients initiate the authentication session by submitting their identifier (username or email) to the server
  2. The server creates an ephemeral (random and temporary) public/private keypair.
  3. The server uses its private key along with the client’s public key to derive a shared secret key.
  4. The server creates a cryptographic proof of its shared secret—a hash of the derived shared secret.
  5. The server then sends the clients’ original salt, its own public key, and its proof of the derived shared key back to the client.
  6. The client uses the salt with its known password to re-derive its public/private keypair.
  7. The client uses its private key with the server’s public key to derive the same shared secret.
  8. The client validates the server’s proof of the shared secret and, if valid, creates its own proof and sends it to the server
  9. The server independently verifies the client’s proof and, if valid, continues with an active user authentication session for the client.

While it does involve a somewhat longer handshake than merely POSTing a username and password to the server, the SRP protocol helps protect user authentication information from any potential eavesdroppers. It also prevents any information about the user’s private credentials from ever being leaked, regardless of whether the leak is the server itself (in the case of a breach) or a man-in-the-middle attack.

Implementing SRP with Libsodium

Traditional SRP implementations leverage the same cryptographic primitives as RSA. In this way, it’s very similar to a Diffie-Hellman key exchange like those underlying communication protocols like TLS. Newer cryptographic libraries, however, provide similar primitives and can be far easier to implement safely for developers. Since version 7.2, PHP has shipped with Libsodium as a core extension.

In particular, Libsodium exposes all of the primitives we can use to implement a secure remote password flow in PHP:

Given these operations, we can easily implement a client/server secure remote password exchange that mutually authenticates each party.

Client-side

The client-side application will need to implement two operations: registration and authentication. The registration routine will take in an email address and desired password and send a derived public key to the server to create the account. Nothing needs to be stored client side as all required information is either known or provided by the server during authentication.

Omitting the actual server request, a registration routine would look like:

function register(string $email, string $password)
{
  // Derive the client's keypair
  $salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
  $seed = sodium_crypto_pwhash(
    SODIUM_CRYPTO_KX_SEEDBYTES,              // Desired hash output length
    $password,                               // User's known password
    $salt,                                   // Random salt
    SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE, // Password hashing ops limit
    SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE  // Password hashing memory limit
  );

  $keypair = sodium_crypto_kx_seed_keypair($seed);
  $public_key = sodium_crypto_kx_publickey($keypair);

  // POST this array to the server to register
  $registration = [
    'identifier' => $email,
    'salt'       => sodium_bin2hex($salt),
    'public_key' => sodium_bin2hex($public_key)
  ];

  // ... Send the request and handle errors  
}

The authentication routine, however, needs to make two requests: one to initialize authentication, and one to send a cryptographic proof to the server. The first operation would only send the client’s email address to the server. It would, however, handle the response (checking for errors) and send any resulting data through to the next operation.

The second operation requires information retrieved from the server regarding the authentication session (the client’s salt, the server’s public key, the server’s proof) and the user’s password. Again, remember the following work is performed by the client:

function prove_identity(
  string $email, 
  string $password, 
  string $salt, 
  string $server_public_key, 
  string $server_proof
)
{
  // Derive the client's keypair
  $salt = sodium_hex2bin($salt);
  $seed = sodium_crypto_pwhash(
    SODIUM_CRYPTO_KX_SEEDBYTES,              // Desired hash output length
    $password,                               // User's known password
    $salt,                                   // Random salt
    SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE, // Password hashing ops limit
    SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE  // Password hashing memory limit
  );
  $keypair = sodium_crypto_kx_seed_keypair($seed);

  // Derive the shared secret
  $server_key = sodium_hex2bin($server_public_key);
  $session_keys = sodium_crypto_kx_client_session_keys($keypair, $server_key);
  $client_secret = $session_keys[0];
  $server_secret = $session_keys[1];

  // Validate the server's proof
  $server_proof = sodium_hex2bin($server_proof);
  $message = substr($server_proof, 0, 32);
  $hash = substr($server_proof, 32);

  if (!hash_equals(sodium_crypto_generichash($message, $server_secret), $hash)) {
    // If the hash doesn't match, err!
    exit;
  }

  // Generate the client's proof
  $client_message = random_bytes(32);
  $client_hash = sodium_crypto_generichash($client_message, $client_secret);
  $proof = sodium_bin2hex($client_message . $client_hash);

  // POST this array to the server to authenticate
  $response = [
    'identifier'        => $email,
    'server_public_key' => $server_public_key,
    'proof'             => $client_proof
  ];

  // ... Send the request and handle errors
}

Assume the server is stateless and use a different keypair for every authentication operation. In that situation, the client needs to send the server’s public key with each request to identify the authentication session. This handles situations where one account (email address) has requested multiple authentication sessions that may or may be complete.

Server-side

The server-side application likewise needs to implement both registration and authentication. It also needs a third endpoint to validate the client-generated authentication proof before returning a valid PHP session for further requests.

Registration merely creates an account for the new user, storing the email address, salt, and public key for future reference:

handle_registration(string $email, string $salt, string $public_key)
{
  $handle = get_database();

  $statement = $handle->prepare('INSERT INTO users (identifier, salt, public_key) VALUES (:identifier, :salt, :public_key);');

  $result = $statement->execute([
    ':identifier' => $email,
    ':salt'       => $salt,
    ':public_key' => $public_key
  ]);

  if (!$result) {
    error_log($statement->errorCode());

    // Handle errors ...
  }
}

Initializing an authentication session will actually trigger multiple operations. First, the server must create a new, random public/private keypair for this session—and record the keys in the database for further verification of the client’s proof. The server must also generate it’s own cryptographic proof of the shared secret to demonstrate its identity to the client. Finally, it must communicate this information to the client:

function initialize_authentication(string $email)
{
  // Get the user from the database
  $handle = get_database();
  $statement = $handle->prepare('SELECT * from users where identifier = :identifier LIMIT 1');
  $statement->execute([':identifier' => $email]);
  $user = $statement->fetch();

  // Create an ephemeral keypair
  $keypair = sodium_crypto_kx_keypair();
  $public_key = sodium_crypto_kx_publickey($keypair);

  // Store the authentication session for later
  $insert_statement = $handle->prepare('INSERT INTO authentication_sessions (identifier,public_key, keypair) VALUES (:identifier, :public_key, :keypair);');
  $statement->execute([
    ':identifier' => $email,
    ':public_key' => sodium_bin2hex($public_key),
    ':keypair'    => sodium_bin2hex($keypair)
  ]);

  // Derive the shared secret
  $client_public_key = sodium_hex2bin($user['public_key']);
  $session_keys = sodium_crypto_kx_server_session_keys($keypair, $client_public_key);
  $client_secret = $session_keys[1];
  $server_secret = $session_keys[0];

  // Generate the server's proof
  $server_message = random_bytes(32);
  $server_hash = sodium_crypto_generichash($server_message, $server_secret);
  $proof = sodium_bin2hex($server_message . $server_hash);

  // Build up the initialization response
  $response = [
    'salt'              => $user['salt'],
    'server_public_key' => sodium_bin2hex($public_key),
    'server_proof'      => $proof
  ];

  // ... Send the response and handle errors
}

Finally, the server verifies the client’s cryptographic proof to check that the client’s identity is valid. If the proof is accurate, then it means the client is who they claim to be and the server can store the client’s identity in a PHP session for further use:

function verify_authentication(string $email, string $server_public_key, string $proof)
{
  // Get the user from the database
  $handle = get_database();
  $statement = $handle->prepare('SELECT * from users where identifier = :identifier LIMIT 1');
  $statement->execute([':identifier' => $email]);
  $user = $statement->fetch();

  $client_public_key = sodium_bin2hex($user['public_key']);

  // Get the server's key information from the database
  $statement = $handle->prepare('SELECT * from authentication_sessions where identifier = :identifier and public_key = :public_key LIMIT 1');
  $statement->execute([':identifier' => $email, ':public_key' => $server_public_key]);
  $session = $statement->fetch();

  $keypair = sodium_hex2bin($session['keypair']);

  // Derive the shared secret
  $session_keys = sodium_crypto_kx_server_session_keys($keypair, $client_public_key);
  $client_secret = $session_keys[0];
  $server_secret = $session_keys[1];

  // Validate the client's proof
  $client_proof = sodium_hex2bin($proof);
  $message = substr($client_proof, 0, 32);
  $hash = substr($client_proof, 32);

  if (!hash_equals(sodium_crypto_generichash($message, $client_secret), $hash)) {
    // If the hash doesn't match, err!
    exit;
  }

  // Store the user's email in a session for subsequent requests
  $_SESSION['identifier'] = $email;

  // Return a successful response to the client!
}

Subsequent requests make use of the presence of a PHP session and don’t require further authentication. A client-side PHP script would need to store its session ID in memory (or in the filesystem) so it can provide the token as a cookie on subsequent requests. In the browser, this final authentication step would happen more transparently, but still provide strong security for the client.

Why bother?

Storing passwords in plaintext in your application’s database is a recipe for disaster. Storing hashed passwords is a commonly-accepted alternative as it helps keep your customers’ data safe even if your database is compromised.

Safer still is to never store passwords in the first place, either hashed or in plaintext. The secure remote password protocol helps achieve this safer reality while still allowing simple, password-based authentication on the part of clients. Having Libsodium shipped as a core part of PHP makes SRP even easier to implement as the exposed primitives are relatively simple and safe for developers to use.

Applications are made secure when the developers who maintain them intentionally prioritize the safety of their customers’ information. Applications leveraging passwords for authentication are made secure when developers focus on protecting those passwords from prying eyes. The Secure Remote Password protocol helps you do just that by making sure your server never sees customers’ passwords in the first place!