Skip to content

Commit

Permalink
Merge pull request #49 from yeojz/feature/two-directional-windows
Browse files Browse the repository at this point in the history
Allow forward and backward windows in TOTP and Authenticator
  • Loading branch information
yeojz authored Apr 21, 2018
2 parents a5dbf52 + 51f5817 commit b23b3e1
Show file tree
Hide file tree
Showing 13 changed files with 236 additions and 69 deletions.
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

0 comments on commit b23b3e1

Please sign in to comment.