Skip to content
Draft
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
23 changes: 23 additions & 0 deletions .changeset/frontend-captcha-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@zitadel/components": minor
---

Implement the frontend captcha gate (ADR 019): a new invisible `<zl-captcha>`
atom solves a step's `captcha` gate — built-in Altcha proof-of-work (with a
Web Worker fast path) or a third-party widget (Turnstile, hCaptcha,
reCAPTCHA) mounted in light DOM — and emits the proof as an opaque string.
The `mandatory-gates` patcher now actually injects the gate consumer it
always documented: any `step.gates` entry without a matching `<zl-captcha>`
in the template gets one, so templates need no gate markup. The orchestrator
collects proofs and sends them as `gate_proofs` on submit, and surfaces
`error.gate_failed` (new locale key in en/de/it) when solving fails.

Invisible atoms are now uniformly null-safe: `<zl-passkey>` and
`<zl-captcha>` idle silently when mounted without challenge data and start
automatically when it arrives; an explicit `startCeremony()`/`startSolve()`
without data still reports an error event.

The api-mock issues a real Altcha challenge on the identifier step
(`bot_check`) and, when `setupMockHandlers({ verifyGates: true })` is set
(the standalone dev server does), verifies submitted proofs and re-renders
the step with a fresh challenge on failure.
40 changes: 40 additions & 0 deletions packages/api-mock/src/altcha.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";

import { generateAltchaChallenge, verifyAltchaProof } from "./altcha.js";

/** Build the proof string the way `<zl-captcha>` does. */
function proofFor(challenge: { algorithm: string; challenge: string; salt: string }, number: number): string {
return btoa(
JSON.stringify({
algorithm: challenge.algorithm,
challenge: challenge.challenge,
number,
salt: challenge.salt,
}),
);
}

describe("altcha challenge mint + verify", () => {
it("accepts the proof for the minted solution", async () => {
const challenge = await generateAltchaChallenge(42, 1000);
expect(challenge.max_number).toBe(1000);
await expect(verifyAltchaProof(challenge, proofFor(challenge, 42))).resolves.toBe(true);
});

it("rejects a proof with the wrong number", async () => {
const challenge = await generateAltchaChallenge(42, 1000);
await expect(verifyAltchaProof(challenge, proofFor(challenge, 41))).resolves.toBe(false);
});

it("rejects a proof whose salt does not match the issued challenge", async () => {
const challenge = await generateAltchaChallenge(7, 1000);
const foreign = { ...challenge, salt: "someone-elses-salt" };
await expect(verifyAltchaProof(challenge, proofFor(foreign, 7))).resolves.toBe(false);
});

it("rejects malformed proof strings instead of throwing", async () => {
const challenge = await generateAltchaChallenge();
await expect(verifyAltchaProof(challenge, "not-base64-json")).resolves.toBe(false);
await expect(verifyAltchaProof(challenge, btoa("[1,2,3]"))).resolves.toBe(false);
});
});
88 changes: 88 additions & 0 deletions packages/api-mock/src/altcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Mock Altcha proof-of-work challenge generator and verifier.
*
* Generates a valid Altcha challenge with a known solution so `<zl-captcha>`
* can solve it trivially in development: `max_number` stays low (1000) and
* the solution is a small number, so the brute-force loop completes in
* milliseconds on any device.
*
* Proofs travel as strings per `flow-submit-request.yaml` — for Altcha
* that's the base64-encoded JSON solution payload the widget standard uses:
* `btoa(JSON.stringify({ algorithm, challenge, number, salt }))`.
*
* Uses the Web Crypto API (`globalThis.crypto.subtle`) — works in Node.js
* ≥ 18 and browsers without polyfills.
*/

