This is part 1 of a series of posts describing a proof-of-concept web app that implements cryptographic authentication using Node.js, Express, Handlebars, MongoDB and Mongoose. All parts are now available. Part 2 describes the registration process. Part 3 describes login session maintenance. Part 4 is concerned with random bit generation.
Update. The name of the constant securityStrength has been changed to rbgSecurityStrength as noted in the last post of the series and reflected the snippets below.
The PJCL library allows full-stack web developers to use the same cryptographic API on a browser front-end and a Node.js back-end, as explained here. At the last IIW we demoed a web app, implemented using Node.js and Express, that featured cryptographic authentication with a DSA key pair, using PJCL both in the browser to sign a challenge and in the Node.js server to verify the signature. Initial implementations of the app were complicated by having to work around a Firefox bug, which we reported and was confirmed. But eventually we found a simple way of bypassing that bug.
The IIW demo app was very simple. It only had a public “home page” and a private “welcome page”, and it emulated the back-end database using JavaScript objects. We are now releasing a more substantial proof of concept of cryptographic authentication that again uses Node.js and Express, but this time uses a MongoDB database, accessed via a Mongoose driver. Besides using an actual rather than emulated database, the new proof-of-concept app includes features such as on-the-fly login and garbage collection of incomplete user registrations. It also shows how to implement random bit generation with full initial entropy and configurable prediction resistance, which I plan to discuss in another blog post of this series.
The new app is available in a new cryptographic authentication page of the Pomcor site. It is bundled together in a zip file with a simpler app that has the same functionality and the same front-end, but emulates the database using JavaScript objects. The two apps, called app-mongodb.js and app-nodb.js, share the same static files and views. Comparing the two apps may help with understanding the code of the more complex app-mongodb.js. The apps may be run in any Node.js server with access to a MongoDB database and a /dev/random device file, as explained in a README file included in the zip archive.
Node.js is able to handle multiple HTTP requests in parallel within one thread, and thus has an inherent performance advantage over traditional web servers such as Apache that fork a separate operating system process for each request. Realizing this advantage requires performing database operations without blocking the request-handling thread. In this post I will show how the closure feature of JavaScript functional programming facilitates the implementation of non-blocking database access, as I go over the login process of the app-mongodb.js app. In other posts of this series I will go over user registration, login session maintenance, and random bit generation.
The three code snippets in this post show how app-mongodb handles a login request.
There is a small page-top login form at the top of each public page, rendered by the partial view pagetop-login-form.handlebars, which has a single HTML input tag with "username" as the value of the name attribute. (No password is needed to log in.) There is also an on-the-fly login form in a page please-log-in.html rendered by the view please-log-in.handlebars. That form has an additional hidden input with name attribute "destination", whose value is the name of a page to which the browser should be redirected after an on-the-fly login. Trying to visit a private page such as /private-page-1.html while not logged in causes a redirection to /please-log-in.html?destination=private-page-1, which results in the user being presented with the on-the-fly login form, where the hidden input has the value "private-page-1".
All login forms have an action attribute that targets the URL /check-username. The snippet below shows how a request that targets that URL is processed by the server.
app.post('/check-username',function(req,res) { var username = req.body.username; var destination = req.body.destination || "private-page-1"; if (destination.search(/^[-A-Za-z0-9]+$/) == -1) { res.redirect(303, "/authentication-failure.html"); return; } if (username.search(/^[A-Za-z0-9]+$/) == -1) { res.redirect(303, "/username-not-found.html"); return; } User.findOne({username: username}).exec(function(err, user) { if (err) throw new Error(err); if (!user) { res.redirect(303, "/username-not-found.html"); return; } var entropyHex = pjclBitArray2Hex(pjclRBGGen(rbgStateObject,rbgSecurityStrength,rbgSecurityStrength)); var challengeHex = pjclBitArray2Hex(pjclRBGGen(rbgStateObject,rbgSecurityStrength,rbgSecurityStrength)); user.loginChallenge = challengeHex; user.loginTimeStamp = new Date().getTime(); user.save(function(err) { if (err) throw new Error(err); res.render("login-redir.handlebars", { entropyHex: entropyHex, challengeHex: challengeHex, username: username, destination: destination }); }); }); });
When Node.js receives an HTTP POST request that targets /check-username, it invokes the callback function whose definition begins at line 1 of the snippet and ends at line 32 (the “line-1 callback”), passing a request object as the value of the parameter req and a response object as the value of res. At lines 2–11, the callback function gets the values of the inputs username and (if present) destination, and validates that they only contain letters, digits and hyphens. (JavaScript has already validated the username on the browser, but server-side validation is needed for security.)
At line 12 the line-1 callback issues a query for a user record where the value of the username field is the username obtained at line 2, and binds a callback (the “line-12 callback”) to the query. The query is issued by the Mongoose driver against the MongoDB users collection. Mongoose derives the collection name from the name of the User model by converting it to lower case and “pluralizing” it.
The line-1 callback returns immediately after issuing the query. The line-12 callback is invoked later, after the query has been run against the database. The callback is passed references to an error object and a user object, which are assigned to the err and the user parameters; either reference may be null.
No error is expected in normal processing, so if it is passed an error the line-12 callback throws an exception. Behind the scenes, the exception is caught by Express and the error object of the exception is passed to the callback argument of the very last app.use invocation in the app-mongodb.js file (not shown in the snippet). (The callback of the last app.use is identified as an error-handling callback by the fact that it is defined as having four parameters, whereas other middleware and route callbacks only have two or three parameters.)
If there is no error but no user record has been found, the line-12 callback invokes the res.redirect method of the response object to indicate that a 303 HTTP response should be used to redirect the browser to the username-not-found.html page (which results from the server-side rendering of the view username-not-found.handlebars). The invocation of the res.redirect method does not alter the flow of control, so it must be followed by a return statement (or an else branch), whereas no return is of course needed after the exception is thrown at line 13.
What is res in line 15? It is neither a global variable nor a parameter or local variable of the line-12 callback where it occurs. It is a reference to the res parameter of the line-1 callback, which is part of the closure of the line-12 callback because the line-12 callback is defined within the line-1 callback. The line-12 callback is thus able to reference a parameter of the line-1 callback after the execution of the line-1 callback has ended, and is able to respond to the /check-username request handled by the app.post method of line 1 after that method is done with that request. Other requests may have been processed in the meantime, thus achieving request-handling parallelism within a single thread.
Finally, if a user record has been found (a “document” in MongoDB terminology), an instance of the “User” model referencing that record is assigned to the “user” parameter of the line-12 callback. Two random hex strings, entropyHex and challengeHex, are generated, in a manner to be discussed in another post of this series, with the purpose of conveying server entropy and a server login challenge to the browser. The login challenge and a login timestamp are assigned to properties of the user model instance, which will become fields of the user record if the model instance is successfully saved to the database.
The user.save method of line 22 initiates a save transaction and returns after associating a callback with the transaction. After the transaction has been executed on the database the line-22 callback is called and, if there is no error, responds to the /check-username request by rendering the view login-redir.handlebars, filling in the server entropy, server challenge, username and on-the-fly-login destination (if present in the /check-username request) as values of view variables. Notice how entropyHex and challengeHex are local variables of the line-12 callback, accessed by the line-22 callback after execution of the line-12 callback has ended, while username and destination are local variables of the line-1 callback, accessed by the line-22 callback after execution of the line-1 callback has ended, all four variables being in the closure of the line-22 callback.
If a user record is found, the response to the /check-username request is a JavaScript POST redirection whose purpose is to convey the challenge to be signed by the browser. By a JavaScript POST redirection I mean an HTTP 200 response with no content other than a script that submits a follow-up HTTP POST request. In this case the response is the login-redir.handlebars view, shown in the following snippet.
<script src="/pjcl.js"></script> <script src="/browser-entropy.js"></script> <script src="/domain-parameters.js"></script> <script> var serverEntropy = "{{entropyHex}}"; var challenge = "{{challengeHex}}"; var username = "{{username}}"; var destination = "{{destination}}"; // the following function is derived from: // https://stackoverflow.com/questions/133925/javascript-post-request-like-a-form-submit function post(path, params) { var form = document.createElement("form"); form.setAttribute("method", "post"); form.setAttribute("action", path); for(var key in params) { if(params.hasOwnProperty(key)) { var hiddenField = document.createElement("input"); hiddenField.setAttribute("type", "hidden"); hiddenField.setAttribute("name", key); hiddenField.setAttribute("value", params[key]); form.appendChild(hiddenField); } } document.body.appendChild(form); // form.submit(); window.onload = function () { form.submit(); }; // to bypass the Firefox bug } var entropy = pjclBrowserEntropy128Bits().concat(pjclHex2BitArray(serverEntropy)); pjclRBG128InstantiateOrReseed(localStorage,entropy); var p = pjclHex2BigInt(pHex); var q = pjclHex2BigInt(qHex); var g = pjclHex2BigInt(gHex); var privKeyHex = localStorage.privKey; var pubKeyHex = localStorage.pubKey; if (!privKeyHex) { location.replace("/credential-not-found.html"); } var x = pjclHex2BigInt(privKeyHex); var msg = pjclHex2BitArray(challenge); var signature = pjclDSASignMsg(localStorage,p,q,g,x,msg); var sigRHex= pjclBigInt2Hex(signature.r); var sigSHex= pjclBigInt2Hex(signature.s); post("/verify-signature", { username: username, publicKey: pubKeyHex, signatureR: sigRHex, signatureS: sigSHex, destination: destination }); </script>
Lines 1–3 of the response source three JavaScript files included in the static directory of the zip file: pjcl.js, which is the current version of the JavaScript library (version 0.9.1 revision 2); browser-entropy.js file, which comes with the PJCL library and uses crypto.getRandomValues (or mscrypto.getRandomValue in the case of Internet Explorer) to obtain entropy from the browser (this file is named browserEntropy.js in the current version of the library, but will be renamed in a future version); and the file domain-parameters.js, which contains the finite field cryptography (FFC) domain parameters (p,q,g) used by the application for computing and verifying DSA signatures.
Lines 5–8 assign the values of the view variables to script variables. In particular, the value of the view variable challengeHex is assigned to the script variable challenge. This is how the challenge is conveyed from the server to the browser.
Lines 12–28 define the general purpose function post, borrowed with a slight modification from this Stack Overflow discussion, which can be used by a script to submit an HTTP post request. The function creates a form element in the DOM, appends it as a child to the body element (which is necessary for some reason), and submits it. The original function in the Stack Overflow discussion simply uses form.submit() to submit the form. This works fine when implementing POST redirection in Chrome, Safari, Edge and IE, but causes Firefox to replay the POST redirection if the user clicks the back button, with disastrous consequences. We have been able to bypass this Firefox bug by postponing the submission of the form to the window.onload event handler, as discussed here.
Lines 30–31 combine browser and server entropy to initialize (“instantiate” in NIST terminology) or reseed a random bit generator (RBG) for the web app in localStorage. Random bit generation will be discussed in more detail in another post of this series.
Lines 32–34 convert the domain parameters, encoded as hex strings in domain-parameters.js, to the big integer format used in the PJCL library.
Lines 35–40 read the DSA key pair from localStorage, redirecting to a credential-not-found error page if the key pair is missing, and converting the private key to big integer format if it is found. The key pair is placed in localStorage by the registration process, to be described in the next post of the series, and can only be accessed by scripts having the same web origin as the registration process. For the sake of simplicity, this proof of concept assumes that there is only one web app per web origin, or, if there are multiple apps, that they share the same key-pair user credentials.
Lines 41–44 sign the challenge with the private key, and convert the (r,s) components of the DSA signature to hex strings.
Finally, at lines 45–51, the post function is used to send to the /verify-signature endpoint of the server the username, the public key, the components of the signature, and the on-the-fly-login destination if present in the /check-username request. It is not strictly necessary to send the public key, which has previously been sent to the server during registration and stored by the server in a public-key field of the user record. The purpose of sending it again here is to allow the server to omit the computationally expensive verification of the signature if an adversary submits a request to the /verify-signature endpoint without knowing the public key.
The third snippet shows how the signature is verified by the callback whose definition begins at line 1 of the snippet. After obtaining and validating the inputs submitted by the browser at lines 2–16, the line-1 callback issues a query for a user record with the submitted username and returns after binding the callback of line 17 to the query.
app.post('/verify-signature',function(req,res) { var username = req.body.username; var pubKeyHex = req.body.publicKey; var sigRHex = req.body.signatureR; var sigSHex = req.body.signatureS; var destination = req.body.destination; if ( username.search(/^[A-Za-z0-9]+$/) == -1 || pubKeyHex.search(/^[A-Fa-f0-9]+$/) == -1 || sigRHex.search(/^[A-Fa-f0-9]+$/) == -1 || sigSHex.search(/^[A-Fa-f0-9]+$/) == -1 || destination.search(/^[-A-Za-z0-9]+$/) == -1 ) { res.redirect(303, "/authentication-failure.html"); return; } User.findOne({username: username}).select('loginTimeStamp loginChallenge publicKey').exec(function(err, user) { if (err) throw new Error(err); if (!user) { res.redirect(303, "/authentication-failure.html"); return; } var now = (new Date()).getTime(); if (!user.loginTimeStamp || (now - user.loginTimeStamp > loginRedirTimeout)) { res.redirect(303, "/authentication-failure.html"); return; } var challengeHex = user.loginChallenge; if (!challengeHex) { res.redirect(303, "/authentication-failure.html"); return; } if (pubKeyHex != user.publicKey) { res.redirect(303, "/authentication-failure.html"); return; } var p = pjclHex2BigInt(pHex); var q = pjclHex2BigInt(qHex); var g = pjclHex2BigInt(gHex); var y = pjclHex2BigInt(pubKeyHex); var msg = pjclHex2BitArray(user.loginChallenge); var r = pjclHex2BigInt(sigRHex); var s = pjclHex2BigInt(sigSHex); if (!pjclDSAVerifyMsg(p,q,g,y,msg,r,s)) { res.redirect(303, "/invalid-credential.html"); return; } user.loginChallenge = ""; user.save(function (err) { if (err) throw new Error(err); var sessionId = pjclBitArray2Hex(pjclRBGGen(rbgStateObject,rbgSecurityStrength,rbgSecurityStrength)); var session = new Session({ sessionId: sessionId, username: username, timeStamp: new Date().getTime() }); session.save(function (err) { if (err) throw new Error(err); res.cookie('session', sessionId, {httpOnly: true, secure: true}); res.redirect(303, `/${destination}.html`); }); }); }); });
Once the query has been processed, the line-1 callback is called and may be passed an error or a user model instance. The callback throws an exception if there has been a database error, or redirects to an authentication-failure error page if no user record has been found. It also redirects to the authentication-failure page if a user record is found but does not have a login timestamp, or has a login timestemp older than a login redirection timeout, or does not have a login challenge; or if the public key received from the browser does not agree with the contents of the public-key field of the user record.
If none of this happens, at lines 37–47 the line-17 callback verifies the signature received from the browser on the login challenge found in the user record, using the public key and the domain parameters of the web app. If verification fails, it redirects to an invalid-credential error page. If verification succeeds, it initiates deletion of the login challenge by assigning the empty string to the user model instance and submitting a user.save transaction to the database. Deleting the login challenge is a defense-in-depth precaution against replay.
The line-17 callback returns immediately, and the callback of line 49 is called when the user.save transaction has been processed. An exception is thrown if a database error is reported. Otherwise the line-49 callback creates a login session and sets a cookie with the session ID. This will be discussed in another post of this series concerned with session maintenance.