Awesome
spow - Proof of Work for Server and Client + WASM
Why?
Everyone loves captcha's right? Well, not that much. They are absolutely annoying and often
even humans cannot solve them, because something is just on the edge or the server on the
other side simply thinks you're stupid and gives you another one.
They provide an absolutely awful UX and the only reason they exist is to prevent spammers.
Proof of Work's (PoW) solve kind of the same task, but without any real impact on the UX, they work differently. Instead of making the user solve a puzzle, they make your computer solve it for you. The user has nothing else to do than wait a small amount of time until the puzzle has been solved automatically.
The idea behind it is that it is just way too costly for any bots and spammers to do this for a single action. If you are just a normal person using an App or a web page, you don't mind and will probably not even notice this small amount of work your computer has to do now and then, but if you are a spammer and are doing this on millions of Apps at the same time, it will simply not be possible.
There are really sophisticated solutions to this out there, like
Cloudflare Turnstile which I really like.
This tiny crate however makes it possible to get the same UX while being able to easily
self-host it and just integrate it into your API without any external dependencies. You don't
even need to run another service on the side. You can either implement the PoW creation into
your Rust backend directly by using the spow
crate, or since v0.4.0
you can find a server
side prebuilt WASM in prebuilt/server/wasm
. The server WASM can not only solve, but also
create and validate PoWs.
How?
In the backend, it could not be much simpler:
- At application startup, you need to call either
Pow::init()
orPow::init_random()
once. This will initialize the secret which is used to sign the challenges, so you know if a challenge actually came from your backend, or if someone else has just generated one himself. - If you want to create a new challenge for a client at some point, you then only need to
create a new PoW with either
Pow::new()
orPow::with_difficulty()
. This will create a random, unique PoW, which can be converted into a challenge for the client with either.build_challenge()
or.to_string()
. - Send the challenge string to the client
- The client needs to do some work to solve that puzzle. To do that, you can use a wasm
or JS-only version. These are in the
prebuilt/browser/
folder and can be used directly in any frontend. If you do not have a really good reason not to, you should always use the wasm version, because it is up to ~20 times faster than JS only. This means you can use higher difficulties with no impact on the UX. - When you get back a solved puzzle from a client, just
Pow::validate()
it. This will ...- validate the version tag
- validate the difficulty (must be 10 - 99)
- validate the expiry of the PoW (set during
Pow::new()
) - validate that the challenge actually came from the same backend and has not been changed
- validate the puzzle result itself using the given difficulty
... that's it.
Under the hood, spow
uses a modified, extended version of the Hashcat
PoW algorithm,
which is being used for instance for the Bitcoin blockchain as well.
Difficulty
The difficulty is an int between 10 and 99. The default is 20
.
With each step, the time to solve the puzzle will increase exponentially, while the time to
validate it in the backend will always stay the same and is independent of the difficulty.
How high you want to set this greatly depends on your use case, where your app will be running (powerful desktop machines, laptops, or an app on some smartphone, ...) and how much you want to simply make the user wait.
The way these puzzles work is, you can get very lucky and solve it quickly, or have bad luck
and need way longer than the median value. All of this depends on the random values of the
PoW during creation. To find a good value, you should test a lot of different PoW's preferably
on your target system to find a good median time.
This project has a bin target which does exactly that, but actually in the backend. You can
run it with for instance cargo run --release 20 100
and it will solve 100 randomly generated
puzzles with difficulty 20, and at the end print out the median time. Please keep in mind though
that the backend code is much more performant than the one inside the browser. In first tests,
the wasm version in the browser needs roughly 2.5 - 3 times the amount of time, that is needed
in the backend in optimized, pure rust code. The JS version is ~20 times slower than wasm.
The examples
folder contains a tiny frontend demo using Svelte. It uses a hardcoded
challenge though, and it only shows how to solve a puzzle at this time. It does not provide
a good indicator for a median time needed to solve puzzles with these difficulties.