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 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/)