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. (Update: The PJCL
the PJCL user forum
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.