Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
69fa80c
Add proof-of-work challenge primitives
premtsd-code Jul 2, 2026
25a73dc
Address review: harden Signer, drop dead code, real PoW round-trip
premtsd-code Jul 2, 2026
58f2a8b
Expose relative WAF challenge expiry
premtsd-code Jul 2, 2026
3217bcc
Add expiresIn and nonce-carried clearance TTL to Issuer
premtsd-code Jul 2, 2026
39cb473
Add JS reference: pure-JS PoW solver + SDK interceptor (phases 1, 3)
premtsd-code Jul 2, 2026
b63d6c6
reference: solve challenge unauthenticated (no API key)
premtsd-code Jul 2, 2026
ea45d86
WAF challenge: drop proof-of-work/captcha branding
premtsd-code Jul 3, 2026
0dc6eac
Add Challenge\Scoring: bot-detection signal schema + scoring engine
premtsd-code Jul 3, 2026
6ba8a73
Add Challenge\Scoring\TlsFingerprint: JA4 -> TLS_MISMATCH classifier
premtsd-code Jul 3, 2026
875328e
Add Challenge\Scoring\ClientSignals: normalize interstitial telemetry
premtsd-code Jul 3, 2026
048b12c
Scoring: ATTACK_SCORE signal + deny-floor (unify WAF attack detection)
premtsd-code Jul 3, 2026
0b6d5d4
Add ML scoring engine and memory-hard proof of work
premtsd-code Jul 3, 2026
ee52772
Harden memory-hard difficulty scaling and the interaction ceiling
premtsd-code Jul 3, 2026
9a032f6
Add interactive (slider) challenge — an unforgeable humanity gate
premtsd-code Jul 3, 2026
8ee4b04
Harden interactive slider against single-solve brute force
premtsd-code Jul 4, 2026
cb61d0b
Note the attempt cap is best-effort under limiter failure
premtsd-code Jul 4, 2026
4375832
refactor: extract humanity gate into premtsd-code/captcha; require ut…
premtsd-code Jul 9, 2026
037db6e
feat: silent challenge tier moves into waf; depend on premtsd-code/ca…
premtsd-code Jul 9, 2026
49e228c
feat: silent tier is now an attestation check, not proof-of-work
premtsd-code Jul 9, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ $firewall->addRule(new Bypass([

$firewall->addRule(new Challenge([
Condition::startsWith('path', '/admin'),
], Challenge::TYPE_CAPTCHA));
], Challenge::TYPE_CUSTOM));

