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: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ Use Docker Compose (recommended):
```bash
# Optionally create a .env file (see below) or set variables in your shell
[ -f .env ] || cp .env.example .env
# Start the stack
# Start the API only (production default)
docker compose up --build
```

- API: http://localhost:3000

To also start the demo service, add the `demo` profile:

```bash
docker compose --profile demo up --build
```

- Demo: http://localhost:8080

To override the secret temporarily:
Expand Down
2 changes: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
include = ["src/**/*.test.ts"]
14 changes: 14 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@ services:
MAXNUMBER: ${MAXNUMBER:-5000}
ports:
- "3000:3000"
restart: unless-stopped
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M

demo:
profiles:
- demo
build:
context: .
target: demo
Expand All @@ -22,3 +30,9 @@ services:
- server
ports:
- "8080:8080"
restart: unless-stopped
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "bun --watch ./src/index.ts",
"start": "bun ./build/index.js",
"start:demo": "bun ./build/demo.js",
"test": "bun test src/**/*.test.ts",
"test": "bun test",
"tsc": "tsc"
},
"keywords": [],
Expand Down
104 changes: 104 additions & 0 deletions src/api-app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { solveChallenge } from "altcha-lib";
import { deriveKey } from "altcha-lib/algorithms/pbkdf2";
import { createApiApp } from "./api-app";
import type { ApiConfig } from "./config";

const TEST_SECRET = "this-is-a-very-long-secret-key-that-is-at-least-32-chars";

const testConfig: ApiConfig = {
algorithm: "PBKDF2/SHA-256",
corsOrigin: "*",
expireMinutes: 10,
hmacKey: TEST_SECRET,
maxNumber: 100,
maxRecords: 1000,
port: 0,
};

