Skip to content

Commit 33d8db9

Browse files
fix: ensured that generateTOTP converts the digits and period params into numbers. (#25)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
1 parent 52dabf0 commit 33d8db9

File tree

2 files changed

+69
-9
lines changed

2 files changed

+69
-9
lines changed

index.js

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,22 +56,44 @@ async function generateHOTP(
5656
)
5757
const signature = await crypto.subtle.sign('HMAC', key, byteCounter)
5858
const hashBytes = new Uint8Array(signature)
59-
60-
// Use more bytes for longer OTPs
61-
const bytesNeeded = Math.ceil((digits * Math.log2(charSet.length)) / 8)
59+
// offset is always the last 4 bits of the signature; its value: 0-15
6260
const offset = hashBytes[hashBytes.length - 1] & 0xf
6361

64-
// Convert bytes to BigInt for larger numbers
6562
let hotpVal = 0n
66-
for (let i = 0; i < Math.min(bytesNeeded, hashBytes.length - offset); i++) {
67-
hotpVal = (hotpVal << 8n) | BigInt(hashBytes[offset + i])
63+
// the original specification allows any amount of digits between 4 and 10,
64+
// so stay on the 32bit number if the digits are less then or equal to 10.
65+
if (digits <= 10) {
66+
// stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt
67+
hotpVal =
68+
0n |
69+
(BigInt(hashBytes[offset] & 0x7f) << 24n) |
70+
(BigInt(hashBytes[offset + 1]) << 16n) |
71+
(BigInt(hashBytes[offset + 2]) << 8n) |
72+
BigInt(hashBytes[offset + 3])
73+
} else {
74+
// otherwise create a 64bit value from the hashBytes
75+
hotpVal =
76+
0n |
77+
(BigInt(hashBytes[offset] & 0x7f) << 56n) |
78+
(BigInt(hashBytes[offset + 1]) << 48n) |
79+
(BigInt(hashBytes[offset + 2]) << 40n) |
80+
(BigInt(hashBytes[offset + 3]) << 32n) |
81+
(BigInt(hashBytes[offset + 4]) << 24n) |
82+
// we have only 20 hashBytes; if offset is 15 these indexes are out of the hashBytes
83+
// fallback to the bytes at the start of the hashBytes
84+
(BigInt(hashBytes[(offset + 5) % 20]) << 16n) |
85+
(BigInt(hashBytes[(offset + 6) % 20]) << 8n) |
86+
BigInt(hashBytes[(offset + 7) % 20])
6887
}
6988

7089
let hotp = ''
7190
const charSetLength = BigInt(charSet.length)
7291
for (let i = 0; i < digits; i++) {
7392
hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp
74-
hotpVal = hotpVal / charSetLength
93+
94+
// Ensures hotpVal decreases at a fixed rate, independent of charSet length.
95+
// 10n is compatible with the original TOTP algorithm used in the authenticator apps.
96+
hotpVal = hotpVal / 10n
7597
}
7698

7799
return hotp
@@ -149,8 +171,8 @@ export async function generateTOTP({
149171
charSet = DEFAULT_CHAR_SET,
150172
} = {}) {
151173
const otp = await generateHOTP(base32Decode(secret, 'RFC4648'), {
152-
counter: getCounter(period),
153-
digits,
174+
counter: getCounter(Number(period)),
175+
digits: Number(digits),
154176
algorithm,
155177
charSet,
156178
})

index.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,41 @@ test('generating a auth uri can be used to generate a otp that can be verified',
165165
const result = await verifyTOTP({ otp, ...totpConfig })
166166
assert.deepStrictEqual(result, { delta: 0 })
167167
})
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+
}
202+
203+
await generate20DigitCodeWithCharSet(shortCharSet)
204+
await generate20DigitCodeWithCharSet(longCharSet)
205+
})

0 commit comments

Comments
 (0)