Skip to content

Commit 4bde99a

Browse files
committed
feat(captcha): proof-of-work, tokens, and Captcha service (Phase 2)
Adds the self-hosted captcha layer composing the Phase 1 detection engine with proof-of-work and session tokens, reproducing FCaptcha's runVerification end-to-end: - PoWManager: HMAC-signed challenges, SHA-256 leading-zero difficulty with reputation/rate scaling (4..6), one-time-use replay protection, 5-min expiry, signals-commitment binding, un-spoofable server-side timing. - TokenManager: base64url HMAC tokens with 5-min expiry, IP-hash binding, single-use replay protection. - Pluggable ChallengeStore/TokenStore (in-memory now; Redis seam for later). - resolveSecret(): hard-fails on missing/default secret in production. - Captcha service: issueChallenge / verify / score (invisible) / verifyToken, including the signals-tampering commitment check. 24 new tests (PoW, tokens, end-to-end) — 82 total, all passing. PoW outcome flows into the engine via the Phase 1 seam; token issued only on success.
1 parent 0f34500 commit 4bde99a

10 files changed

Lines changed: 938 additions & 1 deletion

File tree

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/**
2+
* Tests for the Phase 2 captcha layer: PoW manager, token manager, and the
3+
* end-to-end Captcha service (challenge → solve → verify → token).
4+
*/
5+
6+
import { createHash } from 'crypto';
7+
import { PoWManager } from './pow';
8+
import { TokenManager } from './token';
9+
import { Captcha } from './service';
10+
import { resolveSecret, INSECURE_DEFAULT_SECRET } from './secret';
11+
import type { ChallengeData, PoWSolution } from './types';
12+
13+
const SECRET = 'test-secret-0123456789abcdef';
14+
const UA_CHROME_MAC =
15+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36';
16+
const GOOD_HEADERS = {
17+
'user-agent': UA_CHROME_MAC,
18+
accept: 'text/html',
19+
'accept-language': 'en-US,en;q=0.9',
20+
'accept-encoding': 'gzip, deflate, br',
21+
};
22+
const HUMAN_BEHAVIOR = {
23+
totalPoints: 80,
24+
trajectoryLength: 350,
25+
velocityVariance: 0.8,
26+
microTremorScore: 0.6,
27+
directionChanges: 15,
28+
mouseEventRate: 60,
29+
interactionDuration: 1500,
30+
approachPoints: 12,
31+
overshootCorrections: 3,
32+
eventDeltaVariance: 25,
33+
};
34+
const CHROME_MAC_ENV = {
35+
automationFlags: { chrome: true, platform: 'MacIntel', plugins: 5 },
36+
navigator: { platform: 'MacIntel', maxTouchPoints: 0 },
37+
};
38+
39+
/** Brute-force a PoW solution (test difficulties are small). */
40+
function solve(challenge: ChallengeData, signalsHash?: string): PoWSolution {
41+
const target = '0'.repeat(challenge.difficulty);
42+
for (let n = 0; ; n++) {
43+
const input = signalsHash
44+
? `${challenge.prefix}:${signalsHash}:${n}`
45+
: `${challenge.prefix}:${n}`;
46+
const hash = createHash('sha256').update(input).digest('hex');
47+
if (hash.startsWith(target)) {
48+
return { challengeId: challenge.id, nonce: n, hash, signalsHash };
49+
}
50+
}
51+
}
52+
53+
describe('secret resolution', () => {
54+
const original = process.env.NODE_ENV;
55+
afterEach(() => {
56+
process.env.NODE_ENV = original;
57+
});
58+
59+
it('falls back to the dev default outside production', () => {
60+
process.env.NODE_ENV = 'test';
61+
expect(resolveSecret()).toBe(INSECURE_DEFAULT_SECRET);
62+
});
63+
64+
it('throws on a missing secret in production', () => {
65+
process.env.NODE_ENV = 'production';
66+
expect(() => resolveSecret()).toThrow(/required in production/);
67+
});
68+
69+
it('throws on the default secret in production', () => {
70+
process.env.NODE_ENV = 'production';
71+
expect(() => resolveSecret(INSECURE_DEFAULT_SECRET)).toThrow(/default development secret/);
72+
});
73+
74+
it('accepts a strong secret in production', () => {
75+
process.env.NODE_ENV = 'production';
76+
expect(resolveSecret(SECRET)).toBe(SECRET);
77+
});
78+
});
79+
80+
describe('PoWManager', () => {
81+
it('generates a signed challenge with a bound nonce', () => {
82+
const pow = new PoWManager({ secret: SECRET });
83+
const c = pow.generate('site', '1.2.3.4', 2);
84+
expect(c.difficulty).toBe(2);
85+
expect(c.sig).toHaveLength(64);
86+
expect(c.nonce).toHaveLength(32);
87+
expect(c.prefix).toBe(`${c.id}:${c.timestamp}:2`);
88+
});
89+
90+
it('verifies a correct solution and reports server timing + nonce', () => {
91+
const pow = new PoWManager({ secret: SECRET });
92+
const c = pow.generate('site', '1.2.3.4', 2);
93+
const result = pow.verify(solve(c), 'site');
94+
expect(result.valid).toBe(true);
95+
expect(result.nonce).toBe(c.nonce);
96+
expect(typeof result.serverElapsed).toBe('number');
97+
});
98+
99+
it('rejects replay of a used solution', () => {
100+
const pow = new PoWManager({ secret: SECRET });
101+
const c = pow.generate('site', '1.2.3.4', 2);
102+
const sol = solve(c);
103+
expect(pow.verify(sol, 'site').valid).toBe(true);
104+
// Challenge is consumed; second attempt no longer finds it.
105+
expect(pow.verify(sol, 'site').reason).toBe('challenge_not_found');
106+
});
107+
108+
it('rejects an unknown challenge', () => {
109+
const pow = new PoWManager({ secret: SECRET });
110+
expect(pow.verify({ challengeId: 'nope', nonce: 0, hash: 'x' }, 'site').reason).toBe(
111+
'challenge_not_found',
112+
);
113+
});
114+
115+
it('rejects a site-key mismatch', () => {
116+
const pow = new PoWManager({ secret: SECRET });
117+
const c = pow.generate('site', '1.2.3.4', 2);
118+
expect(pow.verify(solve(c), 'other').reason).toBe('site_key_mismatch');
119+
});
120+
121+
it('rejects an incorrect hash', () => {
122+
const pow = new PoWManager({ secret: SECRET });
123+
const c = pow.generate('site', '1.2.3.4', 2);
124+
expect(pow.verify({ challengeId: c.id, nonce: 0, hash: 'deadbeef' }, 'site').reason).toBe(
125+
'invalid_hash',
126+
);
127+
});
128+
129+
it('rejects insufficient difficulty', () => {
130+
const pow = new PoWManager({ secret: SECRET });
131+
const c = pow.generate('site', '1.2.3.4', 5);
132+
// The correct hash for nonce 0 almost never has 5 leading zeros.
133+
const input = `${c.prefix}:0`;
134+
const hash = createHash('sha256').update(input).digest('hex');
135+
expect(pow.verify({ challengeId: c.id, nonce: 0, hash }, 'site').reason).toBe(
136+
'insufficient_difficulty',
137+
);
138+
});
139+
140+
it('binds the solution to a signals hash', () => {
141+
const pow = new PoWManager({ secret: SECRET });
142+
const c = pow.generate('site', '1.2.3.4', 2);
143+
const signalsHash = createHash('sha256').update('{"a":1}').digest('hex');
144+
const sol = solve(c, signalsHash);
145+
// Verifying without the same signalsHash fails the hash check.
146+
expect(pow.verify({ ...sol, signalsHash: undefined }, 'site').reason).toBe('invalid_hash');
147+
expect(pow.verify(sol, 'site', signalsHash).valid).toBe(true);
148+
});
149+
150+
it('scales difficulty up for datacenter IPs', () => {
151+
const pow = new PoWManager({ secret: SECRET });
152+
expect(pow.scaleDifficulty('site', '52.1.2.3')).toBeGreaterThanOrEqual(5);
153+
expect(pow.scaleDifficulty('site', '73.15.22.100')).toBe(4);
154+
});
155+
});
156+
157+
describe('TokenManager', () => {
158+
it('issues and verifies a token', () => {
159+
const tm = new TokenManager({ secret: SECRET });
160+
const token = tm.issue('1.2.3.4', 'site', 0.12);
161+
const v = tm.verify(token);
162+
expect(v.valid).toBe(true);
163+
expect(v.site_key).toBe('site');
164+
expect(v.score).toBe(0.12);
165+
});
166+
167+
it('enforces single use (replay protection)', () => {
168+
const tm = new TokenManager({ secret: SECRET });
169+
const token = tm.issue('1.2.3.4', 'site', 0.1);
170+
expect(tm.verify(token).valid).toBe(true);
171+
expect(tm.verify(token).reason).toBe('token_already_used');
172+
});
173+
174+
it('binds the token to the client IP', () => {
175+
const tm = new TokenManager({ secret: SECRET });
176+
const token = tm.issue('1.2.3.4', 'site', 0.1);
177+
expect(tm.verify(token, '9.9.9.9').reason).toBe('ip_mismatch');
178+
});
179+
180+
it('rejects a tampered signature', () => {
181+
const tm = new TokenManager({ secret: SECRET });
182+
const other = new TokenManager({ secret: 'different-secret' });
183+
const token = other.issue('1.2.3.4', 'site', 0.1);
184+
expect(tm.verify(token).reason).toBe('invalid_signature');
185+
});
186+
187+
it('rejects an expired token', () => {
188+
const tm = new TokenManager({ secret: SECRET });
189+
const token = tm.issue('1.2.3.4', 'site', 0.1);
190+
const realNow = Date.now();
191+
const spy = jest.spyOn(Date, 'now').mockReturnValue(realNow + 301_000);
192+
try {
193+
expect(tm.verify(token).reason).toBe('expired');
194+
} finally {
195+
spy.mockRestore();
196+
}
197+
});
198+
199+
it('rejects garbage', () => {
200+
const tm = new TokenManager({ secret: SECRET });
201+
expect(tm.verify('not-a-token').valid).toBe(false);
202+
});
203+
});
204+
205+
describe('Captcha end-to-end', () => {
206+
it('passes a clean human with a valid PoW and issues a usable token', () => {
207+
const captcha = new Captcha({ secret: SECRET });
208+
const ip = '73.15.22.100';
209+
210+
// t0: issue challenge.
211+
const realNow = Date.now();
212+
const spy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
213+
let token: string | null;
214+
try {
215+
const challenge = captcha.issueChallenge('site', ip, 2);
216+
const solution = solve(challenge);
217+
218+
// Advance time so server-side elapsed exceeds the too-fast threshold.
219+
spy.mockReturnValue(realNow + 3000);
220+
221+
const result = captcha.verify({
222+
siteKey: 'site',
223+
ip,
224+
userAgent: UA_CHROME_MAC,
225+
headers: GOOD_HEADERS,
226+
signals: {
227+
behavioral: HUMAN_BEHAVIOR,
228+
environmental: CHROME_MAC_ENV,
229+
meta: { challengeNonce: challenge.nonce },
230+
},
231+
powSolution: solution,
232+
});
233+
234+
expect(result.success).toBe(true);
235+
expect(result.recommendation).toBe('allow');
236+
expect(result.token).toBeTruthy();
237+
token = result.token;
238+
} finally {
239+
spy.mockRestore();
240+
}
241+
242+
// The issued token verifies once, then is consumed.
243+
expect(captcha.verifyToken(token!, ip).valid).toBe(true);
244+
expect(captcha.verifyToken(token!, ip).reason).toBe('token_already_used');
245+
});
246+
247+
it('fails a clear bot (headless + CDP + datacenter, no PoW) and issues no token', () => {
248+
const captcha = new Captcha({ secret: SECRET });
249+
const result = captcha.verify({
250+
siteKey: 'site',
251+
ip: '52.1.2.3', // datacenter
252+
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/120.0.0.0',
253+
headers: { 'user-agent': 'HeadlessChrome' },
254+
signals: {
255+
environmental: {
256+
webdriver: true,
257+
cdp: { detected: true, signals: ['chromedriver_cdc'] },
258+
},
259+
},
260+
});
261+
expect(result.success).toBe(false);
262+
expect(result.token).toBeNull();
263+
expect(result.detections.some((d) => d.reason.includes('No PoW solution'))).toBe(true);
264+
});
265+
266+
it('does not block on "no PoW" alone — pushes to challenge, not block', () => {
267+
// A single maxed category cannot cross the 0.5 success threshold; missing
268+
// PoW on an otherwise-clean request yields a "challenge" recommendation.
269+
const captcha = new Captcha({ secret: SECRET });
270+
const result = captcha.verify({
271+
siteKey: 'site',
272+
ip: '52.1.2.3',
273+
userAgent: 'curl/8.0',
274+
headers: { 'user-agent': 'curl/8.0' },
275+
signals: {},
276+
});
277+
expect(result.recommendation).not.toBe('allow');
278+
expect(result.detections.some((d) => d.reason.includes('No PoW solution'))).toBe(true);
279+
});
280+
281+
it('detects signals tampering via the commitment hash', () => {
282+
const captcha = new Captcha({ secret: SECRET });
283+
const ip = '73.15.22.100';
284+
const challenge = captcha.issueChallenge('site', ip, 2);
285+
const realJson = JSON.stringify({ behavioral: HUMAN_BEHAVIOR });
286+
const wrongHash = createHash('sha256').update('something-else').digest('hex');
287+
const solution = solve(challenge, wrongHash);
288+
289+
const result = captcha.verify({
290+
siteKey: 'site',
291+
ip,
292+
userAgent: UA_CHROME_MAC,
293+
headers: GOOD_HEADERS,
294+
signalsJson: realJson,
295+
powSolution: solution,
296+
});
297+
298+
expect(result.detections.some((d) => d.reason.includes('Signals tampered'))).toBe(true);
299+
});
300+
301+
it('returns an action in invisible (score) mode', () => {
302+
const captcha = new Captcha({ secret: SECRET });
303+
const out = captcha.score({
304+
siteKey: 'site',
305+
ip: '73.15.22.100',
306+
userAgent: UA_CHROME_MAC,
307+
headers: GOOD_HEADERS,
308+
action: 'login',
309+
signals: { behavioral: HUMAN_BEHAVIOR, environmental: CHROME_MAC_ENV },
310+
});
311+
expect(out.action).toBe('login');
312+
expect(typeof out.score).toBe('number');
313+
});
314+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Self-hosted captcha: proof-of-work + in-process detection + session tokens.
3+
*
4+
* Ported from FCaptcha. Use {@link Captcha} for the full flow, or the
5+
* {@link PoWManager} / {@link TokenManager} primitives directly.
6+
*/
7+
8+
export { Captcha } from './service';
9+
export type { CaptchaOptions } from './service';
10+
11+
export { PoWManager, InMemoryChallengeStore } from './pow';
12+
export type { PoWManagerOptions, ChallengeStore } from './pow';
13+
14+
export { TokenManager, InMemoryTokenStore } from './token';
15+
export type { TokenManagerOptions, TokenStore } from './token';
16+
17+
export { resolveSecret, INSECURE_DEFAULT_SECRET } from './secret';
18+
19+
export type {
20+
ChallengeData,
21+
StoredChallenge,
22+
PoWSolution,
23+
PoWVerification,
24+
TokenVerification,
25+
VerifyInput,
26+
VerifyResult,
27+
ScoreResult,
28+
} from './types';

0 commit comments

Comments
 (0)