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/dist/index.js ADDED
@@ -0,0 +1,430 @@
1
+ import { timingSafeEqual, createHmac, randomInt } from 'crypto';
2
+
3
+ // src/crypto.ts
4
+
5
+ // src/errors.ts
6
+ var OtpStoreError = class extends Error {
7
+ /** Which store operation failed, e.g. "acquire_cooldown". */
8
+ operation;
9
+ constructor(operation, cause) {
10
+ super(`otp-guard: store operation "${operation}" failed`, { cause });
11
+ this.name = "OtpStoreError";
12
+ this.operation = operation;
13
+ }
14
+ };
15
+ var OtpConfigError = class extends Error {
16
+ constructor(message) {
17
+ super(`otp-guard: ${message}`);
18
+ this.name = "OtpConfigError";
19
+ }
20
+ };
21
+
22
+ // src/crypto.ts
23
+ var ALPHABETS = {
24
+ numeric: "0123456789",
25
+ // No I, L, O, U, 0, 1 — avoids transcription errors when read aloud.
26
+ alphanumeric: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
27
+ base32: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
28
+ };
29
+ var UNIT_MS = {
30
+ s: 1e3,
31
+ m: 6e4,
32
+ h: 36e5,
33
+ d: 864e5
34
+ };
35
+ var DOMAIN = {
36
+ code: "code",
37
+ bind: "bind",
38
+ storeKey: "sk",
39
+ fingerprint: "fp"
40
+ };
41
+ function toMs(d) {
42
+ if (typeof d === "number") {
43
+ if (!Number.isFinite(d) || d <= 0) {
44
+ throw new OtpConfigError(`duration must be a positive number, got ${d}`);
45
+ }
46
+ return Math.floor(d);
47
+ }
48
+ const match = /^(\d+)([smhd])$/.exec(d);
49
+ if (!match) throw new OtpConfigError(`invalid duration: ${String(d)}`);
50
+ const ms = Number(match[1]) * UNIT_MS[match[2]];
51
+ if (ms <= 0) throw new OtpConfigError(`duration must be positive: ${String(d)}`);
52
+ return ms;
53
+ }
54
+ function hmac(pepper, ...parts) {
55
+ return createHmac("sha256", pepper).update(parts.join("\0")).digest("hex");
56
+ }
57
+ function generateCode(length, alphabet) {
58
+ const chars = ALPHABETS[alphabet];
59
+ let out = "";
60
+ for (let i = 0; i < length; i++) out += chars[randomInt(chars.length)];
61
+ return out;
62
+ }
63
+ function hashCode(pepper, key, purpose, code) {
64
+ return hmac(pepper, DOMAIN.code, key, purpose, code);
65
+ }
66
+ function hashBind(pepper, token) {
67
+ return hmac(pepper, DOMAIN.bind, token);
68
+ }
69
+ function deriveStoreKey(pepper, prefix, kind, ...parts) {
70
+ return `${prefix}:${kind}:${hmac(pepper, DOMAIN.storeKey, kind, ...parts).slice(0, 32)}`;
71
+ }
72
+ function keyFingerprint(pepper, key, purpose) {
73
+ return hmac(pepper, DOMAIN.fingerprint, purpose, key).slice(0, 32);
74
+ }
75
+ function safeEqual(a, b) {
76
+ const bufA = Buffer.from(a, "utf8");
77
+ const bufB = Buffer.from(b, "utf8");
78
+ if (bufA.length !== bufB.length) {
79
+ timingSafeEqual(bufA, bufA);
80
+ return false;
81
+ }
82
+ return timingSafeEqual(bufA, bufB);
83
+ }
84
+ function burnHash(pepper) {
85
+ hashCode(pepper, "burn", "burn", "burn");
86
+ }
87
+ function assertInput(name, value, max) {
88
+ if (typeof value !== "string" || value.length === 0) {
89
+ throw new OtpConfigError(`${name} must be a non-empty string`);
90
+ }
91
+ if (value.length > max) {
92
+ throw new OtpConfigError(`${name} exceeds maxInputLength (${max})`);
93
+ }
94
+ if (value.includes("\0")) {
95
+ throw new OtpConfigError(`${name} must not contain NUL bytes`);
96
+ }
97
+ }
98
+
99
+ // src/runtime.ts
100
+ function createRuntime(options) {
101
+ const {
102
+ store,
103
+ pepper,
104
+ length = 6,
105
+ alphabet = "numeric",
106
+ ttl = "5m",
107
+ expiredGrace = "10m",
108
+ maxAttempts = 5,
109
+ lockoutWindow = "15m",
110
+ resendCooldown = "60s",
111
+ maxSendsPerKey = { count: 5, window: "1h" },
112
+ maxSendsPerScope = { count: 20, window: "1h" },
113
+ maxInputLength = 256,
114
+ prefix = "otp",
115
+ hooks = {},
116
+ now = Date.now
117
+ } = options;
118
+ if (!store) throw new OtpConfigError("`store` is required");
119
+ if (typeof pepper !== "string" || pepper.length < 16) {
120
+ throw new OtpConfigError(
121
+ "`pepper` must be a secret of at least 16 characters, loaded from the environment. A 32-byte hex string is the right shape."
122
+ );
123
+ }
124
+ if (!Number.isInteger(length) || length < 4 || length > 32) {
125
+ throw new OtpConfigError("`length` must be an integer between 4 and 32");
126
+ }
127
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
128
+ throw new OtpConfigError("`maxAttempts` must be a positive integer");
129
+ }
130
+ if (!(alphabet in { numeric: 1, alphanumeric: 1, base32: 1 })) {
131
+ throw new OtpConfigError(`unknown alphabet: ${String(alphabet)}`);
132
+ }
133
+ const fire = (name, payload) => {
134
+ const fn = hooks[name];
135
+ if (!fn) return;
136
+ try {
137
+ fn(payload);
138
+ } catch {
139
+ }
140
+ };
141
+ return {
142
+ store,
143
+ pepper,
144
+ length,
145
+ alphabet,
146
+ ttlMs: toMs(ttl),
147
+ recordTtlMs: toMs(ttl) + toMs(expiredGrace),
148
+ maxAttempts,
149
+ lockoutMs: toMs(lockoutWindow),
150
+ cooldownMs: toMs(resendCooldown),
151
+ perKey: { count: maxSendsPerKey.count, windowMs: toMs(maxSendsPerKey.window) },
152
+ perScope: { count: maxSendsPerScope.count, windowMs: toMs(maxSendsPerScope.window) },
153
+ maxInputLength,
154
+ now,
155
+ fire,
156
+ keyFor(kind, ...parts) {
157
+ return deriveStoreKey(pepper, prefix, kind, ...parts);
158
+ },
159
+ context(key, purpose, scope) {
160
+ return { key, keyHash: keyFingerprint(pepper, key, purpose), purpose, scope };
161
+ },
162
+ async guard(operation, fn) {
163
+ try {
164
+ return await fn();
165
+ } catch (error) {
166
+ if (error instanceof OtpStoreError) throw error;
167
+ fire("onStoreError", { operation, error });
168
+ throw new OtpStoreError(operation, error);
169
+ }
170
+ }
171
+ };
172
+ }
173
+ function assertIdentifiers(rt, values) {
174
+ for (const [name, value] of Object.entries(values)) {
175
+ if (value !== void 0) assertInput(name, value, rt.maxInputLength);
176
+ }
177
+ }
178
+
179
+ // src/issue.ts
180
+ async function reserveSlot(rt, ctx) {
181
+ const cooldownKey = rt.keyFor("d", ctx.purpose, ctx.key);
182
+ const took = await rt.guard(
183
+ "acquire_cooldown",
184
+ () => rt.store.acquire(cooldownKey, rt.cooldownMs)
185
+ );
186
+ if (!took) {
187
+ const wait = await rt.guard("ttl_cooldown", () => rt.store.ttl(cooldownKey));
188
+ rt.fire("onRateLimited", { ...ctx, reason: "cooldown", limit: "cooldown" });
189
+ return {
190
+ ok: false,
191
+ result: { ok: false, reason: "cooldown", retryAfter: rt.now() + wait }
192
+ };
193
+ }
194
+ const charged = [];
195
+ return {
196
+ ok: true,
197
+ reservation: {
198
+ charge: (counterKey) => charged.push(counterKey),
199
+ release: async () => {
200
+ await Promise.allSettled([
201
+ rt.store.del(cooldownKey),
202
+ ...charged.map((c) => rt.store.decrCounter(c))
203
+ ]);
204
+ }
205
+ }
206
+ };
207
+ }
208
+ async function consumeQuota(rt, ctx, reservation) {
209
+ const limits = [
210
+ {
211
+ key: rt.keyFor("s", ctx.purpose, ctx.key),
212
+ cap: rt.perKey.count,
213
+ windowMs: rt.perKey.windowMs,
214
+ limit: "key"
215
+ }
216
+ ];
217
+ if (ctx.scope) {
218
+ limits.push({
219
+ key: rt.keyFor("x", ctx.scope),
220
+ cap: rt.perScope.count,
221
+ windowMs: rt.perScope.windowMs,
222
+ limit: "scope"
223
+ });
224
+ }
225
+ for (const { key, cap, windowMs, limit } of limits) {
226
+ const used = await rt.guard(
227
+ `bump_${limit}_quota`,
228
+ () => rt.store.bumpCounter(key, windowMs)
229
+ );
230
+ reservation.charge(key);
231
+ if (used > cap) {
232
+ const wait = await rt.guard(`ttl_${limit}_quota`, () => rt.store.ttl(key));
233
+ rt.fire("onRateLimited", { ...ctx, reason: "rate_limited", limit });
234
+ return {
235
+ ok: false,
236
+ result: { ok: false, reason: "rate_limited", retryAfter: rt.now() + wait }
237
+ };
238
+ }
239
+ }
240
+ return { ok: true };
241
+ }
242
+ async function persistCode(rt, ctx, bindToken) {
243
+ const code = generateCode(rt.length, rt.alphabet);
244
+ const expiresAt = rt.now() + rt.ttlMs;
245
+ const record = {
246
+ hash: hashCode(rt.pepper, ctx.key, ctx.purpose, code),
247
+ expiresAt,
248
+ bindHash: bindToken ? hashBind(rt.pepper, bindToken) : void 0
249
+ };
250
+ await rt.guard(
251
+ "set_record",
252
+ () => rt.store.set(rt.keyFor("c", ctx.purpose, ctx.key), record, rt.recordTtlMs)
253
+ );
254
+ return { code, expiresAt };
255
+ }
256
+ async function issue(rt, input) {
257
+ const { key, purpose = "default", scope, bindToken } = input;
258
+ assertIdentifiers(rt, { key, purpose, scope, bindToken });
259
+ const ctx = rt.context(key, purpose, scope);
260
+ const slot = await reserveSlot(rt, ctx);
261
+ if (!slot.ok) return slot.result;
262
+ const { reservation } = slot;
263
+ try {
264
+ const quota = await consumeQuota(rt, ctx, reservation);
265
+ if (!quota.ok) {
266
+ await reservation.release();
267
+ return quota.result;
268
+ }
269
+ const { code, expiresAt } = await persistCode(rt, ctx, bindToken);
270
+ rt.fire("onIssue", ctx);
271
+ return { ok: true, code, expiresAt, resendAfter: rt.now() + rt.cooldownMs };
272
+ } catch (error) {
273
+ await reservation.release().catch(() => {
274
+ });
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ // src/verify.ts
280
+ var MAX_CODE_LENGTH = 64;
281
+ async function reset(rt, key, purpose = "default") {
282
+ assertIdentifiers(rt, { key, purpose });
283
+ await rt.guard("reset", async () => {
284
+ await Promise.all([
285
+ rt.store.del(rt.keyFor("c", purpose, key)),
286
+ rt.store.del(rt.keyFor("a", purpose, key))
287
+ ]);
288
+ });
289
+ }
290
+ async function verify(rt, input) {
291
+ const { key, code, purpose = "default", bindToken } = input;
292
+ assertIdentifiers(rt, { key, purpose, bindToken });
293
+ const ctx = rt.context(key, purpose);
294
+ if (typeof code !== "string" || code.length === 0 || code.length > MAX_CODE_LENGTH) {
295
+ burnHash(rt.pepper);
296
+ return { ok: false, reason: "invalid" };
297
+ }
298
+ const record = await rt.guard(
299
+ "get_record",
300
+ () => rt.store.get(rt.keyFor("c", purpose, key))
301
+ );
302
+ const attempts = await rt.guard(
303
+ "bump_attempts",
304
+ () => rt.store.bumpCounter(rt.keyFor("a", purpose, key), rt.lockoutMs)
305
+ );
306
+ if (attempts > rt.maxAttempts) {
307
+ if (attempts === rt.maxAttempts + 1) rt.fire("onLockout", ctx);
308
+ rt.fire("onVerifyFailure", { ...ctx, reason: "locked", attempts });
309
+ return { ok: false, reason: "locked" };
310
+ }
311
+ const fail = (reason) => {
312
+ rt.fire("onVerifyFailure", { ...ctx, reason, attempts });
313
+ return { ok: false, reason, attemptsLeft: Math.max(0, rt.maxAttempts - attempts) };
314
+ };
315
+ if (!record) {
316
+ burnHash(rt.pepper);
317
+ return fail("invalid");
318
+ }
319
+ if (record.expiresAt <= rt.now()) {
320
+ await rt.guard("del_expired", () => rt.store.del(rt.keyFor("c", purpose, key)));
321
+ return fail("expired");
322
+ }
323
+ if (record.bindHash) {
324
+ const presented = bindToken ? hashBind(rt.pepper, bindToken) : "";
325
+ if (!safeEqual(presented, record.bindHash)) return fail("bind_mismatch");
326
+ }
327
+ const expected = hashCode(rt.pepper, key, purpose, code);
328
+ if (!safeEqual(expected, record.hash)) return fail("invalid");
329
+ await reset(rt, key, purpose);
330
+ return { ok: true };
331
+ }
332
+
333
+ // src/otp.ts
334
+ function createOtpKit(options) {
335
+ const rt = createRuntime(options);
336
+ return {
337
+ issue: (input) => issue(rt, input),
338
+ verify: (input) => verify(rt, input),
339
+ reset: (key, purpose) => reset(rt, key, purpose),
340
+ close: async () => {
341
+ await rt.store.close?.();
342
+ }
343
+ };
344
+ }
345
+
346
+ // src/stores/memory.ts
347
+ function memoryStore(opts = {}) {
348
+ const now = opts.now ?? Date.now;
349
+ const maxEntries = opts.maxEntries ?? 1e5;
350
+ const sweepIntervalMs = opts.sweepIntervalMs ?? 6e4;
351
+ const map = /* @__PURE__ */ new Map();
352
+ const read = (key) => {
353
+ const entry = map.get(key);
354
+ if (!entry) return void 0;
355
+ if (entry.expiresAt <= now()) {
356
+ map.delete(key);
357
+ return void 0;
358
+ }
359
+ return entry;
360
+ };
361
+ const sweep = () => {
362
+ const t = now();
363
+ for (const [k, v] of map) if (v.expiresAt <= t) map.delete(k);
364
+ };
365
+ const write = (key, entry) => {
366
+ if (!map.has(key) && map.size >= maxEntries) {
367
+ sweep();
368
+ if (map.size >= maxEntries) {
369
+ let evictKey;
370
+ let evictAt = Infinity;
371
+ for (const [k, v] of map) {
372
+ if (v.expiresAt < evictAt) {
373
+ evictAt = v.expiresAt;
374
+ evictKey = k;
375
+ }
376
+ }
377
+ if (evictKey) map.delete(evictKey);
378
+ }
379
+ }
380
+ map.set(key, entry);
381
+ };
382
+ let timer;
383
+ if (sweepIntervalMs > 0) {
384
+ timer = setInterval(sweep, sweepIntervalMs);
385
+ timer.unref?.();
386
+ }
387
+ return {
388
+ async get(key) {
389
+ const entry = read(key);
390
+ return entry ? entry.value : null;
391
+ },
392
+ async set(key, record, ttlMs) {
393
+ write(key, { value: record, expiresAt: now() + ttlMs });
394
+ },
395
+ async del(key) {
396
+ map.delete(key);
397
+ },
398
+ async bumpCounter(key, ttlMs) {
399
+ const entry = read(key);
400
+ if (!entry) {
401
+ write(key, { value: 1, expiresAt: now() + ttlMs });
402
+ return 1;
403
+ }
404
+ entry.value = entry.value + 1;
405
+ return entry.value;
406
+ },
407
+ async decrCounter(key) {
408
+ const entry = read(key);
409
+ if (!entry || typeof entry.value !== "number") return;
410
+ entry.value = Math.max(0, entry.value - 1);
411
+ },
412
+ async acquire(key, ttlMs) {
413
+ if (read(key)) return false;
414
+ write(key, { value: true, expiresAt: now() + ttlMs });
415
+ return true;
416
+ },
417
+ async ttl(key) {
418
+ const entry = read(key);
419
+ return entry ? Math.max(0, entry.expiresAt - now()) : 0;
420
+ },
421
+ close() {
422
+ if (timer) clearInterval(timer);
423
+ map.clear();
424
+ }
425
+ };
426
+ }
427
+
428
+ export { OtpConfigError, OtpStoreError, createOtpKit, memoryStore };
429
+ //# sourceMappingURL=index.js.map
430
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/crypto.ts","../src/runtime.ts","../src/issue.ts","../src/verify.ts","../src/otp.ts","../src/stores/memory.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Thrown when the underlying store fails.\n *\n * otp-guard fails closed: a store outage means codes cannot be issued or\n * verified, never that verification silently succeeds. Catch this at your\n * route layer and return 503.\n *\n * @example\n * ```ts\n * try {\n * await otp.verify({ key, code });\n * } catch (err) {\n * if (err instanceof OtpStoreError) return reply.status(503).send();\n * throw err;\n * }\n * ```\n */\nexport class OtpStoreError extends Error {\n /** Which store operation failed, e.g. \"acquire_cooldown\". */\n readonly operation: string;\n\n constructor(operation: string, cause: unknown) {\n super(`otp-guard: store operation \"${operation}\" failed`, { cause });\n this.name = 'OtpStoreError';\n this.operation = operation;\n }\n}\n\n/**\n * Thrown for caller mistakes: invalid configuration, malformed or oversized\n * identifiers. Always a programming error, never a runtime condition to\n * handle gracefully.\n */\nexport class OtpConfigError extends Error {\n constructor(message: string) {\n super(`otp-guard: ${message}`);\n this.name = 'OtpConfigError';\n }\n}\n","import { createHmac, randomInt, timingSafeEqual } from 'node:crypto';\nimport { OtpConfigError } from './errors.js';\nimport type { Alphabet, Duration } from './types.js';\n\nconst ALPHABETS: Record<Alphabet, string> = {\n numeric: '0123456789',\n // No I, L, O, U, 0, 1 — avoids transcription errors when read aloud.\n alphanumeric: '23456789ABCDEFGHJKMNPQRSTVWXYZ',\n base32: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',\n};\n\nconst UNIT_MS: Record<string, number> = {\n s: 1_000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n};\n\n/** Domain separators. Changing any of these invalidates existing state. */\nconst DOMAIN = {\n code: 'code',\n bind: 'bind',\n storeKey: 'sk',\n fingerprint: 'fp',\n} as const;\n\n/**\n * Parse `\"5m\"` / `\"90s\"` / `30000` into milliseconds.\n *\n * @throws {OtpConfigError} if the value is not a positive duration.\n */\nexport function toMs(d: Duration): number {\n if (typeof d === 'number') {\n if (!Number.isFinite(d) || d <= 0) {\n throw new OtpConfigError(`duration must be a positive number, got ${d}`);\n }\n return Math.floor(d);\n }\n const match = /^(\\d+)([smhd])$/.exec(d);\n if (!match) throw new OtpConfigError(`invalid duration: ${String(d)}`);\n const ms = Number(match[1]) * UNIT_MS[match[2]];\n if (ms <= 0) throw new OtpConfigError(`duration must be positive: ${String(d)}`);\n return ms;\n}\n\n/**\n * HMAC over NUL-separated parts.\n *\n * The separator is safe because every caller-supplied part is rejected at the\n * boundary if it contains NUL, so no two distinct inputs can serialise\n * identically.\n */\nfunction hmac(pepper: string, ...parts: string[]): string {\n return createHmac('sha256', pepper).update(parts.join('\\u0000')).digest('hex');\n}\n\n/**\n * Generate a code with uniformly distributed characters.\n *\n * `crypto.randomInt` performs rejection sampling internally, so there is no\n * modulo bias. Never substitute `Math.random()` — it is seeded predictably\n * and is not a CSPRNG.\n */\nexport function generateCode(length: number, alphabet: Alphabet): string {\n const chars = ALPHABETS[alphabet];\n let out = '';\n for (let i = 0; i < length; i++) out += chars[randomInt(chars.length)];\n return out;\n}\n\n/**\n * HMAC the code together with its identity and purpose, so a code is only\n * ever valid for the identity and action it was issued for.\n *\n * Deliberately not bcrypt or argon2: a 6-digit code has a keyspace of 10^6,\n * so a slow KDF adds latency to every verify without meaningfully raising the\n * offline-cracking bar. The defences that matter are the short TTL, the\n * attempt cap, and keeping the pepper outside the database.\n */\nexport function hashCode(\n pepper: string,\n key: string,\n purpose: string,\n code: string,\n): string {\n return hmac(pepper, DOMAIN.code, key, purpose, code);\n}\n\n/** HMAC of a session-binding token. */\nexport function hashBind(pepper: string, token: string): string {\n return hmac(pepper, DOMAIN.bind, token);\n}\n\n/**\n * Derive a fixed-length, opaque store key.\n *\n * Two reasons this is not string interpolation:\n * 1. Injection — an identity containing the delimiter could otherwise\n * collide with, or escape into, another namespace.\n * 2. Privacy — phone numbers and emails never reach Redis in plaintext,\n * which matters when the cache is dumped or hosted by a third party.\n */\nexport function deriveStoreKey(\n pepper: string,\n prefix: string,\n kind: string,\n ...parts: string[]\n): string {\n return `${prefix}:${kind}:${hmac(pepper, DOMAIN.storeKey, kind, ...parts).slice(0, 32)}`;\n}\n\n/**\n * Stable, non-reversible identifier for logs and metrics.\n *\n * Separate from {@link deriveStoreKey} on purpose: log identifiers and store\n * keys have different lifetimes and different consumers, and coupling them\n * means a change to the key format silently changes every dashboard.\n */\nexport function keyFingerprint(pepper: string, key: string, purpose: string): string {\n return hmac(pepper, DOMAIN.fingerprint, purpose, key).slice(0, 32);\n}\n\n/** Length-safe constant-time comparison of two hex digests. */\nexport function safeEqual(a: string, b: string): boolean {\n const bufA = Buffer.from(a, 'utf8');\n const bufB = Buffer.from(b, 'utf8');\n if (bufA.length !== bufB.length) {\n timingSafeEqual(bufA, bufA); // burn an equivalent comparison\n return false;\n }\n return timingSafeEqual(bufA, bufB);\n}\n\n/**\n * Throwaway hash so the \"no record found\" path costs about the same as the\n * \"record found, wrong code\" path. Without it, response timing reveals\n * whether an identity is registered.\n */\nexport function burnHash(pepper: string): void {\n hashCode(pepper, 'burn', 'burn', 'burn');\n}\n\n/**\n * Validate a caller-supplied identifier.\n *\n * @throws {OtpConfigError} if empty, oversized, or containing a NUL byte.\n */\nexport function assertInput(name: string, value: string, max: number): void {\n if (typeof value !== 'string' || value.length === 0) {\n throw new OtpConfigError(`${name} must be a non-empty string`);\n }\n if (value.length > max) {\n throw new OtpConfigError(`${name} exceeds maxInputLength (${max})`);\n }\n if (value.includes('\\u0000')) {\n throw new OtpConfigError(`${name} must not contain NUL bytes`);\n }\n}\n","import { assertInput, deriveStoreKey, keyFingerprint, toMs } from './crypto.js';\nimport { OtpConfigError, OtpStoreError } from './errors.js';\nimport type { OtpStore } from './store.js';\nimport type { Alphabet, HookEvent, Hooks, OtpKitOptions } from './types.js';\n\n/**\n * Everything `issue` and `verify` need, resolved once at construction so the\n * hot paths do no option parsing and no validation of static config.\n *\n * Internal — not part of the public API.\n */\nexport interface Runtime {\n store: OtpStore;\n pepper: string;\n length: number;\n alphabet: Alphabet;\n ttlMs: number;\n /** ttl plus grace: how long the record is actually persisted. */\n recordTtlMs: number;\n maxAttempts: number;\n lockoutMs: number;\n cooldownMs: number;\n perKey: { count: number; windowMs: number };\n perScope: { count: number; windowMs: number };\n maxInputLength: number;\n now: () => number;\n /** Derive the store key for a given kind of record. */\n keyFor(kind: KeyKind, ...parts: string[]): string;\n /** Build the common hook payload for an operation. */\n context(key: string, purpose: string, scope?: string): HookEvent;\n /** Dispatch a hook. Never throws. */\n fire<K extends keyof Hooks>(name: K, payload: Parameters<NonNullable<Hooks[K]>>[0]): void;\n /**\n * Run a store call, converting any driver exception into an OtpStoreError\n * tagged with the operation name. This is the ONLY place store errors are\n * wrapped — never hand-roll a try/catch around a store call.\n */\n guard<T>(operation: string, fn: () => Promise<T>): Promise<T>;\n}\n\n/**\n * Store key namespaces.\n *\n * - `c` — the outstanding code record\n * - `a` — failed attempt counter (drives lockout)\n * - `d` — resend cooldown flag\n * - `s` — sends-per-key counter\n * - `x` — sends-per-scope counter\n */\nexport type KeyKind = 'c' | 'a' | 'd' | 's' | 'x';\n\nexport function createRuntime(options: OtpKitOptions): Runtime {\n const {\n store,\n pepper,\n length = 6,\n alphabet = 'numeric',\n ttl = '5m',\n expiredGrace = '10m',\n maxAttempts = 5,\n lockoutWindow = '15m',\n resendCooldown = '60s',\n maxSendsPerKey = { count: 5, window: '1h' },\n maxSendsPerScope = { count: 20, window: '1h' },\n maxInputLength = 256,\n prefix = 'otp',\n hooks = {},\n now = Date.now,\n } = options;\n\n if (!store) throw new OtpConfigError('`store` is required');\n if (typeof pepper !== 'string' || pepper.length < 16) {\n throw new OtpConfigError(\n '`pepper` must be a secret of at least 16 characters, loaded from the ' +\n 'environment. A 32-byte hex string is the right shape.',\n );\n }\n if (!Number.isInteger(length) || length < 4 || length > 32) {\n throw new OtpConfigError('`length` must be an integer between 4 and 32');\n }\n if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {\n throw new OtpConfigError('`maxAttempts` must be a positive integer');\n }\n if (!(alphabet in { numeric: 1, alphanumeric: 1, base32: 1 })) {\n throw new OtpConfigError(`unknown alphabet: ${String(alphabet)}`);\n }\n\n const fire: Runtime['fire'] = (name, payload) => {\n const fn = hooks[name] as ((p: unknown) => void) | undefined;\n if (!fn) return;\n try {\n fn(payload);\n } catch {\n // Advisory by design: a broken metrics pipeline must not break auth.\n }\n };\n\n return {\n store,\n pepper,\n length,\n alphabet,\n ttlMs: toMs(ttl),\n recordTtlMs: toMs(ttl) + toMs(expiredGrace),\n maxAttempts,\n lockoutMs: toMs(lockoutWindow),\n cooldownMs: toMs(resendCooldown),\n perKey: { count: maxSendsPerKey.count, windowMs: toMs(maxSendsPerKey.window) },\n perScope: { count: maxSendsPerScope.count, windowMs: toMs(maxSendsPerScope.window) },\n maxInputLength,\n now,\n fire,\n\n keyFor(kind, ...parts) {\n return deriveStoreKey(pepper, prefix, kind, ...parts);\n },\n\n context(key, purpose, scope) {\n return { key, keyHash: keyFingerprint(pepper, key, purpose), purpose, scope };\n },\n\n async guard(operation, fn) {\n try {\n return await fn();\n } catch (error) {\n if (error instanceof OtpStoreError) throw error;\n fire('onStoreError', { operation, error });\n throw new OtpStoreError(operation, error);\n }\n },\n };\n}\n\n/** Validate the identifiers a caller passed in. */\nexport function assertIdentifiers(\n rt: Runtime,\n values: Record<string, string | undefined>,\n): void {\n for (const [name, value] of Object.entries(values)) {\n if (value !== undefined) assertInput(name, value, rt.maxInputLength);\n }\n}\n","import { generateCode, hashBind, hashCode } from './crypto.js';\nimport { assertIdentifiers, type Runtime } from './runtime.js';\nimport type { OtpRecord } from './store.js';\nimport type { HookEvent, IssueInput, IssueResult } from './types.js';\n\n/**\n * A held cooldown slot plus any rate-limit quota consumed under it.\n *\n * Everything taken between acquiring the slot and successfully persisting a\n * code has to be given back if we bail out, or a transient failure would lock\n * the user out for a minute and silently eat one of their hourly sends.\n */\ninterface Reservation {\n release(): Promise<void>;\n /** Register a counter to refund on release. */\n charge(counterKey: string): void;\n}\n\n/**\n * Take the resend cooldown.\n *\n * Atomic test-and-set, so of N concurrent requests for the same identity\n * exactly one proceeds. Without this, a double-tapped \"send OTP\" button sends\n * two SMS and the second overwrites the first.\n */\nasync function reserveSlot(\n rt: Runtime,\n ctx: HookEvent,\n): Promise<{ ok: true; reservation: Reservation } | { ok: false; result: IssueResult }> {\n const cooldownKey = rt.keyFor('d', ctx.purpose, ctx.key);\n\n const took = await rt.guard('acquire_cooldown', () =>\n rt.store.acquire(cooldownKey, rt.cooldownMs),\n );\n\n if (!took) {\n const wait = await rt.guard('ttl_cooldown', () => rt.store.ttl(cooldownKey));\n rt.fire('onRateLimited', { ...ctx, reason: 'cooldown', limit: 'cooldown' });\n return {\n ok: false,\n result: { ok: false, reason: 'cooldown', retryAfter: rt.now() + wait },\n };\n }\n\n const charged: string[] = [];\n return {\n ok: true,\n reservation: {\n charge: (counterKey) => charged.push(counterKey),\n release: async () => {\n // allSettled: a failure while rolling back must not mask the original\n // error, and a partially-successful refund still beats none.\n await Promise.allSettled([\n rt.store.del(cooldownKey),\n ...charged.map((c) => rt.store.decrCounter(c)),\n ]);\n },\n },\n };\n}\n\n/**\n * Apply the per-key and per-scope send caps.\n *\n * These are separate limits because they stop separate attacks: per-key stops\n * someone harassing one number, per-scope stops OTP-pumping fraud that cycles\n * through many numbers to burn your SMS budget.\n */\nasync function consumeQuota(\n rt: Runtime,\n ctx: HookEvent,\n reservation: Reservation,\n): Promise<{ ok: true } | { ok: false; result: IssueResult }> {\n const limits: Array<{ key: string; cap: number; windowMs: number; limit: 'key' | 'scope' }> = [\n {\n key: rt.keyFor('s', ctx.purpose, ctx.key),\n cap: rt.perKey.count,\n windowMs: rt.perKey.windowMs,\n limit: 'key',\n },\n ];\n\n if (ctx.scope) {\n limits.push({\n key: rt.keyFor('x', ctx.scope),\n cap: rt.perScope.count,\n windowMs: rt.perScope.windowMs,\n limit: 'scope',\n });\n }\n\n for (const { key, cap, windowMs, limit } of limits) {\n const used = await rt.guard(`bump_${limit}_quota`, () =>\n rt.store.bumpCounter(key, windowMs),\n );\n reservation.charge(key);\n\n if (used > cap) {\n const wait = await rt.guard(`ttl_${limit}_quota`, () => rt.store.ttl(key));\n rt.fire('onRateLimited', { ...ctx, reason: 'rate_limited', limit });\n return {\n ok: false,\n result: { ok: false, reason: 'rate_limited', retryAfter: rt.now() + wait },\n };\n }\n }\n\n return { ok: true };\n}\n\n/** Generate a code and persist only its hash. */\nasync function persistCode(\n rt: Runtime,\n ctx: HookEvent,\n bindToken?: string,\n): Promise<{ code: string; expiresAt: number }> {\n const code = generateCode(rt.length, rt.alphabet);\n const expiresAt = rt.now() + rt.ttlMs;\n\n const record: OtpRecord = {\n hash: hashCode(rt.pepper, ctx.key, ctx.purpose, code),\n expiresAt,\n bindHash: bindToken ? hashBind(rt.pepper, bindToken) : undefined,\n };\n\n await rt.guard('set_record', () =>\n rt.store.set(rt.keyFor('c', ctx.purpose, ctx.key), record, rt.recordTtlMs),\n );\n\n // The attempt counter is deliberately NOT cleared here. Clearing it would\n // let an attacker refresh their guess budget just by requesting a resend.\n\n return { code, expiresAt };\n}\n\n/**\n * Issue a code: reserve the cooldown, spend quota, persist, hand back the\n * plaintext exactly once.\n */\nexport async function issue(rt: Runtime, input: IssueInput): Promise<IssueResult> {\n const { key, purpose = 'default', scope, bindToken } = input;\n assertIdentifiers(rt, { key, purpose, scope, bindToken });\n\n const ctx = rt.context(key, purpose, scope);\n\n const slot = await reserveSlot(rt, ctx);\n if (!slot.ok) return slot.result;\n const { reservation } = slot;\n\n try {\n const quota = await consumeQuota(rt, ctx, reservation);\n if (!quota.ok) {\n await reservation.release();\n return quota.result;\n }\n\n const { code, expiresAt } = await persistCode(rt, ctx, bindToken);\n rt.fire('onIssue', ctx);\n\n return { ok: true, code, expiresAt, resendAfter: rt.now() + rt.cooldownMs };\n } catch (error) {\n await reservation.release().catch(() => {});\n throw error; // already an OtpStoreError; rt.guard owns the wrapping\n }\n}\n","import { burnHash, hashBind, hashCode, safeEqual } from './crypto.js';\nimport { assertIdentifiers, type Runtime } from './runtime.js';\nimport type { HookEvent, VerifyFailure, VerifyInput, VerifyResult } from './types.js';\n\n/** Longest code we will even hash. Anything longer is malformed input. */\nconst MAX_CODE_LENGTH = 64;\n\n/**\n * Clear the outstanding code and the attempt counter together.\n *\n * Both, always: leaving the attempt counter behind after a success would\n * carry a user's earlier typos into their next login.\n */\nexport async function reset(\n rt: Runtime,\n key: string,\n purpose = 'default',\n): Promise<void> {\n assertIdentifiers(rt, { key, purpose });\n await rt.guard('reset', async () => {\n await Promise.all([\n rt.store.del(rt.keyFor('c', purpose, key)),\n rt.store.del(rt.keyFor('a', purpose, key)),\n ]);\n });\n}\n\n/**\n * Verify a submitted code.\n *\n * Every failure returns the same shape, and the paths cost roughly the same,\n * so neither the response body nor its timing reveals whether an identity is\n * registered or has a code outstanding.\n */\nexport async function verify(rt: Runtime, input: VerifyInput): Promise<VerifyResult> {\n const { key, code, purpose = 'default', bindToken } = input;\n assertIdentifiers(rt, { key, purpose, bindToken });\n\n const ctx = rt.context(key, purpose);\n\n if (typeof code !== 'string' || code.length === 0 || code.length > MAX_CODE_LENGTH) {\n // Malformed input is indistinguishable from a wrong code, by design.\n burnHash(rt.pepper);\n return { ok: false, reason: 'invalid' };\n }\n\n const record = await rt.guard('get_record', () =>\n rt.store.get(rt.keyFor('c', purpose, key)),\n );\n\n // Lockout is evaluated before the record, so a locked key stays locked even\n // after its code expires or a fresh one is issued.\n const attempts = await rt.guard('bump_attempts', () =>\n rt.store.bumpCounter(rt.keyFor('a', purpose, key), rt.lockoutMs),\n );\n\n if (attempts > rt.maxAttempts) {\n // Fire once, on the transition, rather than on every subsequent attempt.\n if (attempts === rt.maxAttempts + 1) rt.fire('onLockout', ctx);\n rt.fire('onVerifyFailure', { ...ctx, reason: 'locked', attempts });\n return { ok: false, reason: 'locked' };\n }\n\n const fail = (reason: VerifyFailure): VerifyResult => {\n rt.fire('onVerifyFailure', { ...ctx, reason, attempts });\n return { ok: false, reason, attemptsLeft: Math.max(0, rt.maxAttempts - attempts) };\n };\n\n if (!record) {\n // Equalise cost with the wrong-code path below.\n burnHash(rt.pepper);\n return fail('invalid');\n }\n\n if (record.expiresAt <= rt.now()) {\n await rt.guard('del_expired', () => rt.store.del(rt.keyFor('c', purpose, key)));\n return fail('expired');\n }\n\n if (record.bindHash) {\n const presented = bindToken ? hashBind(rt.pepper, bindToken) : '';\n if (!safeEqual(presented, record.bindHash)) return fail('bind_mismatch');\n }\n\n const expected = hashCode(rt.pepper, key, purpose, code);\n if (!safeEqual(expected, record.hash)) return fail('invalid');\n\n // Single use: clear before returning, so no replay can land in the gap.\n await reset(rt, key, purpose);\n return { ok: true };\n}\n\nexport type { HookEvent };\n","import { issue } from './issue.js';\nimport { createRuntime } from './runtime.js';\nimport type { OtpKit, OtpKitOptions } from './types.js';\nimport { reset, verify } from './verify.js';\n\n/**\n * Create an OTP issuer/verifier.\n *\n * @example\n * ```ts\n * import { createOtpKit, memoryStore } from 'otp-guard';\n *\n * const otp = createOtpKit({\n * store: memoryStore(),\n * pepper: process.env.OTP_PEPPER!,\n * ttl: '5m',\n * maxAttempts: 5,\n * });\n *\n * const issued = await otp.issue({ key: '+919812345678', purpose: 'login' });\n * if (issued.ok) await sms.send(issued.code);\n *\n * const result = await otp.verify({\n * key: '+919812345678',\n * code: '123456',\n * purpose: 'login',\n * });\n * ```\n *\n * @throws {OtpConfigError} if the options are invalid.\n */\nexport function createOtpKit(options: OtpKitOptions): OtpKit {\n const rt = createRuntime(options);\n\n return {\n issue: (input) => issue(rt, input),\n verify: (input) => verify(rt, input),\n reset: (key, purpose) => reset(rt, key, purpose),\n close: async () => {\n await rt.store.close?.();\n },\n };\n}\n","import type { OtpRecord, OtpStore } from '../store.js';\n\ninterface Entry {\n value: OtpRecord | number | true;\n expiresAt: number;\n}\n\nexport interface MemoryStoreOptions {\n /** Injectable clock. Intended for tests. */\n now?: () => number;\n /**\n * Hard cap on retained entries. When exceeded, the soonest-expiring entries\n * are dropped first. Prevents unbounded growth from codes that are issued\n * and never verified. Default 100_000.\n */\n maxEntries?: number;\n /** Sweep interval for expired entries in ms. Default 60_000. 0 disables. */\n sweepIntervalMs?: number;\n}\n\n/**\n * Process-local store.\n *\n * Suitable for tests, CLIs, and single-instance apps. NOT suitable behind a\n * load balancer: counters live in one process's heap, so N instances means N\n * times your intended rate limit.\n *\n * @example\n * ```ts\n * const otp = createOtpKit({ store: memoryStore(), pepper });\n * ```\n */\nexport function memoryStore(opts: MemoryStoreOptions = {}): OtpStore {\n const now = opts.now ?? Date.now;\n const maxEntries = opts.maxEntries ?? 100_000;\n const sweepIntervalMs = opts.sweepIntervalMs ?? 60_000;\n const map = new Map<string, Entry>();\n\n /** Read through the expiry check, deleting lazily on access. */\n const read = (key: string): Entry | undefined => {\n const entry = map.get(key);\n if (!entry) return undefined;\n if (entry.expiresAt <= now()) {\n map.delete(key);\n return undefined;\n }\n return entry;\n };\n\n const sweep = () => {\n const t = now();\n for (const [k, v] of map) if (v.expiresAt <= t) map.delete(k);\n };\n\n const write = (key: string, entry: Entry) => {\n if (!map.has(key) && map.size >= maxEntries) {\n sweep();\n if (map.size >= maxEntries) {\n let evictKey: string | undefined;\n let evictAt = Infinity;\n for (const [k, v] of map) {\n if (v.expiresAt < evictAt) {\n evictAt = v.expiresAt;\n evictKey = k;\n }\n }\n if (evictKey) map.delete(evictKey);\n }\n }\n map.set(key, entry);\n };\n\n let timer: ReturnType<typeof setInterval> | undefined;\n if (sweepIntervalMs > 0) {\n timer = setInterval(sweep, sweepIntervalMs);\n // Do not hold the event loop open: a CLI using this should still exit.\n timer.unref?.();\n }\n\n return {\n async get(key) {\n const entry = read(key);\n return entry ? (entry.value as OtpRecord) : null;\n },\n async set(key, record, ttlMs) {\n write(key, { value: record, expiresAt: now() + ttlMs });\n },\n async del(key) {\n map.delete(key);\n },\n async bumpCounter(key, ttlMs) {\n const entry = read(key);\n if (!entry) {\n write(key, { value: 1, expiresAt: now() + ttlMs });\n return 1;\n }\n // Original expiry is preserved: fixed window, not sliding.\n entry.value = (entry.value as number) + 1;\n return entry.value as number;\n },\n async decrCounter(key) {\n const entry = read(key);\n if (!entry || typeof entry.value !== 'number') return;\n entry.value = Math.max(0, entry.value - 1);\n },\n async acquire(key, ttlMs) {\n if (read(key)) return false;\n write(key, { value: true, expiresAt: now() + ttlMs });\n return true;\n },\n async ttl(key) {\n const entry = read(key);\n return entry ? Math.max(0, entry.expiresAt - now()) : 0;\n },\n close() {\n if (timer) clearInterval(timer);\n map.clear();\n },\n };\n}\n"]}
package/dist/redis.cjs ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ // src/stores/redis.ts
4
+ function makeEval(client) {
5
+ const isNodeRedis = typeof client.pTTL === "function";
6
+ return async (script, keys, args) => {
7
+ if (isNodeRedis) {
8
+ return client.eval(script, { keys, arguments: args });
9
+ }
10
+ return client.eval(
11
+ script,
12
+ keys.length,
13
+ ...keys,
14
+ ...args
15
+ );
16
+ };
17
+ }
18
+ var SET_PX = `return redis.call('SET', KEYS[1], ARGV[1], 'PX', tonumber(ARGV[2]))`;
19
+ var SET_NX_PX = `
20
+ if redis.call('SET', KEYS[1], '1', 'NX', 'PX', tonumber(ARGV[1])) then
21
+ return 1
22
+ end
23
+ return 0`;
24
+ var INCR_WINDOW = `
25
+ local v = redis.call('INCR', KEYS[1])
26
+ if v == 1 then redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1])) end
27
+ return v`;
28
+ var DECR_SAFE = `
29
+ if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
30
+ local v = redis.call('DECR', KEYS[1])
31
+ if v < 0 then redis.call('SET', KEYS[1], '0', 'KEEPTTL') return 0 end
32
+ return v`;
33
+ function redisStore(client) {
34
+ const run = makeEval(client);
35
+ const pttl = (key) => (client.pTTL ?? client.pttl).call(client, key);
36
+ return {
37
+ async get(key) {
38
+ const raw = await client.get(key);
39
+ if (!raw) return null;
40
+ try {
41
+ return JSON.parse(raw);
42
+ } catch {
43
+ await client.del(key);
44
+ return null;
45
+ }
46
+ },
47
+ async set(key, record, ttlMs) {
48
+ await run(SET_PX, [key], [JSON.stringify(record), String(Math.ceil(ttlMs))]);
49
+ },
50
+ async del(key) {
51
+ await client.del(key);
52
+ },
53
+ async bumpCounter(key, ttlMs) {
54
+ return Number(await run(INCR_WINDOW, [key], [String(Math.ceil(ttlMs))]));
55
+ },
56
+ async decrCounter(key) {
57
+ await run(DECR_SAFE, [key], []);
58
+ },
59
+ async acquire(key, ttlMs) {
60
+ return Number(await run(SET_NX_PX, [key], [String(Math.ceil(ttlMs))])) === 1;
61
+ },
62
+ async ttl(key) {
63
+ const v = await pttl(key);
64
+ return v > 0 ? v : 0;
65
+ }
66
+ };
67
+ }
68
+
69
+ exports.redisStore = redisStore;
70
+ //# sourceMappingURL=redis.cjs.map
71
+ //# sourceMappingURL=redis.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stores/redis.ts"],"names":[],"mappings":"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","file":"redis.cjs","sourcesContent":["import type { OtpRecord, OtpStore } from '../store.js';\n\n/**\n * Structural type satisfied by both `redis` v4+ and `ioredis`, so otp-guard\n * needs no Redis dependency of its own and works with whichever client the\n * host app already has.\n */\nexport interface RedisLike {\n get(key: string): Promise<string | null>;\n del(key: string): Promise<unknown>;\n eval(script: string, ...args: unknown[]): Promise<unknown>;\n set?(key: string, value: string, options?: unknown): Promise<unknown>;\n pTTL?(key: string): Promise<number>;\n pttl?(key: string): Promise<number>;\n}\n\n/**\n * The two clients disagree on `eval`'s signature:\n *\n * - ioredis: `eval(script, numkeys, ...keys, ...args)`\n * - node-redis: `eval(script, { keys, arguments })`\n *\n * Detected once at construction rather than sniffed on every call. Both\n * branches are covered by the integration suite — this is the single most\n * likely place for a \"works on my machine\" bug.\n */\nfunction makeEval(client: RedisLike) {\n const isNodeRedis = typeof client.pTTL === 'function';\n return async (script: string, keys: string[], args: string[]): Promise<unknown> => {\n if (isNodeRedis) {\n return client.eval(script, { keys, arguments: args } as never);\n }\n return (client.eval as (...a: unknown[]) => Promise<unknown>)(\n script,\n keys.length,\n ...keys,\n ...args,\n );\n };\n}\n\n/**\n * Atomic set-with-expiry. One round trip, so there is never a window in which\n * a record exists without a TTL — that window would be a permanently valid\n * one-time code.\n */\nconst SET_PX = `return redis.call('SET', KEYS[1], ARGV[1], 'PX', tonumber(ARGV[2]))`;\n\n/**\n * Atomic test-and-set. Returns 1 only for the caller that took the lock,\n * which is what makes the resend cooldown race-free across instances.\n */\nconst SET_NX_PX = `\nif redis.call('SET', KEYS[1], '1', 'NX', 'PX', tonumber(ARGV[1])) then\n return 1\nend\nreturn 0`;\n\n/** Increment, setting expiry only on the first increment: a fixed window. */\nconst INCR_WINDOW = `\nlocal v = redis.call('INCR', KEYS[1])\nif v == 1 then redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1])) end\nreturn v`;\n\n/** Decrement without resurrecting an expired key or dropping below zero. */\nconst DECR_SAFE = `\nif redis.call('EXISTS', KEYS[1]) == 0 then return 0 end\nlocal v = redis.call('DECR', KEYS[1])\nif v < 0 then redis.call('SET', KEYS[1], '0', 'KEEPTTL') return 0 end\nreturn v`;\n\n/**\n * Redis-backed store. The correct choice for anything running more than one\n * process, since all state and counters are shared.\n *\n * @example\n * ```ts\n * import { createOtpKit } from 'otp-guard';\n * import { redisStore } from 'otp-guard/redis';\n *\n * const otp = createOtpKit({ store: redisStore(redis), pepper });\n * ```\n */\nexport function redisStore(client: RedisLike): OtpStore {\n const run = makeEval(client);\n const pttl = (key: string) =>\n (client.pTTL ?? client.pttl)!.call(client, key) as Promise<number>;\n\n return {\n async get(key) {\n const raw = await client.get(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as OtpRecord;\n } catch {\n // A corrupt or foreign value in our namespace: clear it and treat the\n // code as absent, rather than throwing on every subsequent verify.\n await client.del(key);\n return null;\n }\n },\n async set(key, record, ttlMs) {\n await run(SET_PX, [key], [JSON.stringify(record), String(Math.ceil(ttlMs))]);\n },\n async del(key) {\n await client.del(key);\n },\n async bumpCounter(key, ttlMs) {\n return Number(await run(INCR_WINDOW, [key], [String(Math.ceil(ttlMs))]));\n },\n async decrCounter(key) {\n await run(DECR_SAFE, [key], []);\n },\n async acquire(key, ttlMs) {\n return Number(await run(SET_NX_PX, [key], [String(Math.ceil(ttlMs))])) === 1;\n },\n async ttl(key) {\n const v = await pttl(key);\n return v > 0 ? v : 0;\n },\n };\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import { O as OtpStore } from './store-mOFmlT-6.cjs';
2
+
3
+ /**
4
+ * Structural type satisfied by both `redis` v4+ and `ioredis`, so otp-guard
5
+ * needs no Redis dependency of its own and works with whichever client the
6
+ * host app already has.
7
+ */
8
+ interface RedisLike {
9
+ get(key: string): Promise<string | null>;
10
+ del(key: string): Promise<unknown>;
11
+ eval(script: string, ...args: unknown[]): Promise<unknown>;
12
+ set?(key: string, value: string, options?: unknown): Promise<unknown>;
13
+ pTTL?(key: string): Promise<number>;
14
+ pttl?(key: string): Promise<number>;
15
+ }
16
+ /**
17
+ * Redis-backed store. The correct choice for anything running more than one
18
+ * process, since all state and counters are shared.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { createOtpKit } from 'otp-guard';
23
+ * import { redisStore } from 'otp-guard/redis';
24
+ *
25
+ * const otp = createOtpKit({ store: redisStore(redis), pepper });
26
+ * ```
27
+ */
28
+ declare function redisStore(client: RedisLike): OtpStore;
29
+
30
+ export { type RedisLike, redisStore };
@@ -0,0 +1,30 @@
1
+ import { O as OtpStore } from './store-mOFmlT-6.js';
2
+
3
+ /**
4
+ * Structural type satisfied by both `redis` v4+ and `ioredis`, so otp-guard
5
+ * needs no Redis dependency of its own and works with whichever client the
6
+ * host app already has.
7
+ */
8
+ interface RedisLike {
9
+ get(key: string): Promise<string | null>;
10
+ del(key: string): Promise<unknown>;
11
+ eval(script: string, ...args: unknown[]): Promise<unknown>;
12
+ set?(key: string, value: string, options?: unknown): Promise<unknown>;
13
+ pTTL?(key: string): Promise<number>;
14
+ pttl?(key: string): Promise<number>;
15
+ }
16
+ /**
17
+ * Redis-backed store. The correct choice for anything running more than one
18
+ * process, since all state and counters are shared.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { createOtpKit } from 'otp-guard';
23
+ * import { redisStore } from 'otp-guard/redis';
24
+ *
25
+ * const otp = createOtpKit({ store: redisStore(redis), pepper });
26
+ * ```
27
+ */
28
+ declare function redisStore(client: RedisLike): OtpStore;
29
+
30
+ export { type RedisLike, redisStore };