describe("createApiApp", () => {
let app: Awaited<ReturnType<typeof createApiApp>>;
let server: Server;
let baseUrl: string;

beforeAll(async () => {
app = await createApiApp(testConfig);
server = app.listen(0) as Server;
const address = server.address() as AddressInfo;
baseUrl = `http://localhost:${address.port}`;
});

afterAll(() => {
server.close();
});

test("GET / returns 204", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(204);
});

test("GET /challenge returns 200 with challenge shape", async () => {
const res = await fetch(`${baseUrl}/challenge`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("parameters");
expect(body).toHaveProperty("signature");
expect(typeof body.parameters).toBe("object");
expect(typeof body.signature).toBe("string");
expect(body.parameters).toHaveProperty("algorithm");
expect(body.parameters).toHaveProperty("nonce");
expect(body.parameters).toHaveProperty("salt");
});

test("GET /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?altcha=${encodeURIComponent(payload)}`);
expect(verifyRes.status).toBe(202);
});

test("GET /verify with invalid base64 returns 417 and error invalid", async () => {
const res = await fetch(`${baseUrl}/verify?altcha=not-valid-base64!!!`);
expect(res.status).toBe(417);
const body = await res.json();
expect(body).toEqual({ error: "invalid" });
});

test("GET /verify without altcha query returns 417 and error invalid", async () => {
const res = await fetch(`${baseUrl}/verify`);
expect(res.status).toBe(417);
const body = await res.json();
expect(body).toEqual({ error: "invalid" });
});

test("GET /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?altcha=${encodeURIComponent(payload)}`);
expect(first.status).toBe(202);

const second = await fetch(`${baseUrl}/verify?altcha=${encodeURIComponent(payload)}`);
expect(second.status).toBe(417);
const body = await second.json();
expect(body).toEqual({ error: "replayed" });
});
});
20 changes: 18 additions & 2 deletions src/api-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ export const createApiApp = async (config: ApiConfig): Promise<Express> => {
app.use(express.json());
app.use(cors({ origin: config.corsOrigin }));

if (process.env.NODE_ENV !== "test") {
app.use((req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
next();
});
}

app.get("/", (_req: Request, res: Response) => {
res.sendStatus(204);
});
Expand Down Expand Up @@ -57,12 +68,17 @@ export const createApiApp = async (config: ApiConfig): Promise<Express> => {
app.get("/verify", asyncHandler(async (req: Request, res: Response) => {
const payload = req.query.altcha;
if (typeof payload !== "string" || !payload.length) {
res.sendStatus(417);
res.status(417).json({ error: "invalid" });
return;
}

const result = await verifyPayload(payload, deriveKey, config.hmacKey, hmacKeySignatureSecret, replayStore);
res.sendStatus(result.error ? 417 : 202);
if (result.error) {
const error = result.error === "ALTCHA payload has been already used." ? "replayed" : "invalid";
res.status(417).json({ error });
} else {
res.sendStatus(202);
}
}));

return app;
Expand Down
40 changes: 29 additions & 11 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { describe, expect, test } from "bun:test";
import { parseApiConfig, parseDemoConfig } from "./config";

const LONG_SECRET = "this-is-a-very-long-secret-key-that-is-at-least-32-chars";

describe("parseApiConfig", () => {
test("uses defaults for optional API config", () => {
const config = parseApiConfig({ SECRET: "local-secret" });
const config = parseApiConfig({ SECRET: LONG_SECRET });

expect(config).toEqual({
algorithm: "PBKDF2/SHA-256",
corsOrigin: "*",
expireMinutes: 10,
hmacKey: "local-secret",
hmacKey: LONG_SECRET,
maxNumber: 5000,
maxRecords: 1000,
port: 3000,
Expand All @@ -18,26 +20,26 @@ describe("parseApiConfig", () => {

test("parses numeric and string API config", () => {
const config = parseApiConfig({
ALGORITHM: "SHA-256",
ALGORITHM: "PBKDF2/SHA-256",
CORS_ORIGIN: "https://example.com, https://admin.example.com",
EXPIREMINUTES: "5",
MAXNUMBER: "9000",
MAXRECORDS: "25",
PORT: "4000",
SECRET: "configured-secret",
SECRET: LONG_SECRET,
});

expect(config.algorithm).toBe("SHA-256");
expect(config.algorithm).toBe("PBKDF2/SHA-256");
expect(config.corsOrigin).toEqual(["https://example.com", "https://admin.example.com"]);
expect(config.expireMinutes).toBe(5);
expect(config.maxNumber).toBe(9000);
expect(config.maxRecords).toBe(25);
expect(config.port).toBe(4000);
expect(config.hmacKey).toBe("configured-secret");
expect(config.hmacKey).toBe(LONG_SECRET);
});

test("supports COST as legacy fallback when MAXNUMBER is absent", () => {
const config = parseApiConfig({ COST: "7000", SECRET: "configured-secret" });
const config = parseApiConfig({ COST: "7000", SECRET: LONG_SECRET });

expect(config.maxNumber).toBe(7000);
});
Expand All @@ -46,11 +48,27 @@ describe("parseApiConfig", () => {
expect(() => parseApiConfig({})).toThrow("SECRET is required");
});

test("rejects short secret", () => {
expect(() => parseApiConfig({ SECRET: "short" })).toThrow("SECRET must be at least 32 characters");
});

test("rejects invalid numbers", () => {
expect(() => parseApiConfig({ PORT: "abc", SECRET: "configured-secret" })).toThrow("PORT must be a positive integer");
expect(() => parseApiConfig({ EXPIREMINUTES: "0", SECRET: "configured-secret" })).toThrow("EXPIREMINUTES must be a positive integer");
expect(() => parseApiConfig({ MAXRECORDS: "-1", SECRET: "configured-secret" })).toThrow("MAXRECORDS must be a positive integer");
expect(() => parseApiConfig({ MAXNUMBER: "1.5", SECRET: "configured-secret" })).toThrow("MAXNUMBER must be a positive integer");
expect(() => parseApiConfig({ PORT: "abc", SECRET: LONG_SECRET })).toThrow("PORT must be a positive integer");
expect(() => parseApiConfig({ EXPIREMINUTES: "0", SECRET: LONG_SECRET })).toThrow("EXPIREMINUTES must be a positive integer");
expect(() => parseApiConfig({ MAXRECORDS: "-1", SECRET: LONG_SECRET })).toThrow("MAXRECORDS must be a positive integer");
expect(() => parseApiConfig({ MAXNUMBER: "1.5", SECRET: LONG_SECRET })).toThrow("MAXNUMBER must be a positive integer");
});

test("rejects unsupported algorithm", () => {
expect(() => parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "SHA-256" })).toThrow(
"ALGORITHM must be one of: PBKDF2/SHA-256, PBKDF2/SHA-384, PBKDF2/SHA-512"
);
});

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");
expect(parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "PBKDF2/SHA-512" }).algorithm).toBe("PBKDF2/SHA-512");
});
});

Expand Down
21 changes: 19 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export type CorsOrigin = "*" | string[];

export type SupportedAlgorithm = "PBKDF2/SHA-256" | "PBKDF2/SHA-384" | "PBKDF2/SHA-512";

export type ApiConfig = {
algorithm: string;
algorithm: SupportedAlgorithm;
corsOrigin: CorsOrigin;
expireMinutes: number;
hmacKey: string;
Expand Down Expand Up @@ -46,14 +48,29 @@ const parseUrl = (value: string, name: string): string => {
}
};

const SUPPORTED_ALGORITHMS: SupportedAlgorithm[] = [
"PBKDF2/SHA-256",
"PBKDF2/SHA-384",
"PBKDF2/SHA-512",
];

const parseAlgorithm = (value: string | undefined): SupportedAlgorithm => {
const algorithm = (value || "PBKDF2/SHA-256").trim() as SupportedAlgorithm;
if (!SUPPORTED_ALGORITHMS.includes(algorithm)) {
throw new Error(`ALGORITHM must be one of: ${SUPPORTED_ALGORITHMS.join(", ")}`);
}
return algorithm;
};

export const parseApiConfig = (env: Env = process.env): ApiConfig => {
const hmacKey = env.SECRET?.trim();
if (!hmacKey) throw new Error("SECRET is required");
if (hmacKey.length < 32) throw new Error("SECRET must be at least 32 characters");

const maxNumberSource = env.MAXNUMBER === undefined || env.MAXNUMBER.trim() === "" ? "COST" : "MAXNUMBER";

return {
algorithm: env.ALGORITHM?.trim() || "PBKDF2/SHA-256",
algorithm: parseAlgorithm(env.ALGORITHM),
corsOrigin: parseCorsOrigin(env.CORS_ORIGIN),
expireMinutes: parsePositiveInteger(env, "EXPIREMINUTES", 10),
hmacKey,
Expand Down