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/redis.js ADDED
@@ -0,0 +1,69 @@
1
+ // src/stores/redis.ts
2
+ function makeEval(client) {
3
+ const isNodeRedis = typeof client.pTTL === "function";
4
+ return async (script, keys, args) => {
5
+ if (isNodeRedis) {
6
+ return client.eval(script, { keys, arguments: args });
7
+ }
8
+ return client.eval(
9
+ script,
10
+ keys.length,
11
+ ...keys,
12
+ ...args
13
+ );
14
+ };
15
+ }
16
+ var SET_PX = `return redis.call('SET', KEYS[1], ARGV[1], 'PX', tonumber(ARGV[2]))`;
17
+ var SET_NX_PX = `
18
+ if redis.call('SET', KEYS[1], '1', 'NX', 'PX', tonumber(ARGV[1])) then
19
+ return 1
20
+ end
21
+ return 0`;
22
+ var INCR_WINDOW = `
23
+ local v = redis.call('INCR', KEYS[1])
24
+ if v == 1 then redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1])) end
25
+ return v`;
26
+ var DECR_SAFE = `
27
+ if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
28
+ local v = redis.call('DECR', KEYS[1])
29
+ if v < 0 then redis.call('SET', KEYS[1], '0', 'KEEPTTL') return 0 end
30
+ return v`;
31
+ function redisStore(client) {
32
+ const run = makeEval(client);
33
+ const pttl = (key) => (client.pTTL ?? client.pttl).call(client, key);
34
+ return {
35
+ async get(key) {
36
+ const raw = await client.get(key);
37
+ if (!raw) return null;
38
+ try {
39
+ return JSON.parse(raw);
40
+ } catch {
41
+ await client.del(key);
42
+ return null;
43
+ }
44
+ },
45
+ async set(key, record, ttlMs) {
46
+ await run(SET_PX, [key], [JSON.stringify(record), String(Math.ceil(ttlMs))]);
47
+ },
48
+ async del(key) {
49
+ await client.del(key);
50
+ },
51
+ async bumpCounter(key, ttlMs) {
52
+ return Number(await run(INCR_WINDOW, [key], [String(Math.ceil(ttlMs))]));
53
+ },
54
+ async decrCounter(key) {
55
+ await run(DECR_SAFE, [key], []);
56
+ },
57
+ async acquire(key, ttlMs) {
58
+ return Number(await run(SET_NX_PX, [key], [String(Math.ceil(ttlMs))])) === 1;
59
+ },
60
+ async ttl(key) {
61
+ const v = await pttl(key);
62
+ return v > 0 ? v : 0;
63
+ }
64
+ };
65
+ }
66
+
67
+ export { redisStore };
68
+ //# sourceMappingURL=redis.js.map
69
+ //# sourceMappingURL=redis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stores/redis.ts"],"names":[],"mappings":"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","file":"redis.js","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,49 @@
1
+ /**
2
+ * What is persisted for an outstanding code.
3
+ *
4
+ * The plaintext code never appears here — only its HMAC.
5
+ */
6
+ interface OtpRecord {
7
+ /** HMAC of the code, bound to its identity and purpose. */
8
+ hash: string;
9
+ /** Epoch millis at which the code stops being valid. */
10
+ expiresAt: number;
11
+ /** HMAC of a token that must be replayed at verify time, if bound. */
12
+ bindHash?: string;
13
+ }
14
+ /**
15
+ * Storage contract.
16
+ *
17
+ * Implementations MUST guarantee the following. These are not performance
18
+ * suggestions — each one closes a specific attack.
19
+ *
20
+ * - `set` writes value and expiry atomically. A window in which a record
21
+ * exists without a TTL is a permanently valid one-time code.
22
+ * - `acquire` is atomic test-and-set. Two concurrent callers must never both
23
+ * receive `true`. This is what makes the resend cooldown race-free, and
24
+ * therefore what stops one user action sending two SMS.
25
+ * - `bumpCounter` increments atomically and sets the expiry only on the first
26
+ * increment, giving a fixed window rather than a sliding one.
27
+ * - `decrCounter` must not resurrect an expired key or go below zero.
28
+ *
29
+ * A store that satisfies these on a single process (see `memoryStore`) is
30
+ * still only safe for a single process: counters that live in one instance's
31
+ * heap multiply your effective rate limit by your instance count.
32
+ */
33
+ interface OtpStore {
34
+ get(key: string): Promise<OtpRecord | null>;
35
+ set(key: string, record: OtpRecord, ttlMs: number): Promise<void>;
36
+ del(key: string): Promise<void>;
37
+ /** Atomic increment with fixed-window expiry. Returns the new value. */
38
+ bumpCounter(key: string, ttlMs: number): Promise<number>;
39
+ /** Compensating decrement, used to refund quota when issuing fails. */
40
+ decrCounter(key: string): Promise<void>;
41
+ /** Atomic test-and-set. True only for the caller that took the lock. */
42
+ acquire(key: string, ttlMs: number): Promise<boolean>;
43
+ /** Remaining ttl in ms, or 0 if absent. */
44
+ ttl(key: string): Promise<number>;
45
+ /** Release owned resources (timers, connections). Optional. */
46
+ close?(): Promise<void> | void;
47
+ }
48
+
49
+ export type { OtpStore as O, OtpRecord as a };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What is persisted for an outstanding code.
3
+ *
4
+ * The plaintext code never appears here — only its HMAC.
5
+ */
6
+ interface OtpRecord {
7
+ /** HMAC of the code, bound to its identity and purpose. */
8
+ hash: string;
9
+ /** Epoch millis at which the code stops being valid. */
10
+ expiresAt: number;
11
+ /** HMAC of a token that must be replayed at verify time, if bound. */
12
+ bindHash?: string;
13
+ }
14
+ /**
15
+ * Storage contract.
16
+ *
17
+ * Implementations MUST guarantee the following. These are not performance
18
+ * suggestions — each one closes a specific attack.
19
+ *
20
+ * - `set` writes value and expiry atomically. A window in which a record
21
+ * exists without a TTL is a permanently valid one-time code.
22
+ * - `acquire` is atomic test-and-set. Two concurrent callers must never both
23
+ * receive `true`. This is what makes the resend cooldown race-free, and
24
+ * therefore what stops one user action sending two SMS.
25
+ * - `bumpCounter` increments atomically and sets the expiry only on the first
26
+ * increment, giving a fixed window rather than a sliding one.
27
+ * - `decrCounter` must not resurrect an expired key or go below zero.
28
+ *
29
+ * A store that satisfies these on a single process (see `memoryStore`) is
30
+ * still only safe for a single process: counters that live in one instance's
31
+ * heap multiply your effective rate limit by your instance count.
32
+ */
33
+ interface OtpStore {
34
+ get(key: string): Promise<OtpRecord | null>;
35
+ set(key: string, record: OtpRecord, ttlMs: number): Promise<void>;
36
+ del(key: string): Promise<void>;
37
+ /** Atomic increment with fixed-window expiry. Returns the new value. */
38
+ bumpCounter(key: string, ttlMs: number): Promise<number>;
39
+ /** Compensating decrement, used to refund quota when issuing fails. */
40
+ decrCounter(key: string): Promise<void>;
41
+ /** Atomic test-and-set. True only for the caller that took the lock. */
42
+ acquire(key: string, ttlMs: number): Promise<boolean>;
43
+ /** Remaining ttl in ms, or 0 if absent. */
44
+ ttl(key: string): Promise<number>;
45
+ /** Release owned resources (timers, connections). Optional. */
46
+ close?(): Promise<void> | void;
47
+ }
48
+
49
+ export type { OtpStore as O, OtpRecord as a };
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "otp-guard",
3
+ "version": "0.1.0",
4
+ "description": "Secure one-time passcodes for Node: hashed storage, constant-time verify, single use, and the three rate limits everyone forgets.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./redis": {
16
+ "types": "./dist/redis.d.ts",
17
+ "import": "./dist/redis.js",
18
+ "require": "./dist/redis.cjs"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "sideEffects": false,
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "typecheck": "tsc --noEmit",
31
+ "lint": "eslint .",
32
+ "test": "vitest run",
33
+ "test:integration": "REDIS_URL=redis://127.0.0.1:6379 vitest run",
34
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build"
35
+ },
36
+ "keywords": [
37
+ "otp",
38
+ "one-time-password",
39
+ "otp-verification",
40
+ "2fa",
41
+ "mfa",
42
+ "sms-otp",
43
+ "email-otp",
44
+ "authentication",
45
+ "rate-limit",
46
+ "redis"
47
+ ],
48
+ "license": "MIT",
49
+ "author": "Deepak Chauhan <deepak@vocso.com> (https://www.vocso.com)",
50
+ "homepage": "https://github.com/vocso-com/otp-guard#readme",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/vocso-com/otp-guard.git"
54
+ },
55
+ "bugs": {
56
+ "url": "https://github.com/vocso-com/otp-guard/issues"
57
+ },
58
+ "engines": {
59
+ "node": ">=18"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public",
63
+ "provenance": true
64
+ },
65
+ "devDependencies": {
66
+ "@redis/client": "^1.6.0",
67
+ "@types/node": "^22.0.0",
68
+ "eslint": "^9.39.5",
69
+ "ioredis": "^5.4.0",
70
+ "tsup": "^8.3.0",
71
+ "typescript": "^5.6.0",
72
+ "typescript-eslint": "^8.64.0",
73
+ "vitest": "^2.1.0"
74
+ }
75
+ }