This commit is contained in:
dela
2026-03-11 14:21:26 +08:00
parent f923257af6
commit 502307cbcd
14 changed files with 37495 additions and 125 deletions

View File

@@ -5,3 +5,108 @@ pub mod gift256;
pub mod hash;
pub mod solver;
pub mod feistel;
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
struct JwtPayload {
#[serde(default)]
f: u32,
#[serde(default)]
s: u32,
#[serde(default)]
t: String,
d: String,
#[serde(default)]
l: String,
#[serde(default)]
i: String,
#[serde(default)]
e: u64,
#[serde(default)]
n: String,
#[serde(default)]
c: u32,
}
/// Takes a raw JWT string, runs the full PoW pipeline, returns the base64 proof.
pub fn solve_jwt(jwt_req: &str) -> Result<String, String> {
// 1. Decode JWT payload (base64url, no signature verification)
let parts: Vec<&str> = jwt_req.split('.').collect();
if parts.len() < 2 {
return Err("Invalid JWT format".into());
}
let payload_b64 = parts[1];
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|e| format!("Invalid base64 in JWT payload: {e}"))?;
let payload: JwtPayload = serde_json::from_slice(&payload_bytes)
.map_err(|e| format!("Invalid JSON in JWT payload: {e}"))?;
// 2. Decode challenge data from `d` field
let challenge_data = if let Some(eq_pos) = payload.d.find('=') {
let split_pos = eq_pos + payload.d[eq_pos..].chars().take_while(|&c| c == '=').count();
let part1 = &payload.d[..split_pos];
let part2 = &payload.d[split_pos..];
let mut data = STANDARD.decode(part1)
.map_err(|e| format!("Invalid base64 in challenge part1: {e}"))?;
if !part2.is_empty() {
let p2 = STANDARD.decode(part2).unwrap_or_else(|_| {
let padded = format!("{}{}", part2, "=".repeat((4 - part2.len() % 4) % 4));
STANDARD.decode(&padded).expect("Invalid base64 in challenge part2")
});
data.extend_from_slice(&p2);
}
data
} else {
STANDARD.decode(&payload.d)
.map_err(|e| format!("Invalid base64 in challenge data: {e}"))?
};
// 3. Split into encrypted_payload and seed_bytes
let (encrypted_payload, seed_bytes) = if let Some(eq_pos) = payload.d.find('=') {
let split_pos = eq_pos + payload.d[eq_pos..].chars().take_while(|&c| c == '=').count();
let part1_len = STANDARD.decode(&payload.d[..split_pos])
.map_err(|e| format!("Invalid base64 in part1: {e}"))?.len();
(&challenge_data[..part1_len], &challenge_data[part1_len..])
} else {
(challenge_data.as_slice(), &[] as &[u8])
};
// 4. Feistel decrypt
if encrypted_payload.len() < 33 {
return Err(format!(
"Encrypted payload too short: {} bytes (need >= 33)",
encrypted_payload.len()
));
}
let (key_material, _control) = feistel::feistel_decrypt(encrypted_payload, 0);
let difficulty = if payload.c > 0 { payload.c } else { 1000 };
let challenge = solver::Challenge {
key_material,
difficulty,
};
// 5. Derive PCG seed from seed_bytes
let seed = if seed_bytes.len() >= 8 {
u64::from_le_bytes([
seed_bytes[0], seed_bytes[1], seed_bytes[2], seed_bytes[3],
seed_bytes[4], seed_bytes[5], seed_bytes[6], seed_bytes[7],
])
} else {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64
};
// 6. Solve and encode
let proof = solver::solve(&challenge, seed);
Ok(STANDARD.encode(&proof))
}

View File

