From b9e35b0b05db7a0b8b231b9374125ee4d7997f89 Mon Sep 17 00:00:00 2001 From: Taesu Date: Fri, 12 Jun 2026 19:10:13 -0700 Subject: [PATCH] fix(otp): use constant-time comparison when verifying codes --- src/otp.test.ts | 28 ++++++++++++++++++++++++++++ src/otp.ts | 20 ++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/otp.test.ts b/src/otp.test.ts index 9fdfa7f..aa488c6 100644 --- a/src/otp.test.ts +++ b/src/otp.test.ts @@ -88,6 +88,34 @@ describe("HOTP and TOTP Generation Tests", () => { expect(isValid).toBe(false); }); + it("should check every TOTP window candidate without returning on first match", async () => { + vi.resetModules(); + const sign = vi.fn(async () => { + const buffer = new ArrayBuffer(20); + const result = new Uint8Array(buffer); + result[3] = 1; + return buffer; + }); + vi.doMock("./hmac", () => ({ + createHMAC: () => ({ + sign, + }), + })); + try { + const { createOTP: createMockedOTP } = await import("./otp"); + + const isValid = await createMockedOTP("1234567890").verify("000001", { + window: 1, + }); + + expect(isValid).toBe(true); + expect(sign).toHaveBeenCalledTimes(3); + } finally { + vi.doUnmock("./hmac"); + vi.resetModules(); + } + }); + it("should generate a valid QR code URL", () => { const secret = "1234567890"; const issuer = "my-site.com"; diff --git a/src/otp.ts b/src/otp.ts index f841086..ef23122 100644 --- a/src/otp.ts +++ b/src/otp.ts @@ -5,6 +5,19 @@ import type { SHAFamily } from "./type"; const defaultPeriod = 30; const defaultDigits = 6; +/** + * loops over `expected.length` so timing never depends on input length + * + * @internal + */ +function constantTimeEqualOTP(input: string, expected: string): boolean { + let difference = input.length ^ expected.length; + for (let i = 0; i < expected.length; i++) { + difference |= input.charCodeAt(i) ^ expected.charCodeAt(i); + } + return difference === 0; +} + async function generateHOTP( secret: string, { @@ -66,16 +79,15 @@ async function verifyTOTP( ) { const milliseconds = period * 1000; const counter = Math.floor(Date.now() / milliseconds); + let matched = false; for (let i = -window; i <= window; i++) { const generatedOTP = await generateHOTP(secret, { counter: counter + i, digits, }); - if (otp === generatedOTP) { - return true; - } + matched = constantTimeEqualOTP(otp, generatedOTP) || matched; } - return false; + return matched; } /**