Skip to content

Commit

Permalink
Implement charSet options
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
moishinetzer committed Sep 13, 2023
1 parent 459faba commit a6fa1dc
Show file tree
Hide file tree
Showing 3 changed files with 44 additions and 12 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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.
*
Expand Down
42 changes: 31 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

/**
Expand All @@ -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
Expand All @@ -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 }
}
}
Expand All @@ -98,25 +115,28 @@ 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({
period = DEFAULT_PERIOD,
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 }
}

/**
Expand Down
12 changes: 11 additions & 1 deletion index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit a6fa1dc

Please sign in to comment.