"""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 `..`, 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 `...`. 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