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 +34 -0
- protonlite/__main__.py +3 -0
- protonlite/_modulus_key.py +18 -0
- protonlite/accounts.py +133 -0
- protonlite/api.py +415 -0
- protonlite/calendar.py +495 -0
- protonlite/cli.py +229 -0
- protonlite/client.py +1301 -0
- protonlite/contacts.py +194 -0
- protonlite/crypto.py +180 -0
- protonlite/drive.py +567 -0
- protonlite/errors.py +56 -0
- protonlite/htmltext.py +117 -0
- protonlite/send.py +213 -0
- protonlite/srp.py +244 -0
- protonlite-0.1.0.dist-info/METADATA +193 -0
- protonlite-0.1.0.dist-info/RECORD +25 -0
- protonlite-0.1.0.dist-info/WHEEL +5 -0
- protonlite-0.1.0.dist-info/entry_points.txt +3 -0
- protonlite-0.1.0.dist-info/licenses/LICENSE +21 -0
- protonlite-0.1.0.dist-info/top_level.txt +2 -0
- protonlite_mcp/__init__.py +1 -0
- protonlite_mcp/__main__.py +3 -0
- protonlite_mcp/server.py +86 -0
- protonlite_mcp/tools.py +125 -0
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,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()
|