See also the cryptographic authentication page.
Updated as shown below.
At the last Internet Identity Workshop (IIW) we gave a demo of a sample web app that featured cryptographic authentication, and argued that implementing cryptographic authentication is easy. Later, in the blog post Easy, Password-Free, Cryptographic Authentication for Web Applications I discussed the code of the sample web app and said that cryptographic authentication provides a “simple alternative” to authentication with a password. The issues discussed in the post, however, were not simple! Since then we have had to revise the code of the demo several times to fix bugs and, in the process, we have come to realize that cryptographic authentication is not that easy after all. It does not take much code, but it requires a lot of attention to detail to avoid a variety of pitfalls.
In this post I recapitulate the pitfalls that we have encountered (some of which were already discussed in the earlier post) and explain how we avoid them in the latest version of the demo code.
Hopefully, the code is now bug free. If you find any bugs,
please report them through the
contact form or by posting to
the PJCL user forum. (Update: The PJCL
user forum has been discontinued as of May 27, 2018.) The
date of the latest update will be shown in the PJCL
page.
Cryptographic authentication for a web app is simple in principle: the browser generates a key pair and registers the public key with the back-end of the app. As it registers the key pair, the browser proves possession of the private key, to prevent an attacker from registering a victim’s public key. Later, the browser authenticates to the back-end by proving again possession of the private key. The key pair is kept in browser-controlled storage, protected by the same-origin policy of the web enforced by the browser. In the demo code, the browser-controlled storage is the localStorage of the Web Storage API, but it could also be a database accessed through the IndexedDB API, or a FIDO authenticator accessed through the Web Authentication API.
But the devil is in the details.
As explained in the earlier post, the browser proves possession of the private key by signing a challenge received from the back-end (assuming the key pair pertains to a digital signature scheme, DSA in the demo code). This requires two round trips, which are implemented in the demo code using JavaScript redirection. The challenge is included in JavaScript code embedded in a web page with no displayable content, which is returned by the back-end in the HTTP response to a registration or authentication request. The JavaScript code signs the challenge and returns the signature in a subsequent HTTP request (the redirected request) that uses a unique username to reference the user record. (In the demo code the back-end redirects to a welcome page upon successful registration of login, using a HTTP 303 status code; there are thus two consecutive redirections, a JavaScript redirection followed by an HTTP redirection.)
What if something goes wrong and the redirected request is never received by the back-end? If the proof of possession is for authentication, there is no problem: the user can try to log in again later. If the proof of possession is for registration, on the other hand, there is a problem: as the earlier post says, an incomplete user record is left in the back-end database, to be eventually deleted by a garbage collection process. This is inconvenient for the user, because the incomplete record contains the username, and the user cannot register again with the same username until the record has been garbage-collected.
But what if a redirected registration request is received but cannot be processed? This could happen for many reasons, e.g., because redirection times out, or because the back-end database is temporarily unavailable, or because an attacker submits a bogus signature. It is then tempting to delete the incomplete record right away, thus allowing the user to register again with the same username without waiting for the record to be garbage-collected. But here lurks a potential pitfall. Suppose the code that listens for requests received at the registration redirection endpoint deletes the user record referenced in the request if anything goes wrong, without checking whether or not the record is incomplete. An attacker could then delete a victim’s established user account by submitting a request to the endpoint with a bogus signature. To avoid this pitfall the latest version of the demo code leaves the incomplete record in the database as in the case where the redirected request is never received, to be deleted later by a garbage collection process (not implemented in the demo).
There is yet another pitfall in the two-roundtrip registration process. We have seen that, if the listener at the registration redirection endpoint does not verify that the target user record is incomplete, an attacker may submit bogus data in the hope of causing the user record to be deleted. But the attacker may also try to hijack the victim’s account by registering the attacker’s own public key. The fact that the public key registration must be accompanied by a signature on a challenge provides a defense against this, since computing the signature requires knowing the challenge, which may be treated as a shared secret known only to the user’s browser and the back-end.
But a signature on a challenge is also used for authentication at login time. If the same field in the user record is used to store the registration challenge and the login challenge, the attacker may submit a login request with the victim’s username, causing a login challenge to be stored in the challenge field of the victim’s account and sent to the attacker. The attacker can then sign that challenge with the attacker’s private key, and submit the signature along with the attacker’s public key to the registration redirection endpoint. This attack could conceivably succeed even if the listener at the endpoint verifies that registration has not been completed before accepting a request, if the attacker is able to suppress the redirected registration request and obtain a login challenge while the user record is still incomplete.
The solution to this is to use different fields for the registration challenge and the login challenge, at the cost of increasing the size of the user record, and to treat the registration challenge as a secret.
But this solution is complicated by the Firefox bug that I
mentioned in the earlier post. In a JavaScript redirection, the
redirected request may be submitted as a GET HTTP request or a POST
HTTP request. Submitting it as a GET request transmits the
registration challenge in the query string of the request URL, which
may be recorded in the server log at the discretion of IT personnel,
and data included in a server log cannot be considered secret. The
demo code submits it as a POST request to preserve secrecy, but that
triggers the Firefox bug.
Update, May 13, 2018: The registration challenge is not included
in the redirected request, but the username is. The username must be
protected for privacy and security reasons. This precludes
submitting the redirected request as an HTTP GET request, which may result
in the username being recorded in the server log, at the discretion of
IT personnel. The request must therefore be transmitted
as a POST request, and that triggers the Firefox bug.
We looked in more detail at that bug, then reported it to Mozilla, which has just confirmed it. I will have more to say about it in a future post, but for now it suffices to know that, in the demo code, if the user clicks the back button after registration, the bug causes Firefox to replay the code in the JavaScript redirection response. The code generates a new key pair, overwrites the previous one in browser storage, uses the new private key to sign the registration challenge, and submits the signature together with the new public key in the redirected request.
The replacement of the key pair does not matter: it can be viewed as an unnecessary but benign refresh. But, if not mitigated, the replay causes two problems: (i) the user cannot use the back button to go back to the page containing the registration form, nor any pages visited earlier; and (ii) a fresh login session is created after the replayed registration, maintained with a new session cookie, without the user intending it or being aware of it. Replay also occurs similarly if the user clicks the back button after logging in, causing the same two problems.
The latest version of the demo code checks whether the browser is Firefox. If so, it deals with the second problem by detecting the replay and not creating a login session in that case. To detect registration replay, it takes advantage of a registration timestamp set by the back-end, whose primary purpose is to time out the JavaScript redirection in case of a network glitch. The timestamp is deleted after redirection and a subsequent redirected request is deemed a replay if there is no timestamp. Login replay is similarly detected by the absence of a login timestamp.
For additional security, as a matter of defense in depth, if the browser is not Firefox, the registration challenge is deleted from the user record after registration, and the login challenge is deleted after login.
To make it very clear what portions of the code are executed or
skipped depending on whether the browser if Firefox, the former are
bracketed by if (firefox) {...}
and the latter by
if (!firefox) {...}
. Since Firefox aggressively pushes
updates, once the bug has been fixed it may be possible to simplify
the code by omitting the Firefox-only sections and the
bracketing.
The original demo code redirected to a home page instead of a welcome page when a replay was detected. However this does not generalize, as login and/or registration forms may be present in multiple pages; and it did not really solve the problem of the user not being able to go back to earlier pages. The latest version of the demo code redirects to the welcome page both after the initial redirection and any Firefox replays.
To recapitulate: (i) to prevent denial of service, the demo code leaves the incomplete user record in place when something goes wrong during registration; (ii) to prevent account hijacking, different fields of the user record are used for the registration challenge and the login challenge; (iii) if the browser is Firefox, creation of a new login session is avoided if replay of the JavaScript redirection code is detected, both after registration and login; and (iv) for additional security, the registration and login challenges are deleted from the user record after use if the browser is not Firefox.