Skip to content

Commit e7ee85c

Browse files
authored
Merge pull request #2 from capcom6/feature/encryption
[encryption] add encryption support
2 parents c5b5cf3 + 1f38a7a commit e7ee85c

File tree

9 files changed

+294
-58
lines changed

9 files changed

+294
-58
lines changed

.github/workflows/testing.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
runs-on: ubuntu-20.04
99
strategy:
1010
matrix:
11-
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"]
11+
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
1212

1313
steps:
1414
- uses: actions/checkout@v3
@@ -25,7 +25,8 @@ jobs:
2525
2626
- name: Install dependencies
2727
run: |
28-
pipenv install --dev
28+
pipenv sync --dev
29+
pipenv sync --categories encryption
2930
3031
- name: Lint with flake8
3132
run: pipenv run flake8 android_sms_gateway tests

Pipfile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ importlib-metadata = "*"
1919
python_version = "3"
2020

2121
[requests]
22-
requests = "*"
22+
requests = "~=2.31"
2323

2424
[httpx]
25-
httpx = "*"
25+
httpx = "~=0.26"
2626

2727
[aiohttp]
28-
aiohttp = "*"
28+
aiohttp = "~=3.9"
29+
30+
[encryption]
31+
pycryptodome = "~=3.20"

Pipfile.lock

