summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/captcha.py
blob: e6faf1add30dc219e778a832caf7f926177805c1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
"""Proof-of-work challenge gating registration and password reset.

The hub hands out a challenge; the page finds the number behind it by trying
them in turn and sends the answer with the form. Nobody else is asked: no
script, cookie or address leaves for a third party, which is what the
reCAPTCHA this replaced could not say (docs/MESHBAY_DESIGN.md §7.7).

What it buys is cost, not a verdict on who is human. A person never sees it —
the page solves while the form is being filled in — and a script pays it once
per attempt. The bounds that hold against a script with machines to spare are
the per-IP limits on the two routes and, above all, the mail budget
(`mail.py`): the sign-up and the reset are what make this hub send mail.

The scheme is ALTCHA's, keyed to this hub. A challenge is the SHA-256 of
`<nonce>.<expires>.<number>`, with `number` drawn below `max_number`; its HMAC
is the hub's word that it issued it. The answer carries the nonce, the expiry,
the number and that HMAC, so the hub recomputes the challenge rather than
remembering it — and every worker, and the next restart, agrees on it, since
the key is derived from the hub's own private key and not drawn per process.
What is remembered is an answer once spent, so one solve is one form.
"""

import hashlib
import hmac
import secrets
import time

from meshbay_hub.auth import hub_private_key_pem

# How long a challenge stays good. Long enough for somebody filling in the form
# slowly; the page fetches a fresh one if it outlived this before submitting.
CHALLENGE_TTL = 1800

_KEY_LABEL = b"meshbay captcha v1"
# Answers already accepted, until their challenge would have expired anyway.
_spent: dict[str, int] = {}


def _key() -> bytes:
    return hmac.new(hub_private_key_pem(), _KEY_LABEL, hashlib.sha256).digest()


def _sign(challenge: str) -> str:
    return hmac.new(_key(), challenge.encode(), hashlib.sha256).hexdigest()


def issue(max_number: int) -> dict:
    """A fresh challenge, in the shape the page solves (`static/captcha.js`)."""
    prefix = f"{secrets.token_hex(12)}.{int(time.time()) + CHALLENGE_TTL}."
    number = secrets.randbelow(max_number)
    challenge = hashlib.sha256(f"{prefix}{number}".encode()).hexdigest()
    return {
        "prefix": prefix,
        "challenge": challenge,
        "max_number": max_number,
        "signature": _sign(challenge),
        "expires_in": CHALLENGE_TTL,
    }


def verify(token: str) -> bool:
    """True for an answer this hub issued, still in date and not spent before.

    The token is `<nonce>.<expires>.<number>.<signature>`. Nothing in it is
    trusted on its own: the challenge is recomputed from the first three, and
    only this hub's key turns that into the signature.
    """
    parts = token.split(".")
    if len(parts) != 4:
        return False
    nonce, expires_s, number_s, signature = parts
    if not (expires_s.isdigit() and number_s.isdigit()):
        return False
    now = int(time.time())
    if not now < int(expires_s) <= now + CHALLENGE_TTL:
        return False
    challenge = hashlib.sha256(f"{nonce}.{expires_s}.{number_s}".encode()).hexdigest()
    if not hmac.compare_digest(_sign(challenge), signature):
        return False
    for spent, until in list(_spent.items()):
        if until <= now:
            del _spent[spent]
    if signature in _spent:
        return False
    _spent[signature] = int(expires_s)
    return True