#!/usr/bin/env python3 """ Проверка данных запуска мини-приложения Falcon Black (initData) на сервере автора. Python 3.9+. HMAC — стандартная библиотека; Ed25519 — библиотека cryptography (pip install cryptography) или PyNaCl (pip install pynacl), какая есть. FB_BOT_TOKEN=<ключ бота> python3 validate-initdata.py '' Страница мини-приложения берёт строку из WebApp.initData и шлёт её на ваш сервер В ТЕЛЕ запроса (не в адресе). Два независимых способа проверки: hash — HMAC ключом вашего бота (есть, когда у живого ключа есть ключ приложений); signature — Ed25519 ключом Falcon Black (есть всегда); открытые ключи — https://falconblack.org/sdk/keys.json, проверять всеми. «WebAppData» — постоянная строка схемы, а не имя. """ import base64 import hashlib import hmac import json import os import re import sys import time from urllib.parse import parse_qsl from urllib.request import urlopen CONSTANT = b"WebAppData" # Данные старше часа не принимать; жёсткий предел — сутки. Допуск на часы — минута. MAX_AGE_SECONDS = 3600 def _verify_ed25519(raw32: bytes, message: bytes, signature: bytes) -> bool: try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature try: Ed25519PublicKey.from_public_bytes(raw32).verify(signature, message) return True except InvalidSignature: return False except ImportError: from nacl.signing import VerifyKey from nacl.exceptions import BadSignatureError try: VerifyKey(raw32).verify(message, signature) return True except BadSignatureError: return False def _data_check_string(pairs, exclude): """Поля, кроме исключённых, по ключу, «k=v» с раскодированным v, через \\n.""" rows = sorted((k, v) for k, v in pairs if k not in exclude) return "\n".join(f"{k}={v}" for k, v in rows) def web_app_key(bot_token: str) -> bytes: """Ключ HMAC из ключа бота: HMAC_SHA256(key = "WebAppData", msg = весь ключ «<номер>:fb1_…»).""" return hmac.new(CONSTANT, bot_token.encode(), hashlib.sha256).digest() def fetch_public_keys(url: str = "https://falconblack.org/sdk/keys.json"): """Открытые ключи из /sdk/keys.json → [(kid, raw32)].""" with urlopen(url, timeout=10) as r: body = json.load(r) return [(k["kid"], bytes.fromhex(k["public_key_hex"])) for k in body.get("keys", [])] def validate_init_data(init_data, bot_token=None, public_keys=(), bot_id=None, now=None, max_age=MAX_AGE_SECONDS): """ Итог: {"ok", "hash": ok|bad|absent|nokey, "signature": ok|bad|absent|nokeys, "fresh", "age", "data"}. ok — подпись верна, hash верен (или его нет), данные свежие. """ # Только декодер форм: «+» — это пробел. pairs = parse_qsl(init_data, keep_blank_values=True) keys = [k for k, _ in pairs] if len(keys) != len(set(keys)): # Повтор поля — подмена: строку отвергаем целиком. return {"ok": False, "reason": "duplicate_key", "hash": "bad", "signature": "bad", "fresh": False} data = dict(pairs) number = bot_id if bot_id is not None else (bot_token.split(":")[0] if bot_token else None) hash_state = "absent" if "hash" in data: if not bot_token: hash_state = "nokey" else: expected = hmac.new(web_app_key(bot_token), _data_check_string(pairs, ("hash",)).encode(), hashlib.sha256).hexdigest() got = data["hash"] if re.fullmatch(r"[0-9a-f]{64}", data["hash"]) else "0" * 64 # Сравнение за постоянное время (и чужой вид строки — просто «неверно»). hash_state = "ok" if hmac.compare_digest(expected, got) and got == data["hash"] else "bad" signature_state = "absent" if "signature" in data: if not public_keys or number is None: signature_state = "nokeys" else: message = f"{number}:WebAppData\n{_data_check_string(pairs, ('hash', 'signature'))}".encode() raw = data["signature"] try: sig = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)) except ValueError: sig = b"" signature_state = "ok" if len(sig) == 64 and any( _verify_ed25519(raw32, message, sig) for _, raw32 in public_keys) else "bad" now = int(time.time()) if now is None else now try: age = now - int(data.get("auth_date", "")) except ValueError: age = float("inf") fresh = -60 <= age <= min(max_age, 86400) try: user = json.loads(data["user"]) if "user" in data else None except ValueError: user = None ok = signature_state == "ok" and hash_state in ("ok", "absent", "nokey") and fresh return {"ok": ok, "hash": hash_state, "signature": signature_state, "fresh": fresh, "age": age, "data": {**data, "user": user}} if __name__ == "__main__": if len(sys.argv) < 2: print("usage: FB_BOT_TOKEN=<ключ> python3 validate-initdata.py ''", file=sys.stderr) sys.exit(2) result = validate_init_data(sys.argv[1], bot_token=os.environ.get("FB_BOT_TOKEN") or None, public_keys=fetch_public_keys()) # Сами данные запуска в журнал не пишите: печатаем только итог и номер человека. user = result.get("data", {}).get("user") or {} print(json.dumps({"ok": result["ok"], "hash": result["hash"], "signature": result["signature"], "fresh": result["fresh"], "user_id": user.get("id")})) sys.exit(0 if result["ok"] else 1)