Skip to content

Commit df75482

Browse files
committed
fix verifyTotp window asymmetry, add input validation
1 parent 686029e commit df75482

3 files changed

Lines changed: 67 additions & 10 deletions

File tree

packages/totp/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ const uri = Otp.createTotpKeyUriForQrCode("MyApp", "user@example.com", secret);
2020

2121
## Verification Window
2222

23+
The third argument controls drift tolerance in both directions (behind and ahead):
24+
2325
```typescript
24-
// allow 1 step of drift (default)
26+
// 1 step of drift in each direction (default is 2)
2527
Otp.verifyTotp(secret, code, 1);
2628

2729
// strict - no drift tolerance
2830
Otp.verifyTotp(secret, code, 0);
31+
32+
// asymmetric - 2 steps behind, 0 ahead
33+
Otp.verifyTotp(secret, code, 2, 0);
2934
```

packages/totp/src/index.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { base32 } from "@scure/base";
22
import { describe, expect, it } from "vitest";
3-
import Otp, { InvalidHashFunctionError, InvalidOtpLengthError, InvalidSecretError } from "./index";
3+
import Otp, { InvalidHashFunctionError, InvalidIntervalError, InvalidOtpLengthError, InvalidSecretError } from "./index";
44

55
describe("Otp", () => {
66
it("should generate a secret of default strength", () => {
@@ -72,6 +72,13 @@ describe("Otp", () => {
7272
expect(uri).toContain("issuer=app.example.com");
7373
});
7474

75+
it("should encode issuer with special characters in URI", () => {
76+
const secret = Otp.createSecret();
77+
const uri = Otp.createTotpKeyUriForQrCode("My App/Service", "user@example.com", secret);
78+
expect(uri).toContain("otpauth://totp/My%20App%2FService:");
79+
expect(uri).toContain("issuer=My%20App%2FService");
80+
});
81+
7582
it("should throw an error for invalid secret length", () => {
7683
expect(() => {
7784
Otp.generateTotp("shortsecret");
@@ -229,6 +236,46 @@ describe("Otp - 6 Character Length", () => {
229236
expect(isValid).toBe(false);
230237
});
231238

239+
it("should throw for zero interval", () => {
240+
const secret = Otp.createSecret();
241+
expect(() => Otp.generateTotp(secret, undefined, 6, 0)).toThrow(InvalidIntervalError);
242+
});
243+
244+
it("should throw for negative interval", () => {
245+
const secret = Otp.createSecret();
246+
expect(() => Otp.generateTotp(secret, undefined, 6, -30)).toThrow(InvalidIntervalError);
247+
});
248+
249+
it("should use symmetric window when lookAheadSteps is not provided", () => {
250+
const secret = Otp.createSecret();
251+
const now = Math.floor(Date.now() / 1000);
252+
const interval = 30;
253+
const oneStepAhead = now + interval;
254+
const totp = Otp.generateTotp(secret, oneStepAhead);
255+
expect(Otp.verifyTotp(secret, totp, 0, undefined, now)).toBe(false);
256+
expect(Otp.verifyTotp(secret, totp, 1, undefined, now)).toBe(true);
257+
});
258+
259+
it("should allow asymmetric window when lookAheadSteps is explicit", () => {
260+
const secret = Otp.createSecret();
261+
const now = Math.floor(Date.now() / 1000);
262+
const interval = 30;
263+
const oneStepAhead = now + interval;
264+
const totp = Otp.generateTotp(secret, oneStepAhead);
265+
expect(Otp.verifyTotp(secret, totp, 2, 0, now)).toBe(false);
266+
expect(Otp.verifyTotp(secret, totp, 0, 1, now)).toBe(true);
267+
});
268+
269+
it("should be strict with window of 0", () => {
270+
const secret = Otp.createSecret();
271+
const now = Math.floor(Date.now() / 1000);
272+
const totp = Otp.generateTotp(secret, now);
273+
expect(Otp.verifyTotp(secret, totp, 0, undefined, now)).toBe(true);
274+
const oneStepAgo = now - 30;
275+
const oldTotp = Otp.generateTotp(secret, oneStepAgo);
276+
expect(Otp.verifyTotp(secret, oldTotp, 0, undefined, now)).toBe(false);
277+
});
278+
232279
it("should verify RFC 6238 test vectors for SHA-1 with 6-character OTP", () => {
233280
// const rfc6238TestKeySha1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // Base32 encoding of '12345678901234567890'
234281
const rfc6238TestKeySha1 = base32.encode(new TextEncoder().encode("12345678901234567890")).replace(/=+$/, "");

packages/totp/src/index.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export class InvalidOtpLengthError extends Error {}
55
export class InvalidSecretError extends Error {}
66
export class InvalidHashFunctionError extends Error {}
77
export class InvalidSecretStrengthError extends Error {}
8+
export class InvalidIntervalError extends Error {}
89

910
class Otp {
1011
static OTP_LENGTH_MIN = 6;
@@ -46,7 +47,7 @@ class Otp {
4647
* @throws {Error} - Throws an error if any of the parameters are invalid or missing.
4748
*/
4849
static createTotpKeyUriForQrCode(issuer: string, accountName: string, secret: string): string {
49-
return `otpauth://totp/${issuer}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${issuer}`;
50+
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`;
5051
}
5152

5253
/**
@@ -75,12 +76,14 @@ class Otp {
7576
throw new InvalidOtpLengthError();
7677
}
7778

78-
secret = secret ? secret : "";
79-
t = t ? t : Math.floor(Date.now() / 1000);
80-
t_x = t_x ? t_x : Otp.INTERVAL_LENGTH_DEFAULT;
81-
t_0 = t_0 ? t_0 : Otp.EPOCH_DEFAULT;
79+
if (t_x <= 0) {
80+
throw new InvalidIntervalError();
81+
}
82+
83+
secret = secret ?? "";
84+
t = t ?? Math.floor(Date.now() / 1000);
8285

83-
const c_t = Math.max(0, Math.floor((t - t_0) / t_x)); // Ensure c_t is non-negative
86+
const c_t = Math.max(0, Math.floor((t - t_0) / t_x));
8487

8588
secret = secret.replace(/[^A-Za-z2-7]/g, "").toUpperCase();
8689

@@ -144,13 +147,15 @@ class Otp {
144147
secret: string,
145148
otpValue: string,
146149
lookBehindSteps: number = 2,
147-
lookAheadSteps: number = 2,
150+
lookAheadSteps?: number,
148151
t: number = Math.floor(Date.now() / 1000),
149152
otpLength: number = Otp.OTP_LENGTH_DEFAULT,
150153
t_x: number = Otp.INTERVAL_LENGTH_DEFAULT,
151154
t_0: number = Otp.EPOCH_DEFAULT,
152155
hashFunction: number = Otp.HASH_FUNCTION_DEFAULT,
153156
): boolean {
157+
const ahead = lookAheadSteps ?? lookBehindSteps;
158+
154159
otpValue = otpValue.replace(/[^0-9]/g, "");
155160

156161
if (otpValue.length < Otp.OTP_LENGTH_MIN || otpValue.length > Otp.OTP_LENGTH_MAX) {
@@ -161,7 +166,7 @@ class Otp {
161166
return false;
162167
}
163168

164-
for (let s = -lookBehindSteps; s <= lookAheadSteps; s++) {
169+
for (let s = -lookBehindSteps; s <= ahead; s++) {
165170
const expectedOtpValue = this.generateTotp(secret, t + t_x * s, otpLength, t_x, t_0, hashFunction);
166171
if (crypto.timingSafeEqual(Buffer.from(expectedOtpValue), Buffer.from(otpValue))) {
167172
return true;

0 commit comments

Comments
 (0)