- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| src | ||
| .gitignore | ||
| Cargo.toml | ||
| clippy.toml | ||
| LICENSE | ||
| README.md | ||
chidori-pow
Anti-bruteforce proof-of-work.
Requires Rust 1.87 or newer.
Design
The challenge flow uses an RSA trapdoor repeated-squaring puzzle:
- the server generates or loads RSA factors at process start;
- the signed binary challenge payload contains the public modulus and exact difficulty step count;
- the client performs scheduled sequential modular squaring;
- the server verifies cheaply with the private trapdoor.
The default RSA modulus is 2048 bits, and applications may set a size from 2048
to 4096 bits at initialization. Values outside that range are clamped. The
default difficulty is 450000 scheduled RSA work steps and the maximum is
2000000.
Challenges are signed with Ed25519. The default builder generates a fresh signing key and RSA factors at process start. Signing keys and RSA factors can also be supplied to the builder.
ChallengerBuilder::build() and Challenger::issue_challenge() return
Results. Generated factors are clamped to the minimum modulus size.
with_factors(p, q) injects persisted RSA prime factors and validates that they
are distinct primes with a large enough product.
The challenge string is base64url(payload || signature). signature is the
fixed 64-byte Ed25519 signature suffix and covers the raw payload bytes.
The version 1 payload format is fixed:
| Field | Encoding |
|---|---|
| Format marker | CPW followed by byte 0x01 |
| Ticket | big-endian u64 |
| Issued time | big-endian u64 Unix timestamp |
| Difficulty | big-endian u32 |
| Modulus length | big-endian u16 |
| Modulus | unsigned big-endian bytes |
Decoding rejects unknown format versions, truncated fields, trailing bytes, and moduli too large for the length field.
Binding Data
Applications may bind their own opaque bytes into the puzzle without sending those bytes in the challenge:
let solution = solve_challenge(&challenge, app_binding_data);
challenger.verify_challenge(&challenge, &solution, expected_binding_data);
binding_data is opaque and caller-canonicalized.
Recommended binding_data contents are app-specific request context such as flow
name, route, normalized username hash, CSRF token, form nonce, and a versioned
site-specific prefix. It contains non-secret request context.
Mismatched binding data fails exactly like an invalid proof-of-work solution.
Verification returns false for malformed, expired, replayed, mismatched, or
internally unavailable challenges. Challenges timestamped more than 30 seconds
in the future are also rejected.
Native Example
use chidori_pow::{ChallengerBuilder, solve_challenge};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let binding_data = b"site-login-v1\0/login\0user-hash";
let challenger = ChallengerBuilder::new()
.with_modulus_bits(2048)
.with_difficulty(450_000)
.build()?;
let challenge = challenger.issue_challenge()?;
let solution = solve_challenge(&challenge, binding_data);
assert!(challenger.verify_challenge(
&challenge,
&solution,
binding_data,
));
Ok(())
}
Browser/WASM
The wasm package exports:
solve_challenge(challenge: string, binding_data: Uint8Array): string
The browser/app constructs the same canonical binding_data bytes used by the
server for verification. An empty Uint8Array represents no app-specific
binding data.
The browser solver checks the decoded puzzle before solving. It accepts modulus
sizes from 2048 to 4096 bits and difficulty up to 2000000 scheduled steps.
Benchmark
Run the native benchmark with:
cargo run --release --features native-bin --bin bench -- \
--modulus-bits 2048 \
--difficulty 450000 \
--rounds 5 \
--binding login:user=a
The benchmark builds one challenger, then measures repeated issue/solve/verify rounds, prints min/average/p50/max timings, and measures the early replay-rejection path over 1,000 attempts.
Replay Cache
Solved challenge tickets are remembered for the current and previous validity
windows. The default cache capacity is 250000 tickets per window. When the
current window is full, verification rejects additional solves until the next
rotation.
Applications apply per-client, per-challenge, and service-wide limits to challenge issuance and all verification attempts, including failures, before calling this library.