$firewall->addRule(new RateLimit([
Condition::equal('method', ['POST']),
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
"description": "Lite and extensible Web Application Firewall rules management library for the Utopia PHP ecosystem.",
"type": "library",
"license": "MIT",
"repositories": [
{"type": "vcs", "url": "https://github.com/premtsd-code/captcha", "no-api": true}
],
"require": {
"php": ">=8.2",
"utopia-php/validators": "0.2.*"
"utopia-php/validators": "0.2.*",
"premtsd-code/captcha": "dev-main"
},
Comment on lines +6 to 13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Personal-fork runtime dependency supplying core crypto classes

premtsd-code/captcha: dev-main is a personal GitHub repository (not the utopia-php org) registered as a VCS source with no-api: true. Based on the test imports (Context, Signer, Ip, Clearance from Utopia\WAF\Challenge\*) and the fact that none of those classes appear in the current src/Challenge/ tree, this package provides the entire HMAC signing/verification foundation. Pinning the core cryptographic primitives to a mutable personal fork means any push to premtsd-code/captcha:main silently replaces the WAF's secret-handling and token-validation code for all downstream consumers. Additionally, dev-main with minimum-stability: stable still set will cause composer install to fail outright unless consumers add a stability flag. The dependency should be moved to the official utopia-php organisation, tagged as a stable release, and removed from a personal VCS override.

Prompt To Fix With AI
This is a comment left during a code review.
Path: composer.json
Line: 6-13

Comment:
**Personal-fork runtime dependency supplying core crypto classes**

`premtsd-code/captcha: dev-main` is a personal GitHub repository (not the `utopia-php` org) registered as a VCS source with `no-api: true`. Based on the test imports (`Context`, `Signer`, `Ip`, `Clearance` from `Utopia\WAF\Challenge\*`) and the fact that none of those classes appear in the current `src/Challenge/` tree, this package provides the entire HMAC signing/verification foundation. Pinning the core cryptographic primitives to a mutable personal fork means any push to `premtsd-code/captcha:main` silently replaces the WAF's secret-handling and token-validation code for all downstream consumers. Additionally, `dev-main` with `minimum-stability: stable` still set will cause `composer install` to fail outright unless consumers add a stability flag. The dependency should be moved to the official `utopia-php` organisation, tagged as a stable release, and removed from a personal VCS override.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

"require-dev": {
"laravel/pint": "^1.18",
Expand Down
60 changes: 60 additions & 0 deletions reference/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# WAF Challenge — JS reference

Version-locked JavaScript companions to the PHP `Utopia\WAF\Challenge`
primitives. These are the **single source of truth** for the challenge
solver used by two consumers:

- the **edge browser interstitial** (`Utopia\WAF\Challenge\Interstitial` inlines
this algorithm), and
- the **web + node SDK retry interceptors**.

Keeping them here, next to the primitives, means the solver and the
`DIFFICULTY_DEFAULT_*` constants change together.

## Files

| File | Purpose |
| --- | --- |
| `solver.js` | Dependency-free pure-JS SHA-256 + challenge solver. `solve()` (chunked/yielding, for browsers) and `solveSync()` (blocking, for node). Mirrors the PHP `Verifier` leading-zero-bit rule exactly. |
| `interceptor.js` | Reference SDK retry interceptor: single-flight solve, token caching with a skew-safe deadline, one re-solve on a stale-token 403, never loops. |

## Contract

A client finds the smallest non-negative integer `solution` such that

```
sha256(nonce + '.' + solution) has >= difficulty leading zero bits
```

then `POST`s `{ nonce, solution }` to `/v1/waf/challenge` (API) or
`/__waf/challenge` (edge site) to obtain a clearance token/cookie.

## Why pure-JS (not `crypto.subtle`)

`crypto.subtle.digest` is async and its per-call overhead makes a
digest-in-a-loop 10–100× slower than a tight synchronous JS implementation for
this access pattern. The browser solver therefore uses the inline function and
yields between chunks (`requestAnimationFrame`) to keep the tab responsive.

## Verified interop

`solver.js` is checked against the SHA-256 known-answer vector for `"abc"` and,
end-to-end, a nonce minted by the PHP `Issuer` is solved here and accepted by the
PHP `Verifier` — so browser/SDK solutions are valid server-side.

## SDK integration (phase 3)

The generated `appwrite`/`node-appwrite` SDKs wrap their transport with
`createInterceptor({ endpoint, projectId, doFetch })` and route all requests
through `interceptor.request(url, init)`. `doFetch` performs one raw request and
returns `{ status, headers, json }`. Web solves via `solve()`; node via
`solveSync()`. Ship web first (it exercises both the browser solver and the CORS
header exposure), then node.

## Benchmark gate (phase 1)

Before tagging `0.1.0`, run `solver.js` on the reference devices (mid-range
Android, ~3-gen-old iPhone/Safari, low-end laptop). Acceptance: **p95 solve ≤ 4 s**
on the mid-range Android at `DIFFICULTY_DEFAULT_BROWSER`. Challenge solve time is
exponentially distributed (p95 ≈ 3× median), so target median ≤ ~1.3 s. Set the
final `DIFFICULTY_DEFAULT_BROWSER` to the largest value that passes.
118 changes: 118 additions & 0 deletions reference/interceptor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Reference WAF-challenge retry interceptor for the Appwrite SDKs (web + node).
//
// This is the canonical behaviour the generated SDKs wrap around their transport
// (fetch on web, https/undici on node). It is intentionally transport-agnostic:
// pass a `doFetch` that performs one request and returns { status, headers, json }.
//
// Behaviour (HLD §2.4):
// 1. Trigger only on a response whose error type is 'waf_challenge_required'.
// 2. Read the challenge parameters from the CORS-exposed X-Appwrite-WAF-* headers.
// 3. SINGLE-FLIGHT: N concurrent requests that all 403 share ONE solve, keyed by
// (endpoint, projectId, nonce-audience); all retry with the minted token.
// 4. Solve with solver.js, POST /v1/waf/challenge, cache { token, deadline }.
// deadline = now + expiresIn - 30s (skew margin; prefer expiresIn over the
// absolute Expires so a wrong local clock cannot shorten/extend the window).
// 5. Attach X-Appwrite-WAF-Token on subsequent requests while now < deadline.
// 6. On a challenge 403 DESPITE a cached token (expiry race / IP change): drop
// the cache, re-solve ONCE, then surface the error. Never loop.

'use strict';

const { solve, solveSync } = require('./solver.js');

const CHALLENGE_ERROR = 'waf_challenge_required';
const DEADLINE_SKEW_SECONDS = 30;

// Solve in a browser (yielding) when available, else synchronously (node).
function solveChallenge(nonce, difficulty) {
return (typeof window !== 'undefined')
? solve(nonce, difficulty)
: Promise.resolve(solveSync(nonce, difficulty));
}

function isChallenge(res) {
if (!res || res.status !== 403) return false;
const body = res.json || {};
return body.type === CHALLENGE_ERROR;
}

function readChallenge(res) {
const h = res.headers || {};
const get = (k) => h[k] || h[k.toLowerCase()] || '';
return {
nonce: get('X-Appwrite-WAF-Nonce'),
difficulty: parseInt(get('X-Appwrite-WAF-Difficulty') || '0', 10) || 0,
expiresIn: parseInt(get('X-Appwrite-WAF-Expires-In') || '0', 10) || 0,
};
}
Comment on lines +27 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Memory-hard challenges are silently unsolvable

solveChallenge accepts only (nonce, difficulty), and readChallenge returns no memory field. When the server issues a memory-hard challenge (memory > 0), the interceptor calls solve(nonce, difficulty) with no { memory } option, so the solver uses plain SHA-256. The PHP Verifier reads mem from the signed nonce and uses romix; the digests are independent, so every solution is rejected with a 4xx, mintToken throws "waf challenge solve failed", and the challenge never succeeds.

solver.js already has the full implementation — solve(nonce, difficulty, { memory }) and solveSync(nonce, difficulty, memory) — but the interceptor never threads the value through. readChallenge needs to read an X-Appwrite-WAF-Memory (or X-Appwrite-WAF-Algorithm) header, and solveChallenge needs to forward it to the solver.


/**
* Create an interceptor bound to one project/endpoint.
*
* @param {object} opts
* @param {string} opts.endpoint e.g. https://cloud.appwrite.io/v1
* @param {string} opts.projectId
* @param {(url:string, init:object)=>Promise<{status:number,headers:object,json:any}>} opts.doFetch
* @param {()=>number} [opts.now] injectable clock (seconds), for tests
*/
function createInterceptor(opts) {
const now = opts.now || (() => Math.floor(Date.now() / 1000));
const key = opts.endpoint + '|' + opts.projectId; // single-flight + cache key
const cache = new Map(); // key -> { token, deadline }
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Single-flight key excludes audience, causing incorrect coalescing

The spec comment on line 9 says the single-flight/cache key is keyed by (endpoint, projectId, nonce-audience), but the implementation on line 60 only uses endpoint + '|' + projectId. When a WAF deployment assigns different audiences to different API surfaces (e.g. api vs storage), two concurrent 403s from those surfaces share the same key. The in-flight promise resolves with the token minted for the first audience. The second audience's retry attaches that wrong-audience token, the server rejects it with a 403, and since alreadyRetried is already true, the error is surfaced to the caller — making the request appear permanently broken. Because readChallenge() does not expose the audience header to the client, the simplest fix is to key on the nonce itself (or add a server-side X-Appwrite-WAF-Audience header that readChallenge exposes and includes in the key).

const inflight = new Map(); // key -> Promise<token> (single-flight)

async function mintToken(res) {
// Coalesce concurrent solves for the same audience.
if (inflight.has(key)) return inflight.get(key);

const p = (async () => {
const { nonce, difficulty, expiresIn } = readChallenge(res);
const solution = await solveChallenge(nonce, difficulty);
// Solve UNAUTHENTICATED: send only the project header — never the API key
// or session. Solving a challenge is not a scoped operation, and a
// narrowly-scoped server key would otherwise be rejected by the scope
// check. The server route accepts guests here (scope 'public'/'global').
const solveRes = await opts.doFetch(opts.endpoint + '/waf/challenge', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-appwrite-project': opts.projectId },
body: JSON.stringify({ nonce, solution }),
});
if (solveRes.status < 200 || solveRes.status >= 300) {
throw new Error('waf challenge solve failed: ' + solveRes.status);
}
const token = (solveRes.json || {}).token;
const ttl = (solveRes.json || {}).expiresIn || expiresIn;
cache.set(key, { token, deadline: now() + ttl - DEADLINE_SKEW_SECONDS });
return token;
})().finally(() => inflight.delete(key));

inflight.set(key, p);
return p;
}

function attachToken(init) {
const cached = cache.get(key);
if (cached && now() < cached.deadline) {
init = init || {};
init.headers = Object.assign({}, init.headers, { 'X-Appwrite-WAF-Token': cached.token });
}
return init;
}

// Wrap a single request with challenge handling. `alreadyRetried` guards the
// "re-solve once on a stale-token 403" path so we can never loop.
async function request(url, init, alreadyRetried) {
const res = await opts.doFetch(url, attachToken(init));
if (!isChallenge(res)) return res;

if (alreadyRetried) return res; // solved-and-still-challenged: surface it
if (cache.has(key)) cache.delete(key); // stale token — drop and re-solve once

await mintToken(res);
return request(url, init, true);
}

return { request, _cache: cache };
}

module.exports = { createInterceptor, isChallenge, readChallenge, solveChallenge };
55 changes: 55 additions & 0 deletions reference/ml-engine-model.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"_comment": "Generated by reference/train-ml-engine.js — logistic regression over the bot-detection signal vector. Paste into MlEngine::DEFAULT_COEFFICIENTS / DEFAULT_INTERCEPT.",
"features": [
"ipReputation",
"asnReputation",
"tlsMismatch",
"missingHeaders",
"headless",
"automationFlags",
"behavioralRisk"
],
"intercept": -6.52634,
"coefficients": {
"ipReputation": 3.562063,
"asnReputation": 4.67617,
"tlsMismatch": 1.375458,
"missingHeaders": 1.896716,
"headless": 4.576935,
"automationFlags": 2.71498,
"behavioralRisk": 4.826937
},
"metrics": {
"samples": 20000,
"accuracy": 1,
"precision": 1,
"recall": 1,
"falsePositiveRate": 0
},
"canonicalCases": {
"clean human": {
"p": 0.0015,
"tier": "allow"
},
"one missing header": {
"p": 0.0027,
"tier": "allow"
},
"legit VPN user": {
"p": 0.011,
"tier": "allow"
},
"headless bot (no interaction)": {
"p": 0.9907,
"tier": "deny"
},
"curl/python (tls mismatch)": {
"p": 0.9945,
"tier": "deny"
},
"datacenter abuser": {
"p": 0.6299,
"tier": "interactive"
}
}
}
Loading
Loading