Skip to content

Commit d741ab7

Browse files
authored
fix(otp): use constant-time comparison when verifying codes (#25)
1 parent be85f69 commit d741ab7

2 files changed

Lines changed: 44 additions & 4 deletions

File tree

src/otp.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,34 @@ describe("HOTP and TOTP Generation Tests", () => {
8888
expect(isValid).toBe(false);
8989
});
9090

91+
it("should check every TOTP window candidate without returning on first match", async () => {
92+
vi.resetModules();
93+
const sign = vi.fn(async () => {
94+
const buffer = new ArrayBuffer(20);
95+
const result = new Uint8Array(buffer);
96+
result[3] = 1;
97+
return buffer;
98+
});
99+
vi.doMock("./hmac", () => ({
100+
createHMAC: () => ({
101+
sign,
102+
}),
103+
}));
104+
try {
105+
const { createOTP: createMockedOTP } = await import("./otp");
106+
107+
const isValid = await createMockedOTP("1234567890").verify("000001", {
108+
window: 1,
109+
});
110+
111+
expect(isValid).toBe(true);
112+
expect(sign).toHaveBeenCalledTimes(3);
113+
} finally {
114+
vi.doUnmock("./hmac");
115+
vi.resetModules();
116+
}
117+
});
118+
91119
it("should generate a valid QR code URL", () => {
92120
const secret = "1234567890";
93121
const issuer = "my-site.com";

src/otp.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import type { SHAFamily } from "./type";
55
const defaultPeriod = 30;
66
const defaultDigits = 6;
77

8+
/**
9+
* loops over `expected.length` so timing never depends on input length
10+
*
11+
* @internal
12+
*/
13+
function constantTimeEqualOTP(input: string, expected: string): boolean {
14+
let difference = input.length ^ expected.length;
15+
for (let i = 0; i < expected.length; i++) {
16+
difference |= input.charCodeAt(i) ^ expected.charCodeAt(i);
17+
}
18+
return difference === 0;
19+
}
20+
821
async function generateHOTP(
922
secret: string,
1023
{
@@ -66,16 +79,15 @@ async function verifyTOTP(
6679
) {
6780
const milliseconds = period * 1000;
6881
const counter = Math.floor(Date.now() / milliseconds);
82+
let matched = false;
6983
for (let i = -window; i <= window; i++) {
7084
const generatedOTP = await generateHOTP(secret, {
7185
counter: counter + i,
7286
digits,
7387
});
74-
if (otp === generatedOTP) {
75-
return true;
76-
}
88+
matched = constantTimeEqualOTP(otp, generatedOTP) || matched;
7789
}
78-
return false;
90+
return matched;
7991
}
8092

8193
/**

0 commit comments

Comments
 (0)