redis-otp-manager 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,9 @@ Lightweight, Redis-backed OTP manager for Node.js and NestJS apps.
7
7
  This package currently includes:
8
8
  - OTP generation
9
9
  - OTP verification
10
- - SHA-256 OTP hashing
10
+ - Keyed HMAC support with app secret configuration
11
+ - Secret rotation support for verification
12
+ - Legacy SHA-256 verification compatibility for migrations
11
13
  - Redis-compatible storage adapter
12
14
  - In-memory adapter for tests
13
15
  - Intent-aware key strategy
@@ -37,20 +39,6 @@ This package supports both:
37
39
  - ESM imports
38
40
  - CommonJS/Nest `ts-node/register` style resolution
39
41
 
40
- ESM:
41
-
42
- ```ts
43
- import { OTPManager } from "redis-otp-manager";
44
- import { OTPModule } from "redis-otp-manager/nest";
45
- ```
46
-
47
- CommonJS:
48
-
49
- ```js
50
- const { OTPManager } = require("redis-otp-manager");
51
- const { OTPModule } = require("redis-otp-manager/nest");
52
- ```
53
-
54
42
  ## Quality Checks
55
43
 
56
44
  ```bash
@@ -74,36 +62,53 @@ const otp = new OTPManager({
74
62
  max: 3,
75
63
  },
76
64
  devMode: false,
65
+ hashing: {
66
+ secret: process.env.OTP_HMAC_SECRET,
67
+ },
77
68
  });
69
+ ```
78
70
 
79
- const generated = await otp.generate({
80
- type: "email",
81
- identifier: "abc@gmail.com",
82
- intent: "login",
83
- });
71
+ ## Cryptographic Hardening
72
+
73
+ When `hashing.secret` is configured, new OTPs are stored using keyed HMAC instead of plain SHA-256.
84
74
 
