UNPKG

passcode

Version:

One-time passcode generator (HOTP/TOTP) with URL generation for Google Authenticator

359 lines (306 loc) 11.3 kB
"use strict"; /** * Module dependencies. */ var base32 = require("base32.js"); var crypto = require("crypto"); var url = require("url"); /** * Digest the one-time passcode options. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {Integer} options.counter Counter value * @param {String} [options.encoding="ascii"] Key encoding (ascii, hex, * base32, base64). * @param {String} [options.algorithm="sha1"] Hash algorithm (sha1, sha256, * sha512). * @return {Buffer} The one-time passcode as a buffer. */ exports.digest = function digest (options) { var i; // unpack options var key = options.secret; var counter = options.counter; var encoding = options.encoding || "ascii"; var algorithm = (options.algorithm || "sha1").toLowerCase(); // convert key to buffer if (!Buffer.isBuffer(key)) { key = encoding == "base32" ? base32.decode(key) : new Buffer(key, encoding); } // create an buffer from the counter var buf = new Buffer(8); var tmp = counter; for (i = 0; i < 8; i++) { // mask 0xff over number to get last 8 buf[7 - i] = tmp & 0xff; // shift 8 and get ready to loop over the next batch of 8 tmp = tmp >> 8; } // init hmac with the key var hmac = crypto.createHmac(algorithm, key); // update hmac with the counter hmac.update(buf); // return the digest return hmac.digest(); }; /** * Generate a counter-based one-time passcode. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {Integer} options.counter Counter value * @param {Buffer} [options.digest] Digest, automatically generated by default * @param {Integer} [options.digits=6] The number of digits for the one-time * passcode. * @param {String} [options.encoding="ascii"] Key encoding (ascii, hex, * base32, base64). * @param {String} [options.algorithm="sha1"] Hash algorithm (sha1, sha256, * sha512). * @return {String} The one-time passcode. */ exports.hotp = function hotpGenerate (options) { // unpack options var digits = options.digits || 6; // digest the options var digest = options.digest || exports.digest(options); // compute HOTP offset var offset = digest[digest.length - 1] & 0xf; // calculate binary code (RFC4226 5.4) var code = (digest[offset] & 0x7f) << 24 | (digest[offset + 1] & 0xff) << 16 | (digest[offset + 2] & 0xff) << 8 | (digest[offset + 3] & 0xff); // left-pad code code = new Array(digits + 1).join("0") + code.toString(10); // return length number off digits return code.substr(-digits); }; /** * Verify a counter-based One Time passcode. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {String} options.token Passcode to validate * @param {Integer} options.counter Counter value. This should be stored by * the application and must be incremented for each request. * @param {Integer} [options.digits=6] The number of digits for the one-time * passcode. * @param {Integer} [options.window=50] The allowable margin for the counter. * The function will check "W" codes in the future against the provided * passcode, e.g. if W = 10, and C = 5, this function will check the * passcode against all One Time Passcodes between 5 and 15, inclusive. * @param {String} [options.encoding="ascii"] Key encoding (ascii, hex, * base32, base64). * @param {String} [options.algorithm="sha1"] Hash algorithm (sha1, sha256, * sha512). * @return {Object} On success, returns an object with the counter * difference between the client and the server as the `delta` property. * @method hotp․verify * @global */ exports.hotp.verify = function hotpVerify (options) { var i; // shadow options options = Object.create(options); // unpack options var token = options.token; var window = options.window || 50; var counter = options.counter || 0; // loop from C to C + W for (i = counter; i <= counter + window; ++i) { options.counter = i; if (exports.hotp(options) == token) { // found a matching code, return delta return {delta: i - counter}; } } // no codes have matched }; /** * Calculate counter value based on given options. * * @param {Object} options * @param {Integer} [options.time] Time with which to calculate counter value * @param {Integer} [options.step=30] Time step in seconds * @param {Integer} [options.epoch=0] Initial time since the UNIX epoch from * which to calculate the counter value. Defaults to `Date.now()`. * @return {Integer} The calculated counter value * @private */ exports._counter = function _counter (options) { var step = options.step || 30; var time = options.time != null ? options.time : Date.now(); var epoch = options.epoch || 0; return Math.floor((time - epoch) / step / 1000); }; /** * Generate a time-based one-time passcode. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {Integer} [options.time] Time with which to calculate counter value * @param {Integer} [options.step=30] Time step in seconds * @param {Integer} [options.epoch=0] Initial time since the UNIX epoch from * which to calculate the counter value. Defaults to `Date.now()`. * @param {Integer} [options.counter] Counter value, calculated by default. * @param {Integer} [options.digits=6] The number of digits for the one-time * passcode. * @param {String} [options.encoding="ascii"] Key encoding (ascii, hex, * base32, base64). * @param {String} [options.algorithm="sha1"] Hash algorithm (sha1, sha256, * sha512). * @return {String} The one-time passcode. */ exports.totp = function totpGenerate (options) { // shadow options options = Object.create(options); // calculate default counter value if (options.counter == null) options.counter = exports._counter(options); // pass to hotp return this.hotp(options); }; /** * Verify a time-based One Time passcode. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {String} options.token Passcode to validate * @param {Integer} [options.time] Time with which to calculate counter value * @param {Integer} [options.step=30] Time step in seconds * @param {Integer} [options.epoch=0] Initial time since the UNIX epoch from * which to calculate the counter value. Defaults to `Date.now()`. * @param {Integer} [options.counter] Counter value, calculated by default. * @param {Integer} [options.digits=6] The number of digits for the one-time * passcode. * @param {Integer} [options.window=6] The allowable margin for the counter. * The function will check "W" codes in the future and the past against the * provided passcode, e.g. if W = 5, and C = 1000, this function will check * the passcode against all One Time Passcodes between 995 and 1005, * inclusive. * @param {String} [options.encoding="ascii"] Key encoding (ascii, hex, * base32, base64). * @param {String} [options.algorithm="sha1"] Hash algorithm (sha1, sha256, * sha512). * @return {Object} On success, returns an object with the time step * difference between the client and the server as the `delta` property. * @method totp․verify * @global */ exports.totp.verify = function totpVerify (options) { // shadow options options = Object.create(options); // unpack options var window = options.window != null ? options.window : 0; // calculate default counter value if (options.counter == null) options.counter = exports._counter(options); // adjust for two-sided window options.counter -= window; options.window += window; // pass to hotp.verify return exports.hotp.verify(options); }; /** * Generate an URL for use with the Google Authenticator app. * * Authenticator considers TOTP codes valid for 30 seconds. Additionally, * the app presents 6 digits codes to the user. According to the * documentation, the period and number of digits are currently ignored by * the app. * * To generate a suitable QR Code, pass the generated URL to a QR Code * generator, such as the `qr-image` module. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {Integer} options.label Used to identify the account with which * the secret key is associated, e.g. the user's email address. * @param {Integer} [options.type="totp"] Either "hotp" or "totp". * @param {Integer} [options.counter] The initial counter value, required * for HOTP. * @param {Integer} [options.issuer] The provider or service with which the * secret key is associated. * @param {String} [options.algorithm="sha1"] Hash algorithm (sha1, sha256, * sha512). * @param {Integer} [options.digits=6] The number of digits for the one-time * passcode. Currently ignored by Google Authenticator. * @param {Integer} [options.period=30] The length of time for which a TOTP * code will be valid, in seconds. Currently ignored by Google * Authenticator. * @param {String} [options.encoding] Key encoding (ascii, hex, base32, * base64). If the key is not encoded in Base-32, it will be reencoded. * @return {String} A URL suitable for use with the Google Authenticator. * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format */ exports.url = function (options) { // unpack options var secret = options.secret; var label = options.label; var issuer = options.issuer; var type = (options.type || "totp").toLowerCase(); var counter = options.counter; var algorithm = options.algorithm; var digits = options.digits; var period = options.period; var encoding = options.encoding; // validate type switch (type) { case "totp": case "hotp": break; default: throw new Error("invalid type `" + type + "`"); } // validate required options if (!secret) throw new Error("missing secret"); if (!label) throw new Error("missing label"); // require counter for HOTP if (type == "hotp" && counter == null) { throw new Error("missing counter value for HOTP"); } // build query while validating var query = {secret: secret}; if (options.issuer) query.issuer = options.issuer; // validate algorithm if (algorithm != null) { switch (algorithm.toUpperCase()) { case "SHA1": case "SHA256": case "SHA512": break; default: throw new Error("invalid algorithm `" + algorithm + "`"); } query.algorithm = algorithm.toUpperCase(); } // validate digits if (digits != null) { switch (parseInt(digits, 10)) { case 6: case 8: break; default: throw new Error("invalid digits `" + digits + "`"); } query.digits = digits; } // validate period if (period != null) { if (~~period != period) { throw new Error("invalid period `" + period + "`"); } query.period = period; } // convert secret to base32 if (encoding != "base32") secret = new Buffer(secret, encoding); if (Buffer.isBuffer(secret)) secret = base32.encode(secret); // return url return url.format({ protocol: "otpauth", slashes: true, hostname: type, pathname: label, query: query }); };