wissel.net

Usability - Productivity - Business - The web - Singapore & Twins

TOTP and vert.x


Time-based one-time passwords (TOTP) are a common security feature in Identity Providers (IdP). There are use cases beyond IdP, mine was "Understanding what it takes").

TOTP interaction

You have two phases: enrollment and use. During enrollment a secret is generated and (typically) presented as QR Code. A user points one of the many Authenticator apps to it and gets a numeric code that changes once a minute.

When you use it, you pick the current number and paste it into the provided field. The backend validates the correctness with some time leeway.

What it is not

Typically when enrolling you also get recovery codes, sometimes called scratch codes. They are NOT part of TOTP and implementation is site specific and not standardized. An implementer might choose to check your recovery codes when your TOTP fails or provide a separate interaction using those.

The initial confirmation, is actually the first instance of "use" and one could have a successful enrollment without it. This is depending on the implementation.

It isn't foolproof. An attacker could trick you into typing your TOTP code into a spoofed form or just hijack your session (cookie). That's why responsible web apps run a tight security with CSP and TLS (and once browser support is better Permission Policy)

Setting up a sample application

We need a landing page and its supporting files (css, js, png) served statically and 3 routes:

  • request
  • save
  • verify

Using the vert.x Router that's a breeze:

public Router router(final Vertx vertx) {

  final Router router = Router.router(vertx);

  router.route().handler(BodyHandler.create());
  router.post("/request")
    .consumes(JSON)
    .produces(JSON)
    .handler(this::requestEnrollment);
  router.post("/save")
    .consumes(JSON)
    .produces(JSON)
    .handler(this::saveEnrollment);
  router.post("/verify")
    .consumes(JSON)
    .produces(JSON)
    .handler(this::verifyCode);
  router.route().handler(StaticHandler.create());

  return router;
}

The consumes and produces methods ensure that the framework would reject e.g a post that's not JSON and will produce the correct Content-Type header.

The static parts: HTML, CSS

The HTML and CSS is pretty simple and follows strict CSP by not polluting it with JavaScript.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="stylesheet" href="index.css" />
    <link rel="icon" type="image/ico" href="favicon.ico" />
    <title>TOTP Demo</title>
  </head>
  <body>
    <header>
      <h1>TOTP Demo</h1>
    </header>
    <div class="container">
      <h2 class="hero">Enroll or Login</h2>
      <section id="enroll">
        <article id="actionpoint">
          <form id="totp">
            <label for="email">eMail:</label>
            <input type="email" id="email" />
            <button id="emailnext">Next</button>
          </form>
        </article>
      </section>
    </div>
    <footer>&copy; 2023 Notessensei</footer>
    <template id="pwaitem">
      <article>
        <a href="#">
          <img width="96px" src="#" alt="" />
          <h2></h2>
          <p></p>
        </a>
      </article>
    </template>
    <script src="index.js" type="application/javascript"></script>
  </body>
</html>

The CSS is simple and can be found in this GIST

Static JavaScript

Since the only JS referenced to in the HTML is index.js we need to ensure all the listening hooks are initialized there. I usually call that function bootstrap and check the document state. If not ready, add it as an eventlistener otherwise just call it

const bootstrap = () => {
  const emailNextButton = document.getElementById('emailnext');

  emailNextButton.addEventListener('click', function (event) {
    event.preventDefault();
    checkUser();
  });
};

if (document.readyState != 'loading') {
  bootstrap();
} else {
  document.addEventListener('DOMContentLoaded', bootstrap);
}

The checkUser function will request a new secret and display the QR Code.

const checkUser = () => {
  const email = document.getElementById('email');
  showQR(email.value);
};

