Skip to content

Commit

Permalink
feat: Support a charSet option (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
moishinetzer authored Sep 13, 2023
1 parent 459faba commit 05d468b
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 15 deletions.
57 changes: 56 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,59 @@ if (isValid) {
}
```

## Customizable Character Set for Increased Security

### Why Charset Matters

When it comes to security, every bit of entropy counts. Entropy measures the
unpredictability and in turn the security of your OTPs. The traditional TOTP
setup often employs a 6-digit numerical code, providing a million (10^6)
combinations. This is the default behaviour for this implementation. While that
is robust, there's room for improvement.

By introducing a customizable character set feature, you can exponentially
increase the entropy of the OTPs, making them much more secure against
brute-force attacks. For example, if you extend your character set to include 26
uppercase letters and 10 digits, a 6-character OTP would have 36^6 = 2.1 billion
combinations. When paired with rate-limiting mechanisms, this configuration
becomes practically impervious to brute-force attacks.

### Potential for Main Form of Authentication

With this added complexity, TOTPs can, in theory, be used as the primary form of
authentication, rather than just a second factor. This is particularly useful
for applications requiring heightened security.

### Usage with Custom Character Set

In addition to the existing options, you can specify a charSet in both
`generateTOTP` and `verifyTOTP`.

Here's how you can generate an OTP with a custom character set:

```js
import { generateTOTP, verifyTOTP } from '@epic-web/totp'

const { otp, secret, period, digits, algorithm, charSet } = generateTOTP({
charSet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', // custom character set
})

// Remember to save the charSet to your database as well.

// To verify
const isValid = verifyTOTP({
otp,
secret,
period,
digits,
algorithm,
charSet,
})
```

Just as an aside, you probably want to exclude the letter O and the number 0 to
make it easier for users to enter the code.

## API

This library is built with `jsdoc`, so hopefully your editor supports that and
Expand All @@ -174,7 +227,8 @@ 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().
* @returns {{otp: string, secret: string, period: number, digits: number, algorithm: string}}
* @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, charSet: string}}
* The OTP, secret, and config options used to generate the OTP.
*/
```
Expand All @@ -193,6 +247,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
45 changes: 34 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 Expand Up @@ -168,6 +188,7 @@ export function getTOTPAuthUri({
* @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 All @@ -181,13 +202,15 @@ export function verifyTOTP({
period,
digits,
algorithm,
charSet,
window = DEFAULT_WINDOW,
}) {
return verifyHOTP(otp, base32.decode(secret), {
counter: getCounter(period),
digits,
window,
algorithm,
charSet,
})
}

Expand Down
17 changes: 14 additions & 3 deletions index.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert'
import { test, afterEach } from 'node:test'
import * as base32 from 'thirty-two'
import { test } from 'node:test'
import base32 from 'thirty-two'
import { generateTOTP, getTOTPAuthUri, verifyTOTP } from './index.js'

test('OTP can be generated and verified', () => {
Expand All @@ -17,7 +17,8 @@ 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(),
charSet: 'abcdef',
}
const { otp, ...config } = generateTOTP(options)
assert.deepStrictEqual(config, options)
Expand Down Expand Up @@ -87,6 +88,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 05d468b

Please sign in to comment.