Created
September 28, 2020 22:44
-
-
Save zbjornson/9fdbec5675911200f7482121c0dabfa6 to your computer and use it in GitHub Desktop.
Simple TOTP
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//@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: "[email protected]", | |
secret: "JBSWY3DPEHPK3PXP" | |
}); | |
const expected = "otpauth://totp/ACME%20Co:[email protected]?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))); | |
} | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//@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