Use new algorithm

Signed-off-by: Rin Cat (鈴猫) <rincat@rincat.dev>
This commit is contained in:
2026-05-05 09:42:17 +09:00
parent 4b71ad4d75
commit c2a8d77a12
8 changed files with 1297 additions and 345 deletions

110
README.md
View File

@@ -1,2 +1,112 @@
# 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
`Result`s. 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:
```rust
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
```rust
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:
```ts
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:
```sh
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.