const showQR = (email) => {
  document.body.style.cursor = 'wait';
  fetch('/enroll/request', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ email })
  })
    .then((response) => {
      if (response.ok) {
        return response.json();
      }
      throw new Error(response.statusText);
    })
    .then((json) => {
      const dataUri = json.dataUri;
      const image = document.createElement('img');
      image.setAttribute('src', dataUri);
      image.setAttribute('id', 'totpimg');
      updateForm(image, saveUser, 'Save secret');
    })
    .catch(console.error)
    .finally(() => (document.body.style.cursor = 'default'));
};

There are three more functions updateForm, verifyUser and saveUser which you can view in this GIST

Backend Java Code - requesting a QR Code

Luckily the heavy lifting is provided by Sam Stevens, so generating a secret and the QR is simple

static Map<String, String> secrets = new HashMap<>();

static final String EMAIL = "email";
static final String JSON = "application/json";
static final String CONTENT_TYPE = "Content-Type";

void requestEnrollment(RoutingContext ctx) {
  final JsonObject body = ctx.body().asJsonObject();
  final String eMail = body.getString(EMAIL);
  getQRCode(eMail)
    .onSuccess(dataUri -> {
      JsonObject j = new JsonObject().put("dataUri", dataUri);
      ctx.response().putHeader(CONTENT_TYPE, JSON);
      ctx.end(j.toBuffer());
    })
    .onFailure(err -> endFailed(ctx, err));
}

void endFailed(RoutingContext ctx, Throwable e) {
  JsonObject err = new JsonObject();
  err.put("Status", "failed");
  err.put("Error", e.getMessage());
  ctx.response().putHeader(CONTENT_TYPE, JSON);
  ctx.response().setStatusCode(400).end(err.encodePrettily(), "UTF-8");
}

static Future<String> getQRCode(final String key) {

  Promise<String> promise = Promise.promise();

  try {
      SecretGenerator secretGenerator = new DefaultSecretGenerator(64);
      String secret = secretGenerator.generate();
      QrData data = new QrData.Builder()
              .label(key)
              .secret(secret)
              .issuer("Guardian Service")
              .algorithm(HashingAlgorithm.SHA256)
              .digits(6)
              .period(30)
              .build();
      QrGenerator generator = new ZxingPngQrGenerator();
      byte[] imageData = generator.generate(data);
      String mimeType = generator.getImageMimeType();
      String dataUri = Utils.getDataUriForImage(imageData, mimeType);
      secrets.put(key, secret);
      promise.complete(dataUri);
  } catch (Exception e) {
      promise.fail(e);
  }
  return promise.future();
}

Backend Java Code - verifying

Verification is equally simple to implement.

void verifyCode(RoutingContext ctx) {
    final JsonObject body = ctx.body().asJsonObject();
    final String eMail = body.getString(EMAIL);
    final String code = body.getString("code");
    verify(eMail, code)
          .onSuccess(v -> this.endOK(ctx))
          .onFailure(err -> endFailed(ctx, err));
}

private void endOK(RoutingContext ctx) {
    ctx.response().putHeader(CONTENT_TYPE, JSON);
    ctx.response().end("{ \"Status\" : \"OK\" }", "UTF-8");
}

public static Future<Void> verify(final String key, final String code) {
    Promise<Void> promise = Promise.promise();

    if (secrets.containsKey(key)) {
        final String secret = secrets.get(key);
        TimeProvider timeProvider = new SystemTimeProvider();
        CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA256);
        CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
        boolean successful = verifier.isValidCode(secret, code);
        if (successful) {
            promise.complete();
        } else {
            promise.fail("Code didn't check out");
        }
    } else {
        promise.fail("No secret configured");
    }

    return promise.future();
}

In conclusion

  • Adding TOTP enrollement is easy to achive
  • the hard part (omitted here) is secure storage
  • this is demo code, no persistence, limited error handling

Final warnings

This is demo code, you need to take care of authentication and securing the secrets.
This is not covered here, subject to a story to be told another time

As usual YMMV!


Posted by on 07 February 2023 | Comments (0) | categories: Java vert.x

Comments

  1. No comments yet, be the first to comment