redis-otp-manager 0.2.3 → 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
@@ -15,6 +17,7 @@ This package currently includes:
15
17
  - Rate limiting
16
18
  - Optional resend cooldown
17
19
  - Max-attempt protection
20
+ - Atomic Redis generate and verify paths using Lua scripts
18
21
  - NestJS module integration via `redis-otp-manager/nest`
19
22
  - Dual package support for ESM and CommonJS consumers
20
23
 
@@ -32,24 +35,10 @@ npm install @nestjs/common @nestjs/core reflect-metadata rxjs
32
35
 
33
36
  ## Module Support
34
37
 
35
- This package now supports both:
38
+ This package supports both:
36
39
  - ESM imports
37
40
  - CommonJS/Nest `ts-node/register` style resolution
38
41
 
39
- ESM:
40
-
41
- ```ts
42
- import { OTPManager } from "redis-otp-manager";
43
- import { OTPModule } from "redis-otp-manager/nest";
44
- ```
45
-
46
- CommonJS:
47
-
48
- ```js
49
- const { OTPManager } = require("redis-otp-manager");
50
- const { OTPModule } = require("redis-otp-manager/nest");
51
- ```
52
-
53
42
  ## Quality Checks
54
43
 
55
44
  ```bash
@@ -73,25 +62,52 @@ const otp = new OTPManager({
73
62
  max: 3,
74
63
  },
75
64
  devMode: false,
65
+ hashing: {
66
+ secret: process.env.OTP_HMAC_SECRET,
67
+ },
76
68
  });
69
+ ```
77
70
 
78
- const generated = await otp.generate({
79
- type: "email",
80
- identifier: "abc@gmail.com",
81
- intent: "login",
82
- });
71
+ ## Cryptographic Hardening
83
72
 
84
- await otp.verify({
85
- type: "email",
86
- identifier: "abc@gmail.com",
87
- intent: "login",
88
- otp: generated.otp ?? "123456",
73
+ When `hashing.secret` is configured, new OTPs are stored using keyed HMAC instead of plain SHA-256.
74
+
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
+ },
89
85
  });
90
86
  ```
91
87
 
92
- ## NestJS
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
+
100
+ ## Production Security Notes
93
101
 
94
- Import the Nest integration from the dedicated subpath so non-Nest users do not pull Nest dependencies unless they need them.
102
+ When you use `RedisAdapter`, the package takes the Redis-specific atomic path for:
103
+ - OTP generation plus rate-limit/cooldown checks
104
+ - OTP verification plus attempt tracking and OTP deletion
105
+
106
+ That prevents the most important race condition from earlier versions where two parallel correct verification requests could both succeed.
107
+
108
+ Simpler adapters like `MemoryAdapter` intentionally stay on the non-atomic fallback path to keep tests and local development lightweight.
109
+
110
+ ## NestJS
95
111
 
96
112
  ```ts
97
113
  import { Module } from "@nestjs/common";
@@ -112,6 +128,9 @@ const redisClient = createClient({ url: process.env.REDIS_URL });
112
128
  window: 60,
113
129
  max: 3,
114
130
  },
131
+ hashing: {
132
+ secret: process.env.OTP_HMAC_SECRET,
133
+ },
115
134
  isGlobal: true,
116
135
  }),
117
136
  ],
@@ -119,34 +138,6 @@ const redisClient = createClient({ url: process.env.REDIS_URL });
119
138
  export class AppModule {}
