Rin Cat (鈴猫) c2a8d77a12 Use new algorithm
Signed-off-by: Rin Cat (鈴猫) <rincat@rincat.dev>
2026-05-05 09:42:17 +09:00
2026-05-05 09:42:17 +09:00
2025-02-10 02:04:47 -05:00
2026-05-05 09:42:17 +09:00
2025-02-10 02:04:47 -05:00
2026-05-05 09:42:17 +09:00

chidori-pow

Anti-bruteforce proof-of-work.

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 different modulus at initialization. Generated moduli use at least 512 bits. The default difficulty is 450000 scheduled RSA work steps. Applications should choose modulus and step counts from their own benchmarks and risk policy.

Challenges are signed with Ed25519. The default builder generates a fresh signing key and RSA factors at process start. Persist or inject keys and factors if restart continuity matters.

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). payload is the bincode-serialized Puzzle bytes, signature is the fixed 64-byte Ed25519 signature suffix, and the signature covers the raw payload bytes.

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);

The library does not interpret binding_data; callers are responsible for canonicalizing it.

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. Do not put passwords or raw secrets in binding_data.

Mismatched binding data fails exactly like an invalid proof-of-work solution. Verification returns false for malformed, expired, replayed, mismatched, or internally unavailable challenges.

Native Example

use chidori_pow::{ChallengerBuilder, solve_challenge};

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,
));

Browser/WASM

The wasm package exports:

solve_challenge(challenge: string, binding_data: Uint8Array): string

The browser/app code is responsible for constructing the same canonical binding_data bytes that the server will later use for verification. Pass an empty Uint8Array when no app-specific binding data is needed.

The browser solver checks the decoded puzzle before solving. It accepts modulus sizes from 512 to 8192 bits and difficulty up to 10000000 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 and prints min/average/p50/max timings.

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 fails closed instead of evicting entries, so replay protection is preserved at the cost of rejecting additional solves until the next rotation.

Description
No description provided
Readme MIT 69 KiB
Languages
Rust 100%