Skip to content

Instantly share code, notes, and snippets.

@zbjornson
Created September 28, 2020 22:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zbjornson/9fdbec5675911200f7482121c0dabfa6 to your computer and use it in GitHub Desktop.
Save zbjornson/9fdbec5675911200f7482121c0dabfa6 to your computer and use it in GitHub Desktop.
Simple TOTP
//@ts-check
"use strict";
const TOTP = require("../../app/util/totp.js");
const assert = require("chai").assert;
describe("TOTP", function () {
describe("verify", function () {
it("works according to RFC6238 test vectors", function () {
// From https://www.rfc-editor.org/rfc/rfc6238.html#appendix-B
const tests = [
{date: 59, mode: "sha1", totp: "94287082"},
{date: 59, mode: "sha256", totp: "46119246"},
{date: 59, mode: "sha512", totp: "90693936"},
{date: 1111111109, mode: "sha1", totp: "07081804"},
{date: 1111111109, mode: "sha256", totp: "68084774"},
{date: 1111111109, mode: "sha512", totp: "25091201"},
{date: 1111111111, mode: "sha1", totp: "14050471"},
{date: 1111111111, mode: "sha256", totp: "67062674"},
{date: 1111111111, mode: "sha512", totp: "99943326"},
{date: 1234567890, mode: "sha1", totp: "89005924"},
{date: 1234567890, mode: "sha256", totp: "91819424"},
{date: 1234567890, mode: "sha512", totp: "93441116"},
{date: 2000000000, mode: "sha1", totp: "69279037"},
{date: 2000000000, mode: "sha256", totp: "90698825"},
{date: 2000000000, mode: "sha512", totp: "38618901"},
{date: 20000000000, mode: "sha1", totp: "65353130"},
{date: 20000000000, mode: "sha256", totp: "77737706"},
{date: 20000000000, mode: "sha512", totp: "47863826"}
];
const keys = {
sha1: "12345678901234567890",
sha256: "12345678901234567890123456789012",
sha512: "1234567890123456789012345678901234567890123456789012345678901234"
};
for (const {date, mode, totp} of tests) {
const m = (/** @type {"sha1"|"sha256"|"sha512"} */(mode));
assert.ok(TOTP.verify(totp, keys[mode], date * 1000, 0, m));
}
});
});
describe("formatURL", function () {
it("works", function () {
const actual = TOTP.formatURL({
issuer: "ACME Co",
accountName: "alice@google.com",
secret: "JBSWY3DPEHPK3PXP"
});
const expected = "otpauth://totp/ACME%20Co:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=sha1&digits=6";
assert.equal(actual, expected);
});
});
describe("base32encode", function () {
it("works according to RFC4648 test vectors", function () {
// From https://tools.ietf.org/html/rfc4648#section-10
const b32tests = [
{in: "", out: ""},
{in: "f", out: "MY"},
{in: "fo", out: "MZXQ"},
{in: "foo", out: "MZXW6"},
{in: "foob", out: "MZXW6YQ"},
{in: "fooba", out: "MZXW6YTB"},
{in: "foobar", out: "MZXW6YTBOI"}
];
for (const test of b32tests) {
assert.equal(test.out, TOTP.base32encode(Buffer.from(test.in)));
}
});
});
});
//@ts-check
"use strict";
/**
* @module TOTP
*
* Functions to generate and verify time-based one-time passwords according to
* [RFC 6238](https://www.rfc-editor.org/rfc/inline-errata/rfc6238.html).
*
* Notes:
*
* * Key length: NIST mandates at least 112 bits, RFC says it should be the same
* length as the HMAC.
*
* * Key protection: NIST says keys should be "strongly protected against
* compromise". RFC says "We also RECOMMEND storing the keys securely in the
* validation system, and, more specifically, encrypting them using
* tamper-resistant hardware encryption and exposing them only when required."
*
* * NIST: "OTP authenticators — particularly software-based OTP generators —
* SHOULD discourage and SHALL NOT facilitate the cloning of the secret key
* onto multiple devices." (Something to document since some support this.)
*
* * Google Authenticator only supports SHA1, 6 digits and 30s time step.
*
* * NIST: "verifiers SHALL accept a given time-based OTP only once during the
* validity period." This has to be enforced upstream.
*/
module.exports = {
generate,
verify,
formatURL,
base32encode
};
const crypto = require("crypto");
/**
* Milliseconds. RFC 6238 RECOMMENDs a default of 30s. NIST says it SHALL be
* less than 2m.
*/
const timeStep = 30000;
/** This is the only one supported by Google Authenticator. */
const DEFAULT_SHA = "sha1";
/**
* Generates the TOTP for the given key, time, digits and method.
* @param {string|Buffer|Uint8Array|import("crypto").KeyObject} key
* @param {number} time milliseconds since UNIX epoch
* @param {6|7|8} digits
* @param {"sha1"|"sha256"|"sha512"} method
*/
function generate(key, time = Date.now(), digits = 6, method = "sha1") {
const T = time / timeStep | 0;
return generate_(key, T, digits, method);
}
/**
* Verifies the given TOTP. Also returns `false` if input is not a string.
* @param {string} totp
* @param {string|Buffer|Uint8Array|import("crypto").KeyObject} key
* @param {number} time milliseconds
* @param {number} window Number of steps to allow in the past. RFC 6238
* RECOMMENDs at most one time step.
* @param {"sha1"|"sha256"|"sha512"} method
*/
function verify(totp, key, time = Date.now(), window = 1, method = DEFAULT_SHA) {
if (typeof totp !== "string" || totp.length < 6 || totp.length > 8)
return false;
let T = time / timeStep | 0;
const Tmin = T - window;
while (T >= Tmin) {
if (totp === generate_(key, T, totp.length, method))
return true;
T--;
}
return false;
}
/**
* @param {string|Buffer|Uint8Array|import("crypto").KeyObject} key
* @param {number} T steps
* @param {number} digits
* @param {"sha1"|"sha256"|"sha512"} method
*/
function generate_(key, T, digits, method) {
const Tbytes = Buffer.alloc(8);
Tbytes.writeUInt32BE(T, 4);
const hash = crypto.createHmac(method, key).update(Tbytes).digest();
const offset = hash[hash.length - 1] & 0xf;
const binary = hash.readUInt32BE(offset) & 0x7fffffff;
const otp = binary % 10 ** digits;
return otp.toString().padStart(digits, "0");
}
/**
* Creates an [otpauth:// URL](https://github.com/google/google-authenticator/wiki/Key-Uri-Format).
* @param {Object} opts
* @param {string} opts.issuer "Big Corporation"
* @param {string} opts.accountName email
* @param {string} opts.secret [base32](https://tools.ietf.org/html/rfc4648#section-6)-encoded
* @param {"sha1"|"sha256"|"sha512"} [opts.algorithm]
* @param {6|7|8} [opts.digits]
*/
function formatURL({issuer, accountName, secret, algorithm = DEFAULT_SHA, digits = 6}) {
issuer = encodeURIComponent(issuer);
return `otpauth://totp/${issuer}:${accountName}?` +
`secret=${secret}&issuer=${issuer}&algorithm=${algorithm}&digits=${digits}`;
}
/**
* Adapted from https://github.com/google/google-authenticator-libpam/blob/master/src/base32.c.
* Does not pad output with '='.
* @param {Uint8Array} data
*/
function base32encode(data) {
if (!(data instanceof Uint8Array))
throw new Error("Data must be a Uint8Array");
const length = data.length;
if (length < 0 || length > 512)
throw new Error("Invalid data length");
let result = "";
if (length > 0) {
let buffer = data[0];
let next = 1;
let bitsLeft = 8;
while (bitsLeft > 0 || next < length) {
if (bitsLeft < 5) {
if (next < length) {
buffer <<= 8;
buffer |= data[next++] & 0xFF;
bitsLeft += 8;
} else {
const pad = 5 - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
const index = 0x1F & (buffer >>> (bitsLeft - 5));
bitsLeft -= 5;
result += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[index];
}
}
return result;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment