From b9e5e1ec938c55604314474e03f0c08b0f08757a Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 15 Feb 2024 11:46:37 +0700 Subject: [PATCH 1/2] [encryption] add encryption support --- .github/workflows/testing.yml | 5 +- Pipfile | 3 + Pipfile.lock | 49 ++++++++++++++-- README.md | 28 +++++++-- android_sms_gateway/__init__.py | 2 + android_sms_gateway/client.py | 97 +++++++++++++++++++++++++------ android_sms_gateway/encryption.py | 92 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_encryption.py | 44 ++++++++++++++ 9 files changed, 291 insertions(+), 31 deletions(-) create mode 100644 android_sms_gateway/encryption.py create mode 100644 tests/test_encryption.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 68b7c29..a7664aa 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 @@ -26,7 +26,8 @@ jobs: - name: Install dependencies run: | - pipenv install --dev + pipenv sync --dev + pipenv sync --categories encryption - name: Lint with flake8 run: pipenv run flake8 android_sms_gateway tests diff --git a/Pipfile b/Pipfile index 34a2f80..fc65891 100644 --- a/Pipfile +++ b/Pipfile @@ -26,3 +26,6 @@ httpx = "*" [aiohttp] aiohttp = "*" + +[encryption] +pycryptodome = "*" diff --git a/Pipfile.lock b/Pipfile.lock index db432b1..c4d830b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "dcc31acb4b72b0eb22d30b2fb9f1b73c8f15dd3e651296c94443abdf832c86cb" + "sha256": "b1c636eac4f5adb9596ff1afe3e0f91c6f9d100ef5d15f6731cf64335d1eb700" }, "pipfile-spec": 6, "requires": { @@ -891,6 +891,47 @@ "version": "==3.17.0" } }, + "encryption": { + "pycryptodome": { + "hashes": [ + "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690", + "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7", + "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4", + "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd", + "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5", + "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc", + "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818", + "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab", + "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d", + "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a", + "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25", + "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091", + "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea", + "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a", + "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c", + "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72", + "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9", + "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6", + "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044", + "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04", + "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c", + "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e", + "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f", + "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b", + "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4", + "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33", + "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f", + "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e", + "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a", + "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2", + "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3", + "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==3.20.0" + } + }, "httpx": { "anyio": { "hashes": [ @@ -926,11 +967,11 @@ }, "httpcore": { "hashes": [ - "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7", - "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535" + "sha256:5c0f9546ad17dac4d0772b0808856eb616eb8b48ce94f49ed819fd6982a8a544", + "sha256:9a6a501c3099307d9fd76ac244e08503427679b1e81ceb1d922485e2f2462ad2" ], "markers": "python_version >= '3.8'", - "version": "==1.0.2" + "version": "==1.0.3" }, "httpx": { "hashes": [ diff --git a/README.md b/README.md index 96547fc..3f93341 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ This is a Python client library for interfacing with the [Android SMS Gateway](h - [aiohttp](https://pypi.org/project/aiohttp/) - [httpx](https://pypi.org/project/httpx/) +Optional: + +- [pycryptodome](https://pypi.org/project/pycryptodome/) - end-to-end encryption support + ## Installation ```bash @@ -32,7 +36,13 @@ pip install android_sms_gateway[aiohttp] pip install android_sms_gateway[httpx] ``` -## Usage +With encrypted messages support: + +```bash +pip install android_sms_gateway[encryption] +``` + +## Quickstart Here's an example of using the client: @@ -40,11 +50,11 @@ Here's an example of using the client: import asyncio import os -from android_sms_gateway import client, domain +from android_sms_gateway import client, domain, Encryptor login = os.getenv("ANDROID_SMS_GATEWAY_LOGIN") password = os.getenv("ANDROID_SMS_GATEWAY_PASSWORD") - +# encryptor = Encryptor('passphrase') # for end-to-end encryption, see https://sms.capcom.me/privacy/encryption/ message = domain.Message( "Your message text here.", @@ -52,7 +62,11 @@ message = domain.Message( ) def sync_client(): - with client.APIClient(login, password) as c: + with client.APIClient( + login, + password, + # encryptor=encryptor, + ) as c: state = c.send(message) print(state) @@ -61,7 +75,11 @@ def sync_client(): async def async_client(): - async with client.AsyncAPIClient(login, password) as c: + async with client.AsyncAPIClient( + login, + password, + # encryptor=encryptor, + ) as c: state = await c.send(message) print(state) diff --git a/android_sms_gateway/__init__.py b/android_sms_gateway/__init__.py index b091230..3bb85b1 100644 --- a/android_sms_gateway/__init__.py +++ b/android_sms_gateway/__init__.py @@ -2,6 +2,7 @@ from .client import APIClient, AsyncAPIClient from .constants import VERSION from .domain import Message, MessageState, RecipientState +from .encryption import Encryptor from .http import HttpClient __all__ = ( @@ -12,6 +13,7 @@ "Message", "MessageState", "RecipientState", + "Encryptor", ) __version__ = VERSION diff --git a/android_sms_gateway/client.py b/android_sms_gateway/client.py index c644a65..8e93315 100644 --- a/android_sms_gateway/client.py +++ b/android_sms_gateway/client.py @@ -1,18 +1,25 @@ import abc import base64 +import dataclasses import logging import sys import typing as t from . import ahttp, domain, http from .constants import DEFAULT_URL, VERSION +from .encryption import BaseEncryptor logger = logging.getLogger(__name__) class BaseClient(abc.ABC): def __init__( - self, login: str, password: str, *, base_url: str = DEFAULT_URL + self, + login: str, + password: str, + *, + base_url: str = DEFAULT_URL, + encryptor: t.Optional[BaseEncryptor] = None, ) -> None: credentials = base64.b64encode(f"{login}:{password}".encode("utf-8")).decode( "utf-8" @@ -23,6 +30,44 @@ def __init__( "User-Agent": f"android-sms-gateway/{VERSION} (client; python {sys.version_info.major}.{sys.version_info.minor})", } self.base_url = base_url.rstrip("/") + self.encryptor = encryptor + + def _encrypt(self, message: domain.Message) -> domain.Message: + if self.encryptor is None: + return message + + if message.is_encrypted: + raise ValueError("Message is already encrypted") + + message = dataclasses.replace( + message, + is_encrypted=True, + message=self.encryptor.encrypt(message.message), + phone_numbers=[ + self.encryptor.encrypt(phone) for phone in message.phone_numbers + ], + ) + + return message + + def _decrypt(self, state: domain.MessageState) -> domain.MessageState: + if state.is_encrypted and self.encryptor is None: + raise ValueError("Message is encrypted but encryptor is not set") + + if self.encryptor is None: + return state + + return dataclasses.replace( + state, + recipients=[ + dataclasses.replace( + recipient, + phone_number=self.encryptor.decrypt(recipient.phone_number), + ) + for recipient in state.recipients + ], + is_encrypted=False, + ) class APIClient(BaseClient): @@ -32,10 +77,11 @@ def __init__( password: str, *, base_url: str = DEFAULT_URL, - http_client: t.Optional[http.HttpClient] = None, + encryptor: t.Optional[BaseEncryptor] = None, + http: t.Optional[http.HttpClient] = None, ) -> None: - super().__init__(login, password, base_url=base_url) - self.http = http_client + super().__init__(login, password, base_url=base_url, encryptor=encryptor) + self.http = http def __enter__(self): if self.http is not None: @@ -50,17 +96,22 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.http = None def send(self, message: domain.Message) -> domain.MessageState: - return domain.MessageState.from_dict( - self.http.post( - f"{self.base_url}/message", - payload=message.asdict(), - headers=self.headers, + message = self._encrypt(message) + return self._decrypt( + domain.MessageState.from_dict( + self.http.post( + f"{self.base_url}/message", + payload=message.asdict(), + headers=self.headers, + ) ) ) def get_state(self, _id: str) -> domain.MessageState: - return domain.MessageState.from_dict( - self.http.get(f"{self.base_url}/message/{_id}", headers=self.headers) + return self._decrypt( + domain.MessageState.from_dict( + self.http.get(f"{self.base_url}/message/{_id}", headers=self.headers) + ) ) @@ -71,9 +122,10 @@ def __init__( password: str, *, base_url: str = DEFAULT_URL, + encryptor: t.Optional[BaseEncryptor] = None, http_client: t.Optional[ahttp.AsyncHttpClient] = None, ) -> None: - super().__init__(login, password, base_url=base_url) + super().__init__(login, password, base_url=base_url, encryptor=encryptor) self.http = http_client async def __aenter__(self): @@ -89,15 +141,22 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): self.http = None async def send(self, message: domain.Message) -> domain.MessageState: - return domain.MessageState.from_dict( - await self.http.post( - f"{self.base_url}/message", - payload=message.asdict(), - headers=self.headers, + message = self._encrypt(message) + return self._decrypt( + domain.MessageState.from_dict( + await self.http.post( + f"{self.base_url}/message", + payload=message.asdict(), + headers=self.headers, + ) ) ) async def get_state(self, _id: str) -> domain.MessageState: - return domain.MessageState.from_dict( - await self.http.get(f"{self.base_url}/message/{_id}", headers=self.headers) + return self._decrypt( + domain.MessageState.from_dict( + await self.http.get( + f"{self.base_url}/message/{_id}", headers=self.headers + ) + ) ) diff --git a/android_sms_gateway/encryption.py b/android_sms_gateway/encryption.py new file mode 100644 index 0000000..9e81307 --- /dev/null +++ b/android_sms_gateway/encryption.py @@ -0,0 +1,92 @@ +import abc +import base64 +import typing as t + + +class BaseEncryptor(abc.ABC): + def __init__(self, passphrase: str, *, iterations: int) -> None: + self.passphrase = passphrase + self.iterations = iterations + + @abc.abstractmethod + def encrypt(self, cleartext: str) -> str: + ... + + @abc.abstractmethod + def decrypt(self, encrypted: str) -> str: + ... + + +_Encryptor: t.Optional[t.Type[BaseEncryptor]] = None + + +try: + from Crypto.Cipher import AES + from Crypto.Hash import SHA1 + from Crypto.Protocol.KDF import PBKDF2 + from Crypto.Random import get_random_bytes + from Crypto.Util.Padding import pad, unpad + + class AESEncryptor(BaseEncryptor): + def encrypt(self, cleartext: str) -> str: + saltBytes = self._generate_salt() + key = self._generate_key(saltBytes, self.iterations) + + cipher = AES.new(key, AES.MODE_CBC, iv=saltBytes) + + encrypted_bytes = cipher.encrypt(pad(cleartext.encode(), AES.block_size)) + + salt = base64.b64encode(saltBytes).decode("utf-8") + encrypted = base64.b64encode(encrypted_bytes).decode("utf-8") + + return f"$aes-256-cbc/pbkdf2-sha1$i={self.iterations}${salt}${encrypted}" + + def decrypt(self, encrypted: str) -> str: + chunks = encrypted.split("$") + + if len(chunks) < 5: + raise ValueError("Invalid encryption format") + + if chunks[1] != "aes-256-cbc/pbkdf2-sha1": + raise ValueError("Unsupported algorithm") + + params = self._parse_params(chunks[2]) + if "i" not in params: + raise ValueError("Missing iteration count") + + iterations = int(params["i"]) + salt = base64.b64decode(chunks[-2]) + encrypted_bytes = base64.b64decode(chunks[-1]) + + key = self._generate_key(salt, iterations) + cipher = AES.new(key, AES.MODE_CBC, iv=salt) + + decrypted_bytes = unpad(cipher.decrypt(encrypted_bytes), AES.block_size) + + return decrypted_bytes.decode("utf-8") + + def _generate_salt(self) -> bytes: + return get_random_bytes(16) + + def _generate_key(self, salt: bytes, iterations: int) -> bytes: + return PBKDF2( + self.passphrase, + salt, + count=iterations, + dkLen=32, + hmac_hash_module=SHA1, + ) + + def _parse_params(self, params: str) -> t.Dict[str, str]: + return {k: v for k, v in [p.split("=") for p in params.split(",")]} + + _Encryptor = AESEncryptor +except ImportError: + ... + + +def Encryptor(passphrase: str, *, iterations: int = 75_000) -> BaseEncryptor: + if _Encryptor is None: + raise ImportError("Please install cryptodome") + + return _Encryptor(passphrase, iterations=iterations) diff --git a/pyproject.toml b/pyproject.toml index 4b7619f..1f1a18b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,12 +18,12 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Communications :: Telephony", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", diff --git a/tests/test_encryption.py b/tests/test_encryption.py new file mode 100644 index 0000000..f77752e --- /dev/null +++ b/tests/test_encryption.py @@ -0,0 +1,44 @@ +import pytest + +from android_sms_gateway.encryption import AESEncryptor + + +def test_decrypt(): + passphrase = "passphrase" + cleartext = "hello" + encrypted = "$aes-256-cbc/pbkdf2-sha1$i=75000$obSTW6ittQvTtdAxonQKIw==$g3QFAC9CtBcPxoKlouqsyQ==" + encryptor = AESEncryptor(passphrase, iterations=75000) + + decrypted = encryptor.decrypt(encrypted) + + assert cleartext == decrypted + + +def test_encrypt_decrypt(): + passphrase = "correcthorsebatterystaple" + encryptor = AESEncryptor(passphrase, iterations=1000) + cleartext = "The quick brown fox jumps over the lazy dog" + encrypted = encryptor.encrypt(cleartext) + decrypted = encryptor.decrypt(encrypted) + assert cleartext == decrypted + + +def test_invalid_format_error(): + passphrase = "correcthorsebatterystaple" + encryptor = AESEncryptor(passphrase, iterations=1000) + with pytest.raises(ValueError, match="Invalid encryption format"): + encryptor.decrypt("invalid$format$string") + + +def test_unsupported_algorithm_error(): + passphrase = "correcthorsebatterystaple" + encryptor = AESEncryptor(passphrase, iterations=1000) + with pytest.raises(ValueError, match="Unsupported algorithm"): + encryptor.decrypt("$unsupported-algorithm$i=0$salt$data") + + +def test_missing_iteration_count_error(): + passphrase = "correcthorsebatterystaple" + encryptor = AESEncryptor(passphrase, iterations=1000) + with pytest.raises(ValueError, match="Missing iteration count"): + encryptor.decrypt("$aes-256-cbc/pbkdf2-sha1$x=0$salt$data") From 1f38a7a7475b9959325bf726a33379610e5ffdab Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sat, 17 Feb 2024 14:35:35 +0700 Subject: [PATCH 2/2] [dependencies] versions lock --- Pipfile | 8 ++++---- Pipfile.lock | 26 +------------------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/Pipfile b/Pipfile index fc65891..c0f34d6 100644 --- a/Pipfile +++ b/Pipfile @@ -19,13 +19,13 @@ importlib-metadata = "*" python_version = "3" [requests] -requests = "*" +requests = "~=2.31" [httpx] -httpx = "*" +httpx = "~=0.26" [aiohttp] -aiohttp = "*" +aiohttp = "~=3.9" [encryption] -pycryptodome = "*" +pycryptodome = "~=3.20" diff --git a/Pipfile.lock b/Pipfile.lock index c4d830b..57f1064 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b1c636eac4f5adb9596ff1afe3e0f91c6f9d100ef5d15f6731cf64335d1eb700" + "sha256": "431aa233c3b0cbefd026f87e0ac7aa93472c064d475d0448e7e64033ccfbacc8" }, "pipfile-spec": 6, "requires": { @@ -107,14 +107,6 @@ "markers": "python_version >= '3.7'", "version": "==1.3.1" }, - "async-timeout": { - "hashes": [ - "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", - "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" - ], - "markers": "python_version < '3.11'", - "version": "==4.0.3" - }, "attrs": { "hashes": [ "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", @@ -949,14 +941,6 @@ "markers": "python_version >= '3.6'", "version": "==2024.2.2" }, - "exceptiongroup": { - "hashes": [ - "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", - "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" - ], - "markers": "python_version < '3.11'", - "version": "==1.2.0" - }, "h11": { "hashes": [ "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", @@ -997,14 +981,6 @@ ], "markers": "python_version >= '3.7'", "version": "==1.3.0" - }, - "typing-extensions": { - "hashes": [ - "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", - "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" - ], - "markers": "python_version < '3.11'", - "version": "==4.9.0" } }, "reqeusts": {