120
139
  ```
121
140
 
122
- Async setup is also supported:
123
-
124
- ```ts
125
- OTPModule.forRootAsync({
126
- isGlobal: true,
127
- inject: [ConfigService],
128
- useFactory: (config: ConfigService) => ({
129
- store: new RedisAdapter(createClient({ url: config.getOrThrow("REDIS_URL") })),
130
- ttl: 300,
131
- maxAttempts: 5,
132
- resendCooldown: 45,
133
- }),
134
- });
135
- ```
136
-
137
- Inject in services:
138
-
139
- ```ts
140
- import { Injectable } from "@nestjs/common";
141
- import { OTPManager } from "redis-otp-manager";
142
- import { InjectOTPManager } from "redis-otp-manager/nest";
143
-
144
- @Injectable()
145
- export class AuthService {
146
- constructor(@InjectOTPManager() private readonly otpManager: OTPManager) {}
147
- }
148
- ```
149
-
150
141
  ## API
151
142
 
152
143
  ### `new OTPManager(options)`
@@ -168,47 +159,15 @@ type OTPManagerOptions = {
168
159
  lowercase?: boolean;
169
160
  preserveCaseFor?: string[];
170
161
  };
162
+ hashing?: {
163
+ secret?: string;
164
+ previousSecrets?: string[];
165
+ allowLegacyVerify?: boolean;
166
+ };
171
167
  };
172
168
  ```
173
169
 
174
- Normalization is backward-compatible and conservative by default:
175
- - identifiers are trimmed
176
- - identifiers are lowercased for most channels
177
- - `sms` and `token` preserve case by default
178
-
179
- ### `generate(input)`
180
-
181
- ```ts
182
- const result = await otp.generate({
183
- type: "email",
184
- identifier: "abc@gmail.com",
185
- intent: "login",
186
- });
187
- ```
188
-
189
- Returns:
190
-
191
- ```ts
192
- {
193
- expiresIn: 300,
194
- otp?: "123456"
195
- }
196
- ```
197
-
198
- ### `verify(input)`
199
-
200
- ```ts
201
- await otp.verify({
202
- type: "email",
203
- identifier: "abc@gmail.com",
204
- intent: "login",
205
- otp: "123456",
206
- });
207
- ```
208
-
209
- Returns `true` or throws a typed error.
210
-
211
- ## Errors
170
+ ### Errors
212
171
 
213
172
  - `OTPRateLimitExceededError`
214
173
  - `OTPExpiredError`
@@ -221,7 +180,9 @@ Returns `true` or throws a typed error.
221
180
  - `devMode: false`
222
181
  - `otpLength: 8` for higher-risk flows
223
182
  - `maxAttempts: 3`
224
- - `resendCooldown: 30` or higher to reduce abuse
183
+ - `resendCooldown: 30` or higher
184
+ - `hashing.secret` configured in production
185
+ - prefer `RedisAdapter` in production to get the atomic security path
225
186
  - keep Redis private and behind authenticated network access
226
187
 
227
188
  ## Key Design
@@ -237,7 +198,7 @@ cooldown:{intent}:{type}:{identifier}
237
198
 
238
199
  Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
239
200
  - merge to `main` runs CI only
240
- - publish happens when you push a version tag like `v0.2.0`
201
+ - publish happens when you push a version tag like `v0.4.0`
241
202
 
242
203
  Required GitHub secrets:
243
204
  - `NPM_TOKEN`
@@ -245,12 +206,12 @@ Required GitHub secrets:
245
206
  Tag-based publish:
246
207
 
247
208
  ```bash
248
- git tag v0.2.0
249
- git push origin v0.2.0
209
+ git tag v0.4.0
210
+ git push origin v0.4.0
250
211
  ```
251
212
 
252
213
  ## Next Roadmap
253
214
 
254
- - atomic Redis verification
255
215
  - hooks/events
256
216
  - analytics and observability
217
+ - delivery helper integrations
@@ -7,7 +7,31 @@ export interface RedisLikeClient {
7
7
  del(key: string): Promise<unknown>;
8
8
  incr(key: string): Promise<number>;
9
9
  expire(key: string, seconds: number): Promise<unknown>;
10
+ eval?(script: string, options: {
11
+ keys: string[];
12
+ arguments: string[];
13
+ }): Promise<string | number | null>;
10
14
  }
