From 0c9174fe16ab52c0fb3a13cf1a6f1a38f285046c Mon Sep 17 00:00:00 2001 From: Gerald Yeo Date: Sat, 17 Aug 2019 01:04:44 +0800 Subject: [PATCH] feat: logic fixes and generalise ts defs fix: error in window bound logic chore: copy command fix feat: split out digest token conversion feat: simplify implmentation of clone and create feat: generic passing for all methods --- README.md | 145 +++++++---- .../otplib-authenticator/authenticator.ts | 122 +++++----- packages/otplib-hotp/hotp.ts | 163 +++++++------ packages/otplib-plugin-crypto-js/index.ts | 4 +- packages/otplib-totp/totp.test.ts | 24 +- packages/otplib-totp/totp.ts | 226 +++++++++++------- packages/tests-suites/plugin-base32.ts | 2 +- packages/tests-suites/preset.ts | 37 +++ scripts/build-site.sh | 4 +- 9 files changed, 433 insertions(+), 294 deletions(-) diff --git a/README.md b/README.md index aebd5374..a3528b34 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ and includes additional methods to allow you to work with Google Authenticator. - [x] Presets provided - `default (node)` - `browser` - - `v11 (legacy)` + - `v11 (legacy/backport)` ## Quick Start @@ -90,7 +90,7 @@ npm install otplib thirty-two import { authenticator } from 'otplib/preset-default'; const secret = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD'; -// Alternatively: const secret = authenticator.generateSecret(); +// Alternative: const secret = authenticator.generateSecret(); const token = otplib.authenticator.generate(secret); @@ -99,18 +99,17 @@ try { // or const isValid = otplib.authenticator.verify({ token, secret }); } catch (err) { - // Error possibly thrown by the thirty-two package - // 'Invalid input - it is not base32 encoded string' + // Possible errors + // - options validation + // - "Invalid input - it is not base32 encoded string" (if thiry-two is used) console.error(err); } ``` ### In Browser -The browser preset is a self contained module, with `Buffer` split out as an external dependency. - -As such, there are 2 scripts required. The `preset-browser/index.js` script -as well as `preset-browser/buffer.js`. +The browser preset is a self contained `umd` module with `Buffer` split out as an external dependency. +As such, there are 2 scripts required: `preset-browser/index.js` and `preset-browser/buffer.js`. ```html @@ -123,7 +122,7 @@ as well as `preset-browser/buffer.js`. ``` -The `buffer.js` provided in by this library is a cached copy +The `buffer.js` provided by this library is a cached copy from [https://www.npmjs.com/package/buffer][link-npm-buffer]. You can also download and include the latest version via their project page. @@ -136,11 +135,29 @@ especially before making any major upgrades. Check out the release notes associated with each tagged versions in the [releases](https://github.com/yeojz/otplib/releases) page. +If you're coming from `v11.x`, a preset with available with classes wrapped to provide methods +that behave like `v11.x` of `otplib`. + +```js +// Update +import { authenticator } from 'otplib'; // v11.x +// to +import { authenticator } from 'otplib/preset-v11'; + +// There should be no changes to your current code. +// However, deprecated or modified class methods will have console.warn. +``` + ## Getting Started -This is a more in-depth setup which has more customisation steps. +This is a more in-depth setup guide which includes steps for customising your +dependencies. Check out the [Quick Start][docs-quick-start] if you do need or want +to select your own dependencies. -Check out the [Quick Start][docs-quick-start] if you do not need such customisation. +Other References: + +- [API Documentation][project-api] +- [Demo Website][project-web] ### Install the Package @@ -156,9 +173,13 @@ The crypto modules are used to generate the digest used to derive the OTP tokens By default, Node.js has inbuilt `crypto` functionality, but you might want to replace it for certain environments that do not support it. +Currently out-of-the-box, there are some [Crypto Plugins][docs-plugins-crypto] included. +Install the dependencies for one of them. + ```bash -# choose either node crypto -# (you don't need to install anything else) +# Choose either +# Node.js crypto (you don't need to install anything else - http://nodejs.org/api/crypto.html) + # or npm install crypto-js ``` @@ -168,12 +189,13 @@ npm install crypto-js If you're using Google Authenticator, you'll need a base32 module for encoding and decoding your secrets. -Currently out-of-the-box, there are already some plugins included. +Currently out-of-the-box, there are some [Base32 Plugins][docs-plugins-base32] included. Install the dependencies for one of them. ```bash -# choose either +# Choose either npm install thirty-two + # or npm install base32-encode base32-decode ``` @@ -187,15 +209,15 @@ import { HOTP, TOTP, Authenticator } from 'otplib'; // Base32 Plugin // for thirty-two -import { keyDecoder, keyEncoder } from 'otplib/base32/thirty-two'; +import { keyDecoder, keyEncoder } from 'otplib/plugin-thirty-two'; // for base32-encode and base32-decode -import { keyDecoder, keyEncoder } from 'otplib/base32/base32-endec'; +import { keyDecoder, keyEncoder } from 'otplib/plugin-base32-enc-dec'; // Crypto Plugin // for node crypto -import { createDigest, createRandomBytes } from 'otplib-plugin-crypto'; +import { createDigest, createRandomBytes } from 'otplib/plugin-crypto'; // for crypto-js -import { createDigest, createRandomBytes } from 'otplib-plugin-crypto-js'; +import { createDigest, createRandomBytes } from 'otplib/plugin-crypto-js'; // Setup an OTP instance which you need const hotp = new HOTP({ createDigest }); @@ -219,9 +241,14 @@ Alternatively, if you are using the functions directly instead of the classes, pass these as options into the functions. ```js -import { hotpOptions, hotpToken } from 'otplib/hotp'; -import { totpOptions, totpToken } from 'otplib/totp'; -import { authenticatorOptions, authenticatorToken } from 'otplib/authenticator'; +import { + hotpOptions, + hotpToken, + totpOptions, + totpToken, + authenticatorOptions, + authenticatorToken +} from 'otplib/core'; // As with classes, import your desired Base32 Plugin and Crypto Plugin. // import ... @@ -264,11 +291,11 @@ const token = authenticatorToken(YOUR_SECRET, authenticatorOptions({ > Note: Includes all HOTP Options -| Option | Type | Description | -| ------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| epoch | integer | Starting time since the UNIX epoch (seconds).
epoch format is javascript. i.e. `Date.now()` or `UNIX time * 1000` | -| step | integer | Time step (seconds) | -| window | integer,
[number, number] | 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]` | +| Option | Type | Description | +| ------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| epoch | integer | Starting time since the UNIX epoch (seconds).
epoch format is javascript. i.e. `Date.now()` or `UNIX time * 1000` | +| step | integer | Time step (seconds) | +| window | integer,
[number, number] | 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: `[past, future]` | ```js // TOTP defaults @@ -308,8 +335,9 @@ This library has been split into 3 categories: `core`, `plugin` and `preset`. ### Core -These provides the main functionality of the library. However parts of the logic -has been separated out in order to provide flexibility to the library. +Provides the core functionality of the library. Parts of the logic +has been separated out in order to provide flexibility to the library via +available plugins. | file | description | | -------------------- | ---------------------------------------------------- | @@ -322,38 +350,48 @@ has been separated out in order to provide flexibility to the library. #### Crypto Plugins -These crypto plugins provides: +| plugin | depends on | +| ---------------------- | ---------------------------- | +| otplib/plugin-crypto | crypto (included in Node.js) | +| otplib/plugin-cryptojs | `npm install crypto-js` | -- `createDigest` - used for token derivation -- `createRandomBytes` - used to generate random keys for Google Authenticator +These crypto plugins provides: -| plugin | npm | -| ---------------------- | ----------------------- | -| otplib/plugin-crypto | node crypto | -| otplib/plugin-cryptojs | `npm install crypto-js` | +```js +{ + createDigest, // used for token derivation + createRandomBytes, //used to generate random keys for Google Authenticator +} +``` #### Base32 Plugins -These Base32 plugins provides `keyDecoder` and `keyEncoder` for decoding and encoding -secrets for Google Authenticator respectively. - -| plugin | npm | +| plugin | depends on | | ---------------------------- | ----------------------------------------- | | otplib/plugin-thirty-two | `npm install thirty-two` | | otplib/plugin-base32-enc-dec | `npm install base32-encode base32-decode` | +These Base32 plugins provides: + +```js +{ + keyDecoder, //for decoding Google Authenticator secrets + keyEncoder, // for encoding Google Authenticator secrets. +} +``` + ### Presets -Presets are preconfigured HOTP, TOTP, Authenticator instances to allow for quick starts. +Presets are preconfigured HOTP, TOTP, Authenticator instances to +allow you to get started with the library quickly. -They would need the corresponding npm modules to be installed (except -for `preset-browser` which bundles in the dependents). +Each presets would need the corresponding dependent npm modules to be installed. -| file | description | -| --------------------- | -------------------------------------------------------------- | -| otplib/preset-default | Uses node crypto + thirty-two | -| otplib/preset-browser | Webpack bundle. Uses base32-encode + base32-decode + crypto-js | -| otplib/preset-v11 | Wrapper to adapt the APIs to v11.x compatible format | +| file | depends on | description | +| --------------------- | ------------------------ | ---------------------------------------------------- | +| otplib/preset-default | `npm install thirty-two` | | +| otplib/preset-browser | Buffer | Webpack bundle and is self contained. | +| otplib/preset-v11 | `npm install thirty-two` | Wrapper to adapt the APIs to v11.x compatible format | ## Notes @@ -377,10 +415,10 @@ The approximate **bundle sizes** are as follows: | --------------------------------- | ---------- | | original | 324KB | | original, minified + gzipped | 102KB | -| optimised | 28.3KB | -| **optimised, minified + gzipped** | **9.12KB** | +| optimised | 29.3KB | +| **optimised, minified + gzipped** | **9.42KB** | -Paired with the gzipped browser `buffer.js` module, it would be about `7.65KB + 9.12KB = 16.77KB`. +Paired with the gzipped browser `buffer.js` module, it would be about `7.65KB + 9.42KB = 17.07KB`. ### Google Authenticator @@ -413,7 +451,7 @@ take in a QR code that holds a URL with the protocol `otpauth://`, which you get from `authenticator.keyuri`. Google Authenticator will ignore the `algorithm`, `digits`, and `step` options. -See the [documentation](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) +See the [keyuri documentation](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) for more information. If you are using a different authenticator app, check the documentation @@ -487,11 +525,14 @@ $ [otplib] > otplib.authenticator.generate(secret) [badge-npm]: https://img.shields.io/npm/v/otplib.svg?style=flat-square [badge-pr-welcome]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square&longCache=true [badge-type-ts]: https://img.shields.io/badge/typedef-.d.ts-blue.svg?style=flat-square&longCache=true +[docs-plugins-base32]: #base32-plugins +[docs-plugins-crypto]: #crypto-plugins [docs-quick-start]: #quick-start [link-mdn-classes]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes [link-mdn-crypto]: https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto [link-mdn-functions]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions [link-npm-buffer]: https://www.npmjs.com/package/buffer +[project-api]: https://otplib.yeojz.com/api [project-circle]: https://circleci.com/gh/yeojz/otplib [project-coffee]: https://paypal.me/yeojz [project-coveralls]: https://coveralls.io/github/yeojz/otplib diff --git a/packages/otplib-authenticator/authenticator.ts b/packages/otplib-authenticator/authenticator.ts index 9c56b1a8..d62af013 100644 --- a/packages/otplib-authenticator/authenticator.ts +++ b/packages/otplib-authenticator/authenticator.ts @@ -1,9 +1,4 @@ -import { - HashAlgorithms, - KeyEncodings, - SecretKey, - createInstance -} from 'otplib-hotp'; +import { HashAlgorithms, KeyEncodings, SecretKey } from 'otplib-hotp'; import { TOTP, TOTPOptions, @@ -68,10 +63,10 @@ export interface AuthenticatorOptions extends TOTPOptions { /** * Validates the given [[AuthenticatorOptions]]. */ -export function authenticatorOptionValidator( - options: Readonly> -): void { - totpOptionsValidator(options); +export function authenticatorOptionValidator< + T extends AuthenticatorOptions = AuthenticatorOptions +>(options: Partial): void { + totpOptionsValidator(options); if (typeof options.keyDecoder !== 'function') { throw new Error('Expecting options.keyDecoder to be a function.'); @@ -86,9 +81,11 @@ export function authenticatorOptionValidator( * Encodes a given secret key into a Base32 secret * using a [[KeyEncoder]] method set in the options. */ -export function authenticatorEncoder( +export function authenticatorEncoder< + T extends AuthenticatorOptions = AuthenticatorOptions +>( secret: SecretKey, - options: Pick + options: Pick ): Base32SecretKey { return options.keyEncoder(secret, options.encoding); } @@ -97,9 +94,11 @@ export function authenticatorEncoder( * Decodes a given Base32 secret to a secret key * using a [[KeyDecoder]] method set in the options. */ -export function authenticatorDecoder( +export function authenticatorDecoder< + T extends AuthenticatorOptions = AuthenticatorOptions +>( secret: Base32SecretKey, - options: Pick + options: Pick ): SecretKey { return options.keyDecoder(secret, options.encoding); } @@ -107,12 +106,11 @@ export function authenticatorDecoder( /** * Generates a random Base32 Secret Key. */ -export function authenticatorGenerateSecret( +export function authenticatorGenerateSecret< + T extends AuthenticatorOptions = AuthenticatorOptions +>( numberOfBytes: number, - options: Pick< - AuthenticatorOptions, - 'keyEncoder' | 'encoding' | 'createRandomBytes' - > + options: Pick ): Base32SecretKey { const key = options.createRandomBytes(numberOfBytes, options.encoding); return authenticatorEncoder(key, options); @@ -128,50 +126,60 @@ export function authenticatorGenerateSecret( * - https://en.wikipedia.org/wiki/Google_Authenticator * */ -export function authenticatorToken( - secret: Base32SecretKey, - options: Readonly -): string { - return totpToken(authenticatorDecoder(secret, options), options); +export function authenticatorToken< + T extends AuthenticatorOptions = AuthenticatorOptions +>(secret: Base32SecretKey, options: Readonly): string { + return totpToken(authenticatorDecoder(secret, options), options); } /** * Decodes the encodedSecret and passes it to [[totpCheckWithWindow]] */ -export function authenticatorCheckWithWindow( - token: string, - secret: Base32SecretKey, - options: Readonly -): number | null { - return totpCheckWithWindow( +export function authenticatorCheckWithWindow< + T extends AuthenticatorOptions = AuthenticatorOptions +>(token: string, secret: Base32SecretKey, options: Readonly): number | null { + return totpCheckWithWindow( token, - authenticatorDecoder(secret, options), + authenticatorDecoder(secret, options), options ); } /** - * Takes an Authenticator Option object and provides presets for - * some of the missing required Authenticator option fields and validates - * the resultant options. + * Returns a set of default options for authenticator at the current epoch. */ -export function authenticatorOptions( - opt: Readonly> -): AuthenticatorOptions { - const options: Partial = { +export function authenticatorDefaultOptions< + T extends AuthenticatorOptions = AuthenticatorOptions +>(): Partial { + const options = { algorithm: HashAlgorithms.SHA1, createHmacKey: totpCreateHmacKey, digits: 6, encoding: KeyEncodings.HEX, epoch: Date.now(), step: 30, - window: 0, + window: 0 + }; + + return options as Partial; +} + +/** + * Takes an Authenticator Option object and provides presets for + * some of the missing required Authenticator option fields and validates + * the resultant options. + */ +export function authenticatorOptions< + T extends AuthenticatorOptions = AuthenticatorOptions +>(opt: Partial): Readonly { + const options = { + ...authenticatorDefaultOptions(), ...opt }; - authenticatorOptionValidator(options); + authenticatorOptionValidator(options as Partial); - return options as AuthenticatorOptions; + return Object.freeze(options) as Readonly; } /** @@ -181,24 +189,10 @@ export class Authenticator< T extends AuthenticatorOptions = AuthenticatorOptions > extends TOTP { /** - * Creates a new Authenticator instance with all defaultOptions and options reset. - * - * This is the same as calling `new Authenticator()` + * Creates a new instance with all defaultOptions and options reset. */ public create(defaultOptions: Partial = {}): Authenticator { - return createInstance>(Authenticator, defaultOptions); - } - - /** - * Copies the defaultOptions and options from the current - * Authenticator instance and applies the provided defaultOptions. - */ - public clone(defaultOptions: Partial = {}): Authenticator { - return createInstance>( - Authenticator, - { ...this._defaultOptions, ...defaultOptions }, - this._options - ); + return new Authenticator(defaultOptions); } /** @@ -208,44 +202,44 @@ export class Authenticator< * Refer to [[authenticatorOptions]] */ public allOptions(): Readonly { - return authenticatorOptions({ + return authenticatorOptions({ ...this._defaultOptions, ...this._options - }) as Readonly; + }); } /** * Reference: [[authenticatorToken]] */ public generate(secret: Base32SecretKey): string { - return authenticatorToken(secret, this.allOptions()); + return authenticatorToken(secret, this.allOptions()); } /** * Reference: [[authenticatorCheckWithWindow]] */ public checkDelta(token: string, secret: Base32SecretKey): number | null { - return authenticatorCheckWithWindow(token, secret, this.allOptions()); + return authenticatorCheckWithWindow(token, secret, this.allOptions()); } /** * Reference: [[authenticatorEncoder]] */ public encode(secret: SecretKey): Base32SecretKey { - return authenticatorEncoder(secret, this.allOptions()); + return authenticatorEncoder(secret, this.allOptions()); } /** * Reference: [[authenticatorDecoder]] */ public decode(secret: Base32SecretKey): SecretKey { - return authenticatorDecoder(secret, this.allOptions()); + return authenticatorDecoder(secret, this.allOptions()); } /** * Reference: [[authenticatorGenerateSecret]] */ public generateSecret(numberOfBytes: number = 10): Base32SecretKey { - return authenticatorGenerateSecret(numberOfBytes, this.allOptions()); + return authenticatorGenerateSecret(numberOfBytes, this.allOptions()); } } diff --git a/packages/otplib-hotp/hotp.ts b/packages/otplib-hotp/hotp.ts index 52eedaa9..6bf042aa 100644 --- a/packages/otplib-hotp/hotp.ts +++ b/packages/otplib-hotp/hotp.ts @@ -14,27 +14,19 @@ const HASH_ALGORITHMS = objectValues(HashAlgorithms); const KEY_ENCODINGS = objectValues(KeyEncodings); /** - * Interface method for generating a HMAC digest - * which is then used to generate the token. + * Interface method for formatting the [[SecretKey]] with + * the algorithm constraints before it is given to [[CreateDigest]]. */ -export interface CreateDigest { - ( - algorithm: HashAlgorithms, - hmacKey: HexString, - counter: HexString - ): HexString; +export interface CreateHmacKey { + (algorithm: HashAlgorithms, secret: SecretKey, encoding: KeyEncodings): T; } /** - * Interface method for formatting the [[SecretKey]] with - * the algorithm constraints before it is given to [[CreateDigest]]. + * Interface method for generating a HMAC digest + * which is then used to generate the token. */ -export interface CreateHmacKey { - ( - algorithm: HashAlgorithms, - secret: SecretKey, - encoding: KeyEncodings - ): HexString; +export interface CreateDigest { + (algorithm: HashAlgorithms, hmacKey: HexString, counter: HexString): T; } /** @@ -56,8 +48,8 @@ export interface HOTPOptions { /** * Validates the given [[HOTPOptions]] */ -export function hotpOptionsValidator( - options: Readonly> +export function hotpOptionsValidator( + options: Readonly> ): void { if (typeof options.createDigest !== 'function') { throw new Error('Expecting options.createDigest to be a function.'); @@ -71,7 +63,10 @@ export function hotpOptionsValidator( throw new Error('Expecting options.digits to be a number.'); } - if (!options.algorithm || HASH_ALGORITHMS.indexOf(options.algorithm) < 0) { + if ( + !options.algorithm || + HASH_ALGORITHMS.indexOf(options.algorithm as string) < 0 + ) { throw new Error( `Expecting options.algorithm to be one of ${HASH_ALGORITHMS.join( ', ' @@ -79,7 +74,10 @@ export function hotpOptionsValidator( ); } - if (!options.encoding || KEY_ENCODINGS.indexOf(options.encoding) < 0) { + if ( + !options.encoding || + KEY_ENCODINGS.indexOf(options.encoding as string) < 0 + ) { throw new Error( `Expecting options.encoding to be one of ${KEY_ENCODINGS.join( ', ' @@ -96,6 +94,21 @@ export function hotpCounter(counter: number): HexString { return padStart(hexCounter, 16, '0'); } +/** + * Converts a digest to a token of a specified length. + */ +export function digestToToken(digest: Buffer, digits: number): string { + const offset = digest[digest.length - 1] & 0xf; + const binary = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + + const token = binary % Math.pow(10, digits); + return padStart(String(token), digits, '0'); +} + /** * Generates a HMAC-based One-time Token (HOTP) * @@ -105,32 +118,26 @@ export function hotpCounter(counter: number): HexString { * - http://tools.ietf.org/html/rfc4226 * */ -export function hotpToken( +export function hotpToken( secret: SecretKey, counter: number, - options: Readonly + options: Readonly ): string { const hexCounter = hotpCounter(counter); + const hmacKey = options.createHmacKey( options.algorithm, secret, options.encoding ); - const digest = Buffer.from( - options.createDigest(options.algorithm, hmacKey, hexCounter), - 'hex' + const hexDigest = options.createDigest( + options.algorithm, + hmacKey, + hexCounter ); - const offset = digest[digest.length - 1] & 0xf; - const binary = - ((digest[offset] & 0x7f) << 24) | - ((digest[offset + 1] & 0xff) << 16) | - ((digest[offset + 2] & 0xff) << 8) | - (digest[offset + 3] & 0xff); - - const token = binary % Math.pow(10, options.digits); - return padStart(String(token), options.digits, '0'); + return digestToToken(Buffer.from(hexDigest, 'hex'), options.digits); } /** @@ -138,11 +145,11 @@ export function hotpToken( * * **Note**: Token is valid only if it is a number string */ -export function hotpCheck( +export function hotpCheck( token: string, secret: SecretKey, counter: number, - options: Readonly + options: Readonly ): boolean { if (!isTokenValid(token)) { return false; @@ -169,42 +176,37 @@ export const hotpCreateHmacKey: CreateHmacKey = ( }; /** - * Takes an HOTP Option object and provides presets for - * some of the missing required HOTP option fields and validates - * the resultant options. + * Returns the default options for HOTP */ -export function hotpOptions(opt: Readonly>): HOTPOptions { - const options: Partial = { +export function hotpDefaultOptions< + T extends HOTPOptions = HOTPOptions +>(): Partial { + const options = { algorithm: HashAlgorithms.SHA1, createHmacKey: hotpCreateHmacKey, digits: 6, - encoding: KeyEncodings.ASCII, - ...opt + encoding: KeyEncodings.ASCII }; - hotpOptionsValidator(options); - - return options as HOTPOptions; + return options as Partial; } /** - * This is a helper method which provides a common set of steps - * during class initialisation. - * - * It is mainly for use internally by ".clone()" and ".create()" class methods. - * - * @param OTP - Class object that you want to initialise (eg: HOTP). - * @param defaultOptions - Persistent options. - * @param options - Transient options that can be reset. + * Takes an HOTP Option object and provides presets for + * some of the missing required HOTP option fields and validates + * the resultant options. */ -export function createInstance>( - OTP: { new (opt: Partial): S }, - defaultOptions: Partial, - options: Partial = {} -): S { - const instance = new OTP(defaultOptions); - instance.options = options; - return instance; +export function hotpOptions( + opt: Readonly> +): Readonly { + const options = { + ...hotpDefaultOptions(), + ...opt + }; + + hotpOptionsValidator(options as Partial); + + return Object.freeze(options) as Readonly; } /** @@ -239,24 +241,23 @@ export class HOTP { } /** - * Creates a new HOTP instance with all defaultOptions and options reset. - * - * This is the same as calling `new HOTP()` + * Creates a new instance with all defaultOptions and options reset. */ - public create(defaultOptions: Partial = {}): HOTP { - return createInstance>(HOTP, defaultOptions); + public create(defaultOptions: Partial): HOTP { + return new HOTP(defaultOptions); } /** * Copies the defaultOptions and options from the current - * HOTP instance and applies the provided defaultOptions. + * instance and applies the provided defaultOptions. */ - public clone(defaultOptions: Partial = {}): HOTP { - return createInstance>( - HOTP, - { ...this._defaultOptions, ...defaultOptions }, - this._options - ); + public clone(defaultOptions: Partial = {}): ReturnType { + const instance = this.create({ + ...this._defaultOptions, + ...defaultOptions + }); + instance.options = this._options; + return instance as ReturnType; } /** @@ -287,12 +288,10 @@ export class HOTP { * Reference: [[hotpOptions]] */ public allOptions(): Readonly { - return Object.freeze( - hotpOptions({ - ...this._defaultOptions, - ...this._options - }) - ) as Readonly; + return hotpOptions({ + ...this._defaultOptions, + ...this._options + }); } /** @@ -309,14 +308,14 @@ export class HOTP { * Reference: [[hotpToken]] */ public generate(secret: SecretKey, counter: number): string { - return hotpToken(secret, counter, this.allOptions()); + return hotpToken(secret, counter, this.allOptions()); } /** * Reference: [[hotpCheck]] */ public check(token: string, secret: SecretKey, counter: number): boolean { - return hotpCheck(token, secret, counter, this.allOptions()); + return hotpCheck(token, secret, counter, this.allOptions()); } /** diff --git a/packages/otplib-plugin-crypto-js/index.ts b/packages/otplib-plugin-crypto-js/index.ts index 61fea0cf..8d01279b 100644 --- a/packages/otplib-plugin-crypto-js/index.ts +++ b/packages/otplib-plugin-crypto-js/index.ts @@ -47,9 +47,9 @@ export const createDigest: CreateDigest = ( }; export const createRandomBytes: CreateRandomBytes = ( - numberOfBytes: number, + size: number, encoding: KeyEncodings ): string => { - const words = WordArray.random(numberOfBytes); + const words = WordArray.random(size); return Buffer.from(words.toString(), 'hex').toString(encoding); }; diff --git a/packages/otplib-totp/totp.test.ts b/packages/otplib-totp/totp.test.ts index 59b34b28..f4d046a6 100644 --- a/packages/otplib-totp/totp.test.ts +++ b/packages/otplib-totp/totp.test.ts @@ -105,16 +105,36 @@ describe('totpOptionsValidator', (): void => { hotpOptionsValidator.mockReset(); }); - test('missing options.epoch, should throw error', (): void => { + test('invalid options.window, should throw error', (): void => { const result = runOptionValidator({}); + expect(result.error).toBe(true); + expect(result.message).toContain('options.window'); + }); + + test('invalid options.window, array but non-number, should throw error', (): void => { + const result = runOptionValidator({ + // @ts-ignore + window: ['hi', ' me'] + }); + + expect(result.error).toBe(true); + expect(result.message).toContain('options.window'); + }); + + test('missing options.epoch, should throw error', (): void => { + const result = runOptionValidator({ + window: 0 + }); + expect(result.error).toBe(true); expect(result.message).toContain('options.epoch'); }); test('missing options.step, should throw error', (): void => { const result = runOptionValidator({ - epoch: Date.now() + epoch: Date.now(), + window: 0 }); expect(result.error).toBe(true); diff --git a/packages/otplib-totp/totp.ts b/packages/otplib-totp/totp.ts index 980ab92b..11183793 100644 --- a/packages/otplib-totp/totp.ts +++ b/packages/otplib-totp/totp.ts @@ -7,7 +7,6 @@ import { KeyEncodings, SecretKey, Strategy, - createInstance, hotpOptionsValidator, hotpToken, isTokenValid, @@ -17,6 +16,28 @@ import { const HASH_ALGORITHMS = objectValues(HashAlgorithms); +/** + * Validates and formats the given window into an array + * containing how many windows past and future to check. + */ +function parseWindowBounds(win?: number | [number, number]): [number, number] { + if (typeof win === 'number') { + return [Math.abs(win), Math.abs(win)]; + } + + if (Array.isArray(win)) { + const [past, future] = win; + + if (typeof past === 'number' && typeof future === 'number') { + return [Math.abs(past), Math.abs(future)]; + } + } + + throw new Error( + 'Expecting options.window to be an number or [number, number].' + ); +} + /** * Interface for options used in TOTP. * @@ -35,10 +56,11 @@ export interface TOTPOptions extends HOTPOptions { /** * Validates the given [[TOTPOptions]]. */ -export function totpOptionsValidator( - options: Readonly> +export function totpOptionsValidator( + options: Partial ): void { - hotpOptionsValidator(options); + hotpOptionsValidator(options); + parseWindowBounds(options.window); if (typeof options.epoch !== 'number') { throw new Error('Expecting options.epoch to be a number.'); @@ -71,23 +93,71 @@ export function totpCounter(epoch: number, step: number): number { * - http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm * */ -export function totpToken( +export function totpToken( secret: SecretKey, - options: Readonly + options: Readonly ): string { const counter = totpCounter(options.epoch, options.step); return hotpToken(secret, counter, options); } +function generateEpoch( + epoch: number, + direction: number, + deltaPerEpoch: number, + numOfEpoches: number +): number[] { + const result: number[] = []; + + if (numOfEpoches === 0) { + return result; + } + + for (let i = 1; i <= numOfEpoches; i++) { + const delta = direction * i * deltaPerEpoch; + result.push(epoch + delta); + } + + return result; +} + +/** + * Interface for available epoches derived from + * the current epoch. + */ +export interface EpochAvailable { + current: number; + future: number[]; + past: number[]; +} + +/** + * Gets a set of epoches derived from + * the current epoch and the acceptable window. + */ +export function totpEpochAvailable( + options: Readonly> +): EpochAvailable { + const bounds = parseWindowBounds(options.window); + const epoch = options.epoch; + const delta = options.step * 1000; // to JS Time + + return { + current: epoch, + past: generateEpoch(epoch, -1, delta, bounds[0]), + future: generateEpoch(epoch, 1, delta, bounds[1]) + }; +} + /** * Checks the given token against the system generated token. * * **Note**: Token is valid only if it is a number string. */ -export function totpCheck( +export function totpCheck( token: string, secret: SecretKey, - options: Readonly + options: Readonly ): boolean { if (!isTokenValid(token)) { return false; @@ -98,58 +168,32 @@ export function totpCheck( } /** - * Validates and formats the given window into an array - * containing how many windows past and future to check. - */ -function getWindowBounds(win: number | [number, number]): [number, number] { - if (typeof win === 'number') { - return [win, win]; - } - - if ( - Array.isArray(win) && - typeof win[0] === 'number' && - typeof win[1] === 'number' - ) { - return [win[0], win[1]]; - } - - throw new Error( - 'Expecting options.window to be an number or [number, number].' - ); -} - -type TOTPCheckRunner = (direction: 1 | -1, rounds: number) => number | null; - -/** - * Creats a method which will loop-check TOTP validity by - * the specified number of windows in the specified - * direction (past or future). + * Checks if there is a valid TOTP token in a given list of epoches. + * Returns the (index + 1) of a valid epoch in the list. */ -function createTOTPCheckRunner( +export function totpCheckByEpoch( + epoches: number[], token: string, secret: SecretKey, - options: Readonly -): TOTPCheckRunner { - const delta = options.step * 1000; - const epoch = options.epoch; - - return (direction: 1 | -1, rounds: number): number | null => { - for (let i = 1; i <= rounds; i++) { - const position = direction * i; + options: Readonly +): number | null { + let position = null; - const currentOptions = { - ...options, - epoch: epoch + position * delta - }; + epoches.some((epoch, idx): boolean => { + const result = totpCheck(token, secret, { + ...options, + epoch + }); - if (totpCheck(token, secret, currentOptions)) { - return position; - } + if (result) { + position = idx + 1; + return true; } - return null; - }; + return false; + }); + + return position; } /** @@ -162,20 +206,23 @@ function createTOTPCheckRunner( * - positive number = token at future x * step * - negative number = token at past x * step */ -export function totpCheckWithWindow( +export function totpCheckWithWindow( token: string, secret: SecretKey, - options: Readonly + options: Readonly ): number | null { - const bounds = getWindowBounds(options.window); - if (totpCheck(token, secret, options)) { return 0; } - const totpCheckRunner = createTOTPCheckRunner(token, secret, options); - const backward = totpCheckRunner(-1, bounds[0]); - return backward !== null ? backward : totpCheckRunner(1, bounds[1]); + const epoches = totpEpochAvailable(options); + const backward = totpCheckByEpoch(epoches.past, token, secret, options); + + if (backward !== null) { + return backward * -1; + } + + return totpCheckByEpoch(epoches.future, token, secret, options); } /** @@ -261,25 +308,40 @@ export const totpCreateHmacKey: CreateHmacKey = ( }; /** - * Takes an TOTP Option object and provides presets for - * some of the missing required TOTP option fields and validates - * the resultant options. + * Returns a set of default options for TOTP at the current epoch. */ -export function totpOptions(opt: Readonly>): TOTPOptions { - const options: Partial = { +export function totpDefaultOptions< + T extends TOTPOptions = TOTPOptions +>(): Partial { + const options = { algorithm: HashAlgorithms.SHA1, createHmacKey: totpCreateHmacKey, digits: 6, encoding: KeyEncodings.ASCII, epoch: Date.now(), step: 30, - window: 0, + window: 0 + }; + + return options as Partial; +} + +/** + * Takes an TOTP Option object and provides presets for + * some of the missing required TOTP option fields and validates + * the resultant options. + */ +export function totpOptions( + opt: Partial +): Readonly { + const options = { + ...totpDefaultOptions(), ...opt }; - totpOptionsValidator(options); + totpOptionsValidator(options as Partial); - return options as TOTPOptions; + return Object.freeze(options) as Readonly; } /** @@ -287,24 +349,10 @@ export function totpOptions(opt: Readonly>): TOTPOptions { */ export class TOTP extends HOTP { /** - * Creates a new TOTP instance with all defaultOptions and options reset. - * - * This is the same as calling `new TOTP()` + * Creates a new instance with all defaultOptions and options reset. */ - public create(defaultOptions: Partial = {}): TOTP { - return createInstance>(TOTP, defaultOptions); - } - - /** - * Copies the defaultOptions and options from the current - * TOTP instance and applies the provided defaultOptions. - */ - public clone(defaultOptions: Partial = {}): TOTP { - return createInstance>( - TOTP, - { ...this._defaultOptions, ...defaultOptions }, - this._options - ); + public create(defaultOptions: Partial): TOTP { + return new TOTP(defaultOptions); } /** @@ -314,24 +362,24 @@ export class TOTP extends HOTP { * Reference: [[totpOptions]] */ public allOptions(): Readonly { - return totpOptions({ + return totpOptions({ ...this._defaultOptions, ...this._options - }) as Readonly; + }); } /** * Reference: [[totpToken]] */ public generate(secret: SecretKey): string { - return totpToken(secret, this.allOptions()); + return totpToken(secret, this.allOptions()); } /** * Reference: [[totpCheckWithWindow]] */ public checkDelta(token: string, secret: SecretKey): number | null { - return totpCheckWithWindow(token, secret, this.allOptions()); + return totpCheckWithWindow(token, secret, this.allOptions()); } /** diff --git a/packages/tests-suites/plugin-base32.ts b/packages/tests-suites/plugin-base32.ts index 6808580b..2f710750 100644 --- a/packages/tests-suites/plugin-base32.ts +++ b/packages/tests-suites/plugin-base32.ts @@ -52,7 +52,7 @@ export function base32PluginTestSuite( test(`given decoded key ${entry.decoded}, should receive encoded key ${entry.encoded}`, (): void => { expect(plugin.keyEncoder(entry.decoded, KeyEncodings.HEX)).toBe( - entry.encoded + entry.encoded.toUpperCase() ); }); }); diff --git a/packages/tests-suites/preset.ts b/packages/tests-suites/preset.ts index 25f32217..0401b5d7 100644 --- a/packages/tests-suites/preset.ts +++ b/packages/tests-suites/preset.ts @@ -68,6 +68,10 @@ export function presetTestSuite(name: string, pkg: Presets): void { describe(`${name} - Authenticator`, (): void => { const { authenticator } = pkg; + beforeEach((): void => { + authenticator.resetOptions(); + }); + tokenSets.forEach((entry): void => { test(`given epoch (${entry.epoch}) and secret, should receive expected token ${entry.token}`, (): void => { authenticator.options = { @@ -77,6 +81,39 @@ export function presetTestSuite(name: string, pkg: Presets): void { expect(authenticator.generate(entry.secret)).toBe(entry.token); }); }); + + describe('given a epoch and window, a set of tokens should return the correct validation window', (): void => { + const secret = 'J44DMWLUIFHE63SQKR4FKODQKB2UWZCT'; + const epoch = 1565973031233; + const deltaSets: [string, number | null][] = [ + ['039223', -2], + ['311336', -1], + ['288367', 0], + ['408608', 1], + ['721767', 2], + ['819412', null] + ]; + + deltaSets.forEach(([token, delta]): void => { + test(`given window (2), token (${token}), should return delta (${delta})`, (): void => { + authenticator.options = { + epoch, + window: 2 + }; + + expect(authenticator.checkDelta(token, secret)).toEqual(delta); + }); + + test(`given window ([2, 2]), token (${token}), should return delta (${delta})`, (): void => { + authenticator.options = { + epoch, + window: [2, 2] + }; + + expect(authenticator.checkDelta(token, secret)).toEqual(delta); + }); + }); + }); }); describe('createRandomBytes', (): void => { diff --git a/scripts/build-site.sh b/scripts/build-site.sh index 77cf950e..1943f468 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -11,5 +11,5 @@ npx typedoc \ --out ./builds/typedocs echo "\n--- copying statics to website ---" -cp -r ./builds/otplib/browser ./website/static/otplib-browser -cp -r ./builds/typedocs ./website/static/api +cp -r ./builds/otplib/preset-browser/. ./website/static/otplib-browser +cp -r ./builds/typedocs/. ./website/static/api