Skip to content

Commit cee3d64

Browse files
committed
Allow to generate TOTP codes up to 20 characters
1 parent 3bf1701 commit cee3d64

File tree

2 files changed

+49
-6
lines changed

2 files changed

+49
-6
lines changed

index.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,20 @@ export async function generateHOTP(
7777
BigInt(hashBytes[offset + 4]) << 24n |
7878

7979
// 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)
80+
// fallback to the bytes at the start of the hashBytes
81+
BigInt(hashBytes[(offset + 5) % 20]) << 16n |
82+
BigInt(hashBytes[(offset + 6) % 20]) << 8n |
83+
BigInt(hashBytes[(offset + 7) % 20])
8484
}
8585

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

9396
return hotp

index.test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,43 @@ test('generateHOTP works with maximum HMAC offset value', async () => {
161161
});
162162
});
163163
})
164+
165+
test('20 digits OTP should not pad with first character of charSet regardless of the charSet length', async () => {
166+
const longCharSet = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'
167+
const shortCharSet = 'ABCDEFGHIJK'
168+
169+
async function generate20DigitCodeWithCharSet(charSet) {
170+
171+
const iterations = 100
172+
let allOtps = []
173+
174+
for (let i = 0; i < iterations; i++) {
175+
const { otp } = await generateTOTP({
176+
algorithm: 'SHA-256',
177+
charSet,
178+
digits: 20,
179+
period: 60 * 30,
180+
})
181+
allOtps.push(otp)
182+
183+
// Verify the OTP only contains characters from the charSet
184+
assert.match(
185+
otp,
186+
new RegExp(`^[${charSet}]{20}$`),
187+
'OTP should be 20 characters from the charSet'
188+
)
189+
190+
// The first 6 characters should not all be 'A' (first char of charSet)
191+
const firstSixChars = otp.slice(0, 6)
192+
assert.notStrictEqual(
193+
firstSixChars,
194+
'A'.repeat(6),
195+
'First 6 characters should not all be A'
196+
)
197+
}
198+
}
199+
200+
await generate20DigitCodeWithCharSet(shortCharSet);
201+
await generate20DigitCodeWithCharSet(longCharSet);
202+
203+
})

0 commit comments

Comments
 (0)