15
+ export interface AtomicGenerateParams {
16
+ otpKey: string;
17
+ attemptsKey: string;
18
+ rateLimitKey: string;
19
+ cooldownKey: string;
20
+ hashedOtp: string;
21
+ ttl: number;
22
+ rateWindow?: number;
23
+ rateMax?: number;
24
+ resendCooldown?: number;
25
+ }
26
+ export interface AtomicVerifyParams {
27
+ otpKey: string;
28
+ attemptsKey: string;
29
+ candidateHashes: string[];
30
+ ttl: number;
31
+ maxAttempts: number;
32
+ }
33
+ export type AtomicGenerateResult = "ok" | "rate_limit" | "cooldown";
34
+ export type AtomicVerifyResult = "verified" | "expired" | "invalid" | "max_attempts";
11
35
  export declare class RedisAdapter implements StoreAdapter {
12
36
  private readonly client;
13
37
  constructor(client: RedisLikeClient);
@@ -15,4 +39,6 @@ export declare class RedisAdapter implements StoreAdapter {
15
39
  set(key: string, value: string, ttlSeconds: number): Promise<void>;
16
40
  del(key: string): Promise<void>;
17
41
  increment(key: string, ttlSeconds: number): Promise<number>;
42
+ generateOtpAtomically(params: AtomicGenerateParams): Promise<AtomicGenerateResult | null>;
43
+ verifyOtpAtomically(params: AtomicVerifyParams): Promise<AtomicVerifyResult | null>;
18
44
  }
@@ -1,3 +1,60 @@
1
+ const ATOMIC_GENERATE_SCRIPT = `
2
+ if ARGV[5] ~= '' then
3
+ local cooldownExists = redis.call('GET', KEYS[4])
4
+ if cooldownExists then
5
+ return 'cooldown'
6
+ end
7
+ end
8
+
9
+ if ARGV[3] ~= '' and ARGV[4] ~= '' then
10
+ local nextCount = redis.call('INCR', KEYS[3])
11
+ if nextCount == 1 then
12
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
13
+ end
14
+ if nextCount > tonumber(ARGV[4]) then
15
+ return 'rate_limit'
16
+ end
17
+ end
18
+
19
+ redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
20
+ redis.call('DEL', KEYS[2])
21
+
22
+ if ARGV[5] ~= '' then
23
+ redis.call('SET', KEYS[4], '1', 'EX', tonumber(ARGV[5]))
24
+ end
25
+
26
+ return 'ok'
27
+ `;
28
+ const ATOMIC_VERIFY_SCRIPT = `
29
+ local storedHash = redis.call('GET', KEYS[1])
30
+ if not storedHash then
31
+ return 'expired'
32
+ end
33
+
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
41
+ end
42
+
43
+ local ttlIndex = candidateCount + 2
44
+ local maxAttemptsIndex = candidateCount + 3
45
+ local attempts = redis.call('INCR', KEYS[2])
46
+ if attempts == 1 then
47
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
48
+ end
49
+
50
+ if attempts >= tonumber(ARGV[maxAttemptsIndex]) then
51
+ redis.call('DEL', KEYS[1])
52
+ redis.call('DEL', KEYS[2])
53
+ return 'max_attempts'
54
+ end
55
+
56
+ return 'invalid'
57
+ `;
1
58
  export class RedisAdapter {
2
59
  client;
3
60
  constructor(client) {
@@ -19,4 +76,44 @@ export class RedisAdapter {
19
76
  }
20
77
  return nextValue;
21
78
  }
79
+ async generateOtpAtomically(params) {
80
+ if (!this.client.eval) {
81
+ return null;
82
+ }
83
+ const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
84
+ keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
85
+ arguments: [
86
+ params.hashedOtp,
87
+ String(params.ttl),
88
+ params.rateWindow ? String(params.rateWindow) : "",
89
+ params.rateMax ? String(params.rateMax) : "",
90
+ params.resendCooldown ? String(params.resendCooldown) : "",
91
+ ],
92
+ });
93
+ if (result === "ok" || result === "rate_limit" || result === "cooldown") {
94
+ return result;
95
+ }
96
+ return null;
97
+ }
98
+ async verifyOtpAtomically(params) {
99
+ if (!this.client.eval) {
100
+ return null;
101
+ }
102
+ const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
103
+ keys: [params.otpKey, params.attemptsKey],
104
+ arguments: [
105
+ String(params.candidateHashes.length),
106
+ ...params.candidateHashes,
107
+ String(params.ttl),
108
+ String(params.maxAttempts),
109
+ ],
110
+ });
111
+ if (result === "verified" ||
112
+ result === "expired" ||
113
+ result === "invalid" ||
114
+ result === "max_attempts") {
115
+ return result;
116
+ }
117
+ return null;
118
+ }
22
119
  }
