diff --git a/README.md b/README.md index 7210b7b..ca523de 100644 --- a/README.md +++ b/README.md @@ -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']), diff --git a/composer.json b/composer.json index 87936f0..6117ed8 100644 --- a/composer.json +++ b/composer.json @@ -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" }, "require-dev": { "laravel/pint": "^1.18", diff --git a/reference/README.md b/reference/README.md new file mode 100644 index 0000000..5bd23a5 --- /dev/null +++ b/reference/README.md @@ -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. diff --git a/reference/interceptor.js b/reference/interceptor.js new file mode 100644 index 0000000..215ff26 --- /dev/null +++ b/reference/interceptor.js @@ -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, + }; +} + +/** + * 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 } + const inflight = new Map(); // key -> Promise (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 }; diff --git a/reference/ml-engine-model.json b/reference/ml-engine-model.json new file mode 100644 index 0000000..6ad112f --- /dev/null +++ b/reference/ml-engine-model.json @@ -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" + } + } +} diff --git a/reference/solver.js b/reference/solver.js new file mode 100644 index 0000000..203aa23 --- /dev/null +++ b/reference/solver.js @@ -0,0 +1,180 @@ +// Canonical solver for the Appwrite WAF challenge. +// +// This is the single source of truth for the browser interstitial (inlined into +// Utopia\WAF\Challenge\Interstitial on the edge) and the SDK retry interceptors +// (web + node). It is intentionally dependency-free and environment-agnostic: +// a pure-JS SHA-256 (no crypto.subtle-per-attempt, whose async overhead makes it +// 10-100x slower for this access pattern) plus a leading-zero-bit check that +// mirrors the PHP Verifier exactly. +// +// Contract: find the smallest non-negative integer `solution` such that +// digest(nonce + '.' + solution) has >= difficulty leading zero bits, +// where digest is plain sha256, or the memory-hard `romix` (see below) when the +// challenge is issued with a `memory` cost. Both mirror the PHP Utopia\WAF\ +// Challenge\Pow exactly. +// +// Usage (async, yields between chunks so a browser tab stays responsive): +// const solution = await solve(nonce, difficulty, { memory, onProgress }); +// Usage (synchronous, for node/server callers): +// const solution = solveSync(nonce, difficulty, memory); + +'use strict'; + +const K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +function utf8Bytes(str) { + const b = []; + for (let i = 0; i < str.length; i++) { + let c = str.charCodeAt(i); + if (c < 0x80) b.push(c); + else if (c < 0x800) b.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c < 0xd800 || c >= 0xe000) b.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + else { + c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(++i) & 0x3ff)); + b.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + } + return b; +} + +function sha256Bytes(bytes) { + const l = bytes.length; + const withOne = l + 1; + const k = (56 - (withOne % 64) + 64) % 64; + const total = withOne + k + 8; + const m = new Uint8Array(total); + m.set(bytes, 0); + m[l] = 0x80; + const bitLen = l * 8; + m[total - 4] = (bitLen >>> 24) & 0xff; + m[total - 3] = (bitLen >>> 16) & 0xff; + m[total - 2] = (bitLen >>> 8) & 0xff; + m[total - 1] = bitLen & 0xff; + + let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a, + h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19; + const w = new Int32Array(64); + + for (let off = 0; off < total; off += 64) { + for (let i = 0; i < 16; i++) { + const j = off + i * 4; + w[i] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | m[j + 3]; + } + for (let i = 16; i < 64; i++) { + const a = w[i - 15], b = w[i - 2]; + const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3); + const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + let A = h0, B = h1, C = h2, D = h3, E = h4, F = h5, G = h6, H = h7; + for (let i = 0; i < 64; i++) { + const S1 = ((E >>> 6) | (E << 26)) ^ ((E >>> 11) | (E << 21)) ^ ((E >>> 25) | (E << 7)); + const ch = (E & F) ^ ((~E) & G); + const t1 = (H + S1 + ch + K[i] + w[i]) | 0; + const S0 = ((A >>> 2) | (A << 30)) ^ ((A >>> 13) | (A << 19)) ^ ((A >>> 22) | (A << 10)); + const maj = (A & B) ^ (A & C) ^ (B & C); + const t2 = (S0 + maj) | 0; + H = G; G = F; F = E; E = (D + t1) | 0; D = C; C = B; B = A; A = (t1 + t2) | 0; + } + h0 = (h0 + A) | 0; h1 = (h1 + B) | 0; h2 = (h2 + C) | 0; h3 = (h3 + D) | 0; + h4 = (h4 + E) | 0; h5 = (h5 + F) | 0; h6 = (h6 + G) | 0; h7 = (h7 + H) | 0; + } + const out = new Uint8Array(32); + const hs = [h0, h1, h2, h3, h4, h5, h6, h7]; + for (let i = 0; i < 8; i++) { + out[i * 4] = (hs[i] >>> 24) & 0xff; + out[i * 4 + 1] = (hs[i] >>> 16) & 0xff; + out[i * 4 + 2] = (hs[i] >>> 8) & 0xff; + out[i * 4 + 3] = hs[i] & 0xff; + } + return out; +} + +function leadingZeroBits(digest) { + let bits = 0; + for (let i = 0; i < digest.length; i++) { + const byte = digest[i]; + if (byte === 0) { bits += 8; continue; } + for (let mask = 0x80; mask > 0; mask >>= 1) { + if (byte & mask) return bits; + bits++; + } + } + return bits; +} + +// Sequential memory-hard hash — a SHA-256 ROMix, byte-for-byte identical to the +// PHP Utopia\WAF\Challenge\Pow::romix. Fills a `memory`-block (×32 B) scratchpad +// by iterated hashing, then mixes with data-dependent reads so the whole buffer +// must stay resident. Raises the per-attempt cost floor and, crucially, bounds +// it by memory bandwidth rather than core count. +function romix(inputBytes, memory) { + const V = new Array(memory); + V[0] = sha256Bytes(inputBytes); + for (let i = 1; i < memory; i++) V[i] = sha256Bytes(V[i - 1]); + let x = V[memory - 1]; + const xor = new Uint8Array(32); + for (let i = 0; i < memory; i++) { + const j = (((x[0] << 24) | (x[1] << 16) | (x[2] << 8) | x[3]) >>> 0) % memory; + const vj = V[j]; + for (let k = 0; k < 32; k++) xor[k] = x[k] ^ vj[k]; + x = sha256Bytes(xor); + } + return x; +} + +function powDigest(inputBytes, memory) { + return memory > 0 ? romix(inputBytes, memory) : sha256Bytes(inputBytes); +} + +function meets(nonce, solution, difficulty, memory) { + return leadingZeroBits(powDigest(utf8Bytes(nonce + '.' + solution), memory || 0)) >= difficulty; +} + +// Synchronous solve — for native/server callers (node interceptor) where blocking +// the event loop for a few hundred ms of native-fast hashing is acceptable. +function solveSync(nonce, difficulty, memory) { + memory = memory || 0; + for (let n = 0; ; n++) { + if (meets(nonce, String(n), difficulty, memory)) return String(n); + } +} + +// Chunked, yielding solve — for the browser, so the tab stays responsive and a +// progress callback can drive a UI. Resolves with the solution string. A +// memory-hard challenge (opts.memory > 0) costs far more per attempt, so the +// chunk is smaller to keep each frame short. +function solve(nonce, difficulty, opts) { + opts = opts || {}; + const memory = opts.memory || 0; + const chunk = opts.chunk || (memory > 0 ? 25 : 2000); + const yieldTo = (typeof requestAnimationFrame === 'function') + ? requestAnimationFrame + : (fn) => setTimeout(fn, 0); + const expectedTotal = Math.pow(2, difficulty); + return new Promise((resolve) => { + let n = 0; + function step() { + const end = n + chunk; + for (; n < end; n++) { + if (meets(nonce, String(n), difficulty, memory)) { resolve(String(n)); return; } + } + if (opts.onProgress) opts.onProgress(Math.min(0.99, n / expectedTotal)); + yieldTo(step); + } + yieldTo(step); + }); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { solve, solveSync, meets, romix, powDigest, sha256Bytes, leadingZeroBits, utf8Bytes }; +} diff --git a/reference/train-ml-engine.js b/reference/train-ml-engine.js new file mode 100644 index 0000000..a062640 --- /dev/null +++ b/reference/train-ml-engine.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Trainer for the WAF bot-detection MlEngine (logistic regression). + * + * The MlEngine is the v2 scoring brain: it implements the same + * `Utopia\WAF\Challenge\Scoring\Engine` interface as the v1 HeuristicEngine, but + * replaces the hand-tuned weighted-additive core with a *learned* logistic model + * P(bot) = sigmoid(intercept + Σ coefᵢ · featureᵢ) + * over the numeric signal vector. The deterministic policy overrides + * (interaction ceiling, attack-score deny/challenge floors) stay in the engine — + * only the fuzzy behavioural middle is learned. + * + * There is no production traffic to train on, so this fits the model on + * *synthetic* labelled traffic that encodes the same domain assumptions the + * heuristic weights encoded — several bot archetypes vs. real-browser humans. + * The point is not the exact numbers; it is the swap path: retrain → paste the + * emitted coefficients into MlEngine::DEFAULT_COEFFICIENTS, nothing else changes. + * + * Deterministic: seeded PRNG, so the baked constants are reproducible. + * + * node reference/train-ml-engine.js # prints report + JSON model + * node reference/train-ml-engine.js > reference/ml-engine-model.json + */ +'use strict'; + +// Feature order — MUST match MlEngine::FEATURES. +const FEATURES = [ + 'ipReputation', + 'asnReputation', + 'tlsMismatch', + 'missingHeaders', + 'headless', + 'automationFlags', + 'behavioralRisk', +]; + +// ---- seeded PRNG (mulberry32) so training is reproducible ---------------- +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rnd = mulberry32(0x5eed1234); +const clamp01 = (x) => Math.max(0, Math.min(1, x)); +// non-negative gaussian-ish noise in [0,1] +const near = (mu, spread) => clamp01(mu + (rnd() - 0.5) * 2 * spread); +const pick = (arr) => arr[Math.floor(rnd() * arr.length)]; + +// ---- synthetic sample generators ----------------------------------------- +function humanSample() { + // Real browser, a person driving it. Occasional benign noise (a VPN, a proxy + // that strips one header) but never automation/headless artefacts. + return { + ipReputation: rnd() < 0.08 ? near(0.15, 0.15) : 0, + asnReputation: rnd() < 0.15 ? near(0.2, 0.2) : 0, // some legit users on VPN/hosting ASNs + tlsMismatch: rnd() < 0.02 ? 1 : 0, // a real browser's JA4 matches its UA + missingHeaders: rnd() < 0.2 ? near(0.15, 0.15) : 0, + headless: 0, + automationFlags: 0, + behavioralRisk: near(0.12, 0.12), // humans move the mouse / type + label: 0, + }; +} + +function botSample() { + // Three archetypes, sampled uniformly. + const kind = pick(['headless', 'scripted', 'datacenter']); + if (kind === 'headless') { + // Puppeteer/Selenium: real Chrome TLS, but webdriver + no human input. + return { + ipReputation: rnd() < 0.3 ? near(0.4, 0.3) : 0, + asnReputation: near(0.5, 0.4), + tlsMismatch: rnd() < 0.2 ? 1 : 0, + missingHeaders: near(0.15, 0.15), + headless: near(0.9, 0.1), + automationFlags: near(0.6, 0.4), + behavioralRisk: near(0.85, 0.15), // no organic interaction + label: 1, + }; + } + if (kind === 'scripted') { + // curl / python-requests hitting the interstitial: no JS engine at all, so + // the client probe reports fully headless; TLS fingerprint gives it away. + return { + ipReputation: rnd() < 0.4 ? near(0.5, 0.4) : 0, + asnReputation: near(0.6, 0.35), + tlsMismatch: rnd() < 0.9 ? 1 : 0, + missingHeaders: near(0.5, 0.3), + headless: 1, + automationFlags: near(0.3, 0.3), + behavioralRisk: 1, + label: 1, + }; + } + // datacenter: abusive IP/ASN reputation dominant, mixed client artefacts. + return { + ipReputation: near(0.75, 0.25), + asnReputation: near(0.8, 0.2), + tlsMismatch: rnd() < 0.5 ? 1 : 0, + missingHeaders: near(0.35, 0.3), + headless: rnd() < 0.6 ? near(0.8, 0.2) : 0, + automationFlags: rnd() < 0.5 ? near(0.5, 0.4) : 0, + behavioralRisk: near(0.7, 0.3), + label: 1, + }; +} + +// balanced dataset +const N = 20000; +const data = []; +for (let i = 0; i < N; i++) data.push(i % 2 === 0 ? humanSample() : botSample()); + +// ---- logistic regression (full-batch gradient descent, L2) --------------- +const D = FEATURES.length; +let w = new Array(D).fill(0); +let b = 0; +const lr = 0.5; +const l2 = 1e-4; +const epochs = 4000; +const sigmoid = (z) => 1 / (1 + Math.exp(-z)); + +for (let e = 0; e < epochs; e++) { + const gw = new Array(D).fill(0); + let gb = 0; + for (const s of data) { + let z = b; + for (let j = 0; j < D; j++) z += w[j] * s[FEATURES[j]]; + const err = sigmoid(z) - s.label; + for (let j = 0; j < D; j++) gw[j] += err * s[FEATURES[j]]; + gb += err; + } + for (let j = 0; j < D; j++) w[j] -= lr * (gw[j] / N + l2 * w[j]); + b -= lr * (gb / N); +} + +// ---- evaluate ------------------------------------------------------------- +let tp = 0, tn = 0, fp = 0, fn = 0; +const predict = (s) => { + let z = b; + for (let j = 0; j < D; j++) z += w[j] * s[FEATURES[j]]; + return sigmoid(z); +}; +for (const s of data) { + const p = predict(s) >= 0.5 ? 1 : 0; + if (s.label === 1 && p === 1) tp++; + else if (s.label === 0 && p === 0) tn++; + else if (s.label === 0 && p === 1) fp++; + else fn++; +} + +const coefficients = {}; +FEATURES.forEach((f, j) => (coefficients[f] = Number(w[j].toFixed(6)))); +const model = { + _comment: 'Generated by reference/train-ml-engine.js — logistic regression over the bot-detection signal vector. Paste into MlEngine::DEFAULT_COEFFICIENTS / DEFAULT_INTERCEPT.', + features: FEATURES, + intercept: Number(b.toFixed(6)), + coefficients, + metrics: { + samples: N, + accuracy: Number(((tp + tn) / N).toFixed(4)), + precision: Number((tp / (tp + fp)).toFixed(4)), + recall: Number((tp / (tp + fn)).toFixed(4)), + falsePositiveRate: Number((fp / (fp + tn)).toFixed(4)), + }, +}; + +// canonical decision vectors — a sanity check on tier alignment +const THRESH = { challenge: 0.25, interactive: 0.55, deny: 0.8 }; +const tier = (p) => (p >= THRESH.deny ? 'deny' : p >= THRESH.interactive ? 'interactive' : p >= THRESH.challenge ? 'challenge' : 'allow'); +const vec = (o) => { const s = {}; FEATURES.forEach((f) => (s[f] = o[f] || 0)); return predict(s); }; +const cases = { + 'clean human': vec({}), + 'one missing header': vec({ missingHeaders: 0.33 }), + 'legit VPN user': vec({ asnReputation: 0.3, missingHeaders: 0.33 }), + 'headless bot (no interaction)': vec({ headless: 1, automationFlags: 0.66, behavioralRisk: 1 }), + 'curl/python (tls mismatch)': vec({ tlsMismatch: 1, missingHeaders: 0.5, headless: 1, behavioralRisk: 1 }), + 'datacenter abuser': vec({ ipReputation: 0.8, asnReputation: 0.9 }), +}; +const report = {}; +for (const [k, p] of Object.entries(cases)) report[k] = { p: Number(p.toFixed(4)), tier: tier(p) }; +model.canonicalCases = report; + +process.stdout.write(JSON.stringify(model, null, 2) + '\n'); +process.stderr.write( + `trained on ${N} samples — acc=${model.metrics.accuracy} prec=${model.metrics.precision} recall=${model.metrics.recall} fpr=${model.metrics.falsePositiveRate}\n` +); +for (const [k, r] of Object.entries(report)) process.stderr.write(` ${k.padEnd(32)} p=${r.p.toFixed(4)} → ${r.tier}\n`); diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php new file mode 100644 index 0000000..c0a896f --- /dev/null +++ b/src/Challenge/Issuer.php @@ -0,0 +1,81 @@ + 'challenge', + 'ver' => 1, + 'pid' => $context->projectId, + 'aud' => $context->audience, + 'iph' => $this->signer->fingerprintIp($context->ip), + 'iat' => $issuedAt, + 'exp' => $expiresAt, + 'rnd' => \bin2hex(\random_bytes(16)), + ]; + + if ($clearanceTtl !== null) { + $claims['ctl'] = $clearanceTtl; + } + + $nonce = $this->signer->sign($claims); + + return [ + 'nonce' => $nonce, + 'expiresAt' => $expiresAt, + 'expiresIn' => self::NONCE_TTL, + ]; + } + + /** + * Read the clearance TTL carried by a nonce, or null when it carries none. + * + * The caller MUST have already verified the nonce (see {@see Verifier}); this + * only decodes the signed claims and does not re-check authenticity or expiry. + */ + public function clearanceTtl(string $nonce): ?int + { + $claims = $this->signer->parse($nonce); + if ($claims === null || !isset($claims['ctl']) || !\is_int($claims['ctl'])) { + return null; + } + + return $claims['ctl']; + } +} diff --git a/src/Challenge/Scoring/AttackScore.php b/src/Challenge/Scoring/AttackScore.php new file mode 100644 index 0000000..0f28780 --- /dev/null +++ b/src/Challenge/Scoring/AttackScore.php @@ -0,0 +1,28 @@ + $raw the interstitial telemetry blob + * @return array Signal key => normalized value, to fold into a Signals bag + */ + public static function normalize(array $raw): array + { + return [ + Signal::HEADLESS => self::headless($raw), + Signal::AUTOMATION_FLAGS => self::automation($raw), + Signal::BEHAVIORAL_RISK => self::behavioral($raw), + ]; + } + + /** + * Headless / automation-driver detection. `navigator.webdriver` is near-proof + * on its own; otherwise combine the soft tells (no plugins, no languages, a + * software WebGL renderer — all typical of headless Chromium). + * + * @param array $raw + */ + private static function headless(array $raw): float + { + if (self::boolean($raw, 'webdriver')) { + return 1.0; + } + + $risk = 0.0; + if (self::int($raw, 'plugins', -1) === 0) { + $risk += 0.4; // real desktop browsers ship plugins; headless has none + } + if (self::int($raw, 'languages', -1) === 0) { + $risk += 0.3; // navigator.languages is empty in many headless setups + } + $webgl = \strtolower(self::string($raw, 'webglVendor')); + if ($webgl !== '' && (\str_contains($webgl, 'swiftshader') || \str_contains($webgl, 'llvmpipe'))) { + $risk += 0.3; // software renderer ⇒ no GPU ⇒ almost certainly headless + } + + return \min(1.0, $risk); + } + + /** + * Automation-framework artefacts the page can observe (CDP/Selenium/phantom + * globals). The client reports a count; scale it into [0,1]. + * + * @param array $raw + */ + private static function automation(array $raw): float + { + $flags = self::int($raw, 'automationFlags', 0); + + return \max(0.0, \min(1.0, $flags / 3.0)); + } + + /** + * Behavioral risk: absence of human input while the interstitial was shown. + * A real user moves the mouse / presses keys; a headless script attesting + * silently does neither. No activity ⇒ high risk; activity drives it down. + * + * @param array $raw + */ + private static function behavioral(array $raw): float + { + $events = self::int($raw, 'mouseMoves', 0) + self::int($raw, 'keyPresses', 0); + if ($events <= 0) { + return 1.0; + } + + // saturating: ~a dozen input events reads as clearly human + return \max(0.0, 1.0 - \min(1.0, $events / 12.0)); + } + + /** + * @param array $raw + */ + private static function boolean(array $raw, string $key): bool + { + return isset($raw[$key]) && ($raw[$key] === true || $raw[$key] === 1 || $raw[$key] === '1' || $raw[$key] === 'true'); + } + + /** + * @param array $raw + */ + private static function int(array $raw, string $key, int $default): int + { + return isset($raw[$key]) && \is_numeric($raw[$key]) ? (int) $raw[$key] : $default; + } + + /** + * @param array $raw + */ + private static function string(array $raw, string $key): string + { + return isset($raw[$key]) && \is_string($raw[$key]) ? $raw[$key] : ''; + } +} diff --git a/src/Challenge/Scoring/Engine.php b/src/Challenge/Scoring/Engine.php new file mode 100644 index 0000000..bb0030f --- /dev/null +++ b/src/Challenge/Scoring/Engine.php @@ -0,0 +1,15 @@ + + */ + public const DEFAULT_WEIGHTS = [ + Signal::IP_REPUTATION => 3.0, + Signal::ASN_REPUTATION => 2.0, + Signal::TLS_MISMATCH => 4.0, + Signal::MISSING_HEADERS => 1.0, + Signal::HEADLESS => 3.0, + Signal::AUTOMATION_FLAGS => 3.0, + Signal::BEHAVIORAL_RISK => 2.0, + ]; + + /** Score ceiling applied when the client passed the interaction challenge. */ + public const INTERACTION_PASS_CEILING = 0.10; + + /** ATTACK_SCORE at/above this floors the verdict to deny (a certain attack). */ + public const ATTACK_DENY_FLOOR = 0.85; + + /** ATTACK_SCORE at/above this floors the verdict to at least challenge. */ + public const ATTACK_CHALLENGE_FLOOR = 0.40; + + /** + * @var array + */ + private array $weights; + + /** + * @var array + */ + private array $thresholds; + + private float $totalWeight; + + /** + * @param array $weights signal key => weight + * @param array $thresholds tier boundaries (see RiskTier::DEFAULT_THRESHOLDS) + */ + public function __construct( + array $weights = self::DEFAULT_WEIGHTS, + array $thresholds = RiskTier::DEFAULT_THRESHOLDS, + ) { + $this->weights = $weights; + $this->thresholds = $thresholds; + $this->totalWeight = \array_sum($weights); + } + + public function score(Signals $signals): Score + { + $contributions = []; + $risk = 0.0; + $serverRisk = 0.0; + + foreach ($this->weights as $key => $weight) { + if (!$signals->has($key) || $this->totalWeight <= 0.0) { + continue; + } + + $contribution = $signals->float($key) * $weight / $this->totalWeight; + if ($contribution === 0.0) { + continue; + } + + $contributions[$key] = $contribution; + $risk += $contribution; + + // Track the server-observed portion separately (see the interaction + // cap below). + if (\in_array($key, Signal::SERVER, true)) { + $serverRisk += $contribution; + } + } + + $value = \max(0.0, \min(1.0, $risk)); + $serverValue = \max(0.0, \min(1.0, $serverRisk)); + + if ($signals->bool(Signal::INTERACTION_PASSED)) { + // A passed interaction is a humanity assertion about *behaviour*, so it + // caps the client-behavioral noise (headless/automation/biometrics) + // below the first gate — but it must not floor the score under the + // server-observed evidence (IP/ASN reputation, TLS mismatch, missing + // headers), which the client cannot legitimately assert away. Without + // this floor a forged `interacted:true` would erase a blocklisted IP. + $value = \max($serverValue, \min($value, self::INTERACTION_PASS_CEILING)); + } + + // Attacks are decisive, not fuzzy. A request-inspection verdict floors the + // score to the matching gate — kept OUT of the weighted sum so it can't be + // diluted, and applied AFTER the interaction cap so a solved challenge + // never excuses an injection. High confidence ⇒ deny (you block an attack, + // you don't challenge it); moderate ⇒ at least challenge. + $attack = $signals->float(Signal::ATTACK_SCORE); + if ($attack >= self::ATTACK_DENY_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::DENY)); + } elseif ($attack >= self::ATTACK_CHALLENGE_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::CHALLENGE)); + } + + return new Score($value, RiskTier::fromScore($value, $this->thresholds), $contributions); + } + + private function threshold(RiskTier $tier): float + { + return $this->thresholds[$tier->value] ?? RiskTier::DEFAULT_THRESHOLDS[$tier->value] ?? 0.0; + } +} diff --git a/src/Challenge/Scoring/MlEngine.php b/src/Challenge/Scoring/MlEngine.php new file mode 100644 index 0000000..20ceaf1 --- /dev/null +++ b/src/Challenge/Scoring/MlEngine.php @@ -0,0 +1,156 @@ + + */ + public const FEATURES = [ + Signal::IP_REPUTATION, + Signal::ASN_REPUTATION, + Signal::TLS_MISMATCH, + Signal::MISSING_HEADERS, + Signal::HEADLESS, + Signal::AUTOMATION_FLAGS, + Signal::BEHAVIORAL_RISK, + ]; + + /** + * Learned logistic-regression coefficients (signal key => weight), fit offline + * on synthetic labelled traffic. Regenerate with `reference/train-ml-engine.js`. + * + * @var array + */ + public const DEFAULT_COEFFICIENTS = [ + Signal::IP_REPUTATION => 3.562063, + Signal::ASN_REPUTATION => 4.67617, + Signal::TLS_MISMATCH => 1.375458, + Signal::MISSING_HEADERS => 1.896716, + Signal::HEADLESS => 4.576935, + Signal::AUTOMATION_FLAGS => 2.71498, + Signal::BEHAVIORAL_RISK => 4.826937, + ]; + + /** Learned intercept (bias) — the all-zero-signal baseline logit. */ + public const DEFAULT_INTERCEPT = -6.52634; + + /** Score ceiling applied when the client passed the interaction challenge. */ + public const INTERACTION_PASS_CEILING = 0.10; + + /** ATTACK_SCORE at/above this floors the verdict to deny (a certain attack). */ + public const ATTACK_DENY_FLOOR = 0.85; + + /** ATTACK_SCORE at/above this floors the verdict to at least challenge. */ + public const ATTACK_CHALLENGE_FLOOR = 0.40; + + /** + * @var array + */ + private array $coefficients; + + /** + * @var array + */ + private array $thresholds; + + /** + * @param array $coefficients signal key => learned weight + * @param float $intercept learned bias term + * @param array $thresholds tier boundaries (see RiskTier::DEFAULT_THRESHOLDS) + */ + public function __construct( + array $coefficients = self::DEFAULT_COEFFICIENTS, + private float $intercept = self::DEFAULT_INTERCEPT, + array $thresholds = RiskTier::DEFAULT_THRESHOLDS, + ) { + $this->coefficients = $coefficients; + $this->thresholds = $thresholds; + } + + public function score(Signals $signals): Score + { + $logit = $this->intercept; + $serverLogit = $this->intercept; + $contributions = []; + + foreach ($this->coefficients as $key => $coefficient) { + $value = $signals->float($key); + if ($value === 0.0) { + continue; + } + + // Logit-space contribution: how many log-odds this signal added. Kept + // for the same explainability the heuristic engine offers (the sink + // logs it), even though the final score is the squashed probability. + $term = $coefficient * $value; + $contributions[$key] = $term; + $logit += $term; + + // Track the server-observed portion for the interaction cap below. + if (\in_array($key, Signal::SERVER, true)) { + $serverLogit += $term; + } + } + + // Squash to a calibrated P(bot) in (0,1). + $value = 1.0 / (1.0 + \exp(-$logit)); + + // --- deterministic policy overrides (identical to HeuristicEngine) --- + if ($signals->bool(Signal::INTERACTION_PASSED)) { + // Cap the client-behavioral contribution, but never below the score + // the server-observed signals alone produce — a forged interaction + // must not erase IP/ASN reputation or a TLS mismatch. + $serverValue = 1.0 / (1.0 + \exp(-$serverLogit)); + $value = \max($serverValue, \min($value, self::INTERACTION_PASS_CEILING)); + } + + $attack = $signals->float(Signal::ATTACK_SCORE); + if ($attack >= self::ATTACK_DENY_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::DENY)); + } elseif ($attack >= self::ATTACK_CHALLENGE_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::CHALLENGE)); + } + + $value = \max(0.0, \min(1.0, $value)); + + return new Score($value, RiskTier::fromScore($value, $this->thresholds), $contributions); + } + + private function threshold(RiskTier $tier): float + { + return $this->thresholds[$tier->value] ?? RiskTier::DEFAULT_THRESHOLDS[$tier->value] ?? 0.0; + } +} diff --git a/src/Challenge/Scoring/NullSink.php b/src/Challenge/Scoring/NullSink.php new file mode 100644 index 0000000..c3413bd --- /dev/null +++ b/src/Challenge/Scoring/NullSink.php @@ -0,0 +1,15 @@ + + */ + public const DEFAULT_THRESHOLDS = [ + self::CHALLENGE->value => 0.25, + self::INTERACTIVE->value => 0.55, + self::DENY->value => 0.80, + ]; + + /** + * Map a [0,1] risk score to a tier using the given (or default) boundaries. + * + * @param array $thresholds + */ + public static function fromScore(float $score, array $thresholds = self::DEFAULT_THRESHOLDS): self + { + $score = \max(0.0, \min(1.0, $score)); + + if ($score >= ($thresholds[self::DENY->value] ?? self::DEFAULT_THRESHOLDS[self::DENY->value])) { + return self::DENY; + } + if ($score >= ($thresholds[self::INTERACTIVE->value] ?? self::DEFAULT_THRESHOLDS[self::INTERACTIVE->value])) { + return self::INTERACTIVE; + } + if ($score >= ($thresholds[self::CHALLENGE->value] ?? self::DEFAULT_THRESHOLDS[self::CHALLENGE->value])) { + return self::CHALLENGE; + } + + return self::ALLOW; + } + + /** + * Clamp a tier to what a surface can actually enforce. The cloud API has no + * browser page, so it cannot render the interactive slider — an interactive + * verdict falls back to the silent (invisible) attestation check. Every other + * tier (including deny) is surface-agnostic. + * + * Note: the silent attestation itself is browser-collected, so a non-browser + * API client cannot satisfy it either; on that surface the challenge tier is + * effectively "score on server signals alone" (see the cloud integration). + */ + public function clampTo(bool $interactiveCapable): self + { + if ($this === self::INTERACTIVE && !$interactiveCapable) { + return self::CHALLENGE; + } + + return $this; + } +} diff --git a/src/Challenge/Scoring/Score.php b/src/Challenge/Scoring/Score.php new file mode 100644 index 0000000..b96c825 --- /dev/null +++ b/src/Challenge/Scoring/Score.php @@ -0,0 +1,26 @@ + $contributions signal key => weighted contribution + */ + public function __construct( + public float $value, + public RiskTier $tier, + public array $contributions = [], + ) { + } +} diff --git a/src/Challenge/Scoring/Signal.php b/src/Challenge/Scoring/Signal.php new file mode 100644 index 0000000..562b08d --- /dev/null +++ b/src/Challenge/Scoring/Signal.php @@ -0,0 +1,91 @@ + + */ + public const SERVER = [ + self::IP_REPUTATION, + self::ASN_REPUTATION, + self::TLS_MISMATCH, + self::TLS_FINGERPRINT, + self::MISSING_HEADERS, + self::ATTACK_SCORE, + self::ATTACK_CATEGORIES, + ]; + + /** + * Signal keys that require client JavaScript — edge interstitial only. + * + * @var list + */ + public const CLIENT = [ + self::HEADLESS, + self::AUTOMATION_FLAGS, + self::BEHAVIORAL_RISK, + self::INTERACTION_PASSED, + ]; +} diff --git a/src/Challenge/Scoring/SignalRecord.php b/src/Challenge/Scoring/SignalRecord.php new file mode 100644 index 0000000..301cf76 --- /dev/null +++ b/src/Challenge/Scoring/SignalRecord.php @@ -0,0 +1,53 @@ + + */ + public function toArray(): array + { + return [ + 'type' => 'waf.score', + 'projectId' => $this->projectId, + 'audience' => $this->audience, + 'ipPrefix' => $this->ipPrefix, + 'requestId' => $this->requestId, + 'issuedAt' => $this->issuedAt, + 'score' => $this->score->value, + 'tier' => $this->score->tier->value, + 'decision' => $this->decision->value, + 'enforced' => $this->enforced, + 'signals' => $this->signals->toArray(), + 'contributions' => $this->score->contributions, + ]; + } +} diff --git a/src/Challenge/Scoring/Signals.php b/src/Challenge/Scoring/Signals.php new file mode 100644 index 0000000..7b460b7 --- /dev/null +++ b/src/Challenge/Scoring/Signals.php @@ -0,0 +1,73 @@ + $values signal key => value + */ + public function __construct( + private readonly array $values = [], + ) { + } + + /** + * Return a copy with one signal set (or replaced). Immutable. + */ + public function with(string $key, mixed $value): self + { + return new self([...$this->values, $key => $value]); + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->values); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + /** + * Read a signal as a bot-risk float clamped to [0,1]. Booleans map to + * 1.0/0.0; anything unset or non-numeric returns the default. + */ + public function float(string $key, float $default = 0.0): float + { + if (!$this->has($key)) { + return $default; + } + + $value = $this->values[$key]; + if (\is_bool($value)) { + return $value ? 1.0 : 0.0; + } + if (!\is_numeric($value)) { + return $default; + } + + return \max(0.0, \min(1.0, (float) $value)); + } + + public function bool(string $key, bool $default = false): bool + { + return $this->has($key) ? (bool) $this->values[$key] : $default; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->values; + } +} diff --git a/src/Challenge/Scoring/Sink.php b/src/Challenge/Scoring/Sink.php new file mode 100644 index 0000000..155bb16 --- /dev/null +++ b/src/Challenge/Scoring/Sink.php @@ -0,0 +1,16 @@ + + */ + public const KNOWN_AUTOMATION = [ + 't13d311200_e8f1e7e78f70_b26ce05bbdd6', // curl (OpenSSL) + 't13d311100_e8f1e7e78f70_d41ae481755e', // python (urllib/OpenSSL) + 't13d751100_479067518aa3_fb8d5ffd48c1', // wget + 't13d591000_a33745022dd6_1f22a2ca17c4', // node.js + ]; + + /** + * Reference JA4s of mainstream browsers, for the UA cross-check. + * + * @var list + */ + public const KNOWN_BROWSERS = [ + 't13d1516h2_8daaf6152771_b186095e22b6', // Chrome (BoringSSL, ALPN h2) + 't13d1715h2_5b57614c22b0_93c746dc12af', // Firefox (NSS, ALPN h2) + ]; + + /** + * @var array + */ + private array $automation; + + /** + * @var array + */ + private array $browsers; + + /** + * @param list $automation known automation/library JA4s + * @param list $browsers known mainstream-browser JA4s + */ + public function __construct(array $automation = self::KNOWN_AUTOMATION, array $browsers = self::KNOWN_BROWSERS) + { + $this->automation = \array_fill_keys($automation, true); + $this->browsers = \array_fill_keys($browsers, true); + } + + /** + * Does the fingerprint contradict a real browser? Empty fingerprint (none + * captured) is not a mismatch — it simply yields no signal. + */ + public function mismatches(string $fingerprint, string $userAgent): bool + { + if ($fingerprint === '') { + return false; + } + + if (isset($this->automation[$fingerprint])) { + return true; + } + + if ($this->uaClaimsBrowser($userAgent) && !isset($this->browsers[$fingerprint])) { + return true; + } + + return false; + } + + /** + * Whether the User-Agent claims to be a mainstream browser (and not an + * obvious bot/library UA). + */ + private function uaClaimsBrowser(string $userAgent): bool + { + $ua = \strtolower($userAgent); + + foreach (['bot', 'crawl', 'spider', 'curl', 'wget', 'python', 'java', 'go-http', 'okhttp', 'axios', 'node'] as $needle) { + if (\str_contains($ua, $needle)) { + return false; + } + } + + foreach (['chrome', 'firefox', 'safari', 'edg/', 'edge', 'opera', 'gecko'] as $needle) { + if (\str_contains($ua, $needle)) { + return true; + } + } + + return false; + } +} diff --git a/src/Challenge/SilentChallenge.php b/src/Challenge/SilentChallenge.php new file mode 100644 index 0000000..87e89d3 --- /dev/null +++ b/src/Challenge/SilentChallenge.php @@ -0,0 +1,81 @@ +issuer->issue($context, $clearanceTtl); + } + + /** + * Verify a returned attestation and score it. + * + * The nonce must be authentic, unexpired, and context-bound (anti-replay); the + * attested `$signals` are then scored by the engine. Returns the resulting + * {@see Score} (risk value + tier) so the caller can act on it — allow (mint + * clearance), escalate to the interactive slider, or deny. Returns null only + * when the nonce itself fails integrity (forged, expired, or wrong context), + * which the caller should treat as a hard reject rather than a low score. + */ + public function verify(string $nonce, Signals $signals, Context $context): ?Score + { + if (!$this->verifier->verify($nonce, $context)) { + return null; + } + + return $this->engine->score($signals); + } + + /** + * Read the clearance TTL carried by a verified nonce, or null when it carries + * none. The caller MUST have already verified the nonce. + */ + public function clearanceTtl(string $nonce): ?int + { + return $this->issuer->clearanceTtl($nonce); + } +} diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php new file mode 100644 index 0000000..405d5d7 --- /dev/null +++ b/src/Challenge/Verifier.php @@ -0,0 +1,48 @@ +signer->parse($nonce); + if ($claims === null || ($claims['typ'] ?? null) !== 'challenge') { + return false; + } + + $now = \time(); + $expiresAt = (int) ($claims['exp'] ?? 0); + $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); + if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { + return false; + } + + if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { + return false; + } + + $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; + $expectedIp = $this->signer->fingerprintIp($context->ip, $kid); + + return \hash_equals($expectedIp, (string) ($claims['iph'] ?? '')); + } +} diff --git a/src/Rules/Challenge.php b/src/Rules/Challenge.php index 1d11da8..f9046f0 100644 --- a/src/Rules/Challenge.php +++ b/src/Rules/Challenge.php @@ -6,7 +6,6 @@ class Challenge extends Rule { - public const TYPE_CAPTCHA = 'captcha'; public const TYPE_CUSTOM = 'custom'; private string $type; @@ -14,10 +13,10 @@ class Challenge extends Rule /** * @param array<\Utopia\WAF\Condition|array> $conditions */ - public function __construct(array $conditions = [], string $type = self::TYPE_CAPTCHA) + public function __construct(array $conditions = [], string $type = self::TYPE_CUSTOM) { parent::__construct($conditions); - if (!in_array($type, [self::TYPE_CAPTCHA, self::TYPE_CUSTOM], true)) { + if (!in_array($type, [self::TYPE_CUSTOM], true)) { throw new \InvalidArgumentException('Invalid challenge type: ' . $type); } $this->type = $type; diff --git a/tests/Challenge/AttackScoreTest.php b/tests/Challenge/AttackScoreTest.php new file mode 100644 index 0000000..ef3f4fb --- /dev/null +++ b/tests/Challenge/AttackScoreTest.php @@ -0,0 +1,18 @@ +assertSame(0.0, AttackScore::normalize(0)); + $this->assertSame(0.0, AttackScore::normalize(-3)); // defensive + $this->assertSame(0.5, AttackScore::normalize(5)); // one CRS critical + $this->assertSame(1.0, AttackScore::normalize(10)); // ~2× threshold = certain + $this->assertSame(1.0, AttackScore::normalize(40)); // capped + } +} diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php new file mode 100644 index 0000000..587bb09 --- /dev/null +++ b/tests/Challenge/ChallengeTest.php @@ -0,0 +1,139 @@ +issue($this->context()); + + $this->assertArrayHasKey('nonce', $challenge); + $this->assertSame(Issuer::NONCE_TTL, $challenge['expiresIn']); + $this->assertSame($challenge['expiresIn'], $challenge['expiresAt'] - \time()); + + // No proof-of-work is advertised any more: the nonce carries no difficulty, + // memory, or algorithm. + $claims = \json_decode(\base64_decode(\strtr(\explode('.', $challenge['nonce'])[0], '-_', '+/')), true); + $this->assertArrayNotHasKey('dif', $claims); + $this->assertArrayNotHasKey('mem', $claims); + $this->assertSame('challenge', $claims['typ']); + } + + public function testVerifyAcceptsAuthenticNonce(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + $nonce = $issuer->issue($this->context())['nonce']; + $this->assertTrue($verifier->verify($nonce, $this->context())); + } + + public function testVerifyRejectsExpiredNonce(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + + $nonce = $signer->sign([ + 'typ' => 'challenge', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'iat' => \time() - 1000, + 'exp' => \time() - 500, + ]); + + $this->assertFalse($verifier->verify($nonce, $this->context())); + } + + public function testVerifyRejectsWrongContext(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + $nonce = $issuer->issue($this->context('203.0.113.9'))['nonce']; + + $this->assertFalse($verifier->verify($nonce, new Context('other-project', 'api', '203.0.113.9'))); + $this->assertFalse($verifier->verify($nonce, new Context('proj-123', 'mysite.example', '203.0.113.9'))); + // Different /24 network. + $this->assertFalse($verifier->verify($nonce, new Context('proj-123', 'api', '198.51.100.9'))); + // Same /24 still passes. + $this->assertTrue($verifier->verify($nonce, new Context('proj-123', 'api', '203.0.113.200'))); + } + + public function testVerifyRejectsForeignSecret(): void + { + $issued = (new Issuer(new Signer(self::SECRET)))->issue($this->context())['nonce']; + $foreign = new Verifier(new Signer('a-completely-different-secret')); + + $this->assertFalse($foreign->verify($issued, $this->context())); + } + + public function testVerifyRejectsCrossType(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + + // A clearance-type token is authentically signed and context-bound but is + // not a challenge nonce — it must not verify as one. + $clr = $signer->sign([ + 'typ' => 'clr', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'iat' => \time(), + 'exp' => \time() + 600, + ]); + + $this->assertFalse($verifier->verify($clr, $this->context())); + } + + public function testNonceCarriesClearanceTtlRoundTrip(): void + { + $issuer = new Issuer(new Signer(self::SECRET)); + + // No ttl requested -> nonce carries none. + $plain = $issuer->issue($this->context()); + $this->assertNull($issuer->clearanceTtl($plain['nonce'])); + + // Requested ttl is carried verbatim inside the signed nonce. + $withTtl = $issuer->issue($this->context(), 1800); + $this->assertSame(1800, $issuer->clearanceTtl($withTtl['nonce'])); + + // A tampered nonce yields no ttl (signature no longer parses). + $this->assertNull($issuer->clearanceTtl($withTtl['nonce'] . 'x')); + } + + public function testKeyRotationHonorsNonceKid(): void + { + // Issue under kid 2, then rotate: kid 3 primary, kid 2 kept as previous. + $issued = (new Issuer(new Signer(self::SECRET, 2)))->issue($this->context())['nonce']; + + $rotated = new Verifier(new Signer('new-secret', 3, [2 => self::SECRET])); + $this->assertTrue($rotated->verify($issued, $this->context())); + } +} diff --git a/tests/Challenge/ClientSignalsTest.php b/tests/Challenge/ClientSignalsTest.php new file mode 100644 index 0000000..4f05a8c --- /dev/null +++ b/tests/Challenge/ClientSignalsTest.php @@ -0,0 +1,92 @@ + true]); + $this->assertSame(1.0, $s[Signal::HEADLESS]); + } + + public function testSoftHeadlessTellsCombine(): void + { + $s = ClientSignals::normalize([ + 'webdriver' => false, + 'plugins' => 0, + 'languages' => 0, + 'webglVendor' => 'Google SwiftShader', + ]); + $this->assertSame(1.0, $s[Signal::HEADLESS]); // 0.4 + 0.3 + 0.3 + } + + public function testRealBrowserLooksHuman(): void + { + $s = ClientSignals::normalize([ + 'webdriver' => false, + 'plugins' => 3, + 'languages' => 2, + 'webglVendor' => 'NVIDIA Corporation', + 'automationFlags' => 0, + 'mouseMoves' => 20, + 'keyPresses' => 4, + 'interacted' => true, + ]); + $this->assertSame(0.0, $s[Signal::HEADLESS]); + $this->assertSame(0.0, $s[Signal::AUTOMATION_FLAGS]); + $this->assertSame(0.0, $s[Signal::BEHAVIORAL_RISK]); + // INTERACTION_PASSED is never produced from the blob (unforgeable): it is + // established server-side by verifying the interactive challenge. + $this->assertArrayNotHasKey(Signal::INTERACTION_PASSED, $s); + } + + public function testAutomationFlagsScale(): void + { + $this->assertSame(1.0, ClientSignals::normalize(['automationFlags' => 3])[Signal::AUTOMATION_FLAGS]); + $this->assertSame(1.0, ClientSignals::normalize(['automationFlags' => 9])[Signal::AUTOMATION_FLAGS]); // capped + $this->assertSame(0.0, ClientSignals::normalize(['automationFlags' => 0])[Signal::AUTOMATION_FLAGS]); + } + + public function testBehavioralRiskFromInputActivity(): void + { + // no input at all — a script solving the PoW + $this->assertSame(1.0, ClientSignals::normalize([])[Signal::BEHAVIORAL_RISK]); + // plenty of input — human + $this->assertSame(0.0, ClientSignals::normalize(['mouseMoves' => 10, 'keyPresses' => 2])[Signal::BEHAVIORAL_RISK]); + // a little input — partial + $risk = ClientSignals::normalize(['mouseMoves' => 3])[Signal::BEHAVIORAL_RISK]; + $this->assertGreaterThan(0.0, $risk); + $this->assertLessThan(1.0, $risk); + } + + public function testGarbageBlobDoesNotThrowAndStaysConservative(): void + { + $s = ClientSignals::normalize([ + 'webdriver' => 'not-a-bool', + 'plugins' => 'x', + 'automationFlags' => ['nested'], + 'mouseMoves' => null, + ]); + $this->assertIsFloat($s[Signal::HEADLESS]); + $this->assertSame(0.0, $s[Signal::AUTOMATION_FLAGS]); + $this->assertSame(1.0, $s[Signal::BEHAVIORAL_RISK]); + $this->assertArrayNotHasKey(Signal::INTERACTION_PASSED, $s); + } + + public function testInteractionPassedIsNeverClientAsserted(): void + { + // No form of client-supplied `interacted` yields an interaction pass — the + // whole point of moving the humanity secret server-side. + foreach ([true, 1, 'true', 0, false] as $value) { + $this->assertArrayNotHasKey( + Signal::INTERACTION_PASSED, + ClientSignals::normalize(['interacted' => $value]), + ); + } + } +} diff --git a/tests/Challenge/MlEngineTest.php b/tests/Challenge/MlEngineTest.php new file mode 100644 index 0000000..0c68805 --- /dev/null +++ b/tests/Challenge/MlEngineTest.php @@ -0,0 +1,154 @@ +engine()->score(new Signals()); + + $this->assertSame(RiskTier::ALLOW, $score->tier); + $this->assertLessThan(0.25, $score->value); // well below the challenge gate + } + + public function testBenignSingleSignalStaysAllowed(): void + { + // A stripped header or a VPN ASN alone must never manufacture a challenge: + // the false-positive rate is the metric that hurts most. + $signals = (new Signals()) + ->with(Signal::MISSING_HEADERS, 0.33) + ->with(Signal::ASN_REPUTATION, 0.3); + + $this->assertSame(RiskTier::ALLOW, $this->engine()->score($signals)->tier); + } + + public function testHeadlessBotIsEscalated(): void + { + // navigator.webdriver + no organic interaction — the puppeteer/selenium + // shape. The learned model should push this to the top of the ladder. + $signals = (new Signals()) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 0.66) + ->with(Signal::BEHAVIORAL_RISK, 1.0); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThanOrEqual(0.80, $score->value); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testScriptedClientWithTlsMismatchIsDenied(): void + { + // curl / python-requests hitting the interstitial: no JS engine, TLS + // fingerprint contradicts any browser UA. + $signals = (new Signals()) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::MISSING_HEADERS, 0.5) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0); + + $this->assertSame(RiskTier::DENY, $this->engine()->score($signals)->tier); + } + + public function testPassedInteractionCapsTheScore(): void + { + // Even a bot-shaped request is capped below the first gate once the client + // proves humanity by passing the interaction challenge. + $signals = (new Signals()) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::INTERACTION_PASSED, true); + + $score = $this->engine()->score($signals); + + $this->assertLessThanOrEqual(MlEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertSame(RiskTier::ALLOW, $score->tier); + } + + public function testPassedInteractionDoesNotEraseServerSignals(): void + { + // A forged interaction must not neutralize server-observed evidence. With + // strong reputation + TLS mismatch the learned server-only probability is + // well above the interaction ceiling, so the score cannot be capped to + // ALLOW — only the client-behavioral contribution is neutralized. + $signals = (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::HEADLESS, 1.0) // client noise — neutralized + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::INTERACTION_PASSED, true); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThan(MlEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertNotSame(RiskTier::ALLOW, $score->tier); + + // Score equals the server-only probability (client tells neutralized). + $serverOnly = $this->engine()->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ); + $this->assertEqualsWithDelta($serverOnly->value, $score->value, 1e-9); + } + + public function testAttackScoreFloorsToDeny(): void + { + // A high request-inspection score is decisive: it floors the verdict to + // deny regardless of how benign the behavioural signals look, and it is + // applied after (so it survives) any interaction cap. + $signals = (new Signals()) + ->with(Signal::ATTACK_SCORE, 0.9) + ->with(Signal::INTERACTION_PASSED, true); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThanOrEqual(0.80, $score->value); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testModerateAttackScoreFloorsToChallenge(): void + { + $signals = (new Signals())->with(Signal::ATTACK_SCORE, 0.45); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThanOrEqual(0.25, $score->value); + $this->assertSame(RiskTier::CHALLENGE, $score->tier); + } + + public function testContributionsAreRecordedPerSignal(): void + { + $signals = (new Signals()) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::ASN_REPUTATION, 0.5); + + $contributions = $this->engine()->score($signals)->contributions; + + $this->assertArrayHasKey(Signal::HEADLESS, $contributions); + $this->assertArrayHasKey(Signal::ASN_REPUTATION, $contributions); + // logit-space contribution = coefficient * value + $this->assertGreaterThan(0.0, $contributions[Signal::HEADLESS]); + } + + public function testImplementsEngineInterface(): void + { + // The whole point of the class: it is a drop-in for the heuristic engine. + $this->assertInstanceOf(\Utopia\WAF\Challenge\Scoring\Engine::class, $this->engine()); + } +} diff --git a/tests/Challenge/ScoringTest.php b/tests/Challenge/ScoringTest.php new file mode 100644 index 0000000..ae9b279 --- /dev/null +++ b/tests/Challenge/ScoringTest.php @@ -0,0 +1,231 @@ +with(Signal::IP_REPUTATION, 0.5); + + $this->assertFalse($base->has(Signal::IP_REPUTATION)); // original untouched + $this->assertTrue($withOne->has(Signal::IP_REPUTATION)); + $this->assertSame(0.5, $withOne->float(Signal::IP_REPUTATION)); + + // bool -> 1.0/0.0, clamping, defaults + $this->assertSame(1.0, $withOne->with(Signal::TLS_MISMATCH, true)->float(Signal::TLS_MISMATCH)); + $this->assertSame(0.0, $withOne->with(Signal::TLS_MISMATCH, false)->float(Signal::TLS_MISMATCH)); + $this->assertSame(1.0, $withOne->with(Signal::HEADLESS, 5)->float(Signal::HEADLESS)); // clamped + $this->assertSame(0.0, $base->float(Signal::HEADLESS)); // absent -> default + $this->assertTrue($withOne->with(Signal::INTERACTION_PASSED, true)->bool(Signal::INTERACTION_PASSED)); + } + + public function testRiskTierFromScoreBoundaries(): void + { + $this->assertSame(RiskTier::ALLOW, RiskTier::fromScore(0.0)); + $this->assertSame(RiskTier::ALLOW, RiskTier::fromScore(0.24)); + $this->assertSame(RiskTier::CHALLENGE, RiskTier::fromScore(0.25)); + $this->assertSame(RiskTier::CHALLENGE, RiskTier::fromScore(0.54)); + $this->assertSame(RiskTier::INTERACTIVE, RiskTier::fromScore(0.55)); + $this->assertSame(RiskTier::INTERACTIVE, RiskTier::fromScore(0.79)); + $this->assertSame(RiskTier::DENY, RiskTier::fromScore(0.80)); + $this->assertSame(RiskTier::DENY, RiskTier::fromScore(1.5)); // clamped + } + + public function testRiskTierSurfaceClamp(): void + { + // cloud API (no browser): interactive falls back to proof-of-work + $this->assertSame(RiskTier::CHALLENGE, RiskTier::INTERACTIVE->clampTo(false)); + // edge (browser): interactive stays + $this->assertSame(RiskTier::INTERACTIVE, RiskTier::INTERACTIVE->clampTo(true)); + // every other tier is surface-agnostic + $this->assertSame(RiskTier::DENY, RiskTier::DENY->clampTo(false)); + $this->assertSame(RiskTier::CHALLENGE, RiskTier::CHALLENGE->clampTo(false)); + $this->assertSame(RiskTier::ALLOW, RiskTier::ALLOW->clampTo(false)); + } + + public function testHeuristicEmptySignalsAllow(): void + { + $score = (new HeuristicEngine())->score(new Signals()); + $this->assertSame(0.0, $score->value); + $this->assertSame(RiskTier::ALLOW, $score->tier); + $this->assertSame([], $score->contributions); + } + + public function testHeuristicEscalatesWithSignalWeight(): void + { + $engine = new HeuristicEngine(); + + // single low-weight-ish signal stays ALLOW (conservative, fixed denominator) + $this->assertSame(RiskTier::ALLOW, $engine->score( + (new Signals())->with(Signal::IP_REPUTATION, 1.0) + )->tier); + + // IP reputation + TLS mismatch crosses into the PoW gate + $this->assertSame(RiskTier::CHALLENGE, $engine->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + )->tier); + + // all server signals maxed -> interactive + $this->assertSame(RiskTier::INTERACTIVE, $engine->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::MISSING_HEADERS, 1.0) + )->tier); + + // everything maxed -> deny, score 1.0, contributions recorded + $all = $engine->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::MISSING_HEADERS, 1.0) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0) + ); + $this->assertSame(1.0, $all->value); + $this->assertSame(RiskTier::DENY, $all->tier); + $this->assertCount(7, $all->contributions); + $this->assertEqualsWithDelta(1.0, array_sum($all->contributions), 1e-9); + } + + public function testInteractionPassHardCapsClientBehaviorToAllow(): void + { + // client-behavioral tells (headless/automation/biometrics) are neutralized + // once the request passes the interaction challenge + $score = (new HeuristicEngine())->score( + (new Signals()) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0) + ->with(Signal::INTERACTION_PASSED, true) + ); + $this->assertLessThanOrEqual(HeuristicEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertSame(RiskTier::ALLOW, $score->tier); + } + + public function testInteractionCeilingDoesNotEraseServerSignals(): void + { + // A forged `interacted: true` must not wipe out server-observed evidence + // the client cannot influence. A blocklisted IP + TLS mismatch keeps the + // score at (at least) what those signals alone produce, so a scripted + // client cannot buy its way to ALLOW by claiming interaction. + $signals = (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::HEADLESS, 1.0) // client noise — neutralized + ->with(Signal::INTERACTION_PASSED, true); + + $score = (new HeuristicEngine())->score($signals); + + // Server-only contribution: (IP_REPUTATION 3 + TLS_MISMATCH 4) / 18. + $this->assertGreaterThan(HeuristicEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertNotSame(RiskTier::ALLOW, $score->tier); + + // But the client-behavioral HEADLESS contribution is still neutralized: + // the score equals the server-only value, not the full weighted sum. + $serverOnly = (new HeuristicEngine())->score( + (new Signals())->with(Signal::IP_REPUTATION, 1.0)->with(Signal::TLS_MISMATCH, true) + ); + $this->assertEqualsWithDelta($serverOnly->value, $score->value, 1e-9); + } + + public function testCustomThresholdsAndWeights(): void + { + // a stricter config escalates the same signal further + $engine = new HeuristicEngine( + weights: [Signal::HEADLESS => 1.0], + thresholds: [ + RiskTier::CHALLENGE->value => 0.1, + RiskTier::INTERACTIVE->value => 0.5, + RiskTier::DENY->value => 0.9, + ], + ); + $this->assertSame(RiskTier::DENY, $engine->score( + (new Signals())->with(Signal::HEADLESS, 1.0) + )->tier); + } + + public function testSignalRecordFlattens(): void + { + $signals = (new Signals())->with(Signal::HEADLESS, 0.8); + $score = new Score(0.8, RiskTier::INTERACTIVE, [Signal::HEADLESS => 0.8]); + $record = new SignalRecord( + projectId: 'proj', + audience: 'wafedge.test', + ipPrefix: '203.0.113.0', + signals: $signals, + score: $score, + decision: RiskTier::CHALLENGE, // clamped for a non-interactive surface + enforced: false, + issuedAt: 1000, + requestId: 'req-1', + ); + + $row = $record->toArray(); + $this->assertSame('waf.score', $row['type']); + $this->assertSame('proj', $row['projectId']); + $this->assertSame(0.8, $row['score']); + $this->assertSame('interactive', $row['tier']); + $this->assertSame('challenge', $row['decision']); + $this->assertFalse($row['enforced']); + $this->assertSame(['headless' => 0.8], $row['signals']); + } + + public function testCertainAttackFloorsToDeny(): void + { + // a clear attack blocks even with no other signals + $score = (new HeuristicEngine())->score((new Signals())->with(Signal::ATTACK_SCORE, 1.0)); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testModerateAttackFloorsToChallenge(): void + { + $score = (new HeuristicEngine())->score((new Signals())->with(Signal::ATTACK_SCORE, 0.5)); + $this->assertSame(RiskTier::CHALLENGE, $score->tier); + } + + public function testLowAttackScoreDoesNotFloor(): void + { + $score = (new HeuristicEngine())->score((new Signals())->with(Signal::ATTACK_SCORE, 0.2)); + $this->assertSame(RiskTier::ALLOW, $score->tier); + } + + public function testAttackFloorOverridesInteractionCap(): void + { + // you never excuse an injection because a challenge was solved + $score = (new HeuristicEngine())->score( + (new Signals()) + ->with(Signal::ATTACK_SCORE, 1.0) + ->with(Signal::INTERACTION_PASSED, true) + ); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testAttackScoreIsNotDilutedByWeightedSum(): void + { + // ATTACK_SCORE is a floor, not a weighted contribution — it never appears + // in contributions and never shifts the denominator of the other signals + $score = (new HeuristicEngine())->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ATTACK_SCORE, 1.0) + ); + $this->assertArrayNotHasKey(Signal::ATTACK_SCORE, $score->contributions); + $this->assertArrayHasKey(Signal::IP_REPUTATION, $score->contributions); + } +} diff --git a/tests/Challenge/SilentChallengeTest.php b/tests/Challenge/SilentChallengeTest.php new file mode 100644 index 0000000..bf7b353 --- /dev/null +++ b/tests/Challenge/SilentChallengeTest.php @@ -0,0 +1,138 @@ +silent(new SpyEngine())->issue($this->context()); + + $this->assertArrayHasKey('nonce', $challenge); + $this->assertNotSame('', $challenge['nonce']); + } + + public function testVerifyReturnsEngineScoreForAuthenticNonce(): void + { + $verdict = new Score(0.42, RiskTier::INTERACTIVE, ['headless' => 0.42]); + $engine = new SpyEngine($verdict); + $silent = $this->silent($engine); + + $nonce = $silent->issue($this->context())['nonce']; + $signals = new Signals(['headless' => 1.0]); + + $score = $silent->verify($nonce, $signals, $this->context()); + + $this->assertSame($verdict, $score, 'the engine verdict is returned verbatim'); + $this->assertSame(1, $engine->calls, 'engine is consulted exactly once for a live nonce'); + $this->assertSame($signals, $engine->lastSignals, 'the attested signals are what gets scored'); + } + + public function testVerifyReturnsNullAndSkipsScoringForForgedNonce(): void + { + $engine = new SpyEngine(); + $silent = $this->silent($engine); + + // A nonce signed by another secret fails integrity. + $forged = SilentChallenge::create(new Signer('attacker-secret'), new SpyEngine()) + ->issue($this->context())['nonce']; + + $this->assertNull($silent->verify($forged, new Signals(['headless' => 0.0]), $this->context())); + $this->assertSame(0, $engine->calls, 'a forged nonce must not reach the engine'); + + // Garbage blobs too. + $this->assertNull($silent->verify('not-a-token', new Signals(), $this->context())); + $this->assertSame(0, $engine->calls); + } + + public function testVerifyReturnsNullForWrongContext(): void + { + $engine = new SpyEngine(); + $silent = $this->silent($engine); + + $nonce = $silent->issue($this->context('203.0.113.9'))['nonce']; + + // Different /24 network -> integrity fails, engine untouched. + $this->assertNull($silent->verify($nonce, new Signals(), $this->context('198.51.100.9'))); + $this->assertSame(0, $engine->calls); + + // Same /24 -> integrity passes, engine consulted. + $this->assertNotNull($silent->verify($nonce, new Signals(), $this->context('203.0.113.200'))); + $this->assertSame(1, $engine->calls); + } + + public function testVerifyScoresAttestationWithRealEngine(): void + { + $silent = $this->silent(new HeuristicEngine()); + + $nonce = $silent->issue($this->context())['nonce']; + + // A benign (empty) attestation scores as ALLOW through the real engine. + $benign = $silent->verify($nonce, new Signals(), $this->context()); + $this->assertInstanceOf(Score::class, $benign); + $this->assertSame(RiskTier::ALLOW, $benign->tier); + $this->assertSame(0.0, $benign->value); + } + + public function testClearanceTtlRoundTrip(): void + { + $silent = $this->silent(new SpyEngine()); + + $plain = $silent->issue($this->context()); + $this->assertNull($silent->clearanceTtl($plain['nonce'])); + + $withTtl = $silent->issue($this->context(), 1800); + $this->assertSame(1800, $silent->clearanceTtl($withTtl['nonce'])); + } +} + +/** + * A test double for the scoring engine: records how often it was consulted and + * with what, and returns a preset verdict. + */ +final class SpyEngine implements Engine +{ + public int $calls = 0; + + public ?Signals $lastSignals = null; + + public function __construct(private readonly ?Score $verdict = null) + { + } + + public function score(Signals $signals): Score + { + $this->calls++; + $this->lastSignals = $signals; + + return $this->verdict ?? new Score(0.0, RiskTier::ALLOW); + } +} diff --git a/tests/Challenge/TlsFingerprintTest.php b/tests/Challenge/TlsFingerprintTest.php new file mode 100644 index 0000000..394dffb --- /dev/null +++ b/tests/Challenge/TlsFingerprintTest.php @@ -0,0 +1,54 @@ +assertTrue($tls->mismatches(self::CURL, 'curl/8.5.0')); + // the key case: a library TLS stack forging a browser User-Agent is still caught + $this->assertTrue($tls->mismatches(self::CURL, self::CHROME_UA)); + } + + public function testRealBrowserPasses(): void + { + $tls = new TlsFingerprint(); + $this->assertFalse($tls->mismatches(self::CHROME, self::CHROME_UA)); + } + + public function testBrowserUaWithUnknownFingerprintIsSpoofing(): void + { + // UA claims a browser but the JA4 is not a known browser fingerprint + $tls = new TlsFingerprint(); + $this->assertTrue($tls->mismatches('t13d0304i0_aaaaaaaaaaaa_bbbbbbbbbbbb', self::CHROME_UA)); + } + + public function testHonestNonBrowserClientIsNotAMismatch(): void + { + // an unknown fingerprint whose UA does NOT claim a browser is not flagged + // by this signal (it may still be caught by other signals) + $tls = new TlsFingerprint(); + $this->assertFalse($tls->mismatches('t13d0304i0_aaaaaaaaaaaa_bbbbbbbbbbbb', 'MyServerSDK/1.0')); + } + + public function testEmptyFingerprintYieldsNoSignal(): void + { + $this->assertFalse((new TlsFingerprint())->mismatches('', self::CHROME_UA)); + } + + public function testCustomBlocklist(): void + { + $tls = new TlsFingerprint(automation: ['t13dXXXX_aaa_bbb'], browsers: []); + $this->assertTrue($tls->mismatches('t13dXXXX_aaa_bbb', 'anything')); + $this->assertFalse($tls->mismatches(self::CURL, 'curl/8.5.0')); // default seed not applied + } +} diff --git a/tests/RulesTest.php b/tests/RulesTest.php index 4b848d5..4715c68 100644 --- a/tests/RulesTest.php +++ b/tests/RulesTest.php @@ -38,7 +38,7 @@ public function testChallengeRuleTypeDefaults(): void $customRule = new Challenge([], Challenge::TYPE_CUSTOM); $this->assertSame('challenge', $defaultRule->getAction()); - $this->assertSame(Challenge::TYPE_CAPTCHA, $defaultRule->getType()); + $this->assertSame(Challenge::TYPE_CUSTOM, $defaultRule->getType()); $this->assertSame(Challenge::TYPE_CUSTOM, $customRule->getType()); }