diff --git a/backend/app.py b/backend/app.py index 732b1e2..d0eefae 100644 --- a/backend/app.py +++ b/backend/app.py @@ -13,6 +13,7 @@ from services.link_checker import check_url, limiter as link_limiter from services.phone_checker import check_phone, limiter as phone_limiter from services.abuse_reporter import generate_report, get_all_templates +from services.breach_checker import check_password from config import ADMIN_TOKEN, BLACKLIST_ENTRY_TYPES db = Database() @@ -195,6 +196,14 @@ async def api_stats(_: None = Depends(require_admin_token)): # === Rate Limit Status === +@app.get("/api/breach/password") +async def api_check_password( + password: str = Query(..., min_length=1, max_length=256, description="Password to check"), + _: None = Depends(require_admin_token), +): + return await check_password(password) + + @app.get("/api/ratelimit") async def api_ratelimit_status(_: None = Depends(require_admin_token)): return { diff --git a/backend/services/breach_checker.py b/backend/services/breach_checker.py new file mode 100644 index 0000000..d6485ec --- /dev/null +++ b/backend/services/breach_checker.py @@ -0,0 +1,46 @@ +"""Breach checker — HIBP k-anonymity for passwords (no account needed).""" +import hashlib +import httpx +from typing import Dict + + +async def check_password(password: str) -> Dict: + """Check if a password appears in known breaches via HIBP k-anonymity. + + Only the first 5 chars of the SHA-1 hash are sent to the API. + The full hash never leaves the client. + """ + if not password: + return {"error": "empty password"} + + sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() + prefix = sha1[:5] + suffix = sha1[5:] + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"https://api.pwnedpasswords.com/range/{prefix}", + headers={"Add-Padding": "true"}, + ) + if resp.status_code != 200: + return {"error": f"HIBP returned {resp.status_code}"} + except httpx.HTTPError as e: + return {"error": f"network error: {e}"} + + count = 0 + for line in resp.text.splitlines(): + parts = line.strip().split(":") + if len(parts) == 2 and parts[0] == suffix: + count = int(parts[1]) + break + + return { + "breached": count > 0, + "count": count, + "message": ( + f"Password found in {count} breach(es) — change it immediately!" + if count > 0 + else "Password not found in known breaches." + ), + }