@@ -1,6 +1,63 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RedisAdapter = void 0;
4
+ const ATOMIC_GENERATE_SCRIPT = `
5
+ if ARGV[5] ~= '' then
6
+ local cooldownExists = redis.call('GET', KEYS[4])
7
+ if cooldownExists then
8
+ return 'cooldown'
9
+ end
10
+ end
11
+
12
+ if ARGV[3] ~= '' and ARGV[4] ~= '' then
13
+ local nextCount = redis.call('INCR', KEYS[3])
14
+ if nextCount == 1 then
15
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
16
+ end
17
+ if nextCount > tonumber(ARGV[4]) then
18
+ return 'rate_limit'
19
+ end
20
+ end
21
+
22
+ redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
23
+ redis.call('DEL', KEYS[2])
24
+
25
+ if ARGV[5] ~= '' then
26
+ redis.call('SET', KEYS[4], '1', 'EX', tonumber(ARGV[5]))
27
+ end
28
+
29
+ return 'ok'
30
+ `;
31
+ const ATOMIC_VERIFY_SCRIPT = `
32
+ local storedHash = redis.call('GET', KEYS[1])
33
+ if not storedHash then
34
+ return 'expired'
35
+ end
36
+
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
44
+ end
45
+
46
+ local ttlIndex = candidateCount + 2
47
+ local maxAttemptsIndex = candidateCount + 3
48
+ local attempts = redis.call('INCR', KEYS[2])
49
+ if attempts == 1 then
50
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
51
+ end
52
+
53
+ if attempts >= tonumber(ARGV[maxAttemptsIndex]) then
54
+ redis.call('DEL', KEYS[1])
55
+ redis.call('DEL', KEYS[2])
56
+ return 'max_attempts'
57
+ end
58
+
59
+ return 'invalid'
60
+ `;
4
61
  class RedisAdapter {
5
62
  client;
6
63
  constructor(client) {
@@ -22,5 +79,45 @@ class RedisAdapter {
22
79
  }
23
80
  return nextValue;
24
81
  }
82
+ async generateOtpAtomically(params) {
83
+ if (!this.client.eval) {
84
+ return null;
85
+ }
86
+ const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
87
+ keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
88
+ arguments: [
89
+ params.hashedOtp,
90
+ String(params.ttl),
91
+ params.rateWindow ? String(params.rateWindow) : "",
92
+ params.rateMax ? String(params.rateMax) : "",
93
+ params.resendCooldown ? String(params.resendCooldown) : "",
94
+ ],
95
+ });
96
+ if (result === "ok" || result === "rate_limit" || result === "cooldown") {
97
+ return result;
98
+ }
99
+ return null;
100
+ }
101
+ async verifyOtpAtomically(params) {
102
+ if (!this.client.eval) {
103
+ return null;
104
+ }
105
+ const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
106
+ keys: [params.otpKey, params.attemptsKey],
107
+ arguments: [
108
+ String(params.candidateHashes.length),
109
+ ...params.candidateHashes,
110
+ String(params.ttl),
111
+ String(params.maxAttempts),
112
+ ],
113
+ });
114
+ if (result === "verified" ||
115
+ result === "expired" ||
116
+ result === "invalid" ||
117
+ result === "max_attempts") {
118
+ return result;
119
+ }
120
+ return null;
121
+ }
25
122
  }
26
123
  exports.RedisAdapter = RedisAdapter;
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OTPManager = void 0;
4
+ const redis_adapter_js_1 = require("../adapters/redis.adapter.js");
4
5
  const otp_generator_js_1 = require("./otp.generator.js");
5
6
  const otp_hash_js_1 = require("./otp.hash.js");
6
7
  const otp_errors_js_1 = require("../errors/otp.errors.js");
