otp-guard 0.1.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/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/index.cjs +435 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +259 -0
- package/dist/index.d.ts +259 -0
- package/dist/index.js +430 -0
- package/dist/index.js.map +1 -0
- package/dist/redis.cjs +71 -0
- package/dist/redis.cjs.map +1 -0
- package/dist/redis.d.cts +30 -0
- package/dist/redis.d.ts +30 -0
- package/dist/redis.js +69 -0
- package/dist/redis.js.map +1 -0
- package/dist/store-mOFmlT-6.d.cts +49 -0
- package/dist/store-mOFmlT-6.d.ts +49 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deepak Chauhan / VOCSO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# otp-guard
|
|
2
|
+
|
|
3
|
+
Secure one-time passcodes for Node. Generate, hash, verify, rate-limit — nothing else.
|
|
4
|
+
|
|
5
|
+
Zero runtime dependencies. ESM and CJS. Works with Redis, memory, or your own store.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i otp-guard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No SMS providers, no email templates, no framework coupling. You get a code back; sending it is your job.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Most OTP implementations have at least three of these bugs
|
|
16
|
+
|
|
17
|
+
| Bug | What it costs you | How otp-guard handles it |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Codes stored in plaintext | A database dump hands over every live code | HMAC-SHA256 with a server-side pepper |
|
|
20
|
+
| `Math.random()` | Predictable codes | `crypto.randomInt`, rejection-sampled |
|
|
21
|
+
| `===` on the code | Timing side channel | Constant-time comparison |
|
|
22
|
+
| Code reusable after success | Replay attacks | Deleted on first successful verify |
|
|
23
|
+
| Attempt cap resets on resend | The cap is decorative | Lockout tracked per key, on its own window |
|
|
24
|
+
| Rate limit only on verify | OTP-pumping burns your SMS budget | Independent caps per key **and** per scope |
|
|
25
|
+
| One code works for any action | A login code authorises a withdrawal | Purpose scoping |
|
|
26
|
+
| Different response for unknown users | Account enumeration | Identical shape and timing on both paths |
|
|
27
|
+
| `SET` then `EXPIRE` | A crash between them leaves a permanent code | Single atomic command |
|
|
28
|
+
| "Expired" reported as "invalid" | User never learns to request a new code | Records retained briefly past expiry |
|
|
29
|
+
| Check-then-set cooldown | Concurrent requests send two SMS | Atomic test-and-set |
|
|
30
|
+
| Identities as cache keys | Phone numbers sitting in Redis | Keys are HMACs, never the raw identity |
|
|
31
|
+
|
|
32
|
+
## How this differs from otplib, otpauth, and speakeasy
|
|
33
|
+
|
|
34
|
+
Those libraries implement **TOTP and HOTP** — the algorithms behind Google
|
|
35
|
+
Authenticator and hardware tokens. They generate a code from a shared secret
|
|
36
|
+
and a counter, and verify it statelessly.
|
|
37
|
+
|
|
38
|
+
otp-guard solves the other problem: **delivered codes**. A random code you send
|
|
39
|
+
over SMS, email, or WhatsApp, which has to be stored, expired, rate-limited,
|
|
40
|
+
locked out, and consumed exactly once. That lifecycle is where the bugs above
|
|
41
|
+
live, and none of it is in scope for a TOTP library.
|
|
42
|
+
|
|
43
|
+
If you need authenticator-app support, use otplib. If you need "we texted you a
|
|
44
|
+
code", use this. Plenty of apps need both.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createOtpKit } from 'otp-guard';
|
|
50
|
+
import { redisStore } from 'otp-guard/redis'; // subpath: core stays Redis-free
|
|
51
|
+
|
|
52
|
+
const otp = createOtpKit({
|
|
53
|
+
store: redisStore(redis),
|
|
54
|
+
pepper: process.env.OTP_PEPPER!, // 16+ chars, from your secrets manager
|
|
55
|
+
length: 6,
|
|
56
|
+
ttl: '5m',
|
|
57
|
+
maxAttempts: 5,
|
|
58
|
+
lockoutWindow: '15m',
|
|
59
|
+
resendCooldown: '60s',
|
|
60
|
+
maxSendsPerKey: { count: 5, window: '1h' },
|
|
61
|
+
maxSendsPerScope: { count: 20, window: '1h' },
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Issue
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const res = await otp.issue({
|
|
69
|
+
key: '+919812345678',
|
|
70
|
+
purpose: 'login',
|
|
71
|
+
scope: req.ip,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
return reply.status(429).send({ retryAfter: res.retryAfter });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await sms.send(res.code); // returned once, never stored — do not log it
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Verify
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const result = await otp.verify({
|
|
85
|
+
key: '+919812345678',
|
|
86
|
+
code: req.body.code,
|
|
87
|
+
purpose: 'login',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
// 'invalid' | 'expired' | 'locked' | 'bind_mismatch'
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Reset
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
await otp.reset('+919812345678', 'login'); // clears code, attempts, lockout
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Purpose scoping
|
|
102
|
+
|
|
103
|
+
A code issued for `login` will not verify against `txn:withdraw`. Use a distinct
|
|
104
|
+
purpose for every action that matters, so a phished login code cannot be replayed
|
|
105
|
+
against a payment.
|
|
106
|
+
|
|
107
|
+
## Session binding
|
|
108
|
+
|
|
109
|
+
Pass a `bindToken` at issue time and the same value must be presented at verify.
|
|
110
|
+
This stops an attacker who intercepts the code from redeeming it from their own
|
|
111
|
+
session.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
await otp.issue({ key, bindToken: session.id });
|
|
115
|
+
await otp.verify({ key, code, bindToken: session.id });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Rate limiting
|
|
119
|
+
|
|
120
|
+
Three independent limits, because they stop three different attacks:
|
|
121
|
+
|
|
122
|
+
| Limit | Stops | Default |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `resendCooldown` | Users hammering "resend" | 60s |
|
|
125
|
+
| `maxSendsPerKey` | Targeted harassment of one number | 5 / hour |
|
|
126
|
+
| `maxSendsPerScope` | OTP-pumping fraud against your SMS budget | 20 / hour |
|
|
127
|
+
|
|
128
|
+
The third is the one people skip and the one that produces a surprise invoice.
|
|
129
|
+
Pass `scope: req.ip` (or a device id) to enable it.
|
|
130
|
+
|
|
131
|
+
If a request is rejected, no quota is consumed — the cooldown is released and
|
|
132
|
+
counters are refunded. The same holds if the store throws mid-issue.
|
|
133
|
+
|
|
134
|
+
## Observability
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
createOtpKit({
|
|
138
|
+
hooks: {
|
|
139
|
+
onIssue: (e) => metrics.increment('otp.issued', { purpose: e.purpose }),
|
|
140
|
+
onLockout: (e) => log.warn({ keyHash: e.keyHash }, 'otp lockout'),
|
|
141
|
+
onRateLimited: (e) => metrics.increment('otp.limited', { limit: e.limit }),
|
|
142
|
+
onVerifyFailure: (e) => metrics.increment('otp.failed', { reason: e.reason }),
|
|
143
|
+
onStoreError: (e) => log.error(e, 'otp store error'),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Every event carries `keyHash`, a stable non-reversible identifier that is safe
|
|
149
|
+
to send to your APM. The raw `key` is also present — treat it as personal data
|
|
150
|
+
and do not ship it off-box.
|
|
151
|
+
|
|
152
|
+
Hooks are advisory: exceptions thrown inside them are swallowed, so a broken
|
|
153
|
+
metrics pipeline cannot break authentication.
|
|
154
|
+
|
|
155
|
+
## Failure policy
|
|
156
|
+
|
|
157
|
+
otp-guard **fails closed**. If the store is unreachable, `issue` and `verify` both
|
|
158
|
+
throw `OtpStoreError` rather than degrading open. Catch it at your route layer
|
|
159
|
+
and return a 503 — never treat a store outage as a successful verification.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { OtpStoreError } from 'otp-guard';
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await otp.verify({ key, code });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof OtpStoreError) return reply.status(503).send();
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Project layout
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
src/
|
|
176
|
+
otp.ts createOtpKit — wiring only
|
|
177
|
+
runtime.ts resolved config, key derivation, hook dispatch, store guard
|
|
178
|
+
issue.ts reserve slot -> consume quota -> persist
|
|
179
|
+
verify.ts lockout -> record -> binding -> comparison
|
|
180
|
+
crypto.ts code generation, HMACs, constant-time compare
|
|
181
|
+
store.ts the OtpStore contract and its atomicity requirements
|
|
182
|
+
errors.ts OtpStoreError, OtpConfigError
|
|
183
|
+
types.ts public options and results
|
|
184
|
+
stores/ memory and redis implementations
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Runnable integrations for Express and Fastify are in [`examples/`](./examples).
|
|
188
|
+
|
|
189
|
+
## Stores
|
|
190
|
+
|
|
191
|
+
| Store | Use for |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `memoryStore()` | Tests, CLIs, single-instance apps |
|
|
194
|
+
| `redisStore(client)` | Anything running more than one process |
|
|
195
|
+
|
|
196
|
+
`redisStore` is typed structurally and works with both `redis` v4+ and `ioredis`;
|
|
197
|
+
both are covered by the integration suite. All multi-step operations run as Lua
|
|
198
|
+
so they are atomic across instances.
|
|
199
|
+
|
|
200
|
+
`memoryStore` sweeps expired entries on a timer and caps retained entries
|
|
201
|
+
(default 100k) so it cannot grow without bound. Its counters are per-process, so
|
|
202
|
+
behind a load balancer your effective rate limit is multiplied by your instance
|
|
203
|
+
count. Don't use it there.
|
|
204
|
+
|
|
205
|
+
For anything else — Postgres, DynamoDB, Cloudflare KV — implement `OtpStore`.
|
|
206
|
+
The contract each method must satisfy is documented in `src/types.ts`; the
|
|
207
|
+
atomicity requirements are not optional.
|
|
208
|
+
|
|
209
|
+
## Choosing a pepper
|
|
210
|
+
|
|
211
|
+
A random 32-byte hex string from your secrets manager:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Rotating it invalidates every outstanding code, which is a legitimate emergency
|
|
218
|
+
lever. Keep it out of the database the hashes live in — that separation is the
|
|
219
|
+
entire point.
|
|
220
|
+
|
|
221
|
+
## Why not bcrypt or argon2?
|
|
222
|
+
|
|
223
|
+
A 6-digit numeric code has a keyspace of 10⁶. A slow KDF adds real latency to
|
|
224
|
+
every verify while barely raising the offline-cracking bar. The defences that
|
|
225
|
+
actually matter are the short TTL, the attempt cap, and keeping the pepper
|
|
226
|
+
outside the database.
|
|
227
|
+
|
|
228
|
+
## Threat model
|
|
229
|
+
|
|
230
|
+
**Handled:** database compromise, timing attacks, replay, brute force, account
|
|
231
|
+
enumeration, OTP-pumping, cross-purpose replay, cross-instance races, key
|
|
232
|
+
injection via crafted identities.
|
|
233
|
+
|
|
234
|
+
**Not handled, by design:**
|
|
235
|
+
|
|
236
|
+
- An attacker who controls the delivery channel — SIM swap, mailbox takeover,
|
|
237
|
+
malicious carrier. No OTP library can fix this; `bindToken` narrows it.
|
|
238
|
+
- Clock skew between your instances. Codes expire on wall-clock time; keep NTP
|
|
239
|
+
running.
|
|
240
|
+
- Denial of service against your own store.
|
|
241
|
+
- Phishing. A user who reads their code to an attacker has defeated every
|
|
242
|
+
control here.
|
|
243
|
+
|
|
244
|
+
## Not in scope
|
|
245
|
+
|
|
246
|
+
Delivery (SMS, email, WhatsApp), TOTP/authenticator apps, magic links, and
|
|
247
|
+
backup codes. Each is a different problem with a different failure mode.
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Contributing
|
|
256
|
+
|
|
257
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md). Security reports go to
|
|
258
|
+
[SECURITY.md](./SECURITY.md), not the issue tracker.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
Built and maintained by **[VOCSO](https://www.vocso.com)** — a product
|
|
263
|
+
engineering team that has shipped authentication for fintech, marketplaces, and
|
|
264
|
+
SaaS across India.
|
|
265
|
+
|
|
266
|
+
More on building auth that survives an audit: [vocso.com/blog](https://www.vocso.com/blog/)
|