113 lines
3.7 KiB
Markdown
113 lines
3.7 KiB
Markdown
# 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.
|