Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +41,8 @@ jobs:

- name: Test
run: bun test
env:
REDIS_URL: redis://redis:6379

build:
runs-on: ubuntu-latest
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
MAXRECORDS: ${MAXRECORDS:-1000}
MAXNUMBER: ${MAXNUMBER:-5000}
PORT: ${PORT:-3000}
REDIS_URL: ${REDIS_URL:-}
ports:
- "3000:3000"
restart: unless-stopped
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 14 additions & 3 deletions src/api-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -24,9 +24,20 @@ const asyncHandler = (handler: RequestHandler): RequestHandler => {
export const createApiApp = async (config: ApiConfig): Promise<Express> => {
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());
Expand Down
11 changes: 11 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe("parseApiConfig", () => {
maxNumber: 5000,
maxRecords: 1000,
port: 3000,
redisUrl: undefined,
});
});

Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ApiConfig = {
maxNumber: number;
maxRecords: number;
port: number;
redisUrl?: string;
};

export type DemoConfig = {
Expand Down Expand Up @@ -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),
Expand All @@ -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,
};
};

Expand Down
29 changes: 29 additions & 0 deletions src/replay-store.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 23 additions & 0 deletions src/replay-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Redis from "ioredis";

export type ReplayStore = {
get: (key: string) => boolean | Promise<boolean>;
set: (key: string, value: boolean) => void | Promise<void>;
Expand All @@ -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.");
}
},
};
};