logo~stef/blog/

Why and how to use OPAQUE for user authentication

2022-02-17

Storing clear text passwords in your user database is very stupid.

Traditionally we store hashed passwords of users. When a user logs in, then the user sends their cleartext password to the server (hopefully protected by TLS), the server hashes the password and compares it to to the stored hash, if it matches, all is good. Lets call this symmetric authentication, where both the client and the server knows the cleartext password.

A simple threat model

With authentication you have the channel over which the token is transmitted:

  • here you don't want a passive adversary learn your cleartext password (sniffing),
  • or an active adversary being able to replay your password as in pass-the-hash attacks (replays).

You say that but its all protected by TLS, maybe, but maybe your TLS terminates at cloudflare or another content delivery network (CDN), or the TLS configuration fails for some reason (e.g. certificate verify = false, or you push your private key to github), or your certificate store has certificate authorities that are controlled by some adversary.

On the server itself:

  • you want to be safe against SNAFUs like when Twitter was logging clear text passwords, or heartbleed is leaking them. Also you don't want active attackers on the server to be able to read the plaintext passwords (direct password leaks).
  • And finally you want to avoid in case an attacker leaks your user database that there are already pre-computations available that simplify the recovery of passwords from hashes into cheap lookups, so you use salts. (pre-computation)

You want to protect those passwords of your user so that in case your password database leaks, you want to make it as difficult as possible to anyone to recover passwords, in case those passwords are reused at other sites and enable attackers to abuse these accounts.

So you really don't want your cleartext password to be sent to the server, ever. And it's possible, for example CRAM - a challenge-response protocol - eliminates the sniffing vector. The socialist millionaire protocol (SMP) eliminates both sniffing and replay issues. But they still need the cleartext password on the server. So, that leads us to…

Asymmetric authentication

If there is symmetric authentication, there must be also asymmetric authentication, where only one party knows the password. And the nice thing about asymmetric auth is that it immediately eliminates sniffing and direct password leaks problems.

Let's hypothesize a simple hash-based protocol where password is never sent in cleartext to the server. If instead of directly sending cleartext password,

  1. since we're hashing on the client, we need to know the salt with which the server has the password hashed with during registration, so the client first asks for the global or user specific salt.
  2. the server sends the salt via tls, then
  3. client calculates hash(per-user-salt, pwd) sends to server, and
  4. server hashes this again (otherwise the once hashed password could be used by anyone having access to the user database) and compares that to the stored value that has also been hashed twice.

In case of SNAFU (TLS fail, MitM, CDN, attacker on server, twitter fumbling, etc) the salt leaks, and possibly a unique hash, that needs to be bruteforced to recover the password. However there are a few problems:

  1. the salt must be requested from the server, adding 2 more steps to the auth protocol. Caching the salt TOFU-style in a cookie solves this.
  2. salts mustn't leak if a user exists or not, that means for non-existing users, the same salt must be returned for the same non-existing username. This can be solved by calculating the salt like this:

    salt:=hmac(username, serverkey)
    
  3. global salts enable an attacker to acquire the salt and pre-calculate.
  4. if a username is known, an attacker can query the salt and prepare a pre-calculation.
  5. the hashed password can still be used in a pass-the-hash style replay attack by anyone stealing it.
  6. an active attacker could respond with a salt, for which they have a pre-computation, and then recover the client's password from the received hash.

Of course this hypothetical protocol is only a gedankenspiel, an example of why we should not invent our own crypto, and that every solution also bears new problems.

What we really want is an asymmetric Password-Authenticated Key Exchange (aPAKE), of which the most prominent one is SRP. Coincidentally the first versions of SRP also were fraught with serious issues.

With OPAQUE you never send your password to the server, eliminating sniffing and replay attacks, and an attacker has no pre-computation opportunity. Since using OPRF shields the "salt" and the password from an attacker OPAQUE also eliminate direct password leaks. Beyond this OPAQUE has proofs in a very strong model. Another benefit of using OPAQUE is that the memory-hard key-stretching function can run on the client, and thus reduces the attack surface for computational denial-of-service (DoS) vectors against the server. Also notable is that OPAQUE can run over an insecure communication medium, there is no need for TLS or anything else. Another nice feature of OPAQUE is, that the server can return a fake record to mitigate user enumeration attacks, an attacker will not be able to decide if the user exists or not.