@@ -24,6 +25,33 @@ class OTPManager {
24
25
  const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
25
26
  const rateLimitKey = (0, key_builder_js_1.buildRateLimitKey)(normalizedInput);
26
27
  const cooldownKey = (0, key_builder_js_1.buildCooldownKey)(normalizedInput);
28
+ const otp = (0, otp_generator_js_1.generateNumericOtp)(this.options.otpLength);
29
+ const storedHash = (0, otp_hash_js_1.createStoredOtpHash)(otp, normalizedInput, this.options.hashing);
30
+ if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
31
+ const atomicResult = await this.options.store.generateOtpAtomically({
32
+ otpKey,
33
+ attemptsKey,
34
+ rateLimitKey,
35
+ cooldownKey,
36
+ hashedOtp: storedHash,
37
+ ttl: this.options.ttl,
38
+ rateWindow: this.options.rateLimit?.window,
39
+ rateMax: this.options.rateLimit?.max,
40
+ resendCooldown: this.options.resendCooldown,
41
+ });
42
+ if (atomicResult === "cooldown") {
43
+ throw new otp_errors_js_1.OTPResendCooldownError();
44
+ }
45
+ if (atomicResult === "rate_limit") {
46
+ throw new otp_errors_js_1.OTPRateLimitExceededError();
47
+ }
48
+ if (atomicResult === "ok") {
49
+ return {
50
+ expiresIn: this.options.ttl,
51
+ otp: this.options.devMode ? otp : undefined,
52
+ };
53
+ }
54
+ }
27
55
  if (this.options.resendCooldown) {
28
56
  const cooldownActive = await this.options.store.get(cooldownKey);
29
57
  if (cooldownActive) {
@@ -31,9 +59,7 @@ class OTPManager {
31
59
  }
32
60
  }
33
61
  await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
34
- const otp = (0, otp_generator_js_1.generateNumericOtp)(this.options.otpLength);
35
- const hashedOtp = (0, otp_hash_js_1.hashOtp)(otp);
36
- await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
62
+ await this.options.store.set(otpKey, storedHash, this.options.ttl);
37
63
  await this.options.store.del(attemptsKey);
38
64
  if (this.options.resendCooldown) {
39
65
  await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
@@ -51,11 +77,33 @@ class OTPManager {
51
77
  }
52
78
  const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
53
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);
81
+ if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
82
+ const atomicResult = await this.options.store.verifyOtpAtomically({
83
+ otpKey,
84
+ attemptsKey,
85
+ candidateHashes,
86
+ ttl: this.options.ttl,
87
+ maxAttempts: this.options.maxAttempts,
88
+ });
89
+ if (atomicResult === "verified") {
90
+ return true;
91
+ }
92
+ if (atomicResult === "expired") {
93
+ throw new otp_errors_js_1.OTPExpiredError();
94
+ }
95
+ if (atomicResult === "max_attempts") {
96
+ throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
97
+ }
98
+ if (atomicResult === "invalid") {
99
+ throw new otp_errors_js_1.OTPInvalidError();
100
+ }
101
+ }
54
102
  const storedHash = await this.options.store.get(otpKey);
55
103
  if (!storedHash) {
56
104
  throw new otp_errors_js_1.OTPExpiredError();
57
105
  }
58
- 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);
59
107
  if (!isValid) {
60
108
  const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
61
109
  if (attempts >= this.options.maxAttempts) {
@@ -108,6 +156,20 @@ function validateManagerOptions(options) {
108
156
  throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
109
157
  }
110
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
+ }
111
173
  }
