passcode
Version:
One-time passcode generator (HOTP/TOTP) with URL generation for Google Authenticator
359 lines (306 loc) • 11.3 kB
JavaScript
/**
* 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
});
};
;