Skip to content

Commit 2242335

Browse files
feat: Allow to generate TOTP codes up to 20 character long (#24)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
1 parent 3bf1701 commit 2242335

File tree

3 files changed

+99
-47
lines changed

3 files changed

+99
-47
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,13 @@ const otpUri = getTOTPAuthUri({
9999
const code = await getCodeFromUser()
100100

101101
// now verify the code:
102-
const isValid = await verifyTOTP({ otp: code, secret, period, digits, algorithm })
102+
const isValid = await verifyTOTP({
103+
otp: code,
104+
secret,
105+
period,
106+
digits,
107+
algorithm,
108+
})
103109

104110
// if it's valid, save the secret, period, digits, and algorithm to the database
105111
// along with who it belongs to and use this info to verify the user when they

index.js

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,50 +44,54 @@ export async function generateHOTP(
4444
digits = DEFAULT_DIGITS,
4545
algorithm = DEFAULT_ALGORITHM,
4646
charSet = DEFAULT_CHAR_SET,
47-
} = {}
47+
} = {},
4848
) {
4949
const byteCounter = intToBytes(counter)
5050
const key = await crypto.subtle.importKey(
5151
'raw',
5252
secret,
5353
{ name: 'HMAC', hash: algorithm },
5454
false,
55-
['sign']
55+
['sign'],
5656
)
5757
const signature = await crypto.subtle.sign('HMAC', key, byteCounter)
58-
const hashBytes = new Uint8Array(signature)
59-
// offset is always the last 4 bits of the signature; its value: 0-15
60-
const offset = hashBytes[hashBytes.length - 1] & 0xf
58+
const hashBytes = new Uint8Array(signature)
59+
// offset is always the last 4 bits of the signature; its value: 0-15
60+
const offset = hashBytes[hashBytes.length - 1] & 0xf
6161

62-
let hotpVal = 0n
63-
if (digits === 6) {
64-
// stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt
65-
hotpVal = 0n |
66-
BigInt(hashBytes[offset] & 0x7f) << 24n |
67-
BigInt(hashBytes[offset + 1]) << 16n |
68-
BigInt(hashBytes[offset + 2]) << 8n |
69-
BigInt(hashBytes[offset + 3])
70-
} else {
71-
// otherwise create a 64bit value from the hashBytes
72-
hotpVal = 0n |
73-
BigInt(hashBytes[offset] & 0x7f) << 56n |
74-
BigInt(hashBytes[offset + 1]) << 48n |
75-
BigInt(hashBytes[offset + 2]) << 40n |
76-
BigInt(hashBytes[offset + 3]) << 32n |
77-
BigInt(hashBytes[offset + 4]) << 24n |
78-
79-
// we have only 20 hashBytes; if offset is 15 these indexes are out of the hashBytes
80-
// fallback to zero
81-
BigInt(hashBytes[offset + 5] ?? 0n) << 16n |
82-
BigInt(hashBytes[offset + 6] ?? 0n) << 8n |
83-
BigInt(hashBytes[offset + 7] ?? 0n)
84-
}
62+
let hotpVal = 0n
63+
if (digits === 6) {
64+
// stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt
65+
hotpVal =
66+
0n |
67+
(BigInt(hashBytes[offset] & 0x7f) << 24n) |
68+
(BigInt(hashBytes[offset + 1]) << 16n) |
69+
(BigInt(hashBytes[offset + 2]) << 8n) |
70+
BigInt(hashBytes[offset + 3])
71+
} else {
72+
// otherwise create a 64bit value from the hashBytes
73+
hotpVal =
74+
0n |
75+
(BigInt(hashBytes[offset] & 0x7f) << 56n) |
76+
(BigInt(hashBytes[offset + 1]) << 48n) |
77+
(BigInt(hashBytes[offset + 2]) << 40n) |
78+
(BigInt(hashBytes[offset + 3]) << 32n) |
79+
(BigInt(hashBytes[offset + 4]) << 24n) |
80+
// we have only 20 hashBytes; if offset is 15 these indexes are out of the hashBytes
81+
// fallback to the bytes at the start of the hashBytes
82+
(BigInt(hashBytes[(offset + 5) % 20]) << 16n) |
83+
(BigInt(hashBytes[(offset + 6) % 20]) << 8n) |
84+
BigInt(hashBytes[(offset + 7) % 20])
85+
}
8586

8687
let hotp = ''
8788
const charSetLength = BigInt(charSet.length)
8889
for (let i = 0; i < digits; i++) {
8990
hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp
90-
hotpVal = hotpVal / charSetLength
91+
92+
// Ensures hotpVal decreases at a fixed rate, independent of charSet length.
93+
// 10n is compatible with the original TOTP algorithm used in the authenticator apps.
94+
hotpVal = hotpVal / 10n
9195
}
9296

9397
return hotp
@@ -122,7 +126,7 @@ async function verifyHOTP(
122126
algorithm = DEFAULT_ALGORITHM,
123127
charSet = DEFAULT_CHAR_SET,
124128
window = DEFAULT_WINDOW,
125-
} = {}
129+
} = {},
126130
) {
127131
for (let i = counter - window; i <= counter + window; ++i) {
128132
if (

index.test.js

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import assert from 'node:assert'
22
import { test } from 'node:test'
33
import base32Encode from 'base32-encode'
44
import base32Decode from 'base32-decode'
5-
import { generateTOTP, getTOTPAuthUri, verifyTOTP, generateHOTP } from './index.js'
5+
import {
6+
generateTOTP,
7+
getTOTPAuthUri,
8+
verifyTOTP,
9+
generateHOTP,
10+
} from './index.js'
611

712
test('OTP can be generated and verified', async () => {
813
const { secret, otp, algorithm, period, digits } = await generateTOTP()
@@ -21,7 +26,7 @@ test('options can be customized', async () => {
2126
digits: 8,
2227
secret: base32Encode(
2328
new TextEncoder().encode(Math.random().toString(16).slice(2)),
24-
'RFC4648'
29+
'RFC4648',
2530
).toString(),
2631
charSet: 'abcdef',
2732
}
@@ -134,30 +139,67 @@ test('OTP with digits > 6 should not pad with first character of charSet', async
134139
assert.match(
135140
otp,
136141
new RegExp(`^[${charSet}]{12}$`),
137-
'OTP should be 12 characters from the charSet'
142+
'OTP should be 12 characters from the charSet',
138143
)
139144

140145
// The first 6 characters should not all be 'A' (first char of charSet)
141146
const firstSixChars = otp.slice(0, 6)
142147
assert.notStrictEqual(
143148
firstSixChars,
144149
'A'.repeat(6),
145-
'First 6 characters should not all be A'
150+
'First 6 characters should not all be A',
146151
)
147152
}
148153
})
149154

155+
test('generateHOTP works with maximum HMAC offset value', async () => {
156+
await assert.doesNotReject(async () => {
157+
// These specific secret and counter values will cause offset to be 15
158+
const secret = '6YY3NUMNTQ73NRH3'
159+
const counter = 57988074
160+
await generateHOTP(base32Decode(secret, 'RFC4648'), {
161+
counter,
162+
digits: 12, // trigger the use of the 64bit htopVal
163+
algorithm: 'SHA-1',
164+
charSet: '0123456789',
165+
})
166+
})
167+
})
168+
169+
test('20 digits OTP should not pad with first character of charSet regardless of the charSet length', async () => {
170+
const longCharSet = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'
171+
const shortCharSet = 'ABCDEFGHIJK'
172+
173+
async function generate20DigitCodeWithCharSet(charSet) {
174+
const iterations = 100
175+
let allOtps = []
176+
177+
for (let i = 0; i < iterations; i++) {
178+
const { otp } = await generateTOTP({
179+
algorithm: 'SHA-256',
180+
charSet,
181+
digits: 20,
182+
period: 60 * 30,
183+
})
184+
allOtps.push(otp)
185+
186+
// Verify the OTP only contains characters from the charSet
187+
assert.match(
188+
otp,
189+
new RegExp(`^[${charSet}]{20}$`),
190+
'OTP should be 20 characters from the charSet',
191+
)
192+
193+
// The first 6 characters should not all be 'A' (first char of charSet)
194+
const firstSixChars = otp.slice(0, 6)
195+
assert.notStrictEqual(
196+
firstSixChars,
197+
'A'.repeat(6),
198+
'First 6 characters should not all be A',
199+
)
200+
}
201+
}
150202

151-
test('generateHOTP works with maximum HMAC offset value', async () => {
152-
await assert.doesNotReject( async() => {
153-
// These specific secret and counter values will cause offset to be 15
154-
const secret = '6YY3NUMNTQ73NRH3';
155-
const counter = 57988074;
156-
await generateHOTP(base32Decode(secret, 'RFC4648'),{
157-
counter,
158-
digits: 12, // trigger the use of the 64bit htopVal
159-
algorithm: 'SHA-1',
160-
charSet: '0123456789',
161-
});
162-
});
203+
await generate20DigitCodeWithCharSet(shortCharSet)
204+
await generate20DigitCodeWithCharSet(longCharSet)
163205
})

0 commit comments

Comments
 (0)