easypow 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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."""
easypow/__main__.py ADDED
@@ -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())
easypow/easypow.js ADDED
@@ -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 }
easypow/pow.py ADDED
@@ -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,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.
@@ -0,0 +1,7 @@
1
+ easypow/__init__.py,sha256=omoJVaAFz6UFe3z1qR4Jeq7D5I0h6_3A-g4-hvBinis,361
2
+ easypow/__main__.py,sha256=nDJ2mlBEkxYMHLOuu0zIeVV4TooprD-1eiVnzIjwt0g,2559
3
+ easypow/easypow.js,sha256=CTaEw5zEuqEyiqxt7lyI2cOz0N6cjyzEbZZJousjKoI,810
4
+ easypow/pow.py,sha256=HMwgUXAfJHyeaPCxgeRxofLSreXIK0zg9eSBRnPpMlI,2277
5
+ easypow-0.1.0.dist-info/METADATA,sha256=dcUWJ7b63J2ypGTlgq1qOEp-qqQwMj_WKiOTLivm3o8,7395
6
+ easypow-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ easypow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any