diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7c5292ef..ee3a87ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,11 +29,11 @@ jobs: - macos-latest - windows-latest python-version: - - '3.9' - - '3.10' - - '3.11' - - '3.12' - - '3.13' + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" defaults: # Windows sucks. Force use bash instead of PowerShell @@ -63,7 +63,7 @@ jobs: - name: Install project dependencies run: | - pip install -e .[dev,test,redis,mongo,proxy,i18n,fast] + pip install -e .[dev,test,redis,mongo,proxy,i18n,fast,signature] - name: Lint code run: | @@ -81,7 +81,7 @@ jobs: if: ${{ env.IS_UBUNTU == 'true' }} uses: supercharge/mongodb-github-action@1.10.0 with: - mongodb-version: '7.0' + mongodb-version: "7.0" mongodb-username: mongo mongodb-password: mongo mongodb-port: 27017 @@ -111,8 +111,8 @@ jobs: - macos-latest # - windows-latest python-version: - - 'pypy3.9' - - 'pypy3.10' + - "pypy3.9" + - "pypy3.10" defaults: # Windows sucks. Force use bash instead of PowerShell @@ -134,7 +134,7 @@ jobs: - name: Install project dependencies run: | - pip install -e .[dev,test,redis,mongo,proxy,i18n,fast] + pip install -e .[dev,test,redis,mongo,proxy,i18n,fast,signature] - name: Run tests run: | diff --git a/CHANGES/1715.feature.rst b/CHANGES/1715.feature.rst new file mode 100644 index 00000000..a14da10f --- /dev/null +++ b/CHANGES/1715.feature.rst @@ -0,0 +1 @@ +Support validating init data using only bot id. \ No newline at end of file diff --git a/aiogram/utils/web_app_signature.py b/aiogram/utils/web_app_signature.py new file mode 100644 index 00000000..623cc802 --- /dev/null +++ b/aiogram/utils/web_app_signature.py @@ -0,0 +1,70 @@ +import base64 +from operator import itemgetter +from urllib.parse import parse_qsl + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.exceptions import InvalidSignature + +from .web_app import parse_webapp_init_data, WebAppInitData + +PRODUCTION_PUBLIC_KEY = bytes.fromhex( + "e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d" +) +TEST_PUBLIC_KEY = bytes.fromhex("40055058a4ee38156a06562e52eece92a771bcd8346a8c4615cb7376eddf72ec") + + +def check_webapp_signature( + bot_id: int, init_data: str, public_key_bytes: bytes = PRODUCTION_PUBLIC_KEY +) -> bool: + """ + Check incoming WebApp init data signature without bot token using only bot id. + + Source: https://core.telegram.org/bots/webapps#validating-data-for-third-party-use + + :param bot_id: Bot ID + :param init_data: WebApp init data + :param public_key: Public key + :return: True if signature is valid, False otherwise + """ + try: + parsed_data = dict(parse_qsl(init_data, strict_parsing=True)) + except ValueError: + return False + + signature_b64 = parsed_data.pop("signature", None) + if not signature_b64: + return False + + parsed_data.pop("hash", None) + + data_check_string = f"{bot_id}:WebAppData\n" + "\n".join( + f"{k}={v}" for k, v in sorted(parsed_data.items(), key=itemgetter(0)) + ) + message = data_check_string.encode() + + padding = "=" * (-len(signature_b64) % 4) + signature = base64.urlsafe_b64decode(signature_b64 + padding) + + public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) + + try: + public_key.verify(signature, message) + return True + except InvalidSignature: + return False + + +def safe_check_webapp_init_data_from_signature( + bot_id: int, init_data: str, public_key_bytes: bytes = PRODUCTION_PUBLIC_KEY +) -> WebAppInitData: + """ + Validate raw WebApp init data using only bot id and return it as WebAppInitData object + + :param bot_id: bot id + :param init_data: data from frontend to be parsed and validated + :param public_key_bytes: public key + :return: WebAppInitData object + """ + if check_webapp_signature(bot_id, init_data, public_key_bytes): + return parse_webapp_init_data(init_data) + raise ValueError("Invalid init data signature") diff --git a/pyproject.toml b/pyproject.toml index b4642f2c..646b1d98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,9 @@ i18n = [ cli = [ "aiogram-cli>=1.1.0,<2.0.0", ] +signature = [ + "cryptography>=43.0.0", +] test = [ "pytest~=7.4.2", "pytest-html~=4.0.2", diff --git a/tests/test_utils/test_web_app_signature.py b/tests/test_utils/test_web_app_signature.py new file mode 100644 index 00000000..e8fbba71 --- /dev/null +++ b/tests/test_utils/test_web_app_signature.py @@ -0,0 +1,63 @@ +import pytest + +from aiogram.utils.web_app import WebAppInitData +from aiogram.utils.web_app_signature import ( + check_webapp_signature, + safe_check_webapp_init_data_from_signature, +) + +PRIVATE_KEY = bytes.fromhex("c80e09dc60f5efcf2e1f8d0793358e0ea3371267bef0024588f7bf67cf48dfb9") +PUBLIC_KEY = bytes.fromhex("4112765021341e5415e772cd65903f6b94e3ea1c2ab669e6d3e18ee2db00da61") + + +class TestWebAppSignature: + @pytest.mark.parametrize( + "bot_id,case,result", + [ + [ + 42, + "auth_date=1650385342&user=%7B%22id%22%3A42%2C%22first_name%22%3A%22Test%22%7D&query_id=test&signature=JQ0JR2tjC65yq_jNZV0wuJVX6J-SWPMV0mprUXG34g-NvxL4RcF1Rz5n4VVo00VRghEUBf5t___uoeb1-jU_Cw", + True, + ], + [ + 42, + "auth_date=1650385342&user=%7B%22id%22%3A42%2C%22first_name%22%3A%22Test%22%7D&query_id=test&signature=JQ0JR2tjC65yq_jNZV0wuJVX6J-SWPMV0mprUXG34g-NvxL4RcF1Rz5n4VVo00VRghEUBf5t___uoeb1-j1U_w", + False, + ], + [ + 42, + "auth_date=1650385342&user=%7B%22id%22%3A42%2C%22first_name%22%3A%22Test%22%7D&query_id=test", + False, + ], + [ + 42, + "", + False, + ], + [42, "test&foo=bar=baz", False], + ], + ) + def test_check_webapp_signature(self, bot_id: int, case: str, result: bool): + assert check_webapp_signature(bot_id, case, PUBLIC_KEY) is result + + def test_safe_check_webapp_init_data_from_signature(self): + result = safe_check_webapp_init_data_from_signature( + 42, + "auth_date=1650385342&user=%7B%22id%22%3A42%2C%22first_name%22%3A%22Test%22%7D&query_id=test&hash=123&signature=JQ0JR2tjC65yq_jNZV0wuJVX6J-SWPMV0mprUXG34g-NvxL4RcF1Rz5n4VVo00VRghEUBf5t___uoeb1-jU_Cw", + PUBLIC_KEY, + ) + assert isinstance(result, WebAppInitData) + assert result.user is not None + assert result.user.id == 42 + assert result.user.first_name == "Test" + assert result.query_id == "test" + assert result.auth_date.year == 2022 + assert result.hash == "123" + + def test_safe_check_webapp_init_data_from_signature_invalid(self): + with pytest.raises(ValueError): + safe_check_webapp_init_data_from_signature( + 42, + "auth_date=1650385342&user=%7B%22id%22%3A42%2C%22first_name%22%3A%22Test%22%7D&query_id=test&hash=123&signature=JQ0JR2tjC65yq_jNZV0wuJVX6J-SWPMV0mprUXG34g-NvxL4RcF1Rz5n4VVo00VRghEUBf5t___uoeb1-j1U_w", + PUBLIC_KEY, + ) \ No newline at end of file