diff --git a/docs/USAGE.md b/docs/USAGE.md index 6dfb2e5..49f58f2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -862,9 +862,8 @@ Output format: https://paste.mymx.me/abc12345 ``` -- PoW challenge (difficulty 20) solved per request +- mTLS client cert skips PoW; falls back to PoW challenge if no cert - Content sent as JSON body to FlaskPaste API -- No API key needed -- PoW is the auth mechanism - Raw content available at `/raw` ### `!shorten` -- Shorten URL @@ -882,7 +881,7 @@ https://paste.mymx.me/s/AbCdEfGh ``` - URL must start with `http://` or `https://` -- PoW challenge (difficulty 20) solved per request +- mTLS client cert skips PoW; falls back to PoW challenge if no cert - Also used internally by `!alert` to shorten announcement URLs ### FlaskPaste Configuration @@ -892,4 +891,5 @@ https://paste.mymx.me/s/AbCdEfGh url = "https://paste.mymx.me" # or set FLASKPASTE_URL env var ``` -TLS: custom CA cert at `secrets/flaskpaste/derp.crt` loaded automatically. +Auth: place client cert/key at `secrets/flaskpaste/derp.crt` and `derp.key` +for mTLS (bypasses PoW). Without them, PoW challenges are solved per request. diff --git a/plugins/flaskpaste.py b/plugins/flaskpaste.py index bd1e313..86113dc 100644 --- a/plugins/flaskpaste.py +++ b/plugins/flaskpaste.py @@ -29,13 +29,18 @@ def _get_base_url(bot) -> str: return url.rstrip("/") +def _has_client_cert() -> bool: + """Check if mTLS client cert and key are available.""" + return (_CERT_DIR / "derp.crt").exists() and (_CERT_DIR / "derp.key").exists() + + def _ssl_context() -> ssl.SSLContext: - """Build SSL context with custom CA cert if available.""" + """Build SSL context, loading client cert for mTLS if available.""" + ctx = ssl.create_default_context() cert_path = _CERT_DIR / "derp.crt" - if cert_path.exists(): - ctx = ssl.create_default_context(cafile=str(cert_path)) - else: - ctx = ssl.create_default_context() + key_path = _CERT_DIR / "derp.key" + if cert_path.exists() and key_path.exists(): + ctx.load_cert_chain(str(cert_path), str(key_path)) return ctx @@ -74,55 +79,45 @@ def _get_challenge(base_url: str) -> dict: return json.loads(resp.read()) -def _create_paste(base_url: str, content: str) -> str: - """Challenge + solve + POST / to create a paste. Returns paste URL.""" +def _pow_headers(base_url: str) -> dict: + """Solve PoW challenge and return auth headers. Empty dict if mTLS.""" + if _has_client_cert(): + return {} ch = _get_challenge(base_url) - nonce = ch["nonce"] - difficulty = ch["difficulty"] - token = ch["token"] - solution = _solve_pow(nonce, difficulty) + solution = _solve_pow(ch["nonce"], ch["difficulty"]) + return { + "X-PoW-Token": ch["token"], + "X-PoW-Solution": str(solution), + } + +def _create_paste(base_url: str, content: str) -> str: + """POST / to create a paste. Uses mTLS or PoW. Returns paste URL.""" + headers = { + "Content-Type": "application/json", + "User-Agent": "derp-bot", + **_pow_headers(base_url), + } data = json.dumps({"content": content}).encode() - req = urllib.request.Request( - base_url, - data=data, - headers={ - "Content-Type": "application/json", - "X-PoW-Token": token, - "X-PoW-Solution": str(solution), - "User-Agent": "derp-bot", - }, - ) + req = urllib.request.Request(base_url, data=data, headers=headers) ctx = _ssl_context() with urllib.request.urlopen(req, timeout=_TIMEOUT, context=ctx) as resp: body = json.loads(resp.read()) - # Response should contain the paste URL or ID paste_id = body.get("id", "") if paste_id: return f"{base_url}/{paste_id}" - # Fallback: check for url field return body.get("url", "") def _shorten_url(base_url: str, url: str) -> str: - """Challenge + solve + POST /s to shorten a URL. Returns short URL.""" - ch = _get_challenge(base_url) - nonce = ch["nonce"] - difficulty = ch["difficulty"] - token = ch["token"] - solution = _solve_pow(nonce, difficulty) - + """POST /s to shorten a URL. Uses mTLS or PoW. Returns short URL.""" + headers = { + "Content-Type": "application/json", + "User-Agent": "derp-bot", + **_pow_headers(base_url), + } data = json.dumps({"url": url}).encode() - req = urllib.request.Request( - f"{base_url}/s", - data=data, - headers={ - "Content-Type": "application/json", - "X-PoW-Token": token, - "X-PoW-Solution": str(solution), - "User-Agent": "derp-bot", - }, - ) + req = urllib.request.Request(f"{base_url}/s", data=data, headers=headers) ctx = _ssl_context() with urllib.request.urlopen(req, timeout=_TIMEOUT, context=ctx) as resp: body = json.loads(resp.read())