easypow 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- easypow-0.1.0/.gitignore +16 -0
- easypow-0.1.0/PKG-INFO +99 -0
- easypow-0.1.0/README.md +82 -0
- easypow-0.1.0/c/solve.c +76 -0
- easypow-0.1.0/dist/.gitignore +1 -0
- easypow-0.1.0/easypow/__init__.py +10 -0
- easypow-0.1.0/easypow/__main__.py +89 -0
- easypow-0.1.0/easypow/easypow.js +19 -0
- easypow-0.1.0/easypow/pow.py +67 -0
- easypow-0.1.0/pyproject.toml +61 -0
- easypow-0.1.0/tests/test_cross_language.py +75 -0
- easypow-0.1.0/tests/test_pow.py +101 -0
easypow-0.1.0/.gitignore
ADDED
easypow-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: easypow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Antibot Proof-of-Work protection to verify browsers on web apps.
|
|
5
|
+
Project-URL: Repository, https://git.zi.fi/LeoVasanko/easypow
|
|
6
|
+
Project-URL: Issues, https://github.com/LeoVasanko/easypow
|
|
7
|
+
Author: Leo Vasanko
|
|
8
|
+
License-Expression: MIT OR Unlicense
|
|
9
|
+
Keywords: antibot,proof-of-work,web-security
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: base64url>=1.1.1
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Easy PoW for Web Apps
|
|
19
|
+
|
|
20
|
+
A defense for web services against bots and other spam. A proof-of-work CAPTCHA asks a browser to perform a small amount of computation before a request is accepted. This can be used to reduce automated abuse by making high-volume requests more costly to them. EasyPoW is intended for Python backends that issue proof-of-work challenges to browser clients and verify the returned solutions.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
uv add easypow
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The basic flow is that the server creates a challenge, that is sent to client, and the client responds with the solution, and after validation we allow their original request to proceed.
|
|
27
|
+
|
|
28
|
+
## Python backend
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import easypow
|
|
32
|
+
|
|
33
|
+
# Within your WebSocket handler:
|
|
34
|
+
challenge = easypow.generate(5.0) # ~seconds to solve
|
|
35
|
+
await ws.send_text(challenge)
|
|
36
|
+
# Client runs JS: solution = await solve(challenge)
|
|
37
|
+
solution = await ws.receive_text()
|
|
38
|
+
easypow.validate(challenge, solution) # raises ValueError if invalid
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The same can be adapted to any protocol you have, of course, while maintaining some way of keeping the challenge on server, making sure that clients cannot use the same one more than once. WebSockets are particularly useful because we can simply keep challenge in a local variable and do the dance only when the socket is first connected, and then continuing without any further verifications through the ordinary chat your application would do.
|
|
42
|
+
|
|
43
|
+
Also useful is to start calculating in the background, and submit a response with the next command that requires PoW. This way the verification can happen without disturbing the user, if he spends at least that time filling up a form or whatever activity it is you wish to protect.
|
|
44
|
+
|
|
45
|
+
## JavaScript solver
|
|
46
|
+
|
|
47
|
+
Download [easypow.js](https://git.zi.fi/LeoVasanko/easypow/raw/branch/main/easypow/easypow.js) module to your project. If needed programmatically, this is also available from the Python module as `easypow.js` string or via CLI.
|
|
48
|
+
|
|
49
|
+
It provides `solve` both as a named and as the default export:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import solve from './easypow.js'
|
|
53
|
+
|
|
54
|
+
const solution = await solve(challenge) // optional AbortSignal as 2nd arg
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Plain function version
|
|
58
|
+
|
|
59
|
+
If it better suits you, the entire solution function can be copied from here to where ever you need it.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
async function solve(challenge, signal) {
|
|
63
|
+
const B64URL = { alphabet: 'base64url', omitPadding: true }
|
|
64
|
+
const c = Uint8Array.fromBase64(challenge, B64URL)
|
|
65
|
+
const key = await crypto.subtle.importKey('raw', c, 'PBKDF2', false, ['deriveBits'])
|
|
66
|
+
const salt = new Uint32Array(1)
|
|
67
|
+
const args = { name: 'PBKDF2', salt, iterations: 1 << (c[1] & 0xF), hash: 'SHA-512' }
|
|
68
|
+
const mask = (1 << (c[1] >> 4)) - 1
|
|
69
|
+
const fragments = []
|
|
70
|
+
for (let w=c[0]; w-->0;) {
|
|
71
|
+
if (signal?.aborted) throw new DOMException('PoW operation aborted', 'AbortError')
|
|
72
|
+
do {
|
|
73
|
+
++salt[0]
|
|
74
|
+
} while (new Uint32Array(await crypto.subtle.deriveBits(args, key, 32))[0] & mask)
|
|
75
|
+
fragments.push(new Uint8Array(salt.buffer).toBase64(B64URL).replace(/A+$/, ''))
|
|
76
|
+
}
|
|
77
|
+
return fragments.join('.')
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## CLI
|
|
82
|
+
|
|
83
|
+
Run via `python -m easypow <command> <args>`. Supported commands are **generate**, **validate**, **solve** and **js**, corresponding to the Python API. No console scripts are installed because this module is mainly intended to be used via Python API by other software that might not want such pollution.
|
|
84
|
+
|
|
85
|
+
## Design principles
|
|
86
|
+
|
|
87
|
+
Many proof-of-work schemes use an exponential difficulty measure: for example, the number of initial zero bits required in a hash. Each additional bit roughly doubles the expected work, which gives rather coarse control over difficulty. The search is also inherently random and memoryless. If a challenge is expected to take 5 seconds and 10 seconds have already passed without a solution, the expected remaining time is still about 5 seconds. An unlucky solve can therefore take much longer than intended.
|
|
88
|
+
|
|
89
|
+
To make the work both finer-grained and more predictable, we use smaller rounds and require several of them to be solved. A basic round takes about 0.1 seconds on the target machine, but with large random variation. Requiring 10 independent rounds still gives about 1 second of expected work, while averaging out much of that randomness: the total time is considerably more concentrated around 1 second than a single harder 1-second search. The solution contains the nonce found for each round, and the server verifies them individually.
|
|
90
|
+
|
|
91
|
+
This also separates two useful controls. The zero-bit requirement determines the asymmetry between solving and verification, while the number of required rounds controls total work approximately linearly. Difficulty can therefore be adjusted in small time increments without weakening the basic solve/verify ratio.
|
|
92
|
+
|
|
93
|
+
Verification itself must be cheap in two ways. First, invalid submissions must not be able to consume substantial server resources: an attacker can always send arbitrary solutions without doing any work. Second, producing a valid solution must be much more expensive than checking one.
|
|
94
|
+
|
|
95
|
+
Checking a candidate still requires performing the underlying cryptographic operation, so that operation cannot be made arbitrarily expensive. Conversely, if it is made too cheap and the difficulty is moved entirely into requiring more zero bits, JavaScript overhead and other supporting machinery begin to dominate, making browser performance much worse than native code. The implementation therefore uses PBKDF2-SHA512 through Web Crypto with 128 iterations. That would be extremely low for password hashing, but here it places each trial in a useful range: expensive enough that native cryptographic execution dominates, while still cheap for the server to verify.
|
|
96
|
+
|
|
97
|
+
With the minimal zero-bit requirement, producing a valid round costs 2048 times as much expected work as verifying one candidate. Because a complete solution consists of a sequence of valid rounds, the server can stop at the first failure. Random or fabricated submissions therefore normally cost only a single cheap verification, while a valid solution requires the client to have paid the full proof-of-work cost. Larger work factors increase this asymmetry further because we require a higher number of zero bits while keeping the round count restrained.
|
|
98
|
+
|
|
99
|
+
Finally, we wish the function be abortable, while allowing the browser to keep running. AbortSignals produce a useful mechanism for external termination, but checking for the signal is expensive. The current implementation checks this between rounds only, and for that reason also we wish to keep the basic round fairly quick. This scales well from 0.1s to a few minutes expected cost in the current implementation. For the sake of simplicity, we did not implement further break points or infinite scaling.
|
easypow-0.1.0/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Easy PoW for Web Apps
|
|
2
|
+
|
|
3
|
+
A defense for web services against bots and other spam. A proof-of-work CAPTCHA asks a browser to perform a small amount of computation before a request is accepted. This can be used to reduce automated abuse by making high-volume requests more costly to them. EasyPoW is intended for Python backends that issue proof-of-work challenges to browser clients and verify the returned solutions.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
uv add easypow
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The basic flow is that the server creates a challenge, that is sent to client, and the client responds with the solution, and after validation we allow their original request to proceed.
|
|
10
|
+
|
|
11
|
+
## Python backend
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import easypow
|
|
15
|
+
|
|
16
|
+
# Within your WebSocket handler:
|
|
17
|
+
challenge = easypow.generate(5.0) # ~seconds to solve
|
|
18
|
+
await ws.send_text(challenge)
|
|
19
|
+
# Client runs JS: solution = await solve(challenge)
|
|
20
|
+
solution = await ws.receive_text()
|
|
21
|
+
easypow.validate(challenge, solution) # raises ValueError if invalid
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The same can be adapted to any protocol you have, of course, while maintaining some way of keeping the challenge on server, making sure that clients cannot use the same one more than once. WebSockets are particularly useful because we can simply keep challenge in a local variable and do the dance only when the socket is first connected, and then continuing without any further verifications through the ordinary chat your application would do.
|
|
25
|
+
|
|
26
|
+
Also useful is to start calculating in the background, and submit a response with the next command that requires PoW. This way the verification can happen without disturbing the user, if he spends at least that time filling up a form or whatever activity it is you wish to protect.
|
|
27
|
+
|
|
28
|
+
## JavaScript solver
|
|
29
|
+
|
|
30
|
+
Download [easypow.js](https://git.zi.fi/LeoVasanko/easypow/raw/branch/main/easypow/easypow.js) module to your project. If needed programmatically, this is also available from the Python module as `easypow.js` string or via CLI.
|
|
31
|
+
|
|
32
|
+
It provides `solve` both as a named and as the default export:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import solve from './easypow.js'
|
|
36
|
+
|
|
37
|
+
const solution = await solve(challenge) // optional AbortSignal as 2nd arg
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Plain function version
|
|
41
|
+
|
|
42
|
+
If it better suits you, the entire solution function can be copied from here to where ever you need it.
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
async function solve(challenge, signal) {
|
|
46
|
+
const B64URL = { alphabet: 'base64url', omitPadding: true }
|
|
47
|
+
const c = Uint8Array.fromBase64(challenge, B64URL)
|
|
48
|
+
const key = await crypto.subtle.importKey('raw', c, 'PBKDF2', false, ['deriveBits'])
|
|
49
|
+
const salt = new Uint32Array(1)
|
|
50
|
+
const args = { name: 'PBKDF2', salt, iterations: 1 << (c[1] & 0xF), hash: 'SHA-512' }
|
|
51
|
+
const mask = (1 << (c[1] >> 4)) - 1
|
|
52
|
+
const fragments = []
|
|
53
|
+
for (let w=c[0]; w-->0;) {
|
|
54
|
+
if (signal?.aborted) throw new DOMException('PoW operation aborted', 'AbortError')
|
|
55
|
+
do {
|
|
56
|
+
++salt[0]
|
|
57
|
+
} while (new Uint32Array(await crypto.subtle.deriveBits(args, key, 32))[0] & mask)
|
|
58
|
+
fragments.push(new Uint8Array(salt.buffer).toBase64(B64URL).replace(/A+$/, ''))
|
|
59
|
+
}
|
|
60
|
+
return fragments.join('.')
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## CLI
|
|
65
|
+
|
|
66
|
+
Run via `python -m easypow <command> <args>`. Supported commands are **generate**, **validate**, **solve** and **js**, corresponding to the Python API. No console scripts are installed because this module is mainly intended to be used via Python API by other software that might not want such pollution.
|
|
67
|
+
|
|
68
|
+
## Design principles
|
|
69
|
+
|
|
70
|
+
Many proof-of-work schemes use an exponential difficulty measure: for example, the number of initial zero bits required in a hash. Each additional bit roughly doubles the expected work, which gives rather coarse control over difficulty. The search is also inherently random and memoryless. If a challenge is expected to take 5 seconds and 10 seconds have already passed without a solution, the expected remaining time is still about 5 seconds. An unlucky solve can therefore take much longer than intended.
|
|
71
|
+
|
|
72
|
+
To make the work both finer-grained and more predictable, we use smaller rounds and require several of them to be solved. A basic round takes about 0.1 seconds on the target machine, but with large random variation. Requiring 10 independent rounds still gives about 1 second of expected work, while averaging out much of that randomness: the total time is considerably more concentrated around 1 second than a single harder 1-second search. The solution contains the nonce found for each round, and the server verifies them individually.
|
|
73
|
+
|
|
74
|
+
This also separates two useful controls. The zero-bit requirement determines the asymmetry between solving and verification, while the number of required rounds controls total work approximately linearly. Difficulty can therefore be adjusted in small time increments without weakening the basic solve/verify ratio.
|
|
75
|
+
|
|
76
|
+
Verification itself must be cheap in two ways. First, invalid submissions must not be able to consume substantial server resources: an attacker can always send arbitrary solutions without doing any work. Second, producing a valid solution must be much more expensive than checking one.
|
|
77
|
+
|
|
78
|
+
Checking a candidate still requires performing the underlying cryptographic operation, so that operation cannot be made arbitrarily expensive. Conversely, if it is made too cheap and the difficulty is moved entirely into requiring more zero bits, JavaScript overhead and other supporting machinery begin to dominate, making browser performance much worse than native code. The implementation therefore uses PBKDF2-SHA512 through Web Crypto with 128 iterations. That would be extremely low for password hashing, but here it places each trial in a useful range: expensive enough that native cryptographic execution dominates, while still cheap for the server to verify.
|
|
79
|
+
|
|
80
|
+
With the minimal zero-bit requirement, producing a valid round costs 2048 times as much expected work as verifying one candidate. Because a complete solution consists of a sequence of valid rounds, the server can stop at the first failure. Random or fabricated submissions therefore normally cost only a single cheap verification, while a valid solution requires the client to have paid the full proof-of-work cost. Larger work factors increase this asymmetry further because we require a higher number of zero bits while keeping the round count restrained.
|
|
81
|
+
|
|
82
|
+
Finally, we wish the function be abortable, while allowing the browser to keep running. AbortSignals produce a useful mechanism for external termination, but checking for the signal is expensive. The current implementation checks this between rounds only, and for that reason also we wish to keep the basic round fairly quick. This scales well from 0.1s to a few minutes expected cost in the current implementation. For the sake of simplicity, we did not implement further break points or infinite scaling.
|
easypow-0.1.0/c/solve.c
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* Proof-of-Work solver using PBKDF2-HMAC-SHA512, port of solver.js.
|
|
2
|
+
*
|
|
3
|
+
* Usage: solve <challenge>
|
|
4
|
+
* challenge: 12-char base64url string decoding to 9 bytes:
|
|
5
|
+
* [0] work w, [1] difficulty (high nibble z = zero bits,
|
|
6
|
+
* low nibble n = iteration exponent), [2..8] random.
|
|
7
|
+
* Prints the dot-separated base64url solution.
|
|
8
|
+
*
|
|
9
|
+
* Build: cc -O2 -o solve solve.c -lcrypto
|
|
10
|
+
*/
|
|
11
|
+
#include <stdint.h>
|
|
12
|
+
#include <stdio.h>
|
|
13
|
+
#include <stdlib.h>
|
|
14
|
+
#include <string.h>
|
|
15
|
+
#include <openssl/evp.h>
|
|
16
|
+
|
|
17
|
+
static const char B64[] =
|
|
18
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
19
|
+
|
|
20
|
+
static size_t b64url_decode(const char *in, uint8_t *out) {
|
|
21
|
+
size_t len = strlen(in), n = 0;
|
|
22
|
+
uint32_t acc = 0;
|
|
23
|
+
int bits = 0;
|
|
24
|
+
for (size_t i = 0; i < len; i++) {
|
|
25
|
+
const char *p = strchr(B64, in[i]);
|
|
26
|
+
if (!p) { fprintf(stderr, "invalid base64url\n"); exit(1); }
|
|
27
|
+
acc = (acc << 6) | (uint32_t)(p - B64);
|
|
28
|
+
bits += 6;
|
|
29
|
+
if (bits >= 8) { bits -= 8; out[n++] = (uint8_t)(acc >> bits); }
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static void b64url_encode(const uint8_t *in, size_t len, char *out) {
|
|
35
|
+
size_t n = 0;
|
|
36
|
+
uint32_t acc = 0;
|
|
37
|
+
int bits = 0;
|
|
38
|
+
for (size_t i = 0; i < len; i++) {
|
|
39
|
+
acc = (acc << 8) | in[i];
|
|
40
|
+
bits += 8;
|
|
41
|
+
while (bits >= 6) { bits -= 6; out[n++] = B64[(acc >> bits) & 0x3F]; }
|
|
42
|
+
}
|
|
43
|
+
if (bits) out[n++] = B64[(acc << (6 - bits)) & 0x3F];
|
|
44
|
+
out[n] = '\0';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
int main(int argc, char **argv) {
|
|
48
|
+
if (argc != 2) { fprintf(stderr, "usage: solve <challenge>\n"); return 1; }
|
|
49
|
+
|
|
50
|
+
uint8_t c[9];
|
|
51
|
+
if (b64url_decode(argv[1], c) != 9) {
|
|
52
|
+
fprintf(stderr, "challenge must decode to 9 bytes\n");
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
unsigned w = c[0], z = c[1] >> 4, iterations = 1u << (c[1] & 0xF);
|
|
56
|
+
uint32_t mask = (1u << z) - 1;
|
|
57
|
+
|
|
58
|
+
uint32_t nonce = 0;
|
|
59
|
+
int first = 1;
|
|
60
|
+
while (w--) {
|
|
61
|
+
uint8_t digest[4];
|
|
62
|
+
do {
|
|
63
|
+
nonce++;
|
|
64
|
+
PKCS5_PBKDF2_HMAC((const char *)c, 9, (uint8_t *)&nonce, 4,
|
|
65
|
+
(int)iterations, EVP_sha512(), 4, digest);
|
|
66
|
+
} while (*(uint32_t *)digest & mask);
|
|
67
|
+
char frag[7];
|
|
68
|
+
b64url_encode((uint8_t *)&nonce, 4, frag);
|
|
69
|
+
size_t len = strlen(frag);
|
|
70
|
+
while (len && frag[len - 1] == 'A') frag[--len] = '\0';
|
|
71
|
+
printf("%s%s", first ? "" : ".", frag);
|
|
72
|
+
first = 0;
|
|
73
|
+
}
|
|
74
|
+
printf("\n");
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Proof-of-Work captcha using PBKDF2-SHA512, with a bundled JavaScript solver."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
from easypow.pow import generate, solve, validate
|
|
6
|
+
|
|
7
|
+
__all__ = ["generate", "js", "solve", "validate"]
|
|
8
|
+
|
|
9
|
+
js: str = files("easypow").joinpath("easypow.js").read_text(encoding="utf-8")
|
|
10
|
+
"""Source of the bundled JavaScript PoW solver module."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Command-line interface: python -m easypow <command>."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from easypow import generate, js, solve, validate
|
|
7
|
+
|
|
8
|
+
USAGE = """\
|
|
9
|
+
usage: python -m easypow <command> [args]
|
|
10
|
+
|
|
11
|
+
Commands (unambiguous prefixes accepted):
|
|
12
|
+
generate [seconds] Generate a challenge (default 1.0s of work)
|
|
13
|
+
solve <challenge> Solve a challenge
|
|
14
|
+
validate <challenge> <sol.> Validate a solution (exit 1 if invalid)
|
|
15
|
+
js [filename] Dump the JS solver module (stdout or file)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
_COMMANDS = ("generate", "solve", "validate", "js")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _match(command: str) -> str:
|
|
22
|
+
"""Match a command by unambiguous prefix."""
|
|
23
|
+
if command in _COMMANDS:
|
|
24
|
+
return command
|
|
25
|
+
matches = [c for c in _COMMANDS if c.startswith(command)]
|
|
26
|
+
if len(matches) == 1:
|
|
27
|
+
return matches[0]
|
|
28
|
+
msg = f"Unknown or ambiguous command: {command!r}"
|
|
29
|
+
raise ValueError(msg)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cmd_generate(args: list[str]) -> None:
|
|
33
|
+
if len(args) > 1:
|
|
34
|
+
msg = "generate takes at most one argument: [seconds]"
|
|
35
|
+
raise ValueError(msg)
|
|
36
|
+
print(generate(seconds=float(args[0])) if args else generate())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cmd_solve(args: list[str]) -> None:
|
|
40
|
+
if len(args) != 1:
|
|
41
|
+
msg = "solve takes exactly one argument: <challenge>"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
print(solve(args[0]))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd_validate(args: list[str]) -> int:
|
|
47
|
+
if len(args) != 2:
|
|
48
|
+
msg = "validate takes exactly two arguments: <challenge> <solution>"
|
|
49
|
+
raise ValueError(msg)
|
|
50
|
+
try:
|
|
51
|
+
validate(args[0], args[1])
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
print(e, file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cmd_js(args: list[str]) -> None:
|
|
59
|
+
if len(args) > 1:
|
|
60
|
+
msg = "js takes at most one argument: [filename]"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
if args:
|
|
63
|
+
Path(args[0]).write_text(js, encoding="utf-8")
|
|
64
|
+
else:
|
|
65
|
+
print(js)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
"""Run the easypow CLI; return exit code."""
|
|
70
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
71
|
+
if not args or args[0] in ("-h", "--help"):
|
|
72
|
+
print(USAGE, file=sys.stderr if not args else sys.stdout)
|
|
73
|
+
return 0 if args else 2
|
|
74
|
+
try:
|
|
75
|
+
handler = {
|
|
76
|
+
"generate": _cmd_generate,
|
|
77
|
+
"solve": _cmd_solve,
|
|
78
|
+
"validate": _cmd_validate,
|
|
79
|
+
"js": _cmd_js,
|
|
80
|
+
}[_match(args.pop(0))]
|
|
81
|
+
result = handler(args)
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
print(e, file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
return result or 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
async function solve(challenge, signal) {
|
|
2
|
+
const B64URL = { alphabet: 'base64url', omitPadding: true }
|
|
3
|
+
const c = Uint8Array.fromBase64(challenge, B64URL)
|
|
4
|
+
const key = await crypto.subtle.importKey('raw', c, 'PBKDF2', false, ['deriveBits'])
|
|
5
|
+
const salt = new Uint32Array(1)
|
|
6
|
+
const args = { name: 'PBKDF2', salt, iterations: 1 << (c[1] & 0xF), hash: 'SHA-512' }
|
|
7
|
+
const mask = (1 << (c[1] >> 4)) - 1
|
|
8
|
+
const fragments = []
|
|
9
|
+
for (let w=c[0]; w-->0;) {
|
|
10
|
+
if (signal?.aborted) throw new DOMException('PoW operation aborted', 'AbortError')
|
|
11
|
+
do {
|
|
12
|
+
++salt[0]
|
|
13
|
+
} while (new Uint32Array(await crypto.subtle.deriveBits(args, key, 32))[0] & mask)
|
|
14
|
+
fragments.push(new Uint8Array(salt.buffer).toBase64(B64URL).replace(/A+$/, ''))
|
|
15
|
+
}
|
|
16
|
+
return fragments.join('.')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { solve, solve as default }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Proof-of-Work captcha using PBKDF2-SHA512.
|
|
2
|
+
|
|
3
|
+
Challenges are 9 bytes — work byte w, difficulty byte (high nibble:
|
|
4
|
+
zero bits z; low nibble: PBKDF2 iteration exponent n), 7 random
|
|
5
|
+
bytes — as 12-char base64url. Solutions are `w` dot-separated 4-byte
|
|
6
|
+
nonces, each base64url (6 chars max) with trailing 'A' zero-bit chars
|
|
7
|
+
stripped.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import secrets
|
|
12
|
+
|
|
13
|
+
import base64url
|
|
14
|
+
|
|
15
|
+
# (max work units, zero bits z, 1<<n iterations, work shift); 1 unit = 0.1s
|
|
16
|
+
TIERS = [
|
|
17
|
+
(31, 11, 7, 0),
|
|
18
|
+
(127, 12, 7, 1),
|
|
19
|
+
(511, 13, 7, 2),
|
|
20
|
+
(2047, 14, 7, 3),
|
|
21
|
+
(4095, 15, 7, 4),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate(seconds: float = 1.0) -> str:
|
|
26
|
+
"""Generate a challenge taking about `seconds` to solve (0.1 to 410)."""
|
|
27
|
+
units = max(0, round(seconds * 10))
|
|
28
|
+
_, z, n, shift = next((t for t in TIERS if units <= t[0]), TIERS[-1])
|
|
29
|
+
w = min(units >> shift, 255)
|
|
30
|
+
return base64url.enc(bytes([w, z << 4 | n]) + secrets.token_bytes(7))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def solve(challenge: str) -> str:
|
|
34
|
+
"""Solve a challenge, takes some time."""
|
|
35
|
+
c, w, mask, iterations = _decode(challenge)
|
|
36
|
+
fragments = []
|
|
37
|
+
n = 0
|
|
38
|
+
for _ in range(w):
|
|
39
|
+
while True:
|
|
40
|
+
n += 1
|
|
41
|
+
nonce = n.to_bytes(4, "little")
|
|
42
|
+
digest = hashlib.pbkdf2_hmac("sha512", c, nonce, iterations, 2)
|
|
43
|
+
if not int.from_bytes(digest[:4], "little") & mask:
|
|
44
|
+
fragments.append(base64url.enc(nonce).rstrip("A"))
|
|
45
|
+
break
|
|
46
|
+
return ".".join(fragments)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate(challenge: str, solution: str) -> None:
|
|
50
|
+
"""Validate a solution; raise ValueError if invalid."""
|
|
51
|
+
c, w, mask, iterations = _decode(challenge)
|
|
52
|
+
fragments = solution.split(".") if solution else []
|
|
53
|
+
if len(fragments) != w or any(len(f) > 6 for f in fragments):
|
|
54
|
+
msg = "Malformed easypow solution"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
for fragment in fragments:
|
|
57
|
+
nonce = base64url.dec(fragment.ljust(6, "A"))
|
|
58
|
+
digest = hashlib.pbkdf2_hmac("sha512", c, nonce, iterations, 2)
|
|
59
|
+
if int.from_bytes(digest[:4], "little") & mask:
|
|
60
|
+
msg = "Invalid easypow solution"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _decode(challenge: str) -> tuple[bytes, int, int, int]:
|
|
65
|
+
c = base64url.dec(challenge)
|
|
66
|
+
w, diff = c[0], c[1]
|
|
67
|
+
return c, w, (1 << (diff >> 4)) - 1, 1 << (diff & 0xF)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"hatchling>=1.25.0",
|
|
4
|
+
"hatch-vcs>=0.4.0",
|
|
5
|
+
]
|
|
6
|
+
build-backend = "hatchling.build"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "easypow"
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
description = "Antibot Proof-of-Work protection to verify browsers on web apps."
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Leo Vasanko" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["proof-of-work", "antibot", "web-security"]
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
license = "MIT OR Unlicense"
|
|
18
|
+
requires-python = ">=3.11"
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"base64url>=1.1.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://git.zi.fi/LeoVasanko/easypow"
|
|
31
|
+
Issues = "https://github.com/LeoVasanko/easypow"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
source = "vcs"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["easypow"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
extend-exclude = ["*.md"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["ALL"]
|
|
45
|
+
ignore = [
|
|
46
|
+
"COM812", # Conflicts with the formatter
|
|
47
|
+
"CPY", # No copyright notices
|
|
48
|
+
"D107", # Missing docstring in __init__
|
|
49
|
+
"D203", # incompatible with D211
|
|
50
|
+
"D213", # incompatible with D212
|
|
51
|
+
"PLR2004", # Magic values are clear enough in this domain
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.per-file-ignores]
|
|
55
|
+
"easypow/__main__.py" = ["T201"] # CLI prints to stdout/stderr by design
|
|
56
|
+
"tests/**" = ["D", "INP", "PLR2004", "S101", "S603"]
|
|
57
|
+
|
|
58
|
+
[dependency-groups]
|
|
59
|
+
dev = [
|
|
60
|
+
"pytest>=9.1.1",
|
|
61
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Cross-language tests: the bundled JS solver and the Python validator must agree.
|
|
2
|
+
|
|
3
|
+
These tests execute the actual bundled easypow.js with Node.js, guarding
|
|
4
|
+
against the two implementations drifting apart (e.g. mismatched masks).
|
|
5
|
+
Skipped when node is not available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import secrets
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
|
|
14
|
+
import base64url
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from easypow import generate, solve, validate
|
|
18
|
+
from easypow.pow import _decode
|
|
19
|
+
|
|
20
|
+
NODE = shutil.which("node")
|
|
21
|
+
SOLVER_PATH = files("easypow").joinpath("easypow.js")
|
|
22
|
+
|
|
23
|
+
pytestmark = pytest.mark.skipif(NODE is None, reason="node is not available")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_node(script: str) -> str:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
[NODE, "--input-type=module", "-e", script],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
check=True,
|
|
32
|
+
)
|
|
33
|
+
return result.stdout.strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _js_solves(challenge: str) -> None:
|
|
37
|
+
script = (
|
|
38
|
+
f"import solve from {json.dumps(str(SOLVER_PATH))}\n"
|
|
39
|
+
f"console.log(await solve({json.dumps(challenge)}))"
|
|
40
|
+
)
|
|
41
|
+
validate(challenge, _run_node(script).splitlines()[-1])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_js_solution_validates_in_python() -> None:
|
|
45
|
+
_js_solves(generate(seconds=0.2))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_js_solution_validates_in_python_custom_params() -> None:
|
|
49
|
+
_js_solves(base64url.enc(bytes([2, 9 << 4 | 6]) + secrets.token_bytes(7)))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_python_solution_validates_in_js() -> None:
|
|
53
|
+
challenge = generate(seconds=0.2)
|
|
54
|
+
_, _, mask, iterations = _decode(challenge)
|
|
55
|
+
script = f"""
|
|
56
|
+
const B64URL = {{ alphabet: 'base64url', omitPadding: true }}
|
|
57
|
+
const challenge = Uint8Array.fromBase64({json.dumps(challenge)}, B64URL)
|
|
58
|
+
const key = await crypto.subtle.importKey(
|
|
59
|
+
'raw', challenge, 'PBKDF2', false, ['deriveBits']
|
|
60
|
+
)
|
|
61
|
+
const args = {{
|
|
62
|
+
name: 'PBKDF2', salt: null, hash: 'SHA-512', iterations: {iterations}
|
|
63
|
+
}}
|
|
64
|
+
for (const fragment of {json.dumps(solve(challenge))}.split('.')) {{
|
|
65
|
+
const nonce = Uint8Array.fromBase64(fragment.padEnd(6, 'A'), B64URL)
|
|
66
|
+
const result = new Uint32Array(await crypto.subtle.deriveBits(
|
|
67
|
+
{{ ...args, salt: nonce }}, key, 32
|
|
68
|
+
))
|
|
69
|
+
if (result[0] & {mask}) {{
|
|
70
|
+
console.error('nonce failed mask check')
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}}
|
|
73
|
+
}}
|
|
74
|
+
"""
|
|
75
|
+
_run_node(script)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Unit tests for the proof-of-work utilities."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
import base64url
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from easypow import generate, js, solve, validate
|
|
9
|
+
from easypow.pow import _decode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_generate_is_12_char_b64url() -> None:
|
|
13
|
+
encoded = generate()
|
|
14
|
+
assert len(encoded) == 12
|
|
15
|
+
assert "=" not in encoded
|
|
16
|
+
assert len(base64url.dec(encoded)) == 9
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_generate_embeds_work() -> None:
|
|
20
|
+
assert _decode(generate(seconds=0.7))[1] == 7
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_generate_clamps_to_max() -> None:
|
|
24
|
+
assert _decode(generate(seconds=10000))[1:] == (255, 0x7FFF, 128)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_zero_work_validates_trivially() -> None:
|
|
28
|
+
challenge = generate(seconds=0)
|
|
29
|
+
assert _decode(challenge)[1] == 0
|
|
30
|
+
assert solve(challenge) == ""
|
|
31
|
+
validate(challenge, "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_zero_zero_bits_validates_trivially() -> None:
|
|
35
|
+
# Hand-crafted: zero-bits nibble 0 means mask 0, any nonce passes.
|
|
36
|
+
challenge = base64url.enc(b"\x01\x00" + secrets.token_bytes(7))
|
|
37
|
+
assert _decode(challenge)[2] == 0
|
|
38
|
+
validate(challenge, solve(challenge))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize(
|
|
42
|
+
("work", "embedded", "zero_bits"),
|
|
43
|
+
[
|
|
44
|
+
(1, 1, 11),
|
|
45
|
+
(31, 31, 11),
|
|
46
|
+
(32, 16, 12),
|
|
47
|
+
(127, 63, 12),
|
|
48
|
+
(128, 32, 13),
|
|
49
|
+
(511, 127, 13),
|
|
50
|
+
(512, 64, 14),
|
|
51
|
+
(2047, 255, 14),
|
|
52
|
+
(2048, 128, 15),
|
|
53
|
+
(4095, 255, 15),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
def test_difficulty_tiers(work: int, embedded: int, zero_bits: int) -> None:
|
|
57
|
+
_, w, mask, _ = _decode(generate(seconds=work / 10))
|
|
58
|
+
assert w == embedded
|
|
59
|
+
assert mask == (1 << zero_bits) - 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_decode_default() -> None:
|
|
63
|
+
assert _decode(generate())[1:] == (10, 0x7FF, 128)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_decode_custom() -> None:
|
|
67
|
+
challenge = bytes([2, 9 << 4 | 6]) + secrets.token_bytes(7)
|
|
68
|
+
assert _decode(base64url.enc(challenge))[1:] == (2, 0x1FF, 64)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_solve_format_strips_trailing_a_chars() -> None:
|
|
72
|
+
solution = solve(generate(seconds=0.2))
|
|
73
|
+
assert "." in solution
|
|
74
|
+
assert "=" not in solution
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_validate_valid() -> None:
|
|
78
|
+
challenge = generate(seconds=0.2)
|
|
79
|
+
validate(challenge, solve(challenge))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_validate_invalid_solution() -> None:
|
|
83
|
+
challenge = generate(seconds=0.2)
|
|
84
|
+
with pytest.raises(ValueError, match="Invalid easypow solution"):
|
|
85
|
+
validate(challenge, "AA.AA")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_validate_wrong_work() -> None:
|
|
89
|
+
# Solution valid for work=1 does not satisfy a work=2 challenge.
|
|
90
|
+
solution = solve(generate(seconds=0.1))
|
|
91
|
+
with pytest.raises(ValueError, match="Malformed easypow solution"):
|
|
92
|
+
validate(generate(seconds=0.2), solution)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_validate_fragment_too_long() -> None:
|
|
96
|
+
with pytest.raises(ValueError, match="Malformed easypow solution"):
|
|
97
|
+
validate(generate(seconds=0.2), "AQAAAAA.AQ") # First fragment is 7 chars
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_js_bundled() -> None:
|
|
101
|
+
assert "export { solve, solve as default }" in js
|