From 9f8d43b1d204b343608f8745e62ff5e8e767a5b2 Mon Sep 17 00:00:00 2001 From: Szabolcs Nagy <1876846+szabolcsnagy@users.noreply.github.com> Date: Mon, 17 Feb 2025 15:44:40 -0500 Subject: [PATCH 1/4] Allow to generate TOTP codes up to 20 characters --- index.js | 43 +++++++++++++++++++++++++++++++------------ index.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 13254de..e2a83b3 100644 --- a/index.js +++ b/index.js @@ -56,22 +56,41 @@ async function generateHOTP( ) const signature = await crypto.subtle.sign('HMAC', key, byteCounter) const hashBytes = new Uint8Array(signature) - - // Use more bytes for longer OTPs - const bytesNeeded = Math.ceil((digits * Math.log2(charSet.length)) / 8) - const offset = hashBytes[hashBytes.length - 1] & 0xf - - // Convert bytes to BigInt for larger numbers - let hotpVal = 0n - for (let i = 0; i < Math.min(bytesNeeded, hashBytes.length - offset); i++) { - hotpVal = (hotpVal << 8n) | BigInt(hashBytes[offset + i]) - } + // offset is always the last 4 bits of the signature; its value: 0-15 + const offset = hashBytes[hashBytes.length - 1] & 0xf + + let hotpVal = 0n + if (digits === 6) { + // stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt + hotpVal = 0n | + BigInt(hashBytes[offset] & 0x7f) << 24n | + BigInt(hashBytes[offset + 1]) << 16n | + BigInt(hashBytes[offset + 2]) << 8n | + BigInt(hashBytes[offset + 3]) + } else { + // otherwise create a 64bit value from the hashBytes + hotpVal = 0n | + BigInt(hashBytes[offset] & 0x7f) << 56n | + BigInt(hashBytes[offset + 1]) << 48n | + BigInt(hashBytes[offset + 2]) << 40n | + BigInt(hashBytes[offset + 3]) << 32n | + BigInt(hashBytes[offset + 4]) << 24n | + + // we have only 20 hashBytes; if offset is 15 these indexes are out of the hashBytes + // fallback to the bytes at the start of the hashBytes + BigInt(hashBytes[(offset + 5) % 20]) << 16n | + BigInt(hashBytes[(offset + 6) % 20]) << 8n | + BigInt(hashBytes[(offset + 7) % 20]) + } let hotp = '' const charSetLength = BigInt(charSet.length) for (let i = 0; i < digits; i++) { - hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp - hotpVal = hotpVal / charSetLength + hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp + + // Ensures hotpVal decreases at a fixed rate, independent of charSet length. + // 10n is compatible with the original TOTP algorithm used in the authenticator apps. + hotpVal = hotpVal / 10n } return hotp diff --git a/index.test.js b/index.test.js index 3e20b1b..e68cf86 100644 --- a/index.test.js +++ b/index.test.js @@ -165,3 +165,43 @@ test('generating a auth uri can be used to generate a otp that can be verified', const result = await verifyTOTP({ otp, ...totpConfig }) assert.deepStrictEqual(result, { delta: 0 }) }) + +test('20 digits OTP should not pad with first character of charSet regardless of the charSet length', async () => { + const longCharSet = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789' + const shortCharSet = 'ABCDEFGHIJK' + + async function generate20DigitCodeWithCharSet(charSet) { + + const iterations = 100 + let allOtps = [] + + for (let i = 0; i < iterations; i++) { + const { otp } = await generateTOTP({ + algorithm: 'SHA-256', + charSet, + digits: 20, + period: 60 * 30, + }) + allOtps.push(otp) + + // Verify the OTP only contains characters from the charSet + assert.match( + otp, + new RegExp(`^[${charSet}]{20}$`), + 'OTP should be 20 characters from the charSet' + ) + + // The first 6 characters should not all be 'A' (first char of charSet) + const firstSixChars = otp.slice(0, 6) + assert.notStrictEqual( + firstSixChars, + 'A'.repeat(6), + 'First 6 characters should not all be A' + ) + } + } + + await generate20DigitCodeWithCharSet(shortCharSet); + await generate20DigitCodeWithCharSet(longCharSet); + +}) From eebf6f9d6269184b074a4111812f3c25c53655b7 Mon Sep 17 00:00:00 2001 From: Szabolcs Nagy <1876846+szabolcsnagy@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:51:53 -0500 Subject: [PATCH 2/4] fix: ensured that generateTOTP converts the digits and period params into numbers. --- index.js | 8 +++++--- index.test.js | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index e2a83b3..23f555c 100644 --- a/index.js +++ b/index.js @@ -60,7 +60,9 @@ async function generateHOTP( const offset = hashBytes[hashBytes.length - 1] & 0xf let hotpVal = 0n - if (digits === 6) { + // the original specification allows any amount of digits between 4 and 10, + // so stay on the 32bit number if the digits are less then or equal to 10. + if (digits <= 10) { // stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt hotpVal = 0n | BigInt(hashBytes[offset] & 0x7f) << 24n | @@ -168,8 +170,8 @@ export async function generateTOTP({ charSet = DEFAULT_CHAR_SET, } = {}) { const otp = await generateHOTP(base32Decode(secret, 'RFC4648'), { - counter: getCounter(period), - digits, + counter: getCounter(Number(period)), + digits: Number(digits), algorithm, charSet, }) diff --git a/index.test.js b/index.test.js index e68cf86..efb61a0 100644 --- a/index.test.js +++ b/index.test.js @@ -205,3 +205,23 @@ test('20 digits OTP should not pad with first character of charSet regardless of await generate20DigitCodeWithCharSet(longCharSet); }) + +test('generating a auth uri can be used to generate a otp that can be verified', async () => { + const {otp: _otp, ...totpConfig} = await generateTOTP() + const otpUriString = getTOTPAuthUri({ + issuer: 'test', + accountName: 'test', + ...totpConfig, + }) + + const otpUri = new URL(otpUriString) + const options = Object.fromEntries(otpUri.searchParams) + + const { otp } = await generateTOTP({ + ...options, + // the algorithm will be "SHA1" but we need to generate the OTP with "SHA-1" + algorithm: 'SHA-1', + }) + const result = await verifyTOTP({ otp, ...totpConfig }) + assert.deepStrictEqual(result, { delta: 0 }) +}) From dfdd757b5825a1f95363f768ec4f111058b25bfe Mon Sep 17 00:00:00 2001 From: Szabolcs Nagy <1876846+szabolcsnagy@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:01:58 -0500 Subject: [PATCH 3/4] Removed the duplicated test --- index.test.js | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/index.test.js b/index.test.js index efb61a0..4c887a5 100644 --- a/index.test.js +++ b/index.test.js @@ -206,22 +206,3 @@ test('20 digits OTP should not pad with first character of charSet regardless of }) -test('generating a auth uri can be used to generate a otp that can be verified', async () => { - const {otp: _otp, ...totpConfig} = await generateTOTP() - const otpUriString = getTOTPAuthUri({ - issuer: 'test', - accountName: 'test', - ...totpConfig, - }) - - const otpUri = new URL(otpUriString) - const options = Object.fromEntries(otpUri.searchParams) - - const { otp } = await generateTOTP({ - ...options, - // the algorithm will be "SHA1" but we need to generate the OTP with "SHA-1" - algorithm: 'SHA-1', - }) - const result = await verifyTOTP({ otp, ...totpConfig }) - assert.deepStrictEqual(result, { delta: 0 }) -}) From 6cecbce2e4d89171a7c5d11f158f5e374c787169 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Tue, 25 Feb 2025 13:35:34 -0700 Subject: [PATCH 4/4] format --- index.js | 65 +++++++++++++++++++++++++------------------------ index.test.js | 67 ++++++++++++++++++++++++--------------------------- 2 files changed, 65 insertions(+), 67 deletions(-) diff --git a/index.js b/index.js index 23f555c..145097a 100644 --- a/index.js +++ b/index.js @@ -56,43 +56,44 @@ async function generateHOTP( ) const signature = await crypto.subtle.sign('HMAC', key, byteCounter) const hashBytes = new Uint8Array(signature) - // offset is always the last 4 bits of the signature; its value: 0-15 - const offset = hashBytes[hashBytes.length - 1] & 0xf - - let hotpVal = 0n - // the original specification allows any amount of digits between 4 and 10, - // so stay on the 32bit number if the digits are less then or equal to 10. - if (digits <= 10) { - // stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt - hotpVal = 0n | - BigInt(hashBytes[offset] & 0x7f) << 24n | - BigInt(hashBytes[offset + 1]) << 16n | - BigInt(hashBytes[offset + 2]) << 8n | - BigInt(hashBytes[offset + 3]) - } else { - // otherwise create a 64bit value from the hashBytes - hotpVal = 0n | - BigInt(hashBytes[offset] & 0x7f) << 56n | - BigInt(hashBytes[offset + 1]) << 48n | - BigInt(hashBytes[offset + 2]) << 40n | - BigInt(hashBytes[offset + 3]) << 32n | - BigInt(hashBytes[offset + 4]) << 24n | - - // we have only 20 hashBytes; if offset is 15 these indexes are out of the hashBytes - // fallback to the bytes at the start of the hashBytes - BigInt(hashBytes[(offset + 5) % 20]) << 16n | - BigInt(hashBytes[(offset + 6) % 20]) << 8n | - BigInt(hashBytes[(offset + 7) % 20]) - } + // offset is always the last 4 bits of the signature; its value: 0-15 + const offset = hashBytes[hashBytes.length - 1] & 0xf + + let hotpVal = 0n + // the original specification allows any amount of digits between 4 and 10, + // so stay on the 32bit number if the digits are less then or equal to 10. + if (digits <= 10) { + // stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt + hotpVal = + 0n | + (BigInt(hashBytes[offset] & 0x7f) << 24n) | + (BigInt(hashBytes[offset + 1]) << 16n) | + (BigInt(hashBytes[offset + 2]) << 8n) | + BigInt(hashBytes[offset + 3]) + } else { + // otherwise create a 64bit value from the hashBytes + hotpVal = + 0n | + (BigInt(hashBytes[offset] & 0x7f) << 56n) | + (BigInt(hashBytes[offset + 1]) << 48n) | + (BigInt(hashBytes[offset + 2]) << 40n) | + (BigInt(hashBytes[offset + 3]) << 32n) | + (BigInt(hashBytes[offset + 4]) << 24n) | + // we have only 20 hashBytes; if offset is 15 these indexes are out of the hashBytes + // fallback to the bytes at the start of the hashBytes + (BigInt(hashBytes[(offset + 5) % 20]) << 16n) | + (BigInt(hashBytes[(offset + 6) % 20]) << 8n) | + BigInt(hashBytes[(offset + 7) % 20]) + } let hotp = '' const charSetLength = BigInt(charSet.length) for (let i = 0; i < digits; i++) { - hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp + hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp - // Ensures hotpVal decreases at a fixed rate, independent of charSet length. - // 10n is compatible with the original TOTP algorithm used in the authenticator apps. - hotpVal = hotpVal / 10n + // Ensures hotpVal decreases at a fixed rate, independent of charSet length. + // 10n is compatible with the original TOTP algorithm used in the authenticator apps. + hotpVal = hotpVal / 10n } return hotp diff --git a/index.test.js b/index.test.js index 4c887a5..dbe8a2a 100644 --- a/index.test.js +++ b/index.test.js @@ -169,40 +169,37 @@ test('generating a auth uri can be used to generate a otp that can be verified', test('20 digits OTP should not pad with first character of charSet regardless of the charSet length', async () => { const longCharSet = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789' const shortCharSet = 'ABCDEFGHIJK' - - async function generate20DigitCodeWithCharSet(charSet) { - - const iterations = 100 - let allOtps = [] - - for (let i = 0; i < iterations; i++) { - const { otp } = await generateTOTP({ - algorithm: 'SHA-256', - charSet, - digits: 20, - period: 60 * 30, - }) - allOtps.push(otp) - - // Verify the OTP only contains characters from the charSet - assert.match( - otp, - new RegExp(`^[${charSet}]{20}$`), - 'OTP should be 20 characters from the charSet' - ) - - // The first 6 characters should not all be 'A' (first char of charSet) - const firstSixChars = otp.slice(0, 6) - assert.notStrictEqual( - firstSixChars, - 'A'.repeat(6), - 'First 6 characters should not all be A' - ) - } - } - - await generate20DigitCodeWithCharSet(shortCharSet); - await generate20DigitCodeWithCharSet(longCharSet); -}) + async function generate20DigitCodeWithCharSet(charSet) { + const iterations = 100 + let allOtps = [] + + for (let i = 0; i < iterations; i++) { + const { otp } = await generateTOTP({ + algorithm: 'SHA-256', + charSet, + digits: 20, + period: 60 * 30, + }) + allOtps.push(otp) + + // Verify the OTP only contains characters from the charSet + assert.match( + otp, + new RegExp(`^[${charSet}]{20}$`), + 'OTP should be 20 characters from the charSet' + ) + + // The first 6 characters should not all be 'A' (first char of charSet) + const firstSixChars = otp.slice(0, 6) + assert.notStrictEqual( + firstSixChars, + 'A'.repeat(6), + 'First 6 characters should not all be A' + ) + } + } + await generate20DigitCodeWithCharSet(shortCharSet) + await generate20DigitCodeWithCharSet(longCharSet) +})