From a6fa1dcc9ded9fcb1d473b7ad25c6e253d5a25f2 Mon Sep 17 00:00:00 2001 From: Moishi Netzer Date: Wed, 13 Sep 2023 13:06:44 +0000 Subject: [PATCH] Implement charSet options This allows the user to define different charsets for the OTP to be in. This is incredibly useful for increasing the entropy and time-to-crack when dealing with brute force attacks. When using a substantially larger character set, the increased complexity means, in theory, you could use this as a main form of authentication always rather than relying on a 2FA setup. e.g. 10 digits at length 6 = 1 Million possibilities However, 26 letters + 10 digits at length 6 = 36^6 = 2.1 Billion options. With a rate limitting system, this is practically uncrackable. --- README.md | 2 ++ index.js | 42 +++++++++++++++++++++++++++++++----------- index.test.js | 12 +++++++++++- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fe5f79f..4ab14d4 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ will show you all this stuff, but just in case, here's that: * @param {string} [options.secret] The secret to use for the TOTP. It should be * base32 encoded (you can use https://npm.im/thirty-two). Defaults to a random * secret: base32.encode(crypto.randomBytes(10)).toString(). + * @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9. * @returns {{otp: string, secret: string, period: number, digits: number, algorithm: string}} * The OTP, secret, and config options used to generate the OTP. */ @@ -193,6 +194,7 @@ will show you all this stuff, but just in case, here's that: * @param {number} [options.period] The number of seconds for the OTP to be valid. * @param {number} [options.digits] The length of the OTP. * @param {string} [options.algorithm] The algorithm to use. + * @param {string} [options.charSet] The character set to use, defaults to the numbers 0-9. * @param {number} [options.window] The number of OTPs to check before and after * the current OTP. Defaults to 1. * diff --git a/index.js b/index.js index 234b31c..6fd4772 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ import * as base32 from 'thirty-two' // is longer lived, you can definitely use a more secure algorithm like SHA256. // Learn more: https://www.rfc-editor.org/rfc/rfc4226#page-25 (B.1. SHA-1 Status) const DEFAULT_ALGORITHM = 'SHA1' +const DEFAULT_CHAR_SET = '0123456789' const DEFAULT_DIGITS = 6 const DEFAULT_WINDOW = 1 const DEFAULT_PERIOD = 30 @@ -30,24 +31,36 @@ const DEFAULT_PERIOD = 30 * HOTP. Defaults to 6. * @param {string} [options.algorithm='SHA1'] - The algorithm to use for the * HOTP. Defaults to 'SHA1'. + * @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9. * @returns {string} The generated HOTP. */ function generateHOTP( secret, - { counter = 0, digits = DEFAULT_DIGITS, algorithm = DEFAULT_ALGORITHM } = {} + { + counter = 0, + digits = DEFAULT_DIGITS, + algorithm = DEFAULT_ALGORITHM, + charSet = DEFAULT_CHAR_SET, + } = {} ) { const byteCounter = Buffer.from(intToBytes(counter)) const hmac = crypto.createHmac(algorithm, secret) const digest = hmac.update(byteCounter).digest('hex') const hashBytes = hexToBytes(digest) const offset = hashBytes[19] & 0xf - let hotp = - (((hashBytes[offset] & 0x7f) << 24) | - ((hashBytes[offset + 1] & 0xff) << 16) | - ((hashBytes[offset + 2] & 0xff) << 8) | - (hashBytes[offset + 3] & 0xff)) + - '' - return hotp.slice(-digits) + let hotpVal = + ((hashBytes[offset] & 0x7f) << 24) | + ((hashBytes[offset + 1] & 0xff) << 16) | + ((hashBytes[offset + 2] & 0xff) << 8) | + (hashBytes[offset + 3] & 0xff) + + let hotp = '' + for (let i = 0; i < digits; i++) { + hotp += charSet.charAt(hotpVal % charSet.length) + hotpVal = Math.floor(hotpVal / charSet.length) + } + + return hotp } /** @@ -63,6 +76,7 @@ function generateHOTP( * HOTP. Defaults to 6. * @param {string} [options.algorithm='SHA1'] - The algorithm to use for the * HOTP. Defaults to 'SHA1'. + * @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9. * @param {number} [options.window=1] - The number of counter values to check * before and after the current counter value. Defaults to 1. * @returns {{delta: number}|null} An object with the `delta` property @@ -76,11 +90,14 @@ function verifyHOTP( counter = 0, digits = DEFAULT_DIGITS, algorithm = DEFAULT_ALGORITHM, + charSet = DEFAULT_CHAR_SET, window = DEFAULT_WINDOW, } = {} ) { for (let i = counter - window; i <= counter + window; ++i) { - if (generateHOTP(secret, { counter: i, digits, algorithm }) === otp) { + if ( + generateHOTP(secret, { counter: i, digits, algorithm, charSet }) === otp + ) { return { delta: i - counter } } } @@ -98,10 +115,11 @@ function verifyHOTP( * @param {number} [options.digits=6] The length of the OTP. Defaults to 6. * @param {string} [options.algorithm='SHA1'] The algorithm to use. Defaults to * SHA1. + * @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9. * @param {string} [options.secret] The secret to use for the TOTP. It should be * base32 encoded (you can use https://npm.im/thirty-two). Defaults to a random * secret: base32.encode(crypto.randomBytes(10)).toString(). - * @returns {{otp: string, secret: string, period: number, digits: number, algorithm: string}} + * @returns {{otp: string, secret: string, period: number, digits: number, algorithm: string, charSet: string}} * The OTP, secret, and config options used to generate the OTP. */ export function generateTOTP({ @@ -109,14 +127,16 @@ export function generateTOTP({ digits = DEFAULT_DIGITS, algorithm = DEFAULT_ALGORITHM, secret = base32.encode(crypto.randomBytes(10)).toString(), + charSet = DEFAULT_CHAR_SET, } = {}) { const otp = generateHOTP(base32.decode(secret), { counter: getCounter(period), digits, algorithm, + charSet, }) - return { otp, secret, period, digits, algorithm } + return { otp, secret, period, digits, algorithm, charSet } } /** diff --git a/index.test.js b/index.test.js index 520499a..29aa3d2 100644 --- a/index.test.js +++ b/index.test.js @@ -17,7 +17,7 @@ test('options can be customized', () => { algorithm: 'SHA256', period: 60, digits: 8, - secret: base32.encode(Math.random().toString(16).slice(2)), + secret: base32.encode(Math.random().toString(16).slice(2)).toString(), } const { otp, ...config } = generateTOTP(options) assert.deepStrictEqual(config, options) @@ -87,6 +87,16 @@ test('Generating and verifying also works with the algorithm name alias', () => assert.notStrictEqual(result, null) }) +test('Charset defaults to numbers', () => { + const { otp } = generateTOTP() + assert.match(otp, /^[0-9]+$/) +}) + +test('Charset can be customized', () => { + const { otp } = generateTOTP({ charSet: 'abcdef' }) + assert.match(otp, /^[abcdef]+$/) +}) + test('OTP Auth URI can be generated', () => { const { otp: _otp, secret, ...totpConfig } = generateTOTP() const issuer = Math.random().toString(16).slice(2)