diff --git a/README.md b/README.md index c93fb37b..233c6daa 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ authenticator.options = { algorithm: 'sha512', step: 20, digits: 8, + window: 1, crypto }; @@ -239,7 +240,8 @@ import otplib from 'otplib'; // setting otplib.authenticator.options = { - step: 30 + step: 30, + window: 1 }; // getting @@ -251,16 +253,19 @@ otplib.authenticator.resetOptions(); #### Available Options -| Option | Type | Defaults | Description | -| ---------------- | -------- | --------------------------------- | --------------------------------------------------------------------------------------------------- | -| algorithm | string | 'sha1' | Algorithm used for HMAC | -| createHmacSecret | function | hotpSecret, totpSecret | Transforms the secret and applies any modifications like padding to it. | -| crypto | object | node crypto | Crypto module to use. | -| digits | integer | 6 | The length of the token | -| encoding | string | 'ascii' ('hex' for Authenticator) | The encoding of secret which is given to digest | -| epoch (totp) | integer | null | starting time since the UNIX epoch (seconds). _Note_ non-javascript epoch. i.e. `Date.now() / 1000` | -| step (totp) | integer | 30 | Time step (seconds) | -| window (totp) | integer | 0 | Tokens in the previous x-windows that should be considered valid | +| Option | Type | Defaults | Description | +| ---------------- | ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| algorithm | string | 'sha1' | Algorithm used for HMAC | +| createHmacSecret | function | hotpSecret, totpSecret | Transforms the secret and applies any modifications like padding to it. | +| crypto | object | node crypto | Crypto module to use. | +| digits | integer | 6 | The length of the token | +| encoding | string | 'ascii' ('hex' for Authenticator) | The encoding of secret which is given to digest | +| epoch (totp) | integer | null | starting time since the UNIX epoch (seconds). _Note_ non-javascript epoch. i.e. `Date.now() / 1000` | +| step (totp) | integer | 30 | Time step (seconds) | +| window (totp) | integer or array | 0 | Tokens in the previous and future x-windows that should be considered valid. If integer, same value will be used for both. Alternatively, define array: `[previous, future]` | + +_Note 1_: non "totp" label applies to all +_Note 2_: "totp" applies to authenticator as well ### Seed / secret length diff --git a/packages/otplib-authenticator/Authenticator.js b/packages/otplib-authenticator/Authenticator.js index 95ed593f..961bb97a 100644 --- a/packages/otplib-authenticator/Authenticator.js +++ b/packages/otplib-authenticator/Authenticator.js @@ -1,6 +1,7 @@ import totp from 'otplib-totp'; import { secretKey } from 'otplib-utils'; import check from './check'; +import checkDelta from './checkDelta'; import decodeKey from './decodeKey'; import encodeKey from './encodeKey'; import keyuri from './keyuri'; @@ -48,7 +49,8 @@ class Authenticator extends TOTP { return { encoding: 'hex', epoch: null, - step: 30 + step: 30, + window: 0 }; } @@ -112,11 +114,28 @@ class Authenticator extends TOTP { const opt = this.optionsAll; return check(token, secret || opt.secret, opt); } + + /** + * Checks validity of token. + * Returns the delta (window) which token passes. + * Returns null otherwise. + * Passes instance options to underlying core function + * + * @param {string} token + * @param {string} secret + * @return {number | null} + * @see {@link module:impl/authenticator/checkDelta} + */ + checkDelta(token, secret) { + const opt = this.optionsAll; + return checkDelta(token, secret || opt.secret, opt); + } } Authenticator.prototype.Authenticator = Authenticator; Authenticator.prototype.utils = { check, + checkDelta, decodeKey, encodeKey, keyuri, diff --git a/packages/otplib-authenticator/Authenticator.spec.js b/packages/otplib-authenticator/Authenticator.spec.js index ff3329b7..6dd2dc77 100644 --- a/packages/otplib-authenticator/Authenticator.spec.js +++ b/packages/otplib-authenticator/Authenticator.spec.js @@ -1,12 +1,14 @@ import * as utils from 'otplib-utils'; import Authenticator from './Authenticator'; import check from './check'; +import checkDelta from './checkDelta'; import decodeKey from './decodeKey'; import encodeKey from './encodeKey'; import keyuri from './keyuri'; import token from './token'; jest.mock('./check', () => jest.fn()); +jest.mock('./checkDelta', () => jest.fn()); jest.mock('./decodeKey', () => jest.fn()); jest.mock('./encodeKey', () => jest.fn()); jest.mock('./keyuri', () => jest.fn()); @@ -27,6 +29,7 @@ describe('Authenticator', () => { it('exposes authenticator functions as utils', () => { expect(Object.keys(lib.utils)).toEqual([ 'check', + 'checkDelta', 'decodeKey', 'encodeKey', 'keyuri', @@ -39,7 +42,8 @@ describe('Authenticator', () => { expect(options).toEqual({ encoding: 'hex', epoch: null, - step: 30 + step: 30, + window: 0 }); }); @@ -111,6 +115,20 @@ describe('Authenticator', () => { ); }); + it('method: checkDelta => checkDelta', () => { + methodExpectationWithOptions('checkDelta', checkDelta, ['token', 'secret']); + }); + + it('method: checkDelta => checkDelta (fallback to secret in options)', () => { + lib.options = { secret: 'option-secret' }; + methodExpectationWithOptions( + 'checkDelta', + checkDelta, + ['token', null], + ['token', 'option-secret'] + ); + }); + function methodExpectation(methodName, mockFn, args) { mockFn.mockImplementation(() => testValue); diff --git a/packages/otplib-authenticator/check.js b/packages/otplib-authenticator/check.js index 5a57b0fc..c21528ee 100644 --- a/packages/otplib-authenticator/check.js +++ b/packages/otplib-authenticator/check.js @@ -1,5 +1,4 @@ -import { totpCheckWithWindow } from 'otplib-core'; -import decodeKey from './decodeKey'; +import checkDelta from './checkDelta'; /** * Checks the provided OTP token against system generated token @@ -11,7 +10,8 @@ import decodeKey from './decodeKey'; * @return {boolean} */ function check(token, secret, options) { - return totpCheckWithWindow(token, decodeKey(secret), options) >= 0; + const delta = checkDelta(token, secret, options); + return Number.isInteger(delta); } export default check; diff --git a/packages/otplib-authenticator/checkDelta.js b/packages/otplib-authenticator/checkDelta.js new file mode 100644 index 00000000..4be8830b --- /dev/null +++ b/packages/otplib-authenticator/checkDelta.js @@ -0,0 +1,19 @@ +import { totpCheckWithWindow } from 'otplib-core'; +import decodeKey from './decodeKey'; + +/** + * Checks the provided OTP token against system generated token + * Returns the delta (window) which token passes. + * Returns null otherwise. + * + * @module otplib-authenticator/checkDelta + * @param {string} token - the OTP token to check + * @param {string} secret - your secret that is used to generate the token + * @param {object} options - options which was used to generate it originally + * @return {integer | null} + */ +function checkDelta(token, secret, options) { + return totpCheckWithWindow(token, decodeKey(secret), options); +} + +export default checkDelta; diff --git a/packages/otplib-authenticator/check.spec.js b/packages/otplib-authenticator/checkDelta.spec.js similarity index 96% rename from packages/otplib-authenticator/check.spec.js rename to packages/otplib-authenticator/checkDelta.spec.js index a9524bb9..e574886b 100644 --- a/packages/otplib-authenticator/check.spec.js +++ b/packages/otplib-authenticator/checkDelta.spec.js @@ -4,7 +4,7 @@ import decodeKey from './decodeKey'; jest.mock('./decodeKey', () => jest.fn()); -describe('check', () => { +describe('checkDelta', () => { it('should call and return value from totpToken', () => { const token = '123456'; const secret = 'GEZDGNBVGY3TQOJQGEZDG'; diff --git a/packages/otplib-core/totpCheckWithWindow.js b/packages/otplib-core/totpCheckWithWindow.js index fb49e814..6149ef21 100644 --- a/packages/otplib-core/totpCheckWithWindow.js +++ b/packages/otplib-core/totpCheckWithWindow.js @@ -1,34 +1,52 @@ import totpCheck from './totpCheck'; +function createChecker(token, secret, opt) { + const delta = opt.step * 1000; + const epoch = opt.epoch; + + return (direction, start, bounds) => { + for (let i = start; i <= bounds; i++) { + opt.epoch = epoch + direction * i * delta; + + if (totpCheck(token, secret, opt)) { + return i === 0 ? 0 : direction * i; + } + } + return null; + }; +} + +function getWindowBounds(opt) { + const bounds = Array.isArray(opt.window) + ? opt.window + : [parseInt(opt.window, 10), parseInt(opt.window, 10)]; + + if (!Number.isInteger(bounds[0]) || !Number.isInteger(bounds[1])) { + throw new Error( + 'Expecting options.window to be an integer or an array of integers' + ); + } + + return bounds; +} + /** * Checks the provided OTP token against system generated token - * with support for checking previous x time-step windows + * with support for checking previous or future x time-step windows * * @module otplib-core/totpCheckWithWindow * @param {string} token - the OTP token to check * @param {string} secret - your secret that is used to generate the token * @param {object} options - options which was used to generate it originally - * @return {integer} - the number of windows back it was successful. -1 otherwise + * @return {integer | null} - the number of windows back (eg: -1) or forward if it was successful. null otherwise */ function totpCheckWithWindow(token, secret, options) { let opt = Object.assign({}, options); - if (typeof opt.window !== 'number') { - throw new Error('Expecting options.window to be a number'); - } - - const decrement = opt.step * 1000; - const epoch = opt.epoch; - - for (let i = 0; i <= opt.window; i++) { - opt.epoch = epoch - i * decrement; - - if (totpCheck(token, secret, opt)) { - return i; - } - } - - return -1; + const bounds = getWindowBounds(opt); + const checker = createChecker(token, secret, opt); + const backward = checker(-1, 0, bounds[0]); + return backward !== null ? backward : checker(1, 1, bounds[1]); } export default totpCheckWithWindow; diff --git a/packages/otplib-core/totpCheckWithWindow.spec.js b/packages/otplib-core/totpCheckWithWindow.spec.js index 480a64f8..e015d558 100644 --- a/packages/otplib-core/totpCheckWithWindow.spec.js +++ b/packages/otplib-core/totpCheckWithWindow.spec.js @@ -38,56 +38,118 @@ describe('totpCheck', () => { it('should throw an error when opt.window is undefined', () => { expect(() => totpCheckWithWindow('a', 'b', {})).toThrowError( - 'Expecting options.window to be a number' + 'Expecting options.window to be an integer or an array of integers' ); }); it('should call totpCheck 1 time when window is 0', () => { totpCheck.mockImplementation(() => false); - totpCheckWithWindow(token(0), secret, getOptions(1, 0)); - + totpCheckWithWindow(token(0), secret, getOptions(1, [0, 0])); expect(totpCheck).toHaveBeenCalledTimes(1); }); - it('should call totpCheck 2 times when window is 1', () => { + it('(backward) should call totpCheck 2 times when window is -1', () => { totpCheck.mockImplementation(() => false); - totpCheckWithWindow('', secret, getOptions(1, 1)); + totpCheckWithWindow('', secret, getOptions(1, [1, 0])); expect(totpCheck).toHaveBeenCalledTimes(2); }); - it('time 2, window 1, token 0, called 2, return -1', () => { + it('(backward) time 2, window -1, token 0, called 2, return null', () => { totpCheck.mockImplementation((...args) => { return totpCheckOriginal(...args); }); - const result = totpCheckWithWindow(token(0), secret, getOptions(2, 1)); + const result = totpCheckWithWindow(token(0), secret, getOptions(2, [1, 0])); + + expect(result).toBe(null); + expect(totpCheck).toHaveBeenCalledTimes(2); + }); + + it('(backward) time 1, window -1, token 0, called 2, return -1', () => { + totpCheck.mockImplementation((...args) => { + return totpCheckOriginal(...args); + }); + + const result = totpCheckWithWindow(token(0), secret, getOptions(1, [1, 0])); expect(result).toBe(-1); expect(totpCheck).toHaveBeenCalledTimes(2); }); - it('time 1, window 1, token 1, called 2, return 1', () => { + it('(backward) time 2, window -2, token 0, called 3, return -2', () => { totpCheck.mockImplementation((...args) => { return totpCheckOriginal(...args); }); - const result = totpCheckWithWindow(token(0), secret, getOptions(1, 1)); + const result = totpCheckWithWindow(token(0), secret, getOptions(2, [2, 0])); + + expect(result).toBe(-2); + expect(totpCheck).toHaveBeenCalledTimes(3); + }); + + it('(forward) should call totpCheck 2 times when window is 1', () => { + totpCheck.mockImplementation(() => false); + + totpCheckWithWindow('', secret, getOptions(1, [0, 1])); + + expect(totpCheck).toHaveBeenCalledTimes(2); + }); + + it('(forward) time 0, window 1, token 2, called 2, return null', () => { + totpCheck.mockImplementation((...args) => { + return totpCheckOriginal(...args); + }); + + const result = totpCheckWithWindow(token(2), secret, getOptions(0, [0, 1])); + + expect(result).toBe(null); + expect(totpCheck).toHaveBeenCalledTimes(2); + }); + + it('(forward) time 1, window 1, token 2, called 2, return 1', () => { + totpCheck.mockImplementation((...args) => { + return totpCheckOriginal(...args); + }); + + const result = totpCheckWithWindow(token(2), secret, getOptions(1, [0, 1])); expect(result).toBe(1); expect(totpCheck).toHaveBeenCalledTimes(2); }); - it('time 2, window 2, token 0, called 3, return 2', () => { + it('(forward) time 0, window 2, token 2, called 3, return 2', () => { totpCheck.mockImplementation((...args) => { return totpCheckOriginal(...args); }); - const result = totpCheckWithWindow(token(0), secret, getOptions(2, 2)); + const result = totpCheckWithWindow(token(2), secret, getOptions(0, [0, 2])); expect(result).toBe(2); expect(totpCheck).toHaveBeenCalledTimes(3); }); + + it('(both) time 1, window 1, token 2, called 3, return 1', () => { + totpCheck.mockImplementation((...args) => { + return totpCheckOriginal(...args); + }); + + const result = totpCheckWithWindow(token(2), secret, getOptions(1, 1)); + + expect(result).toBe(1); + expect(totpCheck).toHaveBeenCalledTimes(3); + }); + + it('(both) time 1, window 1, token 0, called 2, return 1', () => { + totpCheck.mockImplementation((...args) => { + return totpCheckOriginal(...args); + }); + + const result = totpCheckWithWindow(token(2), secret, getOptions(1, 1)); + + expect(result).toBe(1); + expect(totpCheck).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/otplib-core/totpOptions.js b/packages/otplib-core/totpOptions.js index d3ed852d..aec1368a 100644 --- a/packages/otplib-core/totpOptions.js +++ b/packages/otplib-core/totpOptions.js @@ -15,14 +15,11 @@ const defaultOptions = { * @param {number} options.digits - the output token length * @param {string} options.epoch - starting time since the UNIX epoch (seconds) * @param {number} options.step - time step (seconds) - * @param {number} options.window - acceptable window where codes a valid. Will be rounded down to nearest integer + * @param {number|array} options.window - acceptable window where codes a valid. * @return {object} */ function totpOptions(options = {}) { let opt = Object.assign(hotpOptions(), defaultOptions, options); - - opt.window = Math.floor(opt.window || 0); - opt.epoch = typeof opt.epoch === 'number' ? opt.epoch * 1000 : Date.now(); return opt; diff --git a/packages/otplib-core/totpOptions.spec.js b/packages/otplib-core/totpOptions.spec.js index cec75907..f2f79099 100644 --- a/packages/otplib-core/totpOptions.spec.js +++ b/packages/otplib-core/totpOptions.spec.js @@ -1,5 +1,5 @@ -import totpSecret from './totpSecret'; import totpOptions from './totpOptions'; +import totpSecret from './totpSecret'; describe('totpOptions', () => { const DateNow = global.Date.now; @@ -48,16 +48,4 @@ describe('totpOptions', () => { expect(totpOptions(opt)).toEqual(expected); }); - - it('should return window with rounded down number', () => { - const opt = Object.assign({}, defaults, { - window: 1.5 - }); - - const expected = Object.assign({}, opt, epoch, { - window: 1 - }); - - expect(totpOptions(opt)).toEqual(expected); - }); }); diff --git a/packages/otplib-totp/TOTP.js b/packages/otplib-totp/TOTP.js index 2d129564..2e8cbecd 100644 --- a/packages/otplib-totp/TOTP.js +++ b/packages/otplib-totp/TOTP.js @@ -75,11 +75,26 @@ class TOTP extends HOTP { * @param {string} token * @param {string} secret * @return {boolean} - * @see {@link module:core/totpCheck} */ check(token, secret) { + const delta = this.checkDelta(token, secret); + return Number.isInteger(delta); + } + + /** + * Checks validity of token + * Returns the delta (window) which token passes. + * Returns null otherwise. + * Passes instance options to underlying core function + * + * @param {string} token + * @param {string} secret + * @return {integer | null} + * @see {@link module:core/totpCheckWithWindow} + */ + checkDelta(token, secret) { const opt = this.optionsAll; - return totpCheckWithWindow(token, secret || opt.secret, opt) >= 0; + return totpCheckWithWindow(token, secret || opt.secret, opt); } /** diff --git a/packages/otplib-totp/TOTP.spec.js b/packages/otplib-totp/TOTP.spec.js index 86d9bad3..2d936b26 100644 --- a/packages/otplib-totp/TOTP.spec.js +++ b/packages/otplib-totp/TOTP.spec.js @@ -40,6 +40,25 @@ describe('TOTP', () => { ]); }); + it('method: check calls checkDelta', () => { + const spy = jest.spyOn(lib, 'checkDelta'); + + lib.check('token', 'secret'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('token', 'secret'); + }); + + it('method: checkDelta', () => { + methodExpectation('checkDelta', 'totpCheckWithWindow'); + }); + + it('method: checkDelta => totpCheckWithWindow ', () => { + methodExpectationWithOptions('checkDelta', 'totpCheckWithWindow', [ + 'token', + 'secret' + ]); + }); + it('method: verify', () => { methodExpectation('verify', 'totpCheckWithWindow'); }); @@ -70,6 +89,7 @@ describe('TOTP', () => { expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith('token', 'secret'); }); + function methodExpectation(methodName, coreName) { jest.spyOn(core, coreName).mockImplementation(() => 'result'); diff --git a/site/public/app.js b/site/public/app.js index ac717d97..2e7f60f3 100644 --- a/site/public/app.js +++ b/site/public/app.js @@ -4,6 +4,10 @@ var step = 30; var timing; + otplib.authenticator.options = { + window: 1 + }; + function toggleTabs(evt) { document.querySelectorAll('.tab-item').forEach(function(tab) { tab.classList.remove('is-active'); @@ -75,21 +79,23 @@ .querySelector('.otp-verify-send') .addEventListener('click', function() { var inputValue = document.querySelector('.otp-verify-input').value; - var isValid = otplib.authenticator.check(inputValue, secret); + var delta = otplib.authenticator.checkDelta(inputValue, secret); var text = document.querySelector('.otp-verify-result .text'); var icon = document.querySelector('.otp-verify-result .fa'); - if (isValid) { + var win = '
(window: ' + delta + ')'; + + if (Number.isInteger(delta)) { icon.classList.add('fa-check'); icon.classList.remove('fa-times'); - text.innerHTML = 'Verified token'; + text.innerHTML = 'Verified token' + win; return; } icon.classList.add('fa-times'); icon.classList.remove('fa-check'); - text.innerHTML = 'Cannot verify token.'; + text.innerHTML = 'Cannot verify token'; }); }