function bufferToHex(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

export type AltchaChallenge = {
algorithm: string;
challenge: string;
salt: string;
max_number: number;
};

/**
* Generate a fresh Altcha PoW challenge. The solution is always within
* `[0, maxNumber]` so it can be brute-forced quickly in dev.
*
* @param solutionNumber - The number that solves the challenge (default: 42).
* Keep this low for fast dev-mode solving.
* @param maxNumber - Upper bound advertised to the solver (default: 1000).
*/
export async function generateAltchaChallenge(
solutionNumber = 42,
maxNumber = 1000,
): Promise<AltchaChallenge> {
const salt = bufferToHex(globalThis.crypto.getRandomValues(new Uint8Array(16)));
const algorithm = "SHA-256";

// challenge = hex(SHA-256(salt + solutionNumber))
const encoder = new TextEncoder();
const data = encoder.encode(salt + solutionNumber);
const hashBuffer = await globalThis.crypto.subtle.digest(algorithm, data);
const challenge = bufferToHex(hashBuffer);

return {
algorithm,
challenge,
salt,
max_number: maxNumber,
};
}

/**
* Verify an Altcha proof string against the originally issued challenge.
*
* Decodes the base64 JSON payload, checks the salt matches the issued
* challenge, and recomputes `SHA-256(salt + number)`. Returns `false` for
* malformed payloads rather than throwing — a garbled proof is just an
* invalid one.
*/
export async function verifyAltchaProof(
original: AltchaChallenge,
proof: string,
): Promise<boolean> {
let payload: { number?: unknown; salt?: unknown };
try {
payload = JSON.parse(globalThis.atob(proof)) as { number?: unknown; salt?: unknown };
} catch {
return false;
}

const { number, salt } = payload;
if (typeof number !== "number" || typeof salt !== "string") return false;
if (salt !== original.salt) return false;

const encoder = new TextEncoder();
const data = encoder.encode(salt + number);
const hashBuffer = await globalThis.crypto.subtle.digest(original.algorithm, data);

return bufferToHex(hashBuffer) === original.challenge;
}
17 changes: 16 additions & 1 deletion packages/api-mock/src/fixtures/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const input: StepFixtureInput = {
};

const syncBuilders = {
identifierStep,
registerStep,
registerPasswordStep,
passwordStep,
Expand All @@ -61,4 +60,20 @@ describe("runtime login-flow fixtures match the generated flow-step schema", ()
const resolved = await doneStep(input);
expect(() => GetFlowStepResponse.parse(resolved)).not.toThrow();
});

// `identifierStep` is async too (it mints an Altcha challenge for the
// `bot_check` gate on every render).
test("identifierStep is spec-conformant and carries the bot_check gate", async () => {
const resolved = await identifierStep(input);
expect(() => GetFlowStepResponse.parse(resolved)).not.toThrow();
const gate = resolved.step.gates?.["bot_check"];
expect(gate?.kind).toBe("captcha");
expect(gate?.provider).toBe("altcha");
expect(gate?.config).toMatchObject({
algorithm: "SHA-256",
max_number: expect.any(Number),
challenge: expect.any(String),
salt: expect.any(String),
});
});
});
10 changes: 8 additions & 2 deletions packages/api-mock/src/fixtures/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/
import type { CreateFlow201, CreateFlow201Step } from "@zitadel/api/generated/model";

import { generateAltchaChallenge } from "../altcha.js";
import { signHandoffToken } from "../crypto.js";
import type { StoredCredential } from "../lib/authn/index.js";

