Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow forward and backward windows in TOTP and Authenticator #49

Merged
merged 5 commits into from
Apr 21, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ authenticator.options = {
algorithm: 'sha512',
step: 20,
digits: 8,
window: 1,
crypto
};

Expand Down Expand Up @@ -239,7 +240,8 @@ import otplib from 'otplib';

// setting
otplib.authenticator.options = {
step: 30
step: 30,
window: 1
};

// getting
Expand All @@ -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

Expand Down
21 changes: 20 additions & 1 deletion packages/otplib-authenticator/Authenticator.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,7 +49,8 @@ class Authenticator extends TOTP {
return {
encoding: 'hex',
epoch: null,
step: 30
step: 30,
window: 0
};
}

Expand Down Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion packages/otplib-authenticator/Authenticator.spec.js
Original file line number Diff line number Diff line change
@@ -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());
Expand All @@ -27,6 +29,7 @@ describe('Authenticator', () => {
it('exposes authenticator functions as utils', () => {
expect(Object.keys(lib.utils)).toEqual([
'check',
'checkDelta',
'decodeKey',
'encodeKey',
'keyuri',
Expand All @@ -39,7 +42,8 @@ describe('Authenticator', () => {
expect(options).toEqual({
encoding: 'hex',
epoch: null,
step: 30
step: 30,
window: 0
});
});

Expand Down Expand Up @@ -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);

Expand Down
6 changes: 3 additions & 3 deletions packages/otplib-authenticator/check.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
19 changes: 19 additions & 0 deletions packages/otplib-authenticator/checkDelta.js
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
54 changes: 36 additions & 18 deletions packages/otplib-core/totpCheckWithWindow.js
Original file line number Diff line number Diff line change
@@ -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;
84 changes: 73 additions & 11 deletions packages/otplib-core/totpCheckWithWindow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading