Skip to content

Commit adc780e

Browse files
committed
feat: improve HOTP generation for longer OTPs
- Use BigInt for handling larger number ranges - Dynamically calculate bytes needed based on digits and character set - Ensure OTP generation works correctly for more than 6 digits - Add test to verify OTP generation with extended character set and length
1 parent 0690464 commit adc780e

File tree

2 files changed

+44
-8
lines changed

2 files changed

+44
-8
lines changed

index.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,22 @@ async function generateHOTP(
5656
)
5757
const signature = await crypto.subtle.sign('HMAC', key, byteCounter)
5858
const hashBytes = new Uint8Array(signature)
59-
const offset = hashBytes[19] & 0xf
60-
let hotpVal =
61-
((hashBytes[offset] & 0x7f) << 24) |
62-
((hashBytes[offset + 1] & 0xff) << 16) |
63-
((hashBytes[offset + 2] & 0xff) << 8) |
64-
(hashBytes[offset + 3] & 0xff)
59+
60+
// Use more bytes for longer OTPs
61+
const bytesNeeded = Math.ceil((digits * Math.log2(charSet.length)) / 8)
62+
const offset = hashBytes[hashBytes.length - 1] & 0xf
63+
64+
// Convert bytes to BigInt for larger numbers
65+
let hotpVal = 0n
66+
for (let i = 0; i < Math.min(bytesNeeded, hashBytes.length - offset); i++) {
67+
hotpVal = (hotpVal << 8n) | BigInt(hashBytes[offset + i])
68+
}
6569

6670
let hotp = ''
71+
const charSetLength = BigInt(charSet.length)
6772
for (let i = 0; i < digits; i++) {
68-
hotp = charSet.charAt(hotpVal % charSet.length) + hotp
69-
hotpVal = Math.floor(hotpVal / charSet.length)
73+
hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp
74+
hotpVal = hotpVal / charSetLength
7075
}
7176

7277
return hotp

index.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,34 @@ test('OTP Auth URI can be generated', async () => {
114114
})
115115
assert.match(uri, /^otpauth:\/\/totp\/(.*)\?/)
116116
})
117+
118+
test('OTP with digits > 6 should not pad with first character of charSet', async () => {
119+
const charSet = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'
120+
const iterations = 100
121+
let allOtps = []
122+
123+
for (let i = 0; i < iterations; i++) {
124+
const { otp } = await generateTOTP({
125+
algorithm: 'SHA-256',
126+
charSet,
127+
digits: 12,
128+
period: 60 * 30,
129+
})
130+
allOtps.push(otp)
131+
132+
// Verify the OTP only contains characters from the charSet
133+
assert.match(
134+
otp,
135+
new RegExp(`^[${charSet}]{12}$`),
136+
'OTP should be 12 characters from the charSet'
137+
)
138+
139+
// The first 6 characters should not all be 'A' (first char of charSet)
140+
const firstSixChars = otp.slice(0, 6)
141+
assert.notStrictEqual(
142+
firstSixChars,
143+
'A'.repeat(6),
144+
'First 6 characters should not all be A'
145+
)
146+
}
147+
})

0 commit comments

Comments
 (0)