112
174
  function validatePayload(input) {
113
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,7 @@
1
+ import { RedisAdapter } from "../adapters/redis.adapter.js";
1
2
  import { generateNumericOtp } from "./otp.generator.js";
2
- import { hashOtp, verifyOtpHash } from "./otp.hash.js";
3
- import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
3
+ import { buildVerificationHashes, createStoredOtpHash, verifyOtpHash } from "./otp.hash.js";
4
+ import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
4
5
  import { buildAttemptsKey, buildCooldownKey, buildOtpKey, buildRateLimitKey, } from "../utils/key-builder.js";
5
6
  import { normalizePayloadIdentifier } from "../utils/identifier-normalizer.js";
6
7
  import { assertWithinRateLimit } from "../utils/rate-limiter.js";
@@ -21,6 +22,33 @@ export class OTPManager {
21
22
  const attemptsKey = buildAttemptsKey(normalizedInput);
22
23
  const rateLimitKey = buildRateLimitKey(normalizedInput);
23
24
  const cooldownKey = buildCooldownKey(normalizedInput);
25
+ const otp = generateNumericOtp(this.options.otpLength);
26
+ const storedHash = createStoredOtpHash(otp, normalizedInput, this.options.hashing);
27
+ if (this.options.store instanceof RedisAdapter) {
28
+ const atomicResult = await this.options.store.generateOtpAtomically({
29
+ otpKey,
30
+ attemptsKey,
31
+ rateLimitKey,
32
+ cooldownKey,
33
+ hashedOtp: storedHash,
34
+ ttl: this.options.ttl,
35
+ rateWindow: this.options.rateLimit?.window,
36
+ rateMax: this.options.rateLimit?.max,
37
+ resendCooldown: this.options.resendCooldown,
38
+ });
39
+ if (atomicResult === "cooldown") {
40
+ throw new OTPResendCooldownError();
41
+ }
42
+ if (atomicResult === "rate_limit") {
43
+ throw new OTPRateLimitExceededError();
44
+ }
45
+ if (atomicResult === "ok") {
46
+ return {
47
+ expiresIn: this.options.ttl,
48
+ otp: this.options.devMode ? otp : undefined,
49
+ };
50
+ }
51
+ }
24
52
  if (this.options.resendCooldown) {
25
53
  const cooldownActive = await this.options.store.get(cooldownKey);
26
54
  if (cooldownActive) {
@@ -28,9 +56,7 @@ export class OTPManager {
28
56
  }
29
57
  }
30
58
  await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
31
- const otp = generateNumericOtp(this.options.otpLength);
32
- const hashedOtp = hashOtp(otp);
33
- await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
59
+ await this.options.store.set(otpKey, storedHash, this.options.ttl);
34
60
  await this.options.store.del(attemptsKey);
35
61
  if (this.options.resendCooldown) {
36
62
  await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
@@ -48,11 +74,33 @@ export class OTPManager {
48
74
  }
49
75
  const otpKey = buildOtpKey(normalizedInput);
50
76
  const attemptsKey = buildAttemptsKey(normalizedInput);
77
+ const candidateHashes = buildVerificationHashes(input.otp, normalizedInput, this.options.hashing);
78
+ if (this.options.store instanceof RedisAdapter) {
79
+ const atomicResult = await this.options.store.verifyOtpAtomically({
80
+ otpKey,
81
+ attemptsKey,
82
+ candidateHashes,
83
+ ttl: this.options.ttl,
84
+ maxAttempts: this.options.maxAttempts,
85
+ });
86
+ if (atomicResult === "verified") {
87
+ return true;
88
+ }
89
+ if (atomicResult === "expired") {
90
+ throw new OTPExpiredError();
91
+ }
92
+ if (atomicResult === "max_attempts") {
93
+ throw new OTPMaxAttemptsExceededError();
94
+ }
95
+ if (atomicResult === "invalid") {
96
+ throw new OTPInvalidError();
97
+ }
98
+ }
51
99
  const storedHash = await this.options.store.get(otpKey);
52
100
  if (!storedHash) {
53
101
  throw new OTPExpiredError();
54
102
  }
55
- const isValid = verifyOtpHash(input.otp, storedHash);
103
+ const isValid = verifyOtpHash(input.otp, normalizedInput, storedHash, this.options.hashing);
56
104
  if (!isValid) {
57
105
  const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
58
106
  if (attempts >= this.options.maxAttempts) {
@@ -104,6 +152,20 @@ function validateManagerOptions(options) {
104
152
  throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
105
153
  }
106
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
+ }
107
169
  }
108
170
  function validatePayload(input) {
109
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.2.3",
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
+