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 +50 -101
- package/dist/adapters/redis.adapter.d.ts +1 -1
- package/dist/adapters/redis.adapter.js +17 -7
- package/dist/cjs/adapters/redis.adapter.js +17 -7
- package/dist/cjs/core/otp.hash.js +41 -5
- package/dist/cjs/core/otp.service.js +20 -5
- package/dist/core/otp.hash.d.ts +4 -1
- package/dist/core/otp.hash.js +40 -6
- package/dist/core/otp.service.js +21 -6
- package/dist/core/otp.types.d.ts +6 -0
- package/dist/index.d.ts +1 -1
- package/package.json +3 -2
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
|
-
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
236
|
-
-
|
|
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.
|
|
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.
|
|
261
|
-
git push origin v0.
|
|
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
|
|
@@ -31,18 +31,23 @@ if not storedHash then
|
|
|
31
31
|
return 'expired'
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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[
|
|
47
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
|
|
43
48
|
end
|
|
44
49
|
|
|
45
|
-
if attempts >= tonumber(ARGV[
|
|
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: [
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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[
|
|
50
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
|
|
46
51
|
end
|
|
47
52
|
|
|
48
|
-
if attempts >= tonumber(ARGV[
|
|
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: [
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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)(
|
|
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
|
|
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,
|
|
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
|
-
|
|
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()) {
|
package/dist/core/otp.hash.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/core/otp.hash.js
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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(
|
|
45
|
+
return timingSafeEqual(candidateBuffer, expectedBuffer);
|
|
12
46
|
}
|
package/dist/core/otp.service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RedisAdapter } from "../adapters/redis.adapter.js";
|
|
2
2
|
import { generateNumericOtp } from "./otp.generator.js";
|
|
3
|
-
import {
|
|
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
|
|
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,
|
|
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
|
-
|
|
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()) {
|
package/dist/core/otp.types.d.ts
CHANGED
|
@@ -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
|
+
"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
|
|
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
|
+
|