Skip to content

Commit bee9324

Browse files
authored
feat: gate tokens on per-device rate; require human key holds for the keyboard exemption (#52)
After 10 verifications in a minute from one page instance on one device at one address, the token is withheld with reason rate_limited. Keyed on the widget instance as well as address and fingerprint so identical machines behind one address do not share a budget. The keyboard-only exemption now asks that key holds look like fingers when hold data exists: three or more holds averaging under 20ms deny it and the session scores as pointerless. No hold data keeps the exemption. The widget reports keyHoldSamples and keyHoldAvg, durations only. Go, Node and Python, ten new tests each. Bench gate: human FPR 0.00%, agent TPR 97.33%. Playwright and E2E suites pass. Claude-Session: https://claude.ai/code/session_0198hVg9KALKecyEFDhsDxdg
1 parent b2e2be7 commit bee9324

17 files changed

Lines changed: 808 additions & 32 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ the project uses [Semantic Versioning](https://semver.org/) — with the caveat
1313
that pre-2.0 it has used minor bumps for behaviour changes that a stricter
1414
reading would call major. Read the **Breaking** entries rather than the number.
1515

16+
## [Unreleased]
17+
18+
### Security and fixes
19+
- Withhold the token when one page instance on one device at one address
20+
verifies more than 10 times in a minute (`reason: rate_limited`). The
21+
per-address rate detection stays as it was; it carries almost no weight, so
22+
it could not stop an automated client verifying every few seconds. Keyed on
23+
the widget instance as well, so identical machines behind one address do not
24+
share a budget.
25+
- The keyboard-only accessibility exemption now checks that key holds look
26+
like fingers when hold data is present. A keyboard-driven agent releases keys
27+
in a millisecond or two and no longer passes as a keyboard user; a visitor
28+
with no hold data, including one on an older widget, keeps the exemption.
29+
The widget reports an average key hold alongside its key count.
30+
1631
## [1.36.0] — 2026-09-09
1732

1833
### Detection

COMPLIANCE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Redis persistence and backups. FCaptcha itself does not write state to disk.
8181
| Suspicion ledger (adaptive cost) | site key + IP | 15 minutes |
8282
| Fingerprint cardinality | site + fingerprint / IP | 15-minute fixed windows; at most 16 members per bucket |
8383
| Rate-limit counters | site key + IP | 60-second windows |
84+
| Per-device verification counters | site key + IP + device fingerprint + widget instance | 60-second windows |
8485
| Site-key state bounds | IP | 1 hour |
8586
| Siteverify idempotency cache | caller-supplied key | 5 minutes |
8687
| TLS fingerprints, when terminating TLS | connection | 5 minutes |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ challenge:
602602
| The score is below the success threshold (0.5) | (score speaks for itself) |
603603
| A **valid proof of work** for a challenge this server issued, with the signals bound to it | `pow_not_satisfied` |
604604
| The minting origin is permitted, if `FCAPTCHA_ALLOWED_HOSTNAMES` is set | `hostname_not_allowed` |
605+
| Fewer than 10 verifications in the last minute from this page instance, device and address | `rate_limited` |
605606

606607
The widget solves a proof of work on every path and aborts rather than submit
607608
without one, so a request that arrives without a valid solution did not come from

bench/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ node capture/record.js
1919
node run-bench.js # human FPR, agent TPR, per-signal budgets
2020
node run-bench.js --gate # non-zero exit when a signal is over budget
2121
node run-bench.js --json out.json # machine-readable
22+
23+
On macOS, run the server and the harness under `caffeinate -i -s` for anything
24+
unattended. Maintenance Sleep pauses both processes together; a challenge
25+
fetched just before a pause is past its five-minute lifetime when the solve
26+
resumes, and the gate reports it as `PoW verification failed: challenge_expired`
27+
on whichever personas were in flight.
2228
```
2329

2430
---

bench/capture/input.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,15 +231,20 @@ const HUMAN_PERSONAS = {
231231
'keyboard-only: zero pointer events, Tab to focus, Space to activate. ' +
232232
'Directly exercises the accessibility exemption (keyEvents >= 2 && totalPoints === 0).',
233233
async run({ page, target, rng }) {
234-
await page.keyboard.press('Tab');
234+
// Hold each key the way a finger does. Playwright's default press has a
235+
// hold of a millisecond or two, which is what an automation protocol
236+
// produces and what the server's key-hold check exists to catch; a human
237+
// persona that pressed keys that way would be measuring the harness.
238+
const hold = () => ({ delay: Math.max(30, rng.gaussian(80, 25)) });
239+
await page.keyboard.press('Tab', hold());
235240
await sleep(rng.range(300, 900));
236241
for (let i = 0; i < 3 && !(await target.evaluate((el) => el === document.activeElement)); i++) {
237-
await page.keyboard.press('Tab');
242+
await page.keyboard.press('Tab', hold());
238243
await sleep(rng.range(200, 700));
239244
}
240245
await target.focus();
241246
await sleep(rng.range(400, 1200));
242-
await page.keyboard.press('Space');
247+
await page.keyboard.press('Space', hold());
243248
},
244249
},
245250

@@ -249,13 +254,14 @@ const HUMAN_PERSONAS = {
249254
'is announced, no pointer. Dwell times are a plausible range, not measured ' +
250255
'against a real AT user.',
251256
async run({ page, target, rng }) {
257+
const hold = () => ({ delay: Math.max(30, rng.gaussian(80, 25)) }); // see keyboard-only
252258
for (let i = 0; i < 6; i++) {
253-
await page.keyboard.press('Tab');
259+
await page.keyboard.press('Tab', hold());
254260
await sleep(rng.range(900, 2400)); // announcement time
255261
}
256262
await target.focus();
257263
await sleep(rng.range(1200, 2600));
258-
await page.keyboard.press('Space');
264+
await page.keyboard.press('Space', hold());
259265
},
260266
},
261267

client/fcaptcha.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@
199199
// Behavioral Signal Collector
200200
// ============================================================
201201

202+
// Keys whose hold says nothing about typing: held across other presses.
203+
const KEY_HOLD_EXCLUDED = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'NumLock', 'ScrollLock']);
204+
202205
class BehavioralCollector {
203206
constructor() {
204207
this.mousePositions = [];
@@ -222,6 +225,13 @@
222225
// Cross-input activity timeline for think-time cadence.
223226
this._lastActivityT = null;
224227
this._cadenceGaps = [];
228+
// Key hold (keydown→keyup) per physical key. Only durations are kept —
229+
// never which key — and modifiers and auto-repeat are excluded. A hand
230+
// on a key holds it for tens of milliseconds; an automation protocol
231+
// releases it in one or two. The server reads the summary before it
232+
// grants the keyboard-only accessibility exemption.
233+
this._keyDownAt = new Map();
234+
this.keyHolds = [];
225235
this._teleportClicks = 0;
226236
// Coalesced pointermove batches: real mice coalesce multiple hardware
227237
// samples per frame; CDP-injected moves produce single-entry batches.
@@ -328,6 +338,17 @@
328338
keyLength: e.key ? e.key.length : 0, // Don't store actual keys
329339
t: now
330340
});
341+
const keyId = e.code || e.key;
342+
if (!keyId || KEY_HOLD_EXCLUDED.has(e.key)) return;
343+
if (e.type === 'keydown') {
344+
if (!e.repeat) this._keyDownAt.set(keyId, now);
345+
} else if (e.type === 'keyup') {
346+
const downAt = this._keyDownAt.get(keyId);
347+
if (downAt === undefined) return;
348+
this._keyDownAt.delete(keyId);
349+
const hold = now - downAt;
350+
if (hold > 0 && hold < 2000 && this.keyHolds.length < 200) this.keyHolds.push(hold);
351+
}
331352
}
332353

333354
recordTouch(e) {
@@ -626,6 +647,7 @@
626647
scrollEvents: this.scrollEvents.length,
627648
scrollMorphology: this._analyzeScrollMorphology(),
628649
keyEvents: this.keyEvents.length,
650+
...this._keyHoldSummary(),
629651
touchEvents: this.touchEvents.length,
630652
focusEvents: this.focusEvents.length,
631653
clickData: this.clickData,
@@ -898,6 +920,15 @@
898920
return arr.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / arr.length;
899921
}
900922

923+
// Average keydown→keyup hold and how many presses it rests on.
924+
_keyHoldSummary() {
925+
const n = this.keyHolds.length;
926+
return {
927+
keyHoldSamples: n,
928+
keyHoldAvg: n ? this.keyHolds.reduce((a, b) => a + b, 0) / n : 0
929+
};
930+
}
931+
901932
_getEmptyAnalysis(totalPoints = 0) {
902933
return {
903934
// A short trace is insufficient for stable trajectory statistics, but
@@ -910,6 +941,7 @@
910941
scrollEvents: this.scrollEvents.length,
911942
scrollMorphology: this._analyzeScrollMorphology(),
912943
keyEvents: this.keyEvents.length,
944+
...this._keyHoldSummary(),
913945
touchEvents: this.touchEvents.length, focusEvents: this.focusEvents.length,
914946
clickData: this.clickData, interactionDuration: Date.now() - this.startTime,
915947
inputForensics: this._analyzeInputForensics(),

server-go/device_rate_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package main
2+
3+
import "testing"
4+
5+
// The per-device verification rate gate. A precondition on token issuance, not
6+
// weighted evidence: see deviceVerificationsPerMinute in scoring.go.
7+
8+
func deviceSignals(instance string) map[string]interface{} {
9+
sig := map[string]interface{}{
10+
"behavioral": map[string]interface{}{
11+
"totalPoints": 60.0, "trajectoryLength": 400.0, "approachPoints": 12.0,
12+
"approachDirectness": 0.4, "microTremorScore": 0.5, "velocityVariance": 0.5,
13+
},
14+
"environmental": map[string]interface{}{"automationFlags": map[string]interface{}{}},
15+
}
16+
if instance != "" {
17+
sig["meta"] = map[string]interface{}{"sessionId": instance}
18+
}
19+
return sig
20+
}
21+
22+
func TestDeviceRateGateWithholdsTheEleventhVerification(t *testing.T) {
23+
e := NewScoringEngine("test-secret")
24+
const ip = "203.0.113.77"
25+
for i := 0; i < deviceVerificationsPerMinute; i++ {
26+
r := e.VerifyWithHeaders(deviceSignals("page-a"), ip, "site", "ua", nil, "", "", false, nil, TokenBinding{})
27+
if r.Reason == "rate_limited" {
28+
t.Fatalf("verification %d of %d was rate limited early", i+1, deviceVerificationsPerMinute)
29+
}
30+
}
31+
r := e.VerifyWithHeaders(deviceSignals("page-a"), ip, "site", "ua", nil, "", "", false, nil, TokenBinding{})
32+
if r.Success || r.Reason != "rate_limited" {
33+
t.Errorf("verification %d should be withheld as rate_limited, got success=%v reason=%q",
34+
deviceVerificationsPerMinute+1, r.Success, r.Reason)
35+
}
36+
if !hasReasonContaining(r.Detections, "for this device") {
37+
t.Errorf("expected the per-device rate detection to be recorded, got %+v", r.Detections)
38+
}
39+
40+
// Another page instance on the same address and device has its own budget:
41+
// identical machines behind one NAT must not share one.
42+
other := e.VerifyWithHeaders(deviceSignals("page-b"), ip, "site", "ua", nil, "", "", false, nil, TokenBinding{})
43+
if other.Reason == "rate_limited" {
44+
t.Errorf("a different widget instance must not inherit the exhausted budget")
45+
}
46+
}
47+
48+
func TestDeviceRateGateNeedsAWidgetInstance(t *testing.T) {
49+
// A client that reports no instance id — an older widget — is not gated,
50+
// because the alternative key (address + fingerprint) is shared by every
51+
// identical machine behind one address.
52+
e := NewScoringEngine("test-secret")
53+
for i := 0; i <= deviceVerificationsPerMinute+2; i++ {
54+
r := e.VerifyWithHeaders(deviceSignals(""), "203.0.113.78", "site", "ua", nil, "", "", false, nil, TokenBinding{})
55+
if r.Reason == "rate_limited" {
56+
t.Fatalf("no instance id must mean no device gate, got rate_limited on call %d", i+1)
57+
}
58+
}
59+
}
60+
61+
func TestWidgetInstanceIsBounded(t *testing.T) {
62+
long := make([]byte, 500)
63+
for i := range long {
64+
long[i] = 'x'
65+
}
66+
sig := map[string]interface{}{"meta": map[string]interface{}{"widgetId": string(long)}}
67+
if got := widgetInstance(sig); len(got) != 64 {
68+
t.Errorf("client-supplied id must be bounded before it becomes a key, got length %d", len(got))
69+
}
70+
if widgetInstance(map[string]interface{}{}) != "" {
71+
t.Error("no meta means no instance")
72+
}
73+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package main
2+
3+
import "testing"
4+
5+
// The keyboard-only accessibility exemption and the key-hold check that keeps a
6+
// keyboard-driven agent from claiming it. See keyboardOnlyUser in scoring.go.
7+
8+
func keyboardSignals(keyEvents float64, extra map[string]interface{}) map[string]interface{} {
9+
b := map[string]interface{}{"totalPoints": 0.0, "trajectoryLength": 0.0, "keyEvents": keyEvents, "touchEvents": 0.0}
10+
for k, v := range extra {
11+
b[k] = v
12+
}
13+
return map[string]interface{}{
14+
"behavioral": b,
15+
"environmental": map[string]interface{}{"automationFlags": map[string]interface{}{}},
16+
}
17+
}
18+
19+
func TestKeyboardExemptionStandsWithoutHoldData(t *testing.T) {
20+
// An older widget, or a visitor who tabbed to the checkbox and pressed
21+
// Space without typing into a field, reports counts and nothing else.
22+
sig := keyboardSignals(8, nil)
23+
if !keyboardOnlyUser(sig, getMap(sig, "behavioral")) {
24+
t.Error("counts alone must keep the exemption")
25+
}
26+
}
27+
28+
func TestKeyboardExemptionStandsForFingers(t *testing.T) {
29+
sig := keyboardSignals(12, map[string]interface{}{"keyHoldSamples": 6.0, "keyHoldAvg": 85.0})
30+
if !keyboardOnlyUser(sig, getMap(sig, "behavioral")) {
31+
t.Error("85ms holds are a hand on a key")
32+
}
33+
}
34+
35+
func TestKeyboardExemptionDeniedForMechanicalHolds(t *testing.T) {
36+
sig := keyboardSignals(12, map[string]interface{}{"keyHoldSamples": 6.0, "keyHoldAvg": 4.0})
37+
if keyboardOnlyUser(sig, getMap(sig, "behavioral")) {
38+
t.Error("4ms holds are an automation protocol, not a keyboard user")
39+
}
40+
}
41+
42+
func TestKeyboardExemptionPoolsFormFieldDwell(t *testing.T) {
43+
// The form analyser's per-field dwell times count too, so a widget that
44+
// typed into a field is judged even without the session-level summary.
45+
mechanical := keyboardSignals(9, nil)
46+
mechanical["formAnalysis"] = map[string]interface{}{"textareaKeyboard": map[string]interface{}{
47+
"message": map[string]interface{}{"dwellTimes": []interface{}{2.0, 3.0, 1.0, 2.0}},
48+
}}
49+
if keyboardOnlyUser(mechanical, getMap(mechanical, "behavioral")) {
50+
t.Error("mechanical field dwell must deny the exemption")
51+
}
52+
human := keyboardSignals(9, nil)
53+
human["formAnalysis"] = map[string]interface{}{"textareaKeyboard": map[string]interface{}{
54+
"message": map[string]interface{}{"dwellTimes": []interface{}{60.0, 75.0, 90.0}},
55+
}}
56+
if !keyboardOnlyUser(human, getMap(human, "behavioral")) {
57+
t.Error("human field dwell must keep the exemption")
58+
}
59+
}
60+
61+
func TestKeyboardExemptionNeedsEnoughHoldsToJudge(t *testing.T) {
62+
sig := keyboardSignals(4, map[string]interface{}{"keyHoldSamples": 2.0, "keyHoldAvg": 3.0})
63+
if !keyboardOnlyUser(sig, getMap(sig, "behavioral")) {
64+
t.Error("two holds are too few to take an exemption away on")
65+
}
66+
}
67+
68+
func TestKeyboardExemptionRequiresKeysAndNoPointer(t *testing.T) {
69+
one := keyboardSignals(1, nil)
70+
if keyboardOnlyUser(one, getMap(one, "behavioral")) {
71+
t.Error("one key event is not keyboard use")
72+
}
73+
moved := keyboardSignals(8, map[string]interface{}{"totalPoints": 3.0})
74+
if keyboardOnlyUser(moved, getMap(moved, "behavioral")) {
75+
t.Error("a visitor who moved the pointer is not keyboard-only")
76+
}
77+
}
78+
79+
// End to end: a keyboard-driven agent is scored as pointerless — both movement
80+
// views fire and corroborate — while a keyboard-only person is not.
81+
func TestKeyboardAgentIsScoredAsPointerless(t *testing.T) {
82+
e := NewScoringEngine("test-secret")
83+
agent := keyboardSignals(14, map[string]interface{}{"keyHoldSamples": 14.0, "keyHoldAvg": 2.0})
84+
dets := append(e.detectVisionAI(agent), e.detectBehavioral(agent)...)
85+
if !hasReasonContaining(dets, "Zero mouse, touch, or keyboard events") {
86+
t.Errorf("agent should lose the exemption and score as pointerless, got %+v", dets)
87+
}
88+
if !hasReasonContaining(dets, "No mouse movement detected before click") {
89+
t.Errorf("agent should trip the vision_ai movement check, got %+v", dets)
90+
}
91+
if got := applyCorroborationFloor(0.1, dets); got < corroborationFloor {
92+
t.Errorf("two movement views should corroborate to %v, got %v", corroborationFloor, got)
93+
}
94+
95+
person := keyboardSignals(14, map[string]interface{}{"keyHoldSamples": 14.0, "keyHoldAvg": 80.0})
96+
dets = append(e.detectVisionAI(person), e.detectBehavioral(person)...)
97+
if hasReasonContaining(dets, "Zero mouse") || hasReasonContaining(dets, "No mouse movement") {
98+
t.Errorf("a keyboard-only person must keep the exemption, got %+v", dets)
99+
}
100+
}

0 commit comments

Comments
 (0)