Legacy Authentication

Struggling to incorporate new features into a legacy codebase? Mike shares a way to safely and efficiently bring your existing users along when starting anew.

What happens when your already-successful product needs a few new features and a technology refresh? Starting from a green field in software is desirable because it limits the number of decisions you must consider for your existing technology. So starting from scratch sounds awesome, but what about that customer database? It’s full of the names of people who are eager to take advantage of all that new stuff you’re building. If you had the forethought to decouple authentication from your existing products, then no doubt you’re thrilled with that decision today. If not, you might be wondering how you’re going to deal with all of that data without assuming the risks of trying to duplicate such a sensitive part of your system. There is one option that isn’t copy and paste that lets your new software trust what your old software says about your existing users.

The Components of Trust

Data sent over the internet passes through all sorts of hands that we can’t control, so just because a message from someone’s computer claims to represent an individual doesn’t make it true. If we could find a way to encode that message such that our new system knew it could trust its source, we could accept user-identifying information from another system. Such a message would need to satisfy three conditions:

  1. The encoding details are known only to the sender and recipient.
  2. The encoded result is difficult to reverse.
  3. The resulting message is difficult to reuse.

A Shared Secret

The first piece of this process is a shared piece of knowledge that only the existing system and the new system know about. If that sounds a little like a password, it should, but it’s critical to note the shared secret is never transmitted by the sender. Rather, it is used as an input to the next component of this process to ensure only the sender and the recipient have all of the details to complete the process.

A One-way Function

A one-way function takes whatever data we throw at it and generates a unique output for every possible combination of inputs1. Crucially, the output is nearly impossible to reverse to its inputs on human time scales, which makes it ideal for our second requirement.

A Time Limit

Malicious users wouldn’t need to reverse that encoding if they could just steal the message and reuse it later to impersonate someone else1. Some part of our input must be predictably variable, but produce an unpredictably variable result. One possibility for this is the inclusion of the current date and time in the one-way function. We’ll also use this input separately to verify that the encoded message was created recently enough to trust.

Bringing it All Together

Once a user logs in, just like they always have, add a further step to the existing system that sends a message to the new system that uniquely identifies a user. In order for the new system to trust that message, include the following:

  • The user’s unique ID in plain text
  • The current date and time in plain text
  • The encoded message satisfying our three conditions

A hash-based message authentication code, or HMAC, is a piece of information produced with a one-way function that accommodates the use of a secret key in addition to its inputs. The existing system produces the HMAC by concatenating the user ID and timestamp and passing that, along with the shared secret, to such a function. All three pieces of information are sent to an endpoint in the new system. Before attempting to validate the HMAC, the plain text timestamp will be checked against the current time. If that timestamp is too far in the past (or the future!) the message will be rejected before any math is done. Then it validates the HMAC by attempting to recreate it from the same inputs. It concatenates the user ID and timestamp in the same order as the existing system and uses the same one-way function and secret key to produce what should be an identical HMAC. The subtle key to the timestamp here is that if someone got clever and attempted to bypass the time-bound check by updating the timestamp, the new timestamp would cause the one-way function to produce different output. If the output matches what it received, the new system should then respond with a more permanent authentication token (JSON Web Tokens are really nice). The existing system can then pass along that token to the client to be stored and used to authenticate requests in the new system.

An Example Implementation

/*
  Example of verifying an HMAC sent to an ExpressJS route.

  Generating the HMAC on the other side of this process looks
  precisely the same, only omitting the equality check at the end.
*/

import moment from 'moment';
import crypto from 'crypto';
import express from 'express';

const app = express();

app.post('/auth', (req, res) {
  // Gather request parameters
  const { timestamp, userId, hmac } = req.body;

  // Get HMAC age in seconds
  const tokenAge = moment().subtract(Number.parseInt(timestamp, 10), 'seconds').unix();

  // Unauthorized if token timestamp is more than 30 seconds off system time
  if (Math.abs(tokenAge) > 30) {
    return res.status(401).send();
  }

  // Create HMAC function using a shared secret configured in the process environment
  // Make sure this is set!
  const hmacFunc = crypto.createHmac('sha256', process.env.SHARED_SECRET);

  // Add our HMAC components to the HMAC function's buffer
  hmacFunc.update(timestamp);
  hmacFunc.update(userid);

  // Digest the buffer and store the result a format matching what was received. In our 
  // case, since we received it via HTTP, it's Base64.
  const digest = hmacFunc.digest('base64');

  // Check for equality with the HMAC we received using a timing-safe function
  try { // crypto.timingSafeEqual throws an error if the buffer lengths don't match
    if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmac))) {
      return res.status(401).send();
    }

    // Here you'll do the work of providing a reusable form of authentication, such as a JWT
    return res.status(201).send(/* ??? */);
  } catch (e) { // An error is as good as a failed match
    return res.status(401).send();
  }
}

Stick to the Standards

Whenever we implement something using cryptography, there’s a risk that our naïveté will get us into trouble. There are standard, or at least well-established, libraries for implementing HMACs in just about any language you can think of. Do a little homework, use off-the-shelf cryptography, and sleep better at night knowing the trust your system depends on is the same trust built into thousands of other systems.