How to Authenticate with OPAQUE?

So you want to eliminate the burden of cleartext passwords on your service, and you are willing to go from one message to 3 messages in the authentication dance. Assuming a user is already registered at your service, which might have been done by running the following binary as supplied by libopaque and storing the resulting record at the server:

echo -n password | ./opaque init user server >record 3>/dev/null

The following examples are using the javascript and python wrappers provided by libopaque.

1. The client initiates a credential request

For example using javascript in a web browser:

client.js:

const opaque = require("../dist/libopaque.debug.js");

(async () => {
  await opaque.ready;

  const pwdU = "password";
  let { ctx, pub } = opaque.createCredentialRequest({ pwdU });

  const userid = "user@example.com";
  send_to_server(request, userid); // you need to implement this fn
  ...

The client sends "request" over to the server, and holds onto "ctx" as securely as possible.

2. The server handles the "request" from the client

server.py:

from opaque import CreateCredentialResponse,
                    UserAuth,
                    Ids)

...

# server reads the request from the client
request, userid = get_request() # you need to implement get_request()

# load the record
record = load_record(userid) # you need to implement load_record()

# wrap the IDs into an opaque.Ids struct:
ids=Ids(userid, "servername")

# create a context string
context = "pyopaque-v0.2.0-demo"

# server responds to credential request
response, _, authU = CreateCredentialResponse(request, record, ids, context)

send_to_client(response) # you need to implement send_to_client()

The request is probably read from the network.

The user record that has been created during user registration is loaded probably from disk or a database based on the user id.

By default peers in OPAQUE are identified by their long-term public keys, in case you want to use something else as identifiers, you need to specify them when creating the credential response, in our example we use the userid as provided by the client and "servername".

Also important is to provide some context-string to prevent cross-protocol or downgrade attacks, hence we provide context string.

When creating a credential response the output does not need any kind of extra protection, it is already encrypted and authenticated.

Another output of this functions is a shared key, which is not needed in this case, where we are using OPAQUE only to authenticate.

However the third output, the users expected authentication token is needed by the server in the last step of this protocol.

3. The client recovers its credentials

client.js:

  ...
  const response = read_response(); // you need to implement this function

  const ids = { idU: userid, idS: "servername" };
  const context = "pyopaque-v0.2.0-demo";

  const {sk, authU, export_key,} = opaque.recoverCredentials({resp, ctx, context, ids});

  send_to_server(authU); // you need to implement this fn.
}

The client receives the servers response and uses its private context "ctx" from the first step, to recover its credentials.

The recovery needs the same ids and context string as the server was using.

The result of the recover credentials are:

  • a shared key, which is not needed in case we use OPAQUE only to authenticate,
  • the authentication token of the user, and
  • an export key which is also not needed for authentication-only use of OPAQUE.

Finally client sends the user's authentication token to the server to explicitly authenticate itself to the server. This concludes OPAQUE for the client.

4. The server authenticates the client

server.py:

authU0 = receive_authU() # you need to implement this function
# server authenticates user
if not UserAuth(authU0, authU): raise AuthenticationFailure

The server takes the user authentication token it generated in the second step and compares it to the token it received from the client, if it matches the user is authenticated.

Conclusion

With OPAQUE, you never send your password to the server, so there is nothing that can be sniffed, or replayed, nor can your password leak in any way. The "salt" is also never available to eavesdroppers, which makes pre-computation impossible. Furthermore memory-hard password hashing functions are running on the client, which makes computational denial of service attacks against servers less of a problem. And there is even support to mitigate user enumeration attacks. The only two major problems are phishing attacks, if you get tricked to reveal your password, then it's game over. And offline bruteforce attacks in case a user database leaks, but that should be also more difficult due to the used memory-hard password hashing function used.

All in all OPAQUE is a very efficent protocol with very strong security guarantees which, thanks to libopaque is easy to integrate into you application.

This project was funded through the NGI0 PET Fund, a fund established by NLnet with financial support from the European Commission's Next Generation Internet programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 825310.

permalink


next posts >
< prev post

CC BY-SA RSS Export
Proudly powered by Utterson