diff --git a/.env.example b/.env.example index 5f99eaf..83c5615 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ CORS_ORIGIN=* ALGORITHM=PBKDF2/SHA-256 MAXNUMBER=5000 +# Optional Redis/Valkey replay store backend. +# When set, the API uses Redis instead of the in-memory cache for replay protection. +# Example: REDIS_URL=redis://valkey:6379 +REDIS_URL= + # Demo settings API_BASE_URL=http://server:3000 DEMO_PORT=8080 diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 0cf19f1..85f1e37 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -24,6 +24,11 @@ jobs: runs-on: ubuntu-latest container: image: oven/bun:1.3.4@sha256:7608db4aeb44f1fe8169cc8ec7055376b3013557b106407ccf092b00e426407d + services: + redis: + image: valkey/valkey:8-alpine + ports: + - 6379:6379 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 @@ -36,6 +41,8 @@ jobs: - name: Test run: bun test + env: + REDIS_URL: redis://redis:6379 build: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 8f3d9ad..280503f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ docker compose --profile demo up --build - Demo: http://localhost:8080 +To start the API with a Valkey-backed replay store, add the `redis` profile and set `REDIS_URL`: + +```bash +REDIS_URL=redis://valkey:6379 docker compose --profile redis up --build +``` + To override the secret temporarily: ```bash @@ -46,6 +52,7 @@ The API service reads the following environment variables: - CORS_ORIGIN: Allowed CORS origin(s), default *. Use comma-separated values for multiple origins. - ALGORITHM: ALTCHA v2 algorithm, default PBKDF2/SHA-256. - MAXNUMBER: ALTCHA v2 proof-of-work cost (difficulty), default 5000. +- REDIS_URL (optional): Redis or Valkey URL for a shared replay-store backend. When set, the API uses Redis instead of the in-memory cache. Example: `redis://valkey:6379`. The demo service reads the following environment variables: @@ -174,7 +181,7 @@ bun run dev - Change `ALTCHA_SECRET` for Docker Compose, or `SECRET` for direct API/container runtime, to a strong unique value. Never use the default. - Do not bake `.env` files or secrets into images; provide runtime environment variables from Compose, your orchestrator, or a secret manager. - Consider terminating TLS in front of the container and restricting access to /verify if needed. -- **Warning:** In-memory replay protection is single-instance only and is cleared on every container restart. Any routine deploy or crash recovery silently opens a replay window for recently-issued challenges. For production, replace the in-memory token cache with a shared store (e.g., Redis) or pair with upstream protections. +- **Warning:** In-memory replay protection is single-instance only and is cleared on every container restart. Any routine deploy or crash recovery silently opens a replay window for recently-issued challenges. For production, set `REDIS_URL` to use a shared Redis or Valkey backend, or pair with upstream protections. - Pin image versions and consider multi-arch builds if deploying across architectures. - Both the `api` and `demo` Dockerfile stages include a `HEALTHCHECK` for orchestrator-level health detection. - The API container handles `SIGTERM`/`SIGINT` gracefully, draining active connections before exit. diff --git a/bun.lock b/bun.lock index c64e9d7..7d16a94 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "express": "^5.2.1", "express-rate-limit": "^7.5.0", "helmet": "^8.1.0", + "ioredis": "^5.6.1", }, "devDependencies": { "@types/bun": "^1.3.12", @@ -22,6 +23,8 @@ }, }, "packages": { + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], + "@types/body-parser": ["@types/body-parser@1.19.5", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg=="], "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], @@ -62,6 +65,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -74,6 +79,8 @@ "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -124,6 +131,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -162,6 +171,10 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -182,6 +195,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -216,6 +231,8 @@ "bun-types/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], + "ioredis/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "@types/body-parser/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], diff --git a/compose.yaml b/compose.yaml index b115f39..170a7fa 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,6 +11,7 @@ services: MAXRECORDS: ${MAXRECORDS:-1000} MAXNUMBER: ${MAXNUMBER:-5000} PORT: ${PORT:-3000} + REDIS_URL: ${REDIS_URL:-} ports: - "3000:3000" restart: unless-stopped @@ -20,6 +21,19 @@ services: cpus: "0.5" memory: 256M + valkey: + profiles: + - redis + image: valkey/valkey:8-alpine + ports: + - "6379:6379" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.5" + memory: 128M + demo: profiles: - demo diff --git a/package.json b/package.json index 1253684..3458219 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^7.5.0", - "helmet": "^8.1.0" + "helmet": "^8.1.0", + "ioredis": "^5.6.1" }, "devDependencies": { "@types/bun": "^1.3.12", diff --git a/src/api-app.ts b/src/api-app.ts index deb60fe..0317a75 100644 --- a/src/api-app.ts +++ b/src/api-app.ts @@ -7,7 +7,7 @@ import rateLimit from "express-rate-limit"; import helmet from "helmet"; import type { ApiConfig } from "./config"; -import { createInMemoryReplayStore } from "./replay-store"; +import { createInMemoryReplayStore, createRedisReplayStore } from "./replay-store"; const addMinutesToDate = (date: Date, n: number) => { const d = new Date(date); @@ -24,9 +24,20 @@ const asyncHandler = (handler: RequestHandler): RequestHandler => { export const createApiApp = async (config: ApiConfig): Promise => { const app: Express = express(); const hmacKeySignatureSecret = await deriveHmacKeySecret(config.hmacKey); - const replayStore = createInMemoryReplayStore(config.maxRecords); - console.log("[ALTCHA]: replay store initialised — in-memory, cleared on restart"); + const replayStore = config.redisUrl + ? await (async () => { + const store = createRedisReplayStore(config.redisUrl!, config.expireMinutes * 60); + await store.get("__connection_check__"); + return store; + })() + : createInMemoryReplayStore(config.maxRecords); + + console.log( + config.redisUrl + ? "[ALTCHA]: replay store initialised — redis" + : "[ALTCHA]: replay store initialised — in-memory, cleared on restart" + ); app.use(helmet()); app.use(express.json()); diff --git a/src/config.test.ts b/src/config.test.ts index 99ebee8..8af664c 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -15,6 +15,7 @@ describe("parseApiConfig", () => { maxNumber: 5000, maxRecords: 1000, port: 3000, + redisUrl: undefined, }); }); @@ -65,6 +66,16 @@ describe("parseApiConfig", () => { ); }); + test("parses REDIS_URL when present", () => { + const config = parseApiConfig({ SECRET: LONG_SECRET, REDIS_URL: "redis://localhost:6379" }); + expect(config.redisUrl).toBe("redis://localhost:6379"); + }); + + test("omits redisUrl when REDIS_URL is absent", () => { + const config = parseApiConfig({ SECRET: LONG_SECRET }); + expect(config.redisUrl).toBeUndefined(); + }); + test("accepts all supported algorithms", () => { expect(parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "PBKDF2/SHA-256" }).algorithm).toBe("PBKDF2/SHA-256"); expect(parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "PBKDF2/SHA-384" }).algorithm).toBe("PBKDF2/SHA-384"); diff --git a/src/config.ts b/src/config.ts index 6ff9cc0..3e28c10 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ export type ApiConfig = { maxNumber: number; maxRecords: number; port: number; + redisUrl?: string; }; export type DemoConfig = { @@ -69,6 +70,8 @@ export const parseApiConfig = (env: Env = process.env): ApiConfig => { const maxNumberSource = env.MAXNUMBER === undefined || env.MAXNUMBER.trim() === "" ? "COST" : "MAXNUMBER"; + const redisUrl = env.REDIS_URL?.trim(); + return { algorithm: parseAlgorithm(env.ALGORITHM), corsOrigin: parseCorsOrigin(env.CORS_ORIGIN), @@ -77,6 +80,7 @@ export const parseApiConfig = (env: Env = process.env): ApiConfig => { maxNumber: parsePositiveInteger(env, maxNumberSource, 5000), maxRecords: parsePositiveInteger(env, "MAXRECORDS", 1000), port: parsePositiveInteger(env, "PORT", 3000), + redisUrl: redisUrl || undefined, }; }; diff --git a/src/replay-store.integration.test.ts b/src/replay-store.integration.test.ts new file mode 100644 index 0000000..d67b474 --- /dev/null +++ b/src/replay-store.integration.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { createRedisReplayStore } from "./replay-store"; + +const REDIS_URL = process.env.REDIS_URL; + +describe.skipIf(!REDIS_URL)("createRedisReplayStore", () => { + const store = createRedisReplayStore(REDIS_URL!, 60); + + test("get returns false for unknown key", async () => { + expect(await store.get("integration-unknown-key")).toBe(false); + }); + + test("set stores a key and get returns true", async () => { + await store.set("integration-key-1", true); + expect(await store.get("integration-key-1")).toBe(true); + }); + + test("set on duplicate key throws", async () => { + await store.set("integration-key-2", true); + await expect(store.set("integration-key-2", true)).rejects.toThrow( + "ALTCHA payload has been already used." + ); + }); + + test("set with false does nothing", async () => { + await store.set("integration-key-3", false); + expect(await store.get("integration-key-3")).toBe(false); + }); +}); diff --git a/src/replay-store.ts b/src/replay-store.ts index 6b026ba..7afa736 100644 --- a/src/replay-store.ts +++ b/src/replay-store.ts @@ -1,3 +1,5 @@ +import Redis from "ioredis"; + export type ReplayStore = { get: (key: string) => boolean | Promise; set: (key: string, value: boolean) => void | Promise; @@ -22,3 +24,24 @@ export const createInMemoryReplayStore = (maxRecords: number): ReplayStore => { }, }; }; + +const REDIS_KEY_PREFIX = "altcha:replay:"; + +export const createRedisReplayStore = (redisUrl: string, ttlSeconds: number): ReplayStore => { + const redis = new Redis(redisUrl, { lazyConnect: true }); + + return { + get: async (key: string) => { + const result = await redis.get(`${REDIS_KEY_PREFIX}${key}`); + return result !== null; + }, + set: async (key: string, value: boolean) => { + if (!value) return; + const fullKey = `${REDIS_KEY_PREFIX}${key}`; + const result = await redis.set(fullKey, "1", "EX", ttlSeconds, "NX"); + if (result === null) { + throw new Error("ALTCHA payload has been already used."); + } + }, + }; +};