Skip to content

Commit c98b503

Browse files
authored
feat(icaptcha-client): solve the iCaptcha proof-of-work on answer (#181)
iCaptcha now requires a proof-of-work with each answer (enforce mode) so that minting a proof costs CPU — the lever that stops a distributed, multi-IP proof-farming flood that per-IP rate limits cannot. Teach the sanctioned client to solve it: parse the `pow` field from the challenge, grind a nonce whose sha256(challenge:nonce) has >= difficulty leading zero bits, and send it as `powNonce` on /v1/answer. Bounded search so a hostile difficulty can't hang the client. Mirrors icaptcha/src/pow.ts. Without this, agents on the updated client cannot obtain a proof once enforce is on. New pow module + unit tests; verified end-to-end against the live enforce-mode service (obtained a level-3 proof incl. PoW).
1 parent 3ec8cbf commit c98b503

3 files changed

Lines changed: 164 additions & 4 deletions

File tree

‎crates/icaptcha-client/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ serde_json = { workspace = true }
1515
anyhow = { workspace = true }
1616
thiserror = { workspace = true }
1717
tracing = { workspace = true }
18+
sha2 = { workspace = true }

‎crates/icaptcha-client/src/lib.rs‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use anyhow::{anyhow, bail, Context, Result};
1919
use serde::Deserialize;
2020
use serde_json::json;
2121

22+
pub mod pow;
2223
pub mod solvers;
2324

2425
/// Default iCaptcha service base URL (used when the node doesn't advertise one).
@@ -201,6 +202,10 @@ pub struct Challenge {
201202
pub difficulty: u32,
202203
pub prompt: String,
203204
pub token: String,
205+
/// Proof-of-work to solve and echo back as `powNonce` on the answer. Present
206+
/// only when the service has PoW enabled; absent/None means no PoW required.
207+
#[serde(default)]
208+
pub pow: Option<pow::PowChallenge>,
204209
}
205210

206211
#[derive(Debug, Deserialize)]
@@ -237,7 +242,28 @@ pub fn obtain_proof(cfg: &IcaptchaCfg, solver: Option<&Solver>) -> Result<String
237242
)
238243
})?;
239244

