protonlite 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
protonlite/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """A small, unofficial Python client for Proton Mail, Calendar, Contacts and Drive.
2
+
3
+ The protocol work is a port of Proton's own MIT Go libraries; see
4
+ THIRD-PARTY-NOTICES.md. Nothing here is derived from GPL sources.
5
+
6
+ This project is not affiliated with Proton AG. It talks to a private, undocumented
7
+ API and is provided as-is, for use on accounts you own.
8
+ """
9
+
10
+ from .client import Contact, Message, MessageSummary, ProtonMail
11
+ from .errors import (
12
+ APIError,
13
+ AuthError,
14
+ HumanVerificationRequired,
15
+ ProtonError,
16
+ SessionExpired,
17
+ TwoFactorRequired,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "ProtonMail",
24
+ "Message",
25
+ "MessageSummary",
26
+ "Contact",
27
+ "APIError",
28
+ "AuthError",
29
+ "HumanVerificationRequired",
30
+ "ProtonError",
31
+ "SessionExpired",
32
+ "TwoFactorRequired",
33
+ "__version__",
34
+ ]
protonlite/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ """Proton's SRP modulus signing key.
2
+
3
+ Verbatim from ProtonMail/go-srp (MIT, Copyright (c) 2019 Proton Technologies AG).
4
+ See THIRD-PARTY-NOTICES.md.
5
+ """
6
+
7
+ MODULUS_SIGNING_KEY = '''-----BEGIN PGP PUBLIC KEY BLOCK-----
8
+
9
+ xjMEXAHLgxYJKwYBBAHaRw8BAQdAFurWXXwjTemqjD7CXjXVyKf0of7n9Ctm
10
+ L8v9enkzggHNEnByb3RvbkBzcnAubW9kdWx1c8J3BBAWCgApBQJcAcuDBgsJ
11
+ BwgDAgkQNQWFxOlRjyYEFQgKAgMWAgECGQECGwMCHgEAAPGRAP9sauJsW12U
12
+ MnTQUZpsbJb53d0Wv55mZIIiJL2XulpWPQD/V6NglBd96lZKBmInSXX/kXat
13
+ Sv+y0io+LR8i2+jV+AbOOARcAcuDEgorBgEEAZdVAQUBAQdAeJHUz1c9+KfE
14
+ kSIgcBRE3WuXC4oj5a2/U3oASExGDW4DAQgHwmEEGBYIABMFAlwBy4MJEDUF
15
+ hcTpUY8mAhsMAAD/XQD8DxNI6E78meodQI+wLsrKLeHn32iLvUqJbVDhfWSU
16
+ WO4BAMcm1u02t4VKw++ttECPt+HUgPUq5pqQWe5Q2cW4TMsE
17
+ =Y4Mw
18
+ -----END PGP PUBLIC KEY BLOCK-----'''
protonlite/accounts.py ADDED
@@ -0,0 +1,133 @@
1
+ """Several Proton accounts side by side.
2
+
3
+ One session file per account, in a directory, with the address recorded inside
4
+ each one. Which account a request means is resolved by that address, never
5
+ guessed: with more than one signed in and nothing said, the tools ask instead
6
+ of picking. Mailing the wrong person from the wrong account is not a mistake
7
+ worth being convenient about.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from .errors import ProtonError, SessionExpired
19
+
20
+ #: Where the sessions live, overridable for tests and odd layouts.
21
+ ROOT_ENV = "PROTONLITE_HOME"
22
+
23
+ _SAFE = re.compile(r"[^A-Za-z0-9._-]+")
24
+
25
+
26
+ def default_root() -> Path:
27
+ return Path(os.environ.get(ROOT_ENV) or Path.home() / ".config" / "protonlite")
28
+
29
+
30
+ def _slug(address: str) -> str:
31
+ """A filename for an address. Cosmetic: the address itself lives in the file."""
32
+ return _SAFE.sub("-", address.strip().lower()).strip("-") or "account"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Account:
37
+ address: str
38
+ path: Path
39
+
40
+ @property
41
+ def label(self) -> str:
42
+ return self.address or self.path.stem
43
+
44
+
45
+ class AccountStore:
46
+ """The set of accounts signed in on this machine."""
47
+
48
+ def __init__(self, root: str | os.PathLike[str] | None = None) -> None:
49
+ self.root = Path(root) if root else default_root()
50
+
51
+ # -- layout -----------------------------------------------------------
52
+
53
+ @property
54
+ def sessions_dir(self) -> Path:
55
+ return self.root / "sessions"
56
+
57
+ @property
58
+ def legacy_session(self) -> Path:
59
+ return self.root / "session.json"
60
+
61
+ def path_for(self, address: str) -> Path:
62
+ return self.sessions_dir / f"{_slug(address)}.json"
63
+
64
+ def migrate_legacy(self) -> Account | None:
65
+ """Move a single-account session into the per-account layout, once.
66
+
67
+ Early versions kept one ``session.json``. Rather than carry two code
68
+ paths forever, the file is moved the first time the store is read.
69
+ """
70
+ if not self.legacy_session.exists():
71
+ return None
72
+ try:
73
+ address = json.loads(self.legacy_session.read_text()).get("username", "")
74
+ except (OSError, ValueError):
75
+ address = ""
76
+ target = self.path_for(address or "default")
77
+ target.parent.mkdir(parents=True, exist_ok=True)
78
+ if target.exists(): # a newer session wins; leave the old file alone
79
+ return None
80
+ os.replace(self.legacy_session, target)
81
+ return Account(address=address, path=target)
82
+
83
+ # -- reading ----------------------------------------------------------
84
+
85
+ def list(self) -> list[Account]:
86
+ self.migrate_legacy()
87
+ if not self.sessions_dir.is_dir():
88
+ return []
89
+ accounts: list[Account] = []
90
+ for path in sorted(self.sessions_dir.glob("*.json")):
91
+ try:
92
+ address = json.loads(path.read_text()).get("username", "")
93
+ except (OSError, ValueError):
94
+ continue # a half-written file is not an account
95
+ accounts.append(Account(address=address, path=path))
96
+ return accounts
97
+
98
+ def resolve(self, selector: str = "") -> Account:
99
+ """Find the account a request means, or explain why it cannot.
100
+
101
+ An empty selector is fine with exactly one account signed in and an
102
+ error with several -- that ambiguity is the caller's to settle.
103
+ """
104
+ accounts = self.list()
105
+ if not accounts:
106
+ raise SessionExpired(
107
+ "no Proton account is signed in yet; sign in before reading mail"
108
+ )
109
+
110
+ selector = selector.strip().lower()
111
+ if not selector:
112
+ if len(accounts) == 1:
113
+ return accounts[0]
114
+ names = ", ".join(a.label for a in accounts)
115
+ raise ProtonError(
116
+ f"there are {len(accounts)} accounts signed in ({names}); say which one"
117
+ )
118
+
119
+ exact = [a for a in accounts if a.address.lower() == selector]
120
+ if exact:
121
+ return exact[0]
122
+
123
+ # A local part or any distinctive fragment is enough when it picks out
124
+ # exactly one account; anything vaguer is reported rather than guessed.
125
+ partial = [a for a in accounts if selector in a.address.lower()]
126
+ if len(partial) == 1:
127
+ return partial[0]
128
+ if partial:
129
+ names = ", ".join(a.label for a in partial)
130
+ raise ProtonError(f"{selector!r} matches several accounts ({names}); be exact")
131
+
132
+ names = ", ".join(a.label for a in accounts)
133
+ raise ProtonError(f"no account matches {selector!r}; signed in: {names}")
protonlite/api.py ADDED
@@ -0,0 +1,415 @@
1
+ """HTTP transport and session handling for the Proton API.
2
+
3
+ Endpoint paths, payload shapes and the refresh dance follow Proton's own
4
+ MIT-licensed Go client (ProtonMail/go-proton-api); see THIRD-PARTY-NOTICES.md.
5
+
6
+ What a saved session holds is a deliberate choice: the access and refresh
7
+ tokens, and the *salted key passphrases* -- never the account password. The
8
+ passphrase derivation for keys uses a different salt than the one the login
9
+ handshake uses, so a leaked session file decrypts mail but cannot log in or
10
+ change the password.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import secrets
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import requests
24
+
25
+ from . import srp
26
+ from .errors import (
27
+ APIError,
28
+ AuthError,
29
+ HumanVerificationRequired,
30
+ SessionExpired,
31
+ TwoFactorRequired,
32
+ )
33
+
34
+ DEFAULT_HOST = "https://mail.proton.me/api"
35
+ # Proton rejects a request with no app version (HTTP 400). "Other" is the
36
+ # value reserved for clients that are not one of Proton's own apps.
37
+ DEFAULT_APP_VERSION = "Other"
38
+ DEFAULT_USER_AGENT = "protonlite/0.1 (+https://github.com/Maytrella007/protonlite)"
39
+
40
+ #: ``2FA.Enabled`` is a bitfield: a code from an app, a security key, or both.
41
+ _TOTP = 1
42
+ _FIDO2 = 2
43
+
44
+ _CODE_OK = 1000
45
+ _CODE_HUMAN_VERIFICATION = 9001
46
+ _CODE_BAD_CREDENTIALS = 8002
47
+ _CODE_INVALID_TOKEN = 10013
48
+ _CODE_MULTIPLE = 1001 # batch endpoints: see per-item Responses
49
+
50
+
51
+ @dataclass
52
+ class Session:
53
+ """Everything needed to keep talking to the API after a login."""
54
+
55
+ uid: str = ""
56
+ access_token: str = ""
57
+ refresh_token: str = ""
58
+ username: str = ""
59
+ # One passphrase unlocks every key: it is derived from the primary user
60
+ # key's salt, exactly as Proton's own clients do it.
61
+ salted_key_pass: str = ""
62
+
63
+ @property
64
+ def authenticated(self) -> bool:
65
+ return bool(self.uid and self.access_token)
66
+
67
+ def to_json(self) -> str:
68
+ return json.dumps(
69
+ {
70
+ "version": 1,
71
+ "uid": self.uid,
72
+ "access_token": self.access_token,
73
+ "refresh_token": self.refresh_token,
74
+ "username": self.username,
75
+ "salted_key_pass": self.salted_key_pass,
76
+ },
77
+ indent=2,
78
+ )
79
+
80
+ @classmethod
81
+ def from_json(cls, raw: str) -> "Session":
82
+ data = json.loads(raw)
83
+ if data.get("version") != 1:
84
+ raise SessionExpired("unrecognised session file version; log in again")
85
+ return cls(
86
+ uid=data["uid"],
87
+ access_token=data["access_token"],
88
+ refresh_token=data["refresh_token"],
89
+ username=data.get("username", ""),
90
+ salted_key_pass=data.get("salted_key_pass", ""),
91
+ )
92
+
93
+
94
+ def write_session(session: Session, path: str | os.PathLike[str]) -> None:
95
+ """Write the session with 0600 permissions, replacing any previous file atomically."""
96
+ target = Path(path)
97
+ target.parent.mkdir(parents=True, exist_ok=True)
98
+ tmp = target.with_suffix(target.suffix + ".tmp")
99
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
100
+ try:
101
+ with os.fdopen(fd, "w") as handle:
102
+ handle.write(session.to_json())
103
+ except BaseException:
104
+ tmp.unlink(missing_ok=True)
105
+ raise
106
+ os.replace(tmp, target)
107
+ os.chmod(target, 0o600)
108
+
109
+
110
+ def read_session(path: str | os.PathLike[str]) -> Session:
111
+ return Session.from_json(Path(path).read_text())
112
+
113
+
114
+ class ProtonAPI:
115
+ """A thin, synchronous Proton API client."""
116
+
117
+ def __init__(
118
+ self,
119
+ *,
120
+ host: str = DEFAULT_HOST,
121
+ app_version: str = DEFAULT_APP_VERSION,
122
+ user_agent: str = DEFAULT_USER_AGENT,
123
+ session: Session | None = None,
124
+ timeout: int = 30,
125
+ on_session_change: Any = None,
126
+ ) -> None:
127
+ self.host = host.rstrip("/")
128
+ self.timeout = timeout
129
+ self.session = session or Session()
130
+ self._on_session_change = on_session_change
131
+ self._http = requests.Session()
132
+ self._http.headers.update(
133
+ {
134
+ "x-pm-appversion": app_version,
135
+ "User-Agent": user_agent,
136
+ "Accept": "application/vnd.protonmail.v1+json",
137
+ "Content-Type": "application/json",
138
+ }
139
+ )
140
+
141
+ # -- plumbing ---------------------------------------------------------
142
+
143
+ def set_verification_token(self, token: str, method: str = "captcha") -> None:
144
+ """Carry a verification token a person already completed into later requests.
145
+
146
+ Header names are Proton's own (``manager_auth.go``). The token comes
147
+ from the page Proton points at in :class:`HumanVerificationRequired`.
148
+ """
149
+ self._http.headers["x-pm-human-verification-token"] = token
150
+ self._http.headers["x-pm-human-verification-token-type"] = method
151
+
152
+ def _auth_headers(self) -> dict[str, str]:
153
+ if not self.session.authenticated:
154
+ return {}
155
+ return {
156
+ "x-pm-uid": self.session.uid,
157
+ "Authorization": f"Bearer {self.session.access_token}",
158
+ }
159
+
160
+ def request(
161
+ self,
162
+ method: str,
163
+ path: str,
164
+ *,
165
+ json_body: Any = None,
166
+ params: dict[str, Any] | None = None,
167
+ authenticated: bool = True,
168
+ accept_multiple: bool = False,
169
+ _retry: bool = True,
170
+ ) -> dict[str, Any]:
171
+ headers = self._auth_headers() if authenticated else {}
172
+ response = self._http.request(
173
+ method,
174
+ f"{self.host}{path}",
175
+ json=json_body,
176
+ params=params,
177
+ headers=headers,
178
+ timeout=self.timeout,
179
+ )
180
+
181
+ if response.status_code == 429:
182
+ delay = int(response.headers.get("Retry-After", "5"))
183
+ if _retry and delay <= 30:
184
+ time.sleep(delay)
185
+ return self.request(
186
+ method,
187
+ path,
188
+ json_body=json_body,
189
+ params=params,
190
+ authenticated=authenticated,
191
+ accept_multiple=accept_multiple,
192
+ _retry=False,
193
+ )
194
+ raise APIError(429, None, f"rate limited; retry in {delay}s")
195
+
196
+ try:
197
+ payload = response.json()
198
+ except ValueError:
199
+ raise APIError(response.status_code, None, response.text[:200]) from None
200
+
201
+ code = payload.get("Code")
202
+ message = payload.get("Error", "")
203
+
204
+ if code == _CODE_HUMAN_VERIFICATION:
205
+ details = payload.get("Details") or {}
206
+ raise HumanVerificationRequired(
207
+ message or "Proton is asking for verification before this login",
208
+ token=details.get("HumanVerificationToken", ""),
209
+ methods=list(details.get("HumanVerificationMethods") or []),
210
+ web_url=details.get("WebUrl", ""),
211
+ expires_at=details.get("ExpiresAt"),
212
+ )
213
+ if code == _CODE_BAD_CREDENTIALS:
214
+ raise AuthError("Proton rejected the username or password.")
215
+ if response.status_code == 401 and authenticated:
216
+ # The access token lasts a day; the refresh token outlives it.
217
+ if _retry and self.session.refresh_token:
218
+ self.refresh()
219
+ return self.request(
220
+ method,
221
+ path,
222
+ json_body=json_body,
223
+ params=params,
224
+ authenticated=authenticated,
225
+ accept_multiple=accept_multiple,
226
+ _retry=False,
227
+ )
228
+ raise SessionExpired("the session is no longer valid; log in again")
229
+ if code == _CODE_INVALID_TOKEN:
230
+ raise SessionExpired("the stored token was revoked; log in again")
231
+ ok_codes = {_CODE_OK, _CODE_MULTIPLE} if accept_multiple else {_CODE_OK}
232
+ if not response.ok or (code is not None and code not in ok_codes):
233
+ raise APIError(response.status_code, code, message or response.text[:200])
234
+
235
+ return payload
236
+
237
+ def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
238
+ return self.request("GET", path, **kwargs)
239
+
240
+ def get_bytes(self, path: str, params: dict[str, Any] | None = None, *, _retry: bool = True) -> bytes:
241
+ """Fetch a raw, non-JSON body such as an attachment's encrypted data."""
242
+ response = self._http.request(
243
+ "GET",
244
+ f"{self.host}{path}",
245
+ params=params,
246
+ headers=self._auth_headers(),
247
+ timeout=self.timeout,
248
+ )
249
+ if response.status_code == 401 and _retry and self.session.refresh_token:
250
+ self.refresh()
251
+ return self.get_bytes(path, params, _retry=False)
252
+ if not response.ok:
253
+ raise APIError(response.status_code, None, response.text[:200])
254
+ return response.content
255
+
256
+ def post_multipart(
257
+ self, path: str, data: dict[str, Any], files: dict[str, Any], *, _retry: bool = True
258
+ ) -> dict[str, Any]:
259
+ """POST a multipart form (an attachment upload), returning the JSON body."""
260
+ # Drop the session's default JSON Content-Type so requests sets the
261
+ # multipart/form-data boundary; otherwise Proton tries to parse the
262
+ # body as JSON and rejects it.
263
+ headers = {**self._auth_headers(), "Content-Type": None}
264
+ response = self._http.request(
265
+ "POST",
266
+ f"{self.host}{path}",
267
+ data=data,
268
+ files=files,
269
+ headers=headers,
270
+ timeout=self.timeout,
271
+ )
272
+ if response.status_code == 401 and _retry and self.session.refresh_token:
273
+ self.refresh()
274
+ return self.post_multipart(path, data, files, _retry=False)
275
+ try:
276
+ payload = response.json()
277
+ except ValueError:
278
+ raise APIError(response.status_code, None, response.text[:200]) from None
279
+ code = payload.get("Code")
280
+ if not response.ok or (code is not None and code != _CODE_OK):
281
+ raise APIError(response.status_code, code, payload.get("Error") or response.text[:200])
282
+ return payload
283
+
284
+ def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
285
+ return self.request("POST", path, **kwargs)
286
+
287
+ def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
288
+ return self.request("PUT", path, **kwargs)
289
+
290
+ def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
291
+ return self.request("DELETE", path, **kwargs)
292
+
293
+ def _session_changed(self) -> None:
294
+ if self._on_session_change is not None:
295
+ self._on_session_change(self.session)
296
+
297
+ # -- authentication ---------------------------------------------------
298
+
299
+ def login(
300
+ self, username: str, password: bytes, *, totp_code: str = ""
301
+ ) -> dict[str, Any]:
302
+ """Run the SRP handshake. Returns the auth payload (scopes, password mode...).
303
+
304
+ *totp_code* is the six-digit code from an authenticator app, needed only
305
+ by accounts that have one. It cannot be supplied in advance usefully --
306
+ codes last about thirty seconds -- so a caller without one gets
307
+ :class:`TwoFactorRequired` and asks for it then.
308
+ """
309
+ info = self.post(
310
+ "/auth/v4/info", json_body={"Username": username}, authenticated=False
311
+ )
312
+ proofs = srp.generate_proofs(
313
+ version=info["Version"],
314
+ password=password,
315
+ b64_salt=info["Salt"],
316
+ signed_modulus=info["Modulus"],
317
+ b64_server_ephemeral=info["ServerEphemeral"],
318
+ )
319
+ auth = self.post(
320
+ "/auth/v4",
321
+ json_body={
322
+ "Username": username,
323
+ "ClientEphemeral": proofs.b64_client_ephemeral,
324
+ "ClientProof": proofs.b64_client_proof,
325
+ "SRPSession": info["SRPSession"],
326
+ },
327
+ authenticated=False,
328
+ )
329
+
330
+ # Authenticating the server too is the point of SRP: a wrong proof here
331
+ # means we are not talking to Proton, even though the password matched.
332
+ if not proofs.server_proof_matches(auth["ServerProof"]):
333
+ raise AuthError("the server proof did not match; aborting the login")
334
+
335
+ # The second factor is submitted *against the session this login just
336
+ # created*, so the tokens have to be in place before it is settled --
337
+ # but nothing is persisted until the account is fully open.
338
+ self.session.uid = auth["UID"]
339
+ self.session.access_token = auth["AccessToken"]
340
+ self.session.refresh_token = auth["RefreshToken"]
341
+ self.session.username = username
342
+
343
+ enabled = int((auth.get("2FA") or {}).get("Enabled") or 0)
344
+ if enabled:
345
+ self._settle_second_factor(enabled, totp_code)
346
+
347
+ self._session_changed()
348
+ return auth
349
+
350
+ def _settle_second_factor(self, enabled: int, code: str) -> None:
351
+ """Submit a one-time code, or explain exactly what is missing."""
352
+ if not enabled & _TOTP:
353
+ self._abandon_partial_session()
354
+ raise TwoFactorRequired(
355
+ "this account's second factor is a security key (FIDO2/U2F), which this "
356
+ "client cannot do — a code from an authenticator app can be added in "
357
+ "Proton's settings as an alternative"
358
+ )
359
+ if not code.strip():
360
+ self._abandon_partial_session()
361
+ raise TwoFactorRequired(
362
+ "this account needs the six-digit code from your authenticator app; "
363
+ "sign in again and pass the code that is showing now"
364
+ )
365
+
366
+ try:
367
+ self.post(
368
+ "/auth/v4/2fa",
369
+ json_body={"TwoFactorCode": code.strip().replace(" ", "")},
370
+ _retry=False,
371
+ )
372
+ except APIError as exc:
373
+ self._abandon_partial_session()
374
+ raise TwoFactorRequired(
375
+ f"Proton rejected that code ({exc.message or 'invalid'}). They last about "
376
+ "thirty seconds, so use the one showing now — and check the server clock "
377
+ "if it keeps failing"
378
+ ) from exc
379
+
380
+ def _abandon_partial_session(self) -> None:
381
+ """Throw away a session that never got past the second factor.
382
+
383
+ Leaving it would list a half-open session as a signed-in device on the
384
+ account, which is confusing and pointless.
385
+ """
386
+ try:
387
+ self.request("DELETE", "/auth/v4", _retry=False)
388
+ except Exception:
389
+ pass # best effort: the session is useless to us either way
390
+ self.session = Session()
391
+
392
+ def refresh(self) -> None:
393
+ payload = self.post(
394
+ "/auth/v4/refresh",
395
+ json_body={
396
+ "UID": self.session.uid,
397
+ "RefreshToken": self.session.refresh_token,
398
+ "ResponseType": "token",
399
+ "GrantType": "refresh_token",
400
+ "RedirectURI": "https://protonmail.ch",
401
+ "State": secrets.token_hex(16),
402
+ },
403
+ authenticated=True,
404
+ _retry=False,
405
+ )
406
+ self.session.access_token = payload["AccessToken"]
407
+ self.session.refresh_token = payload["RefreshToken"]
408
+ self._session_changed()
409
+
410
+ def logout(self) -> None:
411
+ """Revoke this session server-side. Proton counts it as a signed-in device."""
412
+ if self.session.authenticated:
413
+ self.request("DELETE", "/auth/v4", _retry=False)
414
+ self.session = Session()
415
+ self._session_changed()