@@ -1,132 +1,28 @@
//! hCaptcha PoW solver CLI
//! Parses JWT challenge -> calls solver -> outputs base64 proof
use base64::{engine::general_purpose::STANDARD, Engine};
use axum::{extract::Json, http::StatusCode, response::IntoResponse, routing::post, Router};
use serde::Deserialize;
mod pcg;
mod sbox;
mod gift256;
mod hash;
mod solver;
mod util;
mod feistel;
#[derive(Deserialize)]
#[allow(dead_code)]
struct JwtPayload {
#[serde(default)]
f: u32,
#[serde(default)]
s: u32,
#[serde(default)]
t: String,
d: String,
#[serde(default)]
l: String,
#[serde(default)]
i: String,
#[serde(default)]
e: u64,
#[serde(default)]
n: String,
#[serde(default)]
c: u32,
struct SolveRequest {
jwt: String,
}
fn main() {
// 1. Read JWT from command line
let jwt_req = std::env::args().nth(1).expect("Usage: hcaptcha-pow <jwt>");
// 2. Decode JWT payload (base64url, no signature verification)
let parts: Vec<&str> = jwt_req.split('.').collect();
if parts.len() < 2 {
eprintln!("Invalid JWT format");
std::process::exit(1);
async fn solve_handler(Json(req): Json<SolveRequest>) -> impl IntoResponse {
let jwt = req.jwt;
match tokio::task::spawn_blocking(move || hcaptcha_pow::solve_jwt(&jwt)).await {
Ok(Ok(proof)) => (StatusCode::OK, Json(serde_json::json!({ "n": proof }))),
Ok(Err(e)) => (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e }))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Task panicked: {e}") })),
),
}
let payload_b64 = parts[1];
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.expect("Invalid base64 in JWT payload");
let payload: JwtPayload = serde_json::from_slice(&payload_bytes).expect("Invalid JSON in JWT payload");
println!("Algorithm: {}", payload.n);
println!("Difficulty: {}", payload.c);
println!("Expiration: {}", payload.e);
// 3. Decode challenge data from `d` field
// The `d` field is two concatenated base64 strings:
// encrypted_payload (with = padding) + seed (no padding)
// Split at the padding boundary and decode each part separately.
let challenge_data = if let Some(eq_pos) = payload.d.find('=') {
let split_pos = eq_pos + payload.d[eq_pos..].chars().take_while(|&c| c == '=').count();
let part1 = &payload.d[..split_pos];
let part2 = &payload.d[split_pos..];
let mut data = STANDARD.decode(part1).expect("Invalid base64 in challenge part1");
if !part2.is_empty() {
let p2 = STANDARD.decode(part2).unwrap_or_else(|_| {
// part2 may lack padding — add it
let padded = format!("{}{}", part2, "=".repeat((4 - part2.len() % 4) % 4));
STANDARD.decode(&padded).expect("Invalid base64 in challenge part2")
});
data.extend_from_slice(&p2);
}
data
} else {
STANDARD.decode(&payload.d).expect("Invalid base64 in challenge data")
};
// Split challenge_data into encrypted_payload (part1) and seed_bytes (part2)
// Part1 = first decode (203 bytes), Part2 = second decode (12 bytes)
let (encrypted_payload, seed_bytes) = if let Some(eq_pos) = payload.d.find('=') {
let split_pos = eq_pos + payload.d[eq_pos..].chars().take_while(|&c| c == '=').count();
let part1_len = STANDARD.decode(&payload.d[..split_pos])
.expect("Invalid base64 in part1").len();
(&challenge_data[..part1_len], &challenge_data[part1_len..])
} else {
(challenge_data.as_slice(), &[] as &[u8])
};
println!("Encrypted payload: {} bytes, Seed: {} bytes", encrypted_payload.len(), seed_bytes.len());
// 4. Feistel decrypt 33 bytes → 32 byte key + 1 byte control
if encrypted_payload.len() < 33 {
eprintln!("Encrypted payload too short: {} bytes (need >= 33)", encrypted_payload.len());
std::process::exit(1);
}
let (key_material, control) = feistel::feistel_decrypt(encrypted_payload, 0);
println!("Feistel decrypted: key[0..4]={:02x}{:02x}{:02x}{:02x}, control=0x{:02x}",
key_material[0], key_material[1], key_material[2], key_material[3], control);
let difficulty = if payload.c > 0 { payload.c } else { 1000 };
let challenge = solver::Challenge {
key_material,
difficulty,
};
// 5. Derive PCG seed from seed_bytes (part2 of d)
let seed = if seed_bytes.len() >= 8 {
u64::from_le_bytes([
seed_bytes[0], seed_bytes[1], seed_bytes[2], seed_bytes[3],
seed_bytes[4], seed_bytes[5], seed_bytes[6], seed_bytes[7],
])
} else {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64
};
println!("PCG seed: 0x{:016x}", seed);
println!("Running {} iterations...", difficulty);
let proof = solver::solve(&challenge, seed);
// 6. Output base64-encoded proof
let proof_b64 = STANDARD.encode(&proof);
println!("Proof size: {} bytes ({} digests)", proof.len(), proof.len() / 16);
println!("n={}", proof_b64);
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/solve", post(solve_handler));
let addr = "0.0.0.0:3000";
println!("hcaptcha-pow service listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}