85
- await otp.verify({
86
- type: "email",
87
- identifier: "abc@gmail.com",
88
- intent: "login",
89
- otp: generated.otp ?? "123456",
75
+ ```ts
76
+ const otp = new OTPManager({
77
+ store: new RedisAdapter(redisClient),
78
+ ttl: 300,
79
+ maxAttempts: 3,
80
+ hashing: {
81
+ secret: process.env.OTP_HMAC_SECRET,
82
+ previousSecrets: [process.env.OTP_PREVIOUS_SECRET ?? ""].filter(Boolean),
83
+ allowLegacyVerify: true,
84
+ },
90
85
  });
91
86
  ```
92
87
 
88
+ Recommended migration strategy:
89
+ - deploy `hashing.secret`
90
+ - keep `allowLegacyVerify: true` during migration
91
+ - optionally add `previousSecrets` during secret rotation
92
+ - after old in-flight OTPs naturally expire, you can disable legacy verification if desired
93
+
94
+ Secure secret management guidance:
95
+ - store secrets in environment variables or your secret manager
96
+ - never hardcode secrets in source control
97
+ - rotate secrets deliberately and keep the previous secret only as long as needed
98
+ - use different secrets across environments
99
+
93
100
  ## Production Security Notes
94
101
 
95
- When you use `RedisAdapter`, the package now takes the Redis-specific atomic path for:
102
+ When you use `RedisAdapter`, the package takes the Redis-specific atomic path for:
96
103
  - OTP generation plus rate-limit/cooldown checks
97
104
  - OTP verification plus attempt tracking and OTP deletion
98
105
 
99
- That prevents the most important race condition in earlier versions where two parallel correct verification requests could both succeed.
106
+ That prevents the most important race condition from earlier versions where two parallel correct verification requests could both succeed.
100
107
 
101
108
  Simpler adapters like `MemoryAdapter` intentionally stay on the non-atomic fallback path to keep tests and local development lightweight.
102
109
 
103
110
  ## NestJS
104
111
 
105
- Import the Nest integration from the dedicated subpath so non-Nest users do not pull Nest dependencies unless they need them.
106
-
107
112
  ```ts
108
113
  import { Module } from "@nestjs/common";
109
114
  import { createClient } from "redis";
@@ -123,6 +128,9 @@ const redisClient = createClient({ url: process.env.REDIS_URL });
123
128
  window: 60,
124
129
  max: 3,
125
130
  },
131
+ hashing: {
132
+ secret: process.env.OTP_HMAC_SECRET,
133
+ },
126
134
  isGlobal: true,
127
135
  }),
128
136
  ],
@@ -130,34 +138,6 @@ const redisClient = createClient({ url: process.env.REDIS_URL });
130
138
  export class AppModule {}
131
139
  ```
132
140
 
133
- Async setup is also supported:
134
-
135
- ```ts
136
- OTPModule.forRootAsync({
137
- isGlobal: true,
138
- inject: [ConfigService],
139
- useFactory: (config: ConfigService) => ({
140
- store: new RedisAdapter(createClient({ url: config.getOrThrow("REDIS_URL") })),
141
- ttl: 300,
142
- maxAttempts: 5,
143
- resendCooldown: 45,
144
- }),
145
- });
146
- ```
147
-
148
- Inject in services:
149
-
150
- ```ts
151
- import { Injectable } from "@nestjs/common";
152
- import { OTPManager } from "redis-otp-manager";
153
- import { InjectOTPManager } from "redis-otp-manager/nest";
154
-
155
- @Injectable()
156
- export class AuthService {
157
- constructor(@InjectOTPManager() private readonly otpManager: OTPManager) {}
158
- }
159
- ```
160
-
161
141
  ## API
162
142
 
163
143
  ### `new OTPManager(options)`
@@ -179,47 +159,15 @@ type OTPManagerOptions = {
179
159
  lowercase?: boolean;
180
160
  preserveCaseFor?: string[];
181
161
  };
162
+ hashing?: {
163
+ secret?: string;
164
+ previousSecrets?: string[];
165
+ allowLegacyVerify?: boolean;
166
+ };
182
167
  };
183
168
  ```
184
169
 
185
- Normalization is backward-compatible and conservative by default:
186
- - identifiers are trimmed
187
- - identifiers are lowercased for most channels
188
- - `sms` and `token` preserve case by default
189
-
190
- ### `generate(input)`
191
-
192
- ```ts
193
- const result = await otp.generate({
194
- type: "email",
195
- identifier: "abc@gmail.com",
196
- intent: "login",
197
- });
198
- ```
199
-
200
- Returns:
201
-
202
- ```ts
203
- {
204
- expiresIn: 300,
205
- otp?: "123456"
206
- }
207
- ```
208
-
209
- ### `verify(input)`
210
-
211
- ```ts
212
- await otp.verify({
213
- type: "email",
214
- identifier: "abc@gmail.com",
215
- intent: "login",
216
- otp: "123456",
217
- });
218
- ```
219
-
220
- Returns `true` or throws a typed error.
221
-
222
- ## Errors
170
+ ### Errors
223
171
 
224
172
  - `OTPRateLimitExceededError`
225
173
  - `OTPExpiredError`
@@ -232,9 +180,10 @@ Returns `true` or throws a typed error.
232
180
  - `devMode: false`
233
181
  - `otpLength: 8` for higher-risk flows
234
182
  - `maxAttempts: 3`
235
- - `resendCooldown: 30` or higher to reduce abuse
236
- - keep Redis private and behind authenticated network access
183
+ - `resendCooldown: 30` or higher
184
+ - `hashing.secret` configured in production
237
185
  - prefer `RedisAdapter` in production to get the atomic security path
186
+ - keep Redis private and behind authenticated network access
238
187
 
239
188
  ## Key Design
240
189
 
@@ -249,7 +198,7 @@ cooldown:{intent}:{type}:{identifier}
249
198
 
250
199
  Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
251
200
  - merge to `main` runs CI only
252
- - publish happens when you push a version tag like `v0.3.0`
201
+ - publish happens when you push a version tag like `v0.4.0`
253
202
 
254
203
  Required GitHub secrets:
255
204
  - `NPM_TOKEN`
@@ -257,12 +206,12 @@ Required GitHub secrets:
257
206
  Tag-based publish:
258
207
 
259
208
  ```bash
260
- git tag v0.3.0
261
- git push origin v0.3.0
209
+ git tag v0.4.0
210
+ git push origin v0.4.0
262
211
  ```
263
212
 
264
213
  ## Next Roadmap
265
214
 
266
- - keyed HMAC for OTP hashing
267
215
  - hooks/events
268
216
  - analytics and observability
217
+ - delivery helper integrations
@@ -26,7 +26,7 @@ export interface AtomicGenerateParams {
26
26
  export interface AtomicVerifyParams {
27
27
  otpKey: string;
28
28
  attemptsKey: string;
29
- providedHash: string;
29
+ candidateHashes: string[];
30
30
  ttl: number;
31
31
  maxAttempts: number;
32
32
  }
@@ -31,18 +31,23 @@ if not storedHash then
31
31
  return 'expired'
32
32
  end
33
33
 
34
- if storedHash == ARGV[1] then
35
- redis.call('DEL', KEYS[1])
36
- redis.call('DEL', KEYS[2])
37
- return 'verified'
34
+ local candidateCount = tonumber(ARGV[1])
35
+ for index = 1, candidateCount do
36
+ if storedHash == ARGV[index + 1] then
37
+ redis.call('DEL', KEYS[1])
38
+ redis.call('DEL', KEYS[2])
39
+ return 'verified'
40
+ end
38
41
  end
39
42
 
43
+ local ttlIndex = candidateCount + 2
44
+ local maxAttemptsIndex = candidateCount + 3
40
45
  local attempts = redis.call('INCR', KEYS[2])
41
46
  if attempts == 1 then
42
- redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2]))
47
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
43
48
  end
44
49
 
45
- if attempts >= tonumber(ARGV[3]) then
50
+ if attempts >= tonumber(ARGV[maxAttemptsIndex]) then
46
51
  redis.call('DEL', KEYS[1])
47
52
  redis.call('DEL', KEYS[2])
48
53
  return 'max_attempts'
@@ -96,7 +101,12 @@ export class RedisAdapter {
96
101
  }
97
102
  const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
98
103
  keys: [params.otpKey, params.attemptsKey],
99
- arguments: [params.providedHash, String(params.ttl), String(params.maxAttempts)],
104
+ arguments: [
105
+ String(params.candidateHashes.length),
106
+ ...params.candidateHashes,
107
+ String(params.ttl),
108
+ String(params.maxAttempts),
109
+ ],
100
110
  });
101
111
  if (result === "verified" ||
102
112
  result === "expired" ||
@@ -34,18 +34,23 @@ if not storedHash then
34
34
  return 'expired'
35
35
  end
36
36
 
37
- if storedHash == ARGV[1] then
38
- redis.call('DEL', KEYS[1])
39
- redis.call('DEL', KEYS[2])
40
- return 'verified'
37
+ local candidateCount = tonumber(ARGV[1])
38
+ for index = 1, candidateCount do
39
+ if storedHash == ARGV[index + 1] then
40
+ redis.call('DEL', KEYS[1])
41
+ redis.call('DEL', KEYS[2])
42
+ return 'verified'
43
+ end
41
44
  end
42
45
 
46
+ local ttlIndex = candidateCount + 2
47
+ local maxAttemptsIndex = candidateCount + 3
43
48
  local attempts = redis.call('INCR', KEYS[2])
44
49
  if attempts == 1 then
45
- redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2]))
50
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
46
51
  end
47
52
 
48
- if attempts >= tonumber(ARGV[3]) then
53
+ if attempts >= tonumber(ARGV[maxAttemptsIndex]) then
49
54
  redis.call('DEL', KEYS[1])
50
55
  redis.call('DEL', KEYS[2])
51
56
  return 'max_attempts'
@@ -99,7 +104,12 @@ class RedisAdapter {
99
104
  }
100
105
  const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
101
106
  keys: [params.otpKey, params.attemptsKey],
102
- arguments: [params.providedHash, String(params.ttl), String(params.maxAttempts)],
107
+ arguments: [
108
+ String(params.candidateHashes.length),
109
+ ...params.candidateHashes,
110
+ String(params.ttl),
111
+ String(params.maxAttempts),
112
+ ],
103
113
  });
104
114
  if (result === "verified" ||
105
115
  result === "expired" ||
@@ -1,16 +1,52 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.hashOtp = hashOtp;
4
+ exports.createStoredOtpHash = createStoredOtpHash;
5
+ exports.buildVerificationHashes = buildVerificationHashes;
4
6
  exports.verifyOtpHash = verifyOtpHash;
5
7
  const node_crypto_1 = require("node:crypto");
8
+ const LEGACY_PREFIX = "sha256:";
9
+ const HMAC_PREFIX = "hmac-sha256:";
6
10
  function hashOtp(otp) {
7
11
  return (0, node_crypto_1.createHash)("sha256").update(otp).digest("hex");
8
12
  }
9
- function verifyOtpHash(otp, expectedHash) {
10
- const actualBuffer = Buffer.from(hashOtp(otp), "hex");
11
- const expectedBuffer = Buffer.from(expectedHash, "hex");
12
- if (actualBuffer.length !== expectedBuffer.length) {
13
+ function createStoredOtpHash(otp, payload, hashing) {
14
+ if (hashing?.secret) {
15
+ return `${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`;
16
+ }
17
+ return `${LEGACY_PREFIX}${hashOtp(otp)}`;
18
+ }
19
+ function buildVerificationHashes(otp, payload, hashing) {
20
+ const candidates = new Set();
21
+ if (hashing?.secret) {
22
+ candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`);
23
+ for (const secret of hashing.previousSecrets ?? []) {
24
+ candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, secret)}`);
25
+ }
26
+ }
27
+ const allowLegacyVerify = hashing?.allowLegacyVerify ?? true;
28
+ if (!hashing?.secret || allowLegacyVerify) {
29
+ const legacyHash = hashOtp(otp);
30
+ candidates.add(legacyHash);
31
+ candidates.add(`${LEGACY_PREFIX}${legacyHash}`);
32
+ }
33
+ return [...candidates];
34
+ }
35
+ function verifyOtpHash(otp, payload, expectedHash, hashing) {
36
+ const candidates = buildVerificationHashes(otp, payload, hashing);
37
+ return candidates.some((candidate) => safeCompareHashes(candidate, expectedHash));
38
+ }
39
+ function hashOtpWithSecret(otp, payload, secret) {
40
+ return (0, node_crypto_1.createHmac)("sha256", secret).update(buildHmacMaterial(otp, payload)).digest("hex");
41
+ }
42
+ function buildHmacMaterial(otp, payload) {
43
+ return [payload.intent ?? "default", payload.type, payload.identifier, otp].join(":");
44
+ }
45
+ function safeCompareHashes(candidate, expectedHash) {
46
+ const candidateBuffer = Buffer.from(candidate);
47
+ const expectedBuffer = Buffer.from(expectedHash);
48
+ if (candidateBuffer.length !== expectedBuffer.length) {
13
49
  return false;
14
50
  }
15
- return (0, node_crypto_1.timingSafeEqual)(actualBuffer, expectedBuffer);
51
+ return (0, node_crypto_1.timingSafeEqual)(candidateBuffer, expectedBuffer);
16
52
  }
@@ -26,14 +26,14 @@ class OTPManager {
26
26
  const rateLimitKey = (0, key_builder_js_1.buildRateLimitKey)(normalizedInput);
27
27
  const cooldownKey = (0, key_builder_js_1.buildCooldownKey)(normalizedInput);
28
28
  const otp = (0, otp_generator_js_1.generateNumericOtp)(this.options.otpLength);
29
- const hashedOtp = (0, otp_hash_js_1.hashOtp)(otp);
29
+ const storedHash = (0, otp_hash_js_1.createStoredOtpHash)(otp, normalizedInput, this.options.hashing);
30
30
  if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
31
31
  const atomicResult = await this.options.store.generateOtpAtomically({
32
32
  otpKey,
33
33
  attemptsKey,
34
34
  rateLimitKey,
35
35
  cooldownKey,
36
- hashedOtp,
36
+ hashedOtp: storedHash,
37
37
  ttl: this.options.ttl,
38
38
  rateWindow: this.options.rateLimit?.window,
39
39
  rateMax: this.options.rateLimit?.max,
@@ -59,7 +59,7 @@ class OTPManager {
59
59
  }
60
60
  }
61
61
  await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
62
- await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
62
+ await this.options.store.set(otpKey, storedHash, this.options.ttl);
63
63
  await this.options.store.del(attemptsKey);
64
64
  if (this.options.resendCooldown) {
65
65
  await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
@@ -77,11 +77,12 @@ class OTPManager {
77
77
  }
78
78
  const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
79
79
  const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
80
+ const candidateHashes = (0, otp_hash_js_1.buildVerificationHashes)(input.otp, normalizedInput, this.options.hashing);
80
81
  if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
81
82
  const atomicResult = await this.options.store.verifyOtpAtomically({
82
83
  otpKey,
83
84
  attemptsKey,
84
- providedHash: (0, otp_hash_js_1.hashOtp)(input.otp),
85
+ candidateHashes,
85
86
  ttl: this.options.ttl,
86
87
  maxAttempts: this.options.maxAttempts,
87
88
  });
@@ -102,7 +103,7 @@ class OTPManager {
102
103
  if (!storedHash) {
103
104
  throw new otp_errors_js_1.OTPExpiredError();
104
105
  }
105
- const isValid = (0, otp_hash_js_1.verifyOtpHash)(input.otp, storedHash);
106
+ const isValid = (0, otp_hash_js_1.verifyOtpHash)(input.otp, normalizedInput, storedHash, this.options.hashing);
106
107
  if (!isValid) {
107
108
  const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
108
109
  if (attempts >= this.options.maxAttempts) {
@@ -155,6 +156,20 @@ function validateManagerOptions(options) {
155
156
  throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
156
157
  }
157
158
  }
159
+ if (options.hashing) {
160
+ if (options.hashing.secret !== undefined && !options.hashing.secret.trim()) {
161
+ throw new TypeError("hashing.secret must be a non-empty string when provided.");
162
+ }
163
+ if (options.hashing.previousSecrets !== undefined &&
164
+ (!Array.isArray(options.hashing.previousSecrets) ||
165
+ options.hashing.previousSecrets.some((secret) => !secret || !secret.trim()))) {
166
+ throw new TypeError("hashing.previousSecrets must contain only non-empty strings.");
167
+ }
168
+ if (options.hashing.allowLegacyVerify !== undefined &&
169
+ typeof options.hashing.allowLegacyVerify !== "boolean") {
170
+ throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
171
+ }
172
+ }
158
173
  }
159
174
  function validatePayload(input) {
160
175
  if (!input.type || !input.type.trim()) {
@@ -1,2 +1,5 @@
1
+ import type { OTPHashingOptions, OTPPayload } from "./otp.types.js";
1
2
  export declare function hashOtp(otp: string): string;
2
- export declare function verifyOtpHash(otp: string, expectedHash: string): boolean;
3
+ export declare function createStoredOtpHash(otp: string, payload: OTPPayload, hashing?: OTPHashingOptions): string;
4
+ export declare function buildVerificationHashes(otp: string, payload: OTPPayload, hashing?: OTPHashingOptions): string[];
5
+ export declare function verifyOtpHash(otp: string, payload: OTPPayload, expectedHash: string, hashing?: OTPHashingOptions): boolean;
@@ -1,12 +1,46 @@
1
- import { createHash, timingSafeEqual } from "node:crypto";
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ const LEGACY_PREFIX = "sha256:";
3
+ const HMAC_PREFIX = "hmac-sha256:";
2
4
  export function hashOtp(otp) {
3
5
  return createHash("sha256").update(otp).digest("hex");
4
6
  }
5
- export function verifyOtpHash(otp, expectedHash) {
6
- const actualBuffer = Buffer.from(hashOtp(otp), "hex");
7
- const expectedBuffer = Buffer.from(expectedHash, "hex");
8
- if (actualBuffer.length !== expectedBuffer.length) {
7
+ export function createStoredOtpHash(otp, payload, hashing) {
8
+ if (hashing?.secret) {
9
+ return `${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`;
10
+ }
11
+ return `${LEGACY_PREFIX}${hashOtp(otp)}`;
12
+ }
13
+ export function buildVerificationHashes(otp, payload, hashing) {
14
+ const candidates = new Set();
15
+ if (hashing?.secret) {
16
+ candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`);
17
+ for (const secret of hashing.previousSecrets ?? []) {
18
+ candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, secret)}`);
19
+ }
20
+ }
21
+ const allowLegacyVerify = hashing?.allowLegacyVerify ?? true;
22
+ if (!hashing?.secret || allowLegacyVerify) {
23
+ const legacyHash = hashOtp(otp);
24
+ candidates.add(legacyHash);
25
+ candidates.add(`${LEGACY_PREFIX}${legacyHash}`);
26
+ }
27
+ return [...candidates];
28
+ }
29
+ export function verifyOtpHash(otp, payload, expectedHash, hashing) {
30
+ const candidates = buildVerificationHashes(otp, payload, hashing);
31
+ return candidates.some((candidate) => safeCompareHashes(candidate, expectedHash));
32
+ }
33
+ function hashOtpWithSecret(otp, payload, secret) {
34
+ return createHmac("sha256", secret).update(buildHmacMaterial(otp, payload)).digest("hex");
35
+ }
36
+ function buildHmacMaterial(otp, payload) {
37
+ return [payload.intent ?? "default", payload.type, payload.identifier, otp].join(":");
38
+ }
39
+ function safeCompareHashes(candidate, expectedHash) {
40
+ const candidateBuffer = Buffer.from(candidate);
41
+ const expectedBuffer = Buffer.from(expectedHash);
42
+ if (candidateBuffer.length !== expectedBuffer.length) {
9
43
  return false;
10
44
  }
11
- return timingSafeEqual(actualBuffer, expectedBuffer);
45
+ return timingSafeEqual(candidateBuffer, expectedBuffer);
12
46
  }
@@ -1,6 +1,6 @@
1
1
  import { RedisAdapter } from "../adapters/redis.adapter.js";
2
2
  import { generateNumericOtp } from "./otp.generator.js";
3
- import { hashOtp, verifyOtpHash } from "./otp.hash.js";
3
+ import { buildVerificationHashes, createStoredOtpHash, verifyOtpHash } from "./otp.hash.js";
4
4
  import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
5
5
  import { buildAttemptsKey, buildCooldownKey, buildOtpKey, buildRateLimitKey, } from "../utils/key-builder.js";
6
6
  import { normalizePayloadIdentifier } from "../utils/identifier-normalizer.js";
@@ -23,14 +23,14 @@ export class OTPManager {
23
23
  const rateLimitKey = buildRateLimitKey(normalizedInput);
24
24
  const cooldownKey = buildCooldownKey(normalizedInput);
25
25
  const otp = generateNumericOtp(this.options.otpLength);
26
- const hashedOtp = hashOtp(otp);
26
+ const storedHash = createStoredOtpHash(otp, normalizedInput, this.options.hashing);
27
27
  if (this.options.store instanceof RedisAdapter) {
28
28
  const atomicResult = await this.options.store.generateOtpAtomically({
29
29
  otpKey,
30
30
  attemptsKey,
31
31
  rateLimitKey,
32
32
  cooldownKey,
33
- hashedOtp,
33
+ hashedOtp: storedHash,
34
34
  ttl: this.options.ttl,
35
35
  rateWindow: this.options.rateLimit?.window,
36
36
  rateMax: this.options.rateLimit?.max,
@@ -56,7 +56,7 @@ export class OTPManager {
56
56
  }
57
57
  }
58
58
  await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
59
- await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
59
+ await this.options.store.set(otpKey, storedHash, this.options.ttl);
60
60
  await this.options.store.del(attemptsKey);
61
61
  if (this.options.resendCooldown) {
62
62
  await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
@@ -74,11 +74,12 @@ export class OTPManager {
74
74
  }
75
75
  const otpKey = buildOtpKey(normalizedInput);
76
76
  const attemptsKey = buildAttemptsKey(normalizedInput);
77
+ const candidateHashes = buildVerificationHashes(input.otp, normalizedInput, this.options.hashing);
77
78
  if (this.options.store instanceof RedisAdapter) {
78
79
  const atomicResult = await this.options.store.verifyOtpAtomically({
79
80
  otpKey,
80
81
  attemptsKey,
81
- providedHash: hashOtp(input.otp),
82
+ candidateHashes,
82
83
  ttl: this.options.ttl,
83
84
  maxAttempts: this.options.maxAttempts,
84
85
  });
@@ -99,7 +100,7 @@ export class OTPManager {
99
100
  if (!storedHash) {
100
101
  throw new OTPExpiredError();
101
102
  }
102
- const isValid = verifyOtpHash(input.otp, storedHash);
103
+ const isValid = verifyOtpHash(input.otp, normalizedInput, storedHash, this.options.hashing);
103
104
  if (!isValid) {
104
105
  const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
105
106
  if (attempts >= this.options.maxAttempts) {
@@ -151,6 +152,20 @@ function validateManagerOptions(options) {
151
152
  throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
152
153
  }
153
154
  }
155
+ if (options.hashing) {
156
+ if (options.hashing.secret !== undefined && !options.hashing.secret.trim()) {
157
+ throw new TypeError("hashing.secret must be a non-empty string when provided.");
158
+ }
159
+ if (options.hashing.previousSecrets !== undefined &&
160
+ (!Array.isArray(options.hashing.previousSecrets) ||
161
+ options.hashing.previousSecrets.some((secret) => !secret || !secret.trim()))) {
162
+ throw new TypeError("hashing.previousSecrets must contain only non-empty strings.");
163
+ }
164
+ if (options.hashing.allowLegacyVerify !== undefined &&
165
+ typeof options.hashing.allowLegacyVerify !== "boolean") {
166
+ throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
167
+ }
168
+ }
154
169
  }
155
170
  function validatePayload(input) {
156
171
  if (!input.type || !input.type.trim()) {
@@ -18,6 +18,11 @@ export interface IdentifierNormalizationConfig {
18
18
  lowercase?: boolean;
19
19
  preserveCaseFor?: OTPChannel[];
20
20
  }
21
+ export interface OTPHashingOptions {
22
+ secret?: string;
23
+ previousSecrets?: string[];
24
+ allowLegacyVerify?: boolean;
25
+ }
21
26
  export interface OTPManagerOptions {
22
27
  store: StoreAdapter;
23
28
  ttl: number;
@@ -27,6 +32,7 @@ export interface OTPManagerOptions {
27
32
  otpLength?: number;
28
33
  resendCooldown?: number;
29
34
  identifierNormalization?: IdentifierNormalizationConfig;
35
+ hashing?: OTPHashingOptions;
30
36
  }
31
37
  export interface GenerateOTPResult {
32
38
  expiresIn: number;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { OTPManager } from "./core/otp.service.js";
2
2
  export { RedisAdapter } from "./adapters/redis.adapter.js";
3
3
  export { MemoryAdapter } from "./adapters/memory.adapter.js";
4
- export type { GenerateOTPInput, GenerateOTPResult, IdentifierNormalizationConfig, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
4
+ export type { GenerateOTPInput, GenerateOTPResult, IdentifierNormalizationConfig, OTPHashingOptions, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
5
5
  export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "./errors/otp.errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redis-otp-manager",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Lightweight, Redis-backed OTP manager for Node.js apps",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,7 +37,7 @@
37
37
  "scripts": {
38
38
  "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
39
39
  "build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/prepare-cjs.cjs",
40
- "test": "tsx --test test/**/*.test.ts",
40
+ "test": "tsx --test test/*.test.ts",
41
41
  "check": "npm run build && npm run test",
42
42
  "prepublishOnly": "npm run build"
43
43
  },
@@ -91,3 +91,4 @@
91
91
  "typescript": "^5.8.0"
92
92
  }
93
93
  }
94
+