Expand Down Expand Up @@ -76,7 +77,10 @@ function wrap(input: StepFixtureInput, step: CreateFlow201Step, extras?: Partial
* Matches the Flow API shape in `docs/design/flowengine/flow-engine.md`
* (single `login` step with `fields: [email, password]`).
*/
export function identifierStep(input: StepFixtureInput): CreateFlow201 {
export async function identifierStep(input: StepFixtureInput): Promise<CreateFlow201> {
// Async on purpose: every render mints a fresh Altcha challenge for the
// `bot_check` gate, the way the real engine will (ADR 019).
const altcha = await generateAltchaChallenge();
return wrap(input, {
name: "identifier",
texts: { title_key: "identifier.title" },
Expand All @@ -100,7 +104,9 @@ export function identifierStep(input: StepFixtureInput): CreateFlow201 {
{ name: "register", kind: "navigate", text_key: "identifier.action.register.link" },
{ name: "recover", kind: "navigate", text_key: "action.forgot_password" },
],
gates: {},
gates: {
bot_check: { kind: "captcha", provider: "altcha", config: altcha },
},
});
}

Expand Down
51 changes: 47 additions & 4 deletions packages/api-mock/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
} from "@zitadel/api/generated/model";
import type { RequestHandler } from "msw";

import { verifyAltchaProof, type AltchaChallenge } from "./altcha.js";
import { withBranding } from "./branding.js";
import { startFlowActor, type FlowActor, type FlowStepName } from "./flow-machine.js";
import { AuthnStore, type PasskeyProof } from "./lib/authn/index.js";
Expand Down Expand Up @@ -77,17 +78,41 @@ const FLOW_ID = "flow_mock";
* @param options.iss - Issuer URL embedded in the handoff token (default:
* `"http://localhost:4000"`). Pass the server's own origin so that
* `verifyHandoffToken` can enforce issuer consistency.
* @param options.verifyGates - When true, submits on gated steps are
* rejected (re-rendered with `error.gate_failed` and a fresh challenge)
* unless `gate_proofs` carries a valid solution for every issued
* challenge. Off by default so unit tests that drive steps directly
* don't have to solve captchas; the standalone dev server opts in.
*/
export function setupMockHandlers(options: { iss?: string } = {}): MockHandle {
export function setupMockHandlers(options: { iss?: string; verifyGates?: boolean } = {}): MockHandle {
const iss = options.iss ?? "http://localhost:8080";
const verifyGates = options.verifyGates ?? false;
let actor: FlowActor = startFlowActor();
let captured: CapturedRequest[] = [];
const authn = new AuthnStore();
/** Altcha challenges minted by the last rendered step, keyed by gate name. */
const issuedChallenges = new Map<string, AltchaChallenge>();

function reset(): void {
actor = startFlowActor();
captured = [];
authn.clear();
issuedChallenges.clear();
}

/**
* Record the Altcha challenges a response hands out so the next submit
* can be verified against exactly what was issued (a re-render replaces
* the tracked challenge, mirroring the engine's fresh-challenge-per-render
* behaviour in ADR 019).
*/
function trackChallenges(response: CreateFlow201): CreateFlow201 {
for (const [name, gate] of Object.entries(response.step.gates ?? {})) {
if (gate.kind === "captcha" && gate.provider === "altcha" && gate.config) {
issuedChallenges.set(name, gate.config as AltchaChallenge);
}
}
return response;
}

function registerCredential(userHandle: string, credentialId: string): void {
Expand Down Expand Up @@ -136,7 +161,7 @@ export function setupMockHandlers(options: { iss?: string } = {}): MockHandle {
case "done":
return withBranding(await doneStep(input));
default:
return withBranding(identifierStep(input));
return trackChallenges(withBranding(await identifierStep(input)));
}
}

Expand All @@ -163,6 +188,24 @@ export function setupMockHandlers(options: { iss?: string } = {}): MockHandle {
iss,
};

// Gate verification (ADR 019): a gated step's submit must carry a
// valid proof for every challenge issued with that step's render.
// Only the identifier step carries a gate in these fixtures; failed
// verification re-renders it with a fresh challenge, like the engine.
if (verifyGates && before === "identifier" && body.action === "submit" && issuedChallenges.size > 0) {
for (const [name, challenge] of issuedChallenges) {
const proof = body.gate_proofs?.[name];
const valid = proof ? await verifyAltchaProof(challenge, proof) : false;
if (!valid) {
console.warn(`❌ [api-mock] gate "${name}" proof ${proof ? "INVALID" : "MISSING"}`);
const base = withBranding(await identifierStep(fixtureInput));
return trackChallenges({ ...base, step: { ...base.step, error: "error.gate_failed" } });
}
console.info(`✅ [api-mock] gate "${name}" proof VALID`);
}
issuedChallenges.clear();
}

const registrationErrorKey = before === "register" && body.action === "submit" && email
? authn.registrationError(email)
: null;
Expand All @@ -175,8 +218,8 @@ export function setupMockHandlers(options: { iss?: string } = {}): MockHandle {
? authn.loginError(email)
: null;
if (loginErrorKey) {
const base = withBranding(identifierStep(fixtureInput));
return { ...base, step: { ...base.step, error: loginErrorKey } };
const base = withBranding(await identifierStep(fixtureInput));
return trackChallenges({ ...base, step: { ...base.step, error: loginErrorKey } });
}

const contextEmail = snapshot.context.capturedFields.email;
Expand Down
84 changes: 84 additions & 0 deletions packages/api-mock/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,87 @@ describe("setupMockHandlers", () => {
expect(next.branding).toBeUndefined();
});
});

/** Brute-force the Altcha solution the way `<zl-captcha>` does. */
async function solveGate(config: Record<string, unknown>): Promise<string> {
const { algorithm, challenge, salt, max_number } = config as {
algorithm: string;
challenge: string;
salt: string;
max_number: number;
};
const encoder = new TextEncoder();
for (let n = 0; n <= max_number; n++) {
const digest = await crypto.subtle.digest(algorithm, encoder.encode(salt + n));
const hex = Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (hex === challenge) {
return btoa(JSON.stringify({ algorithm, challenge, number: n, salt }));
}
}
throw new Error("gate unsolvable within max_number");
}

describe("gate verification (verifyGates: true)", () => {
beforeEach(() => {
const gated = setupMockHandlers({ verifyGates: true });
server.use(...gated.handlers);
});

test("issues a bot_check gate on the identifier step", async () => {
const start = await createFlow({ purpose: "login", project_id: PROJECT_ID });
const gate = start.step.gates?.["bot_check"];
expect(gate?.kind).toBe("captcha");
expect(gate?.provider).toBe("altcha");
expect(gate?.config?.challenge).toBeTruthy();
});

test("rejects a submit without a proof and re-renders with a fresh challenge", async () => {
const start = await createFlow({ purpose: "login", project_id: PROJECT_ID });
const firstChallenge = start.step.gates?.["bot_check"]?.config?.challenge;

const rejected = await submitFlowStep(start.id, {
session_token: start.session_token,
action: "submit",
fields: { email: "alice@acme.com", password: "hunter2" },
});
expect(rejected.step.name).toBe("identifier");
expect(rejected.step.error).toBe("error.gate_failed");
const freshChallenge = rejected.step.gates?.["bot_check"]?.config?.challenge;
expect(freshChallenge).toBeTruthy();
expect(freshChallenge).not.toBe(firstChallenge);
});

test("rejects a tampered proof", async () => {
const start = await createFlow({ purpose: "login", project_id: PROJECT_ID });
const config = start.step.gates?.["bot_check"]?.config as Record<string, unknown>;
const valid = await solveGate(config);
const tampered = btoa(
JSON.stringify({ ...(JSON.parse(atob(valid)) as Record<string, unknown>), number: -1 }),
);

const rejected = await submitFlowStep(start.id, {
session_token: start.session_token,
action: "submit",
fields: { email: "alice@acme.com", password: "hunter2" },
gate_proofs: { bot_check: tampered },
});
expect(rejected.step.error).toBe("error.gate_failed");
});

test("accepts a valid proof and advances the flow", async () => {
const start = await createFlow({ purpose: "login", project_id: PROJECT_ID });
const config = start.step.gates?.["bot_check"]?.config as Record<string, unknown>;
const proof = await solveGate(config);

const done = await submitFlowStep(start.id, {
session_token: start.session_token,
action: "submit",
fields: { email: "alice@acme.com", password: "hunter2" },
gate_proofs: { bot_check: proof },
});
expect(done.step.name).toBe("done");
expect(done.step.error).toBeUndefined();
});
});
Loading
Loading