Lines changed: 45 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ This is a Python client library for interfacing with the [Android SMS Gateway](h
1818
- [aiohttp](https://pypi.org/project/aiohttp/)
1919
- [httpx](https://pypi.org/project/httpx/)
2020

21+
Optional:
22+
23+
- [pycryptodome](https://pypi.org/project/pycryptodome/) - end-to-end encryption support
24+
2125
## Installation
2226

2327
```bash
@@ -32,27 +36,37 @@ pip install android_sms_gateway[aiohttp]
3236
pip install android_sms_gateway[httpx]
3337
```
3438

35-
## Usage
39+
With encrypted messages support:
40+
41+
```bash
42+
pip install android_sms_gateway[encryption]
43+
```
44+
45+
## Quickstart
3646

3747
Here's an example of using the client:
3848

3949
```python
4050
import asyncio
4151
import os
4252

43-
from android_sms_gateway import client, domain
53+
from android_sms_gateway import client, domain, Encryptor
4454

4555
login = os.getenv("ANDROID_SMS_GATEWAY_LOGIN")
4656
password = os.getenv("ANDROID_SMS_GATEWAY_PASSWORD")
47-
57+
# encryptor = Encryptor('passphrase') # for end-to-end encryption, see https://sms.capcom.me/privacy/encryption/
4858

4959
message = domain.Message(
5060
"Your message text here.",
5161
["+1234567890"],
5262
)
5363

5464
def sync_client():
55-
with client.APIClient(login, password) as c:
65+
with client.APIClient(
66+
login,
67+
password,
68+
# encryptor=encryptor,
69+
) as c:
5670
state = c.send(message)
5771
print(state)
5872

@@ -61,7 +75,11 @@ def sync_client():
6175

6276

6377
async def async_client():
64-
async with client.AsyncAPIClient(login, password) as c:
78+
async with client.AsyncAPIClient(
79+
login,
80+
password,
81+
# encryptor=encryptor,
82+
) as c:
6583
state = await c.send(message)
6684
print(state)
6785

android_sms_gateway/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .client import APIClient, AsyncAPIClient
33
from .constants import VERSION
44
from .domain import Message, MessageState, RecipientState
5+
from .encryption import Encryptor
56
from .http import HttpClient
67

78
__all__ = (
@@ -12,6 +13,7 @@
1213
"Message",
1314
"MessageState",
1415
"RecipientState",
16+
"Encryptor",
1517
)
1618

1719
__version__ = VERSION

android_sms_gateway/client.py

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import abc
22
import base64
3+
import dataclasses
34
import logging
45
import sys
56
import typing as t
67

78
from . import ahttp, domain, http
89
from .constants import DEFAULT_URL, VERSION
10+
from .encryption import BaseEncryptor
911

1012
logger = logging.getLogger(__name__)
1113

1214

1315
class BaseClient(abc.ABC):
1416
def __init__(
15-
self, login: str, password: str, *, base_url: str = DEFAULT_URL
17+
self,
18+
login: str,
19+
password: str,
20+
*,
21+
base_url: str = DEFAULT_URL,
22+
encryptor: t.Optional[BaseEncryptor] = None,
1623
) -> None:
1724
credentials = base64.b64encode(f"{login}:{password}".encode("utf-8")).decode(
1825
"utf-8"
@@ -23,6 +30,44 @@ def __init__(
2330
"User-Agent": f"android-sms-gateway/{VERSION} (client; python {sys.version_info.major}.{sys.version_info.minor})",
2431
}
2532
self.base_url = base_url.rstrip("/")
33+
self.encryptor = encryptor
34+
35+
def _encrypt(self, message: domain.Message) -> domain.Message:
36+
if self.encryptor is None:
37+
return message
38+
39+
if message.is_encrypted:
40+
raise ValueError("Message is already encrypted")
41+
42+
message = dataclasses.replace(
43+
message,
44+
is_encrypted=True,
45+
message=self.encryptor.encrypt(message.message),
46+
phone_numbers=[
47+
self.encryptor.encrypt(phone) for phone in message.phone_numbers
48+
],
49+
)
50+
51+
return message
52+
53+
def _decrypt(self, state: domain.MessageState) -> domain.MessageState:
54+
if state.is_encrypted and self.encryptor is None:
55+
raise ValueError("Message is encrypted but encryptor is not set")
56+
57+
if self.encryptor is None:
58+
return state
59+
60+
return dataclasses.replace(
61+
state,
62+
recipients=[
63+
dataclasses.replace(
64+
recipient,
65+
phone_number=self.encryptor.decrypt(recipient.phone_number),
66+
)
67+
for recipient in state.recipients
68+
],
69+
is_encrypted=False,
70+
)
2671

2772

2873
class APIClient(BaseClient):
@@ -32,10 +77,11 @@ def __init__(
3277
password: str,
3378
*,
3479
base_url: str = DEFAULT_URL,
35-
http_client: t.Optional[http.HttpClient] = None,
80+
encryptor: t.Optional[BaseEncryptor] = None,
81+
http: t.Optional[http.HttpClient] = None,
3682
) -> None:
37-
super().__init__(login, password, base_url=base_url)
38-
self.http = http_client
83+
super().__init__(login, password, base_url=base_url, encryptor=encryptor)
84+
self.http = http
3985

4086
def __enter__(self):
4187
if self.http is not None:
@@ -50,17 +96,22 @@ def __exit__(self, exc_type, exc_val, exc_tb):
5096
self.http = None
5197

5298
def send(self, message: domain.Message) -> domain.MessageState:
53-
return domain.MessageState.from_dict(
54-
self.http.post(
55-
f"{self.base_url}/message",
56-
payload=message.asdict(),
57-
headers=self.headers,
99+
message = self._encrypt(message)
100+
return self._decrypt(
101+
domain.MessageState.from_dict(
102+
self.http.post(
103+
f"{self.base_url}/message",
104+
payload=message.asdict(),
105+
headers=self.headers,
106+
)
58107
)
59108
)
60109

61110
def get_state(self, _id: str) -> domain.MessageState:
62-
return domain.MessageState.from_dict(
63-
self.http.get(f"{self.base_url}/message/{_id}", headers=self.headers)
111+
return self._decrypt(
112+
domain.MessageState.from_dict(
113+
self.http.get(f"{self.base_url}/message/{_id}", headers=self.headers)
114+
)
64115
)
65116

66117

@@ -71,9 +122,10 @@ def __init__(
71122
password: str,
72123
*,
73124
base_url: str = DEFAULT_URL,
125+
encryptor: t.Optional[BaseEncryptor] = None,
74126
http_client: t.Optional[ahttp.AsyncHttpClient] = None,
75127
) -> None:
76-
super().__init__(login, password, base_url=base_url)
128+
super().__init__(login, password, base_url=base_url, encryptor=encryptor)
77129
self.http = http_client
78130

79131
async def __aenter__(self):
@@ -89,15 +141,22 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
89141
self.http = None
90142

91143
async def send(self, message: domain.Message) -> domain.MessageState:
92-
return domain.MessageState.from_dict(
93-
await self.http.post(
94-
f"{self.base_url}/message",
95-
payload=message.asdict(),
96-
headers=self.headers,
144+
message = self._encrypt(message)
145+
return self._decrypt(
146+
domain.MessageState.from_dict(
147+
await self.http.post(
148+
f"{self.base_url}/message",
149+
payload=message.asdict(),
150+
headers=self.headers,
151+
)
97152
)
98153
)
99154

100155
async def get_state(self, _id: str) -> domain.MessageState:
101-
return domain.MessageState.from_dict(
102-
await self.http.get(f"{self.base_url}/message/{_id}", headers=self.headers)
156+
return self._decrypt(
157+
domain.MessageState.from_dict(
158+
await self.http.get(
159+
f"{self.base_url}/message/{_id}", headers=self.headers
160+
)
161+
)
103162
)

0 commit comments

Comments
 (0)