240-
match submit_answer(&client, cfg, &challenge.token, &answer)? {
245+
// Solve the proof-of-work bound to this challenge, if the service
246+
// requires one. A required-but-unsolvable PoW (unknown algorithm or a
247+
// difficulty above our cap) is a hard error — submitting without it
248+
// would just be rejected.
249+
let pow_nonce = match &challenge.pow {
250+
Some(p) => Some(pow::solve(p).ok_or_else(|| {
251+
anyhow!(
252+
"cannot solve iCaptcha proof-of-work (algorithm '{}', difficulty {})",
253+
p.algorithm,
254+
p.difficulty
255+
)
256+
})?),
257+
None => None,
258+
};
259+
260+
match submit_answer(
261+
&client,
262+
cfg,
263+
&challenge.token,
264+
&answer,
265+
pow_nonce.as_deref(),
266+
)? {
241267
AnswerResult::Passed { proof } => return Ok(proof),
242268
AnswerResult::Continue { challenge: next } => challenge = next,
243269
AnswerResult::Failed { reason } => {
@@ -273,11 +299,14 @@ fn submit_answer(
273299
cfg: &IcaptchaCfg,
274300
token: &str,
275301
answer: &str,
302+
pow_nonce: Option<&str>,
276303
) -> Result<AnswerResult> {
277304
let url = format!("{}/v1/answer", cfg.url.trim_end_matches('/'));
278-
let mut req = client
279-
.post(&url)
280-
.json(&json!({ "token": token, "answer": answer }));
305+
let mut body = json!({ "token": token, "answer": answer });
306+
if let Some(nonce) = pow_nonce {
307+
body["powNonce"] = json!(nonce);
308+
}
309+
let mut req = client.post(&url).json(&body);
281310
if let Some(key) = &cfg.api_key {
282311
req = req.bearer_auth(key);
283312
}

‎crates/icaptcha-client/src/pow.rs‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//! Proof-of-work solver for the iCaptcha answer step.
2+
//!
3+
//! iCaptcha requires a small proof-of-work with each answer so that minting a
4+
//! proof costs CPU — a per-proof cost a distributed abuser cannot dodge by
5+
//! rotating IPs. The work is bound to the per-challenge id: find a `nonce` such
6+
//! that `sha256("{challenge}:{nonce}")` has at least `difficulty` leading zero
7+
//! bits. This mirrors `icaptcha/src/pow.ts`.
8+
//!
9+
//! At the service default (~20 bits) this is well under a second for a single
10+
//! solve; we still cap the search so a hostile/misconfigured difficulty can
11+
//! never hang the client.
12+
13+
use sha2::{Digest, Sha256};
14+
15+
/// PoW parameters advertised by the service in a challenge (mirrors the
16+
/// service's `PowChallenge`).
17+
#[derive(Debug, Clone, serde::Deserialize)]
18+
pub struct PowChallenge {
19+
/// Only `sha256-leading-zero-bits` is understood; other algorithms are
20+
/// rejected by [`solve`] rather than silently mis-solved.
21+
pub algorithm: String,
22+
/// The string to hash against (the per-challenge id).
23+
pub challenge: String,
24+
/// Required leading zero bits.
25+
pub difficulty: u32,
26+
}
27+
28+
/// Hard cap on nonce search iterations. 2^26 ≈ 67M keeps a ~20-bit target (~1M
29+
/// expected hashes) comfortably solvable while bounding worst-case work if the
30+
/// service ever advertises an unexpectedly high difficulty.
31+
const MAX_ITERS: u64 = 1 << 26;
32+
33+
const ALGORITHM: &str = "sha256-leading-zero-bits";
34+
35+
/// Leading zero bits of a byte slice.
36+
fn leading_zero_bits(bytes: &[u8]) -> u32 {
37+
let mut bits = 0;
38+
for &b in bytes {
39+
if b == 0 {
40+
bits += 8;
41+
} else {
42+
bits += b.leading_zeros();
43+
break;
44+
}
45+
}
46+
bits
47+
}
48+
49+
/// sha256(`{challenge}:{nonce}`).
50+
fn pow_hash(challenge: &str, nonce: &str) -> [u8; 32] {
51+
let mut hasher = Sha256::new();
52+
hasher.update(challenge.as_bytes());
53+
hasher.update(b":");
54+
hasher.update(nonce.as_bytes());
55+
hasher.finalize().into()
56+
}
57+
58+
/// Solve the PoW, returning a nonce whose hash meets the difficulty. Returns
59+
/// `None` for an unknown algorithm or if no nonce is found within the cap (a
60+
/// difficulty far above what the service uses in practice).
61+
pub fn solve(pow: &PowChallenge) -> Option<String> {
62+
if pow.algorithm != ALGORITHM {
63+
tracing::warn!(algorithm = %pow.algorithm, "unknown iCaptcha PoW algorithm; cannot solve");
64+
return None;
65+
}
66+
if pow.difficulty == 0 {
67+
return Some("0".to_string());
68+
}
69+
for i in 0..MAX_ITERS {
70+
let nonce = format!("{i:x}");
71+
if leading_zero_bits(&pow_hash(&pow.challenge, &nonce)) >= pow.difficulty {
72+
return Some(nonce);
73+
}
74+
}
75+
tracing::warn!(
76+
difficulty = pow.difficulty,
77+
max_iters = MAX_ITERS,
78+
"iCaptcha PoW not solved within iteration cap"
79+
);
80+
None
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
87+
fn pow(challenge: &str, difficulty: u32) -> PowChallenge {
88+
PowChallenge {
89+
algorithm: ALGORITHM.to_string(),
90+
challenge: challenge.to_string(),
91+
difficulty,
92+
}
93+
}
94+
95+
#[test]
96+
fn leading_zero_bits_counts_across_bytes() {
97+
assert_eq!(leading_zero_bits(&[0xff]), 0);
98+
assert_eq!(leading_zero_bits(&[0x7f]), 1);
99+
assert_eq!(leading_zero_bits(&[0x01]), 7);
100+
assert_eq!(leading_zero_bits(&[0x00, 0xff]), 8);
101+
assert_eq!(leading_zero_bits(&[0x00, 0x01]), 15);
102+
}
103+
104+
#[test]
105+
fn solves_and_the_solution_verifies() {
106+
let p = pow("challenge-abc", 12);
107+
let nonce = solve(&p).expect("should solve a 12-bit target");
108+
assert!(leading_zero_bits(&pow_hash(&p.challenge, &nonce)) >= 12);
109+
}
110+
111+
#[test]
112+
fn solution_is_bound_to_the_challenge() {
113+
// A nonce solving one challenge should (overwhelmingly) not solve another
114+
// at the same difficulty — the work is challenge-specific.
115+
let nonce = solve(&pow("challenge-A", 12)).unwrap();
116+
assert!(leading_zero_bits(&pow_hash("challenge-B", &nonce)) < 12);
117+
}
118+
119+
#[test]
120+
fn rejects_unknown_algorithm() {
121+
let mut p = pow("c", 8);
122+
p.algorithm = "scrypt".to_string();
123+
assert!(solve(&p).is_none());
124+
}
125+
126+
#[test]
127+
fn zero_difficulty_is_trivially_solved() {
128+
assert_eq!(solve(&pow("c", 0)).as_deref(), Some("0"));
129+
}
130+
}

0 commit comments

Comments
 (0)