This is the canonical source repository. Please report issues and submit pull requests through the GitHub repository. https://github.com/RinCatShrine/chidori-pow
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Rin Cat (鈴猫) 3167ae9653
fix: remove unmaintained dependencies and improve speed
Signed-off-by: Rin Cat (鈴猫) <rincat@rincat.dev>
2026-08-21 20:45:00 +09:00
src fix: remove unmaintained dependencies and improve speed 2026-08-21 20:45:00 +09:00
.gitignore init 2025-02-10 02:04:47 -05:00
Cargo.toml fix: remove unmaintained dependencies and improve speed 2026-08-21 20:45:00 +09:00
clippy.toml fix: remove unmaintained dependencies and improve speed 2026-08-21 20:45:00 +09:00
LICENSE init 2025-02-10 02:04:47 -05:00
README.md fix: remove unmaintained dependencies and improve speed 2026-08-21 20:45:00 +09:00

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.