'use strict'; var base32 = require('base32.js'); var crypto = require('crypto'); var url = require('url'); var util = require('util'); /** * 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). * @param {String} [options.key] (DEPRECATED. Use `secret` instead.) * Shared secret key * @return {Buffer} The one-time passcode as a buffer. */ exports.digest = function digest (options) { var i; // unpack options var secret = options.secret; var counter = options.counter; var encoding = options.encoding || 'ascii'; var algorithm = (options.algorithm || 'sha1').toLowerCase(); // Backwards compatibility - deprecated if (options.key != null) { console.warn('Speakeasy - Deprecation Notice - Specifying the secret using `key` is no longer supported. Use `secret` instead.'); secret = options.key; } // convert secret to buffer if (!Buffer.isBuffer(secret)) { if (encoding === 'base32') { secret = base32.decode(secret); } secret = new Buffer(secret, encoding); } var secret_buffer_size; if (algorithm === 'sha1') { secret_buffer_size = 20; // 20 bytes } else if (algorithm === 'sha256') { secret_buffer_size = 32; // 32 bytes } else if (algorithm === 'sha512') { secret_buffer_size = 64; // 64 bytes } else { console.warn('Speakeasy - The algorithm provided (`' + algorithm + '`) is not officially supported, results may be different than expected.'); } // The secret for sha1, sha256 and sha512 needs to be a fixed number of bytes for the one-time-password to be calculated correctly // Pad the buffer to the correct size be repeating the secret to the desired length if (secret_buffer_size && secret.length !== secret_buffer_size) { secret = new Buffer(Array(Math.ceil(secret_buffer_size / secret.length) + 1).join(secret.toString('hex')), 'hex').slice(0, secret_buffer_size); } // 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, secret); // update hmac with the counter hmac.update(buf); // return the digest return hmac.digest(); }; /** * Generate a counter-based one-time token. Specify the key and counter, and * receive the one-time password for that counter position as a string. You can * also specify a token length, as well as the encoding (ASCII, hexadecimal, or * base32) and the hashing algorithm to use (SHA1, SHA256, SHA512). * * @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). * @param {String} [options.key] (DEPRECATED. Use `secret` instead.) * Shared secret key * @param {Integer} [options.length=6] (DEPRECATED. Use `digits` instead.) The * number of digits for the one-time passcode. * @return {String} The one-time passcode. */ exports.hotp = function hotpGenerate (options) { // verify secret and counter exists var secret = options.secret; var key = options.key; var counter = options.counter; if (key === null || typeof key === 'undefined') { if (secret === null || typeof secret === 'undefined') { throw new Error('Speakeasy - hotp - Missing secret'); } } if (counter === null || typeof counter === 'undefined') { throw new Error('Speakeasy - hotp - Missing counter'); } // unpack digits // backward compatibility: `length` is also accepted here, but deprecated var digits = (options.digits != null ? options.digits : options.length) || 6; if (options.length != null) console.warn('Speakeasy - Deprecation Notice - Specifying token digits using `length` is no longer supported. Use `digits` instead.'); // 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); }; // Alias counter() for hotp() exports.counter = exports.hotp; /** * Verify a counter-based one-time token against the secret and return the delta. * By default, it verifies the token at the given counter value, with no leeway * (no look-ahead or look-behind). A token validated at the current counter value * will have a delta of 0. * * You can specify a window to add more leeway to the verification process. * Setting the window param will check for the token at the given counter value * as well as `window` tokens ahead (one-sided window). See param for more info. * * `verifyDelta()` will return the delta between the counter value of the token * and the given counter value. For example, if given a counter 5 and a window * 10, `verifyDelta()` will look at tokens from 5 to 15, inclusive. If it finds * it at counter position 7, it will return `{ delta: 2 }`. * * @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=0] 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 (i.e. * `{ delta: 0 }`). * @method hotp․verifyDelta * @global */ exports.hotp.verifyDelta = function hotpVerifyDelta (options) { var i; // shadow options options = Object.create(options); // verify secret and token exist var secret = options.secret; var token = options.token; if (secret === null || typeof secret === 'undefined') throw new Error('Speakeasy - hotp.verifyDelta - Missing secret'); if (token === null || typeof token === 'undefined') throw new Error('Speakeasy - hotp.verifyDelta - Missing token'); // unpack options var token = String(options.token); var digits = parseInt(options.digits, 10) || 6; var window = parseInt(options.window, 10) || 0; var counter = parseInt(options.counter, 10) || 0; // fail if token is not of correct length if (token.length !== digits) { return; } // parse token to integer token = parseInt(token, 10); // fail if token is NA if (isNaN(token)) { return; } // loop from C to C + W inclusive for (i = counter; i <= counter + window; ++i) { options.counter = i; // domain-specific constant-time comparison for integer codes if (parseInt(exports.hotp(options), 10) === token) { // found a matching code, return delta return {delta: i - counter}; } } // no codes have matched }; /** * Verify a counter-based one-time token against the secret and return true if * it verifies. Helper function for `hotp.verifyDelta()`` that returns a boolean * instead of an object. For more on how to use a window with this, see * {@link hotp.verifyDelta}. * * @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=0] 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 {Boolean} Returns true if the token matches within the given * window, false otherwise. * @method hotp․verify * @global */ exports.hotp.verify = function hotpVerify (options) { return exports.hotp.verifyDelta(options) != null; }; /** * Calculate counter value based on given options. A counter value converts a * TOTP time into a counter value by finding the number of time steps that have * passed since the epoch to the current time. * * @param {Object} options * @param {Integer} [options.time] Time in seconds with which to calculate * counter value. Defaults to `Date.now()`. * @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 0 (no offset). * @param {Integer} [options.initial_time=0] (DEPRECATED. Use `epoch` instead.) * Initial time in seconds since the UNIX epoch from which to calculate the * counter value. Defaults to 0 (no offset). * @return {Integer} The calculated counter value. * @private */ exports._counter = function _counter (options) { var step = options.step || 30; var time = options.time != null ? (options.time * 1000) : Date.now(); // also accepts 'initial_time', but deprecated var epoch = (options.epoch != null ? (options.epoch * 1000) : (options.initial_time * 1000)) || 0; if (options.initial_time != null) console.warn('Speakeasy - Deprecation Notice - Specifying the epoch using `initial_time` is no longer supported. Use `epoch` instead.'); return Math.floor((time - epoch) / step / 1000); }; /** * Generate a time-based one-time token. Specify the key, and receive the * one-time password for that time as a string. By default, it uses the current * time and a time step of 30 seconds, so there is a new token every 30 seconds. * You may override the time step and epoch for custom timing. You can also * specify a token length, as well as the encoding (ASCII, hexadecimal, or * base32) and the hashing algorithm to use (SHA1, SHA256, SHA512). * * Under the hood, TOTP calculates the counter value by finding how many time * steps have passed since the epoch, and calls HOTP with that counter value. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {Integer} [options.time] Time in seconds with which to calculate * counter value. Defaults to `Date.now()`. * @param {Integer} [options.step=30] Time step in seconds * @param {Integer} [options.epoch=0] Initial time in seconds since the UNIX * epoch from which to calculate the counter value. Defaults to 0 (no offset). * @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). * @param {String} [options.key] (DEPRECATED. Use `secret` instead.) * Shared secret key * @param {Integer} [options.initial_time=0] (DEPRECATED. Use `epoch` instead.) * Initial time in seconds since the UNIX epoch from which to calculate the * counter value. Defaults to 0 (no offset). * @param {Integer} [options.length=6] (DEPRECATED. Use `digits` instead.) The * number of digits for the one-time passcode. * @return {String} The one-time passcode. */ exports.totp = function totpGenerate (options) { // shadow options options = Object.create(options); // verify secret exists if key is not specified var key = options.key; var secret = options.secret; if (key === null || typeof key === 'undefined') { if (secret === null || typeof secret === 'undefined') { throw new Error('Speakeasy - totp - Missing secret'); } } // calculate default counter value if (options.counter == null) options.counter = exports._counter(options); // pass to hotp return this.hotp(options); }; // Alias time() for totp() exports.time = exports.totp; /** * Verify a time-based one-time token against the secret and return the delta. * By default, it verifies the token at the current time window, with no leeway * (no look-ahead or look-behind). A token validated at the current time window * will have a delta of 0. * * You can specify a window to add more leeway to the verification process. * Setting the window param will check for the token at the given counter value * as well as `window` tokens ahead and `window` tokens behind (two-sided * window). See param for more info. * * `verifyDelta()` will return the delta between the counter value of the token * and the given counter value. For example, if given a time at counter 1000 and * a window of 5, `verifyDelta()` will look at tokens from 995 to 1005, * inclusive. In other words, if the time-step is 30 seconds, it will look at * tokens from 2.5 minutes ago to 2.5 minutes in the future, inclusive. * If it finds it at counter position 1002, it will return `{ delta: 2 }`. * If it finds it at counter position 997, it will return `{ delta: -3 }`. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {String} options.token Passcode to validate * @param {Integer} [options.time] Time in seconds with which to calculate * counter value. Defaults to `Date.now()`. * @param {Integer} [options.step=30] Time step in seconds * @param {Integer} [options.epoch=0] Initial time in seconds since the UNIX * epoch from which to calculate the counter value. Defaults to 0 (no offset). * @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=0] 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 (e.g. * `{ delta: 0 }`). * @method totp․verifyDelta * @global */ exports.totp.verifyDelta = function totpVerifyDelta (options) { // shadow options options = Object.create(options); // verify secret and token exist var secret = options.secret; var token = options.token; if (secret === null || typeof secret === 'undefined') throw new Error('Speakeasy - totp.verifyDelta - Missing secret'); if (token === null || typeof token === 'undefined') throw new Error('Speakeasy - totp.verifyDelta - Missing token'); // unpack options var window = parseInt(options.window, 10) || 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.verifyDelta var delta = exports.hotp.verifyDelta(options); // adjust for two-sided window if (delta) { delta.delta -= window; } return delta; }; /** * Verify a time-based one-time token against the secret and return true if it * verifies. Helper function for verifyDelta() that returns a boolean instead of * an object. For more on how to use a window with this, see * {@link totp.verifyDelta}. * * @param {Object} options * @param {String} options.secret Shared secret key * @param {String} options.token Passcode to validate * @param {Integer} [options.time] Time in seconds with which to calculate * counter value. Defaults to `Date.now()`. * @param {Integer} [options.step=30] Time step in seconds * @param {Integer} [options.epoch=0] Initial time in seconds since the UNIX * epoch from which to calculate the counter value. Defaults to 0 (no offset). * @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=0] 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 {Boolean} Returns true if the token matches within the given * window, false otherwise. * @method totp․verify * @global */ exports.totp.verify = function totpVerify (options) { return exports.totp.verifyDelta(options) != null; }; /** * @typedef GeneratedSecret * @type Object * @property {String} ascii ASCII representation of the secret * @property {String} hex Hex representation of the secret * @property {String} base32 Base32 representation of the secret * @property {String} qr_code_ascii URL for the QR code for the ASCII secret. * @property {String} qr_code_hex URL for the QR code for the hex secret. * @property {String} qr_code_base32 URL for the QR code for the base32 secret. * @property {String} google_auth_qr URL for the Google Authenticator otpauth * URL's QR code. * @property {String} otpauth_url Google Authenticator-compatible otpauth URL. */ /** * Generates a random secret with the set A-Z a-z 0-9 and symbols, of any length * (default 32). Returns the secret key in ASCII, hexadecimal, and base32 format, * along with the URL used for the QR code for Google Authenticator (an otpauth * URL). Use a QR code library to generate a QR code based on the Google * Authenticator URL to obtain a QR code you can scan into the app. * * @param {Object} options * @param {Integer} [options.length=32] Length of the secret * @param {Boolean} [options.symbols=false] Whether to include symbols * @param {Boolean} [options.otpauth_url=true] Whether to output a Google * Authenticator-compatible otpauth:// URL (only returns otpauth:// URL, no * QR code) * @param {String} [options.name] The name to use with Google Authenticator. * @param {Boolean} [options.qr_codes=false] (DEPRECATED. Do not use to prevent * leaking of secret to a third party. Use your own QR code implementation.) * Output QR code URLs for the token. * @param {Boolean} [options.google_auth_qr=false] (DEPRECATED. Do not use to * prevent leaking of secret to a third party. Use your own QR code * implementation.) Output a Google Authenticator otpauth:// QR code URL. * @param {String} [options.issuer=''] The provider or service with which the * secret key is associated. * @return {Object} * @return {GeneratedSecret} The generated secret key. */ exports.generateSecret = function generateSecret (options) { // options if (!options) options = {}; var length = options.length || 32; var name = options.name || 'SecretKey'; var qr_codes = options.qr_codes || false; var google_auth_qr = options.google_auth_qr || false; var otpauth_url = options.otpauth_url != null ? options.otpauth_url : true; var symbols = true; var issuer = options.issuer; // turn off symbols only when explicity told to if (options.symbols !== undefined && options.symbols === false) { symbols = false; } // generate an ascii key var key = this.generateSecretASCII(length, symbols); // return a SecretKey with ascii, hex, and base32 var SecretKey = {}; SecretKey.ascii = key; SecretKey.hex = Buffer(key, 'ascii').toString('hex'); SecretKey.base32 = base32.encode(Buffer(key)).toString().replace(/=/g, ''); // generate some qr codes if requested if (qr_codes) { console.warn('Speakeasy - Deprecation Notice - generateSecret() QR codes are deprecated and no longer supported. Please use your own QR code implementation.'); SecretKey.qr_code_ascii = 'https://chart.googleapis.com/chart?chs=166x166&chld=L|0&cht=qr&chl=' + encodeURIComponent(SecretKey.ascii); SecretKey.qr_code_hex = 'https://chart.googleapis.com/chart?chs=166x166&chld=L|0&cht=qr&chl=' + encodeURIComponent(SecretKey.hex); SecretKey.qr_code_base32 = 'https://chart.googleapis.com/chart?chs=166x166&chld=L|0&cht=qr&chl=' + encodeURIComponent(SecretKey.base32); } // add in the Google Authenticator-compatible otpauth URL if (otpauth_url) { SecretKey.otpauth_url = exports.otpauthURL({ secret: SecretKey.ascii, label: name, issuer: issuer }); } // generate a QR code for use in Google Authenticator if requested if (google_auth_qr) { console.warn('Speakeasy - Deprecation Notice - generateSecret() Google Auth QR code is deprecated and no longer supported. Please use your own QR code implementation.'); SecretKey.google_auth_qr = 'https://chart.googleapis.com/chart?chs=166x166&chld=L|0&cht=qr&chl=' + encodeURIComponent(exports.otpauthURL({ secret: SecretKey.base32, label: name })); } return SecretKey; }; // Backwards compatibility - generate_key is deprecated exports.generate_key = util.deprecate(function (options) { return exports.generateSecret(options); }, 'Speakeasy - Deprecation Notice - `generate_key()` is depreciated, please use `generateSecret()` instead.'); /** * Generates a key of a certain length (default 32) from A-Z, a-z, 0-9, and * symbols (if requested). * * @param {Integer} [length=32] The length of the key. * @param {Boolean} [symbols=false] Whether to include symbols in the key. * @return {String} The generated key. */ exports.generateSecretASCII = function generateSecretASCII (length, symbols) { var bytes = crypto.randomBytes(length || 32); var set = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz'; if (symbols) { set += '!@#$%^&*()<>?/[]{},.:;'; } var output = ''; for (var i = 0, l = bytes.length; i < l; i++) { output += set[Math.floor(bytes[i] / 255.0 * (set.length - 1))]; } return output; }; // Backwards compatibility - generate_key_ascii is deprecated exports.generate_key_ascii = util.deprecate(function (length, symbols) { return exports.generateSecretASCII(length, symbols); }, 'Speakeasy - Deprecation Notice - `generate_key_ascii()` is depreciated, please use `generateSecretASCII()` instead.'); /** * Generate a Google Authenticator-compatible otpauth:// URL for passing the * secret to a mobile device to install the secret. * * 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 {String} options.label Used to identify the account with which * the secret key is associated, e.g. the user's email address. * @param {String} [options.type="totp"] Either "hotp" or "totp". * @param {Integer} [options.counter] The initial counter value, required * for HOTP. * @param {String} [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. * @throws Error if secret or label is missing, or if hotp is used and a counter is missing, if the type is not one of `hotp` or `totp`, if the number of digits is non-numeric, or an invalid period is used. Warns if the number of digits is not either 6 or 8 (though 6 is the only one supported by Google Authenticator), and if the hashihng algorithm is not one of the supported SHA1, SHA256, or SHA512. * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format */ exports.otpauthURL = function otpauthURL (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 || 'sha1').toLowerCase(); var digits = options.digits || 6; var period = options.period || 30; var encoding = options.encoding || 'ascii'; // validate type switch (type) { case 'totp': case 'hotp': break; default: throw new Error('Speakeasy - otpauthURL - Invalid type `' + type + '`; must be `hotp` or `totp`'); } // validate required options if (!secret) throw new Error('Speakeasy - otpauthURL - Missing secret'); if (!label) throw new Error('Speakeasy - otpauthURL - Missing label'); // require counter for HOTP if (type === 'hotp' && (counter === null || typeof counter === 'undefined')) { throw new Error('Speakeasy - otpauthURL - Missing counter value for HOTP'); } // convert secret to base32 if (encoding !== 'base32') secret = new Buffer(secret, encoding); if (Buffer.isBuffer(secret)) secret = base32.encode(secret); // build query while validating var query = {secret: secret}; if (issuer) query.issuer = issuer; if (type === 'hotp') { query.counter = counter; } // validate algorithm if (algorithm != null) { switch (algorithm.toUpperCase()) { case 'SHA1': case 'SHA256': case 'SHA512': break; default: console.warn('Speakeasy - otpauthURL - Warning - Algorithm generally should be SHA1, SHA256, or SHA512'); } query.algorithm = algorithm.toUpperCase(); } // validate digits if (digits != null) { if (isNaN(digits)) { throw new Error('Speakeasy - otpauthURL - Invalid digits `' + digits + '`'); } else { switch (parseInt(digits, 10)) { case 6: case 8: break; default: console.warn('Speakeasy - otpauthURL - Warning - Digits generally should be either 6 or 8'); } } query.digits = digits; } // validate period if (period != null) { period = parseInt(period, 10); if (~~period !== period) { throw new Error('Speakeasy - otpauthURL - Invalid period `' + period + '`'); } query.period = period; } // return url return url.format({ protocol: 'otpauth', slashes: true, hostname: type, pathname: encodeURIComponent(label), query: query }); };