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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,15 @@ MAXNUMBER=5000
- 200 OK with challenge payload.

- GET /verify?altcha=<payload>
- Verifies the provided ALTCHA solution.
- Verifies the provided ALTCHA solution via query string.
- 202 Accepted on success.
- 417 Expectation Failed on failure or when a token is reused (single-use enforced with an in-memory cache).

- POST /verify
- Verifies the provided ALTCHA solution via JSON body (`{ "altcha": string }`).
- 202 Accepted on success.
- 417 Expectation Failed on failure or reuse.

Notes:

- CORS is open (origin: \*).
Expand Down Expand Up @@ -169,7 +174,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.
- In-memory replay protection is single-instance only; for horizontal scaling, replace the in-memory token cache with a shared store.
- **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.
- 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
244 changes: 0 additions & 244 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ services:
SECRET: ${ALTCHA_SECRET:-$$ecret.key}
NODE_ENV: production
CORS_ORIGIN: ${CORS_ORIGIN:-*}
EXPIREMINUTES: ${EXPIREMINUTES:-10}
MAXRECORDS: ${MAXRECORDS:-1000}
MAXNUMBER: ${MAXNUMBER:-5000}
PORT: ${PORT:-3000}
ports:
- "3000:3000"
restart: unless-stopped
Expand Down
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^22.15.0",
"@yarnpkg/pnpify": "^4.1.6",
"ts-node": "^10.9.2",
"typescript": "^6.0.2"
}
}
59 changes: 59 additions & 0 deletions src/api-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,63 @@ describe("createApiApp", () => {
const body = await second.json();
expect(body).toEqual({ error: "replayed" });
});

test("POST /verify with valid payload returns 202", async () => {
const challengeRes = await fetch(`${baseUrl}/challenge`);
const challenge = await challengeRes.json();

const solution = await solveChallenge({
challenge,
deriveKey,
});
expect(solution).not.toBeNull();

const payload = btoa(JSON.stringify({ challenge, solution }));
const verifyRes = await fetch(`${baseUrl}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ altcha: payload }),
});
expect(verifyRes.status).toBe(202);
});

test("POST /verify with invalid payload returns 417 and error invalid", async () => {
const res = await fetch(`${baseUrl}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ altcha: "not-valid" }),
});
expect(res.status).toBe(417);
const body = await res.json();
expect(body).toEqual({ error: "invalid" });
});

test("POST /verify with replayed payload returns 417 and error replayed", async () => {
const challengeRes = await fetch(`${baseUrl}/challenge`);
const challenge = await challengeRes.json();

const solution = await solveChallenge({
challenge,
deriveKey,
});
expect(solution).not.toBeNull();

const payload = btoa(JSON.stringify({ challenge, solution }));

const first = await fetch(`${baseUrl}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ altcha: payload }),
});
expect(first.status).toBe(202);

const second = await fetch(`${baseUrl}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ altcha: payload }),
});
expect(second.status).toBe(417);
const body = await second.json();
expect(body).toEqual({ error: "replayed" });
});
});
13 changes: 11 additions & 2 deletions src/api-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const createApiApp = async (config: ApiConfig): Promise<Express> => {
const hmacKeySignatureSecret = await deriveHmacKeySecret(config.hmacKey);
const replayStore = createInMemoryReplayStore(config.maxRecords);

console.log("[ALTCHA]: replay store initialised — in-memory, cleared on restart");

app.use(helmet());
app.use(express.json());
app.use(cors({ origin: config.corsOrigin }));
Expand Down Expand Up @@ -65,8 +67,7 @@ export const createApiApp = async (config: ApiConfig): Promise<Express> => {
res.status(200).json(challenge);
}));

app.get("/verify", asyncHandler(async (req: Request, res: Response) => {
const payload = req.query.altcha;
const handleVerify = async (payload: unknown, res: Response) => {
if (typeof payload !== "string" || !payload.length) {
res.status(417).json({ error: "invalid" });
return;
Expand All @@ -79,6 +80,14 @@ export const createApiApp = async (config: ApiConfig): Promise<Express> => {
} else {
res.sendStatus(202);
}
};

app.get("/verify", asyncHandler(async (req: Request, res: Response) => {
await handleVerify(req.query.altcha, res);
}));

app.post("/verify", asyncHandler(async (req: Request, res: Response) => {
await handleVerify(req.body.altcha, res);
}));

return app;
Expand Down
10 changes: 6 additions & 4 deletions src/demo-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ export const createDemoApp = (config: DemoConfig): Express => {

app.post("/test", async (req: Request, res: Response) => {
try {
const url = new URL("/verify", config.apiBaseUrl);
if (typeof req.body.altcha === "string") url.searchParams.set("altcha", req.body.altcha);

const upstream = await fetch(url, { signal: AbortSignal.timeout(5000) });
const upstream = await fetch(`${config.apiBaseUrl}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ altcha: req.body.altcha }),
signal: AbortSignal.timeout(5000),
});
res.sendStatus(proxyStatus(upstream.status, 417));
} catch (error: unknown) {
console.error("[ALTCHA]: demo verify proxy failed", error);
Expand Down
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ if (process.env.NODE_ENV !== "production") {
const start = async () => {
const config = parseApiConfig();

if (config.hmacKey === "$ecret.key") console.log(" [WARNING] CHANGE ALTCHA SECRET KEY - its still default !!! ");

const app = await createApiApp(config);

const server = app.listen(config.port, () => {
Expand Down