waidrop-cli 1.0.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.
@@ -0,0 +1,3 @@
1
+ """Query encrypted WAiDrop archives."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
waidrop_cli/archive.py ADDED
@@ -0,0 +1,103 @@
1
+ """Archive format detection and encrypted object access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .crypto import (
9
+ chat_hash,
10
+ decrypt_media,
11
+ decrypt_text,
12
+ derive_keys,
13
+ derive_media_key,
14
+ media_hash,
15
+ )
16
+ from .errors import RemoteError, UsageError
17
+
18
+ if TYPE_CHECKING:
19
+ from .config import DropboxConfig
20
+
21
+ ENCRYPTED = "encrypted"
22
+ SUPPORTED_FORMAT = 4
23
+ FORMAT_OBJECT = "_format.json"
24
+
25
+
26
+ class ArchiveCodec:
27
+ """Read encrypted archive objects."""
28
+
29
+ def __init__(self, archive_key: str = "", version: int = SUPPORTED_FORMAT):
30
+ self.version = version
31
+ if not archive_key:
32
+ raise UsageError(
33
+ "archive is encrypted; encryption key required "
34
+ "(--key, WAIDROP_KEY, or `waidrop config`)"
35
+ )
36
+ try:
37
+ self._hash_key, self._enc_key = derive_keys(archive_key)
38
+ self._media_key = derive_media_key(archive_key)
39
+ except ValueError as exc:
40
+ raise UsageError(str(exc)) from exc
41
+
42
+ @property
43
+ def part_suffix(self) -> str:
44
+ return ".enc"
45
+
46
+ @property
47
+ def chat_suffix(self) -> str:
48
+ return ".enc"
49
+
50
+ @property
51
+ def media_suffix(self) -> str:
52
+ return ".enc"
53
+
54
+ def decode_text(self, payload: bytes | bytearray) -> str:
55
+ return decrypt_text(self._enc_key, bytes(payload))
56
+
57
+ def decode_media(self, relative_path: str, payload: bytes | bytearray) -> bytes:
58
+ return decrypt_media(self._media_key, relative_path, bytes(payload))
59
+
60
+ def chat_hash(self, chat_id: str) -> str:
61
+ return chat_hash(self._hash_key, chat_id)
62
+
63
+ def media_hash(self, msg_id: str) -> str:
64
+ return media_hash(self._hash_key, msg_id)
65
+
66
+
67
+ def parse_format_marker(payload: bytes) -> tuple[int, str]:
68
+ try:
69
+ document = json.loads(payload.decode("utf-8"))
70
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
71
+ raise RemoteError(f"archive format marker is invalid: {exc}") from exc
72
+ if not isinstance(document, dict):
73
+ raise RemoteError("archive format marker is invalid: expected a JSON object")
74
+ layout = document.get("layout")
75
+ if layout != "waidrop":
76
+ raise RemoteError(f"unsupported archive layout: {layout!r}")
77
+
78
+ version = document.get("format")
79
+ if not isinstance(version, int) or isinstance(version, bool) or version < 1:
80
+ raise RemoteError("archive format marker is invalid: missing format version")
81
+ if version != SUPPORTED_FORMAT:
82
+ raise RemoteError(f"unsupported archive format {version}; update waidrop-cli")
83
+
84
+ mode = document.get("mode")
85
+ if mode == "plaintext":
86
+ raise RemoteError(
87
+ "plaintext archives are no longer supported; re-export with encryption enabled"
88
+ )
89
+ if mode != ENCRYPTED:
90
+ raise RemoteError(f"unsupported archive mode: {mode!r}")
91
+ return version, mode
92
+
93
+
94
+ def read_format(client, cfg: DropboxConfig) -> tuple[int, str]:
95
+ payload = client.download(f"{cfg.account_root}/{FORMAT_OBJECT}")
96
+ if payload is None:
97
+ raise RemoteError("archive format marker is missing")
98
+ return parse_format_marker(payload)
99
+
100
+
101
+ def resolve_archive(client, cfg: DropboxConfig) -> ArchiveCodec:
102
+ version, _mode = read_format(client, cfg)
103
+ return ArchiveCodec(archive_key=cfg.archive_key, version=version)
waidrop_cli/auth.py ADDED
@@ -0,0 +1,321 @@
1
+ """Dropbox OAuth (PKCE, offline access) and local credential storage.
2
+
3
+ The CLI is a public client: it ships only the app key (client id), never a
4
+ client secret. Each user authorizes once; we store a long-lived refresh token
5
+ and mint short-lived access tokens on demand. The archive lives in the Dropbox
6
+ App Folder, which is namespaced per app, so the CLI must use the same app key
7
+ as the extension to see the same folder.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import secrets
17
+ import stat
18
+ import tempfile
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from .errors import RemoteError, UsageError
27
+
28
+ AUTHORIZE_URL = "https://www.dropbox.com/oauth2/authorize"
29
+ DEFAULT_TOKEN_URL = "https://api.dropboxapi.com/oauth2/token"
30
+ DEFAULT_REVOKE_URL = "https://api.dropboxapi.com/2/auth/token/revoke"
31
+ DEFAULT_API_BASE = "https://api.dropboxapi.com/2"
32
+ DEFAULT_CONTENT_BASE = "https://content.dropboxapi.com/2"
33
+
34
+ # Read-only scopes for the CLI. The extension additionally requests
35
+ # files.content.write so it can upload the archive.
36
+ CLI_SCOPES = ("account_info.read", "files.metadata.read", "files.content.read")
37
+ REQUIRED_SCOPES = ("files.metadata.read", "files.content.read")
38
+
39
+ # Set at release; must match the extension's DEFAULT_DROPBOX_APP_KEY. The app
40
+ # key is a public client id and is safe to ship. Never ship a client secret.
41
+ DEFAULT_APP_KEY = "ydtsl7okrf4xuee"
42
+
43
+ EXPIRY_SKEW_SECONDS = 60
44
+ CREDENTIALS_VERSION = 1
45
+
46
+
47
+ def _b64url(data: bytes) -> str:
48
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
49
+
50
+
51
+ def generate_pkce() -> tuple[str, str]:
52
+ """Return (code_verifier, code_challenge) using S256."""
53
+ verifier = secrets.token_urlsafe(48)
54
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
55
+ return verifier, _b64url(digest)
56
+
57
+
58
+ def build_authorize_url(
59
+ app_key: str,
60
+ *,
61
+ code_challenge: str,
62
+ redirect_uri: str | None = None,
63
+ state: str | None = None,
64
+ scopes: tuple[str, ...] = CLI_SCOPES,
65
+ ) -> str:
66
+ params = {
67
+ "client_id": app_key,
68
+ "response_type": "code",
69
+ "token_access_type": "offline",
70
+ "code_challenge": code_challenge,
71
+ "code_challenge_method": "S256",
72
+ "scope": " ".join(scopes),
73
+ }
74
+ if redirect_uri:
75
+ params["redirect_uri"] = redirect_uri
76
+ if state:
77
+ params["state"] = state
78
+ return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
79
+
80
+
81
+ def _read_error_body(exc: urllib.error.HTTPError) -> str:
82
+ cached = getattr(exc, "_cached_body", None)
83
+ if cached is not None:
84
+ return cached
85
+ try:
86
+ detail = exc.read().decode("utf-8", errors="replace")[:300]
87
+ except Exception:
88
+ detail = ""
89
+ exc._cached_body = detail
90
+ return detail
91
+
92
+
93
+ def _token_error(detail: str, context: str) -> RemoteError:
94
+ try:
95
+ document = json.loads(detail)
96
+ except (json.JSONDecodeError, TypeError):
97
+ return RemoteError(f"Dropbox token request failed: {detail or 'unknown error'}")
98
+ code = document.get("error")
99
+ description = document.get("error_description") or ""
100
+ if code == "invalid_grant":
101
+ if context == "refresh token":
102
+ return RemoteError(
103
+ "Dropbox refresh token was revoked or expired; run `waidrop auth` again"
104
+ )
105
+ return RemoteError(
106
+ "the authorization code expired or was already used; run `waidrop auth` again"
107
+ )
108
+ if code in ("invalid_scope", "missing_scope"):
109
+ return RemoteError(
110
+ f"Dropbox scope error ({code}): {description or 'enable the required scopes in the App Console'}"
111
+ )
112
+ if code == "unauthorized_client":
113
+ return RemoteError(
114
+ "Dropbox rejected the app key; check WAIDROP_DROPBOX_APP_KEY / --dropbox-app-key"
115
+ )
116
+ message = f"Dropbox token request failed: {code or 'unknown'}"
117
+ if description:
118
+ message += f" ({description})"
119
+ return RemoteError(message)
120
+
121
+
122
+ def _token_request(token_url: str, payload: dict, timeout: float, context: str) -> dict:
123
+ request = urllib.request.Request(
124
+ token_url,
125
+ data=urllib.parse.urlencode(payload).encode("utf-8"),
126
+ method="POST",
127
+ headers={"content-type": "application/x-www-form-urlencoded"},
128
+ )
129
+ try:
130
+ with urllib.request.urlopen(request, timeout=timeout) as response:
131
+ return json.loads(response.read().decode("utf-8"))
132
+ except urllib.error.HTTPError as exc:
133
+ raise _token_error(_read_error_body(exc), context) from exc
134
+ except urllib.error.URLError as exc:
135
+ raise RemoteError(f"cannot reach Dropbox: {exc.reason}") from exc
136
+
137
+
138
+ def exchange_code(
139
+ *,
140
+ app_key: str,
141
+ code: str,
142
+ code_verifier: str,
143
+ token_url: str = DEFAULT_TOKEN_URL,
144
+ redirect_uri: str | None = None,
145
+ client_secret: str | None = None,
146
+ timeout: float = 60.0,
147
+ ) -> dict:
148
+ payload = {
149
+ "grant_type": "authorization_code",
150
+ "code": code,
151
+ "client_id": app_key,
152
+ "code_verifier": code_verifier,
153
+ }
154
+ if redirect_uri:
155
+ payload["redirect_uri"] = redirect_uri
156
+ if client_secret:
157
+ payload["client_secret"] = client_secret
158
+ return _token_request(token_url, payload, timeout, context="authorization code")
159
+
160
+
161
+ def refresh_access_token(
162
+ *,
163
+ app_key: str,
164
+ refresh_token: str,
165
+ token_url: str = DEFAULT_TOKEN_URL,
166
+ client_secret: str | None = None,
167
+ timeout: float = 60.0,
168
+ ) -> dict:
169
+ payload = {
170
+ "grant_type": "refresh_token",
171
+ "refresh_token": refresh_token,
172
+ "client_id": app_key,
173
+ }
174
+ if client_secret:
175
+ payload["client_secret"] = client_secret
176
+ return _token_request(token_url, payload, timeout, context="refresh token")
177
+
178
+
179
+ def _split_scope(value) -> list[str]:
180
+ if not value:
181
+ return []
182
+ if isinstance(value, (list, tuple)):
183
+ return [str(item) for item in value]
184
+ return str(value).split()
185
+
186
+
187
+ def parse_token_document(document: dict) -> dict:
188
+ access_token = document.get("access_token")
189
+ if not access_token:
190
+ raise RemoteError("Dropbox token response contained no access token")
191
+ expires_in = document.get("expires_in")
192
+ return {
193
+ "access_token": access_token,
194
+ "refresh_token": document.get("refresh_token"),
195
+ "expires_at": time.time() + float(expires_in) if expires_in else None,
196
+ "scope": _split_scope(document.get("scope")),
197
+ "account_id": document.get("account_id"),
198
+ }
199
+
200
+
201
+ def missing_scopes(granted, required: tuple[str, ...] = REQUIRED_SCOPES) -> list[str]:
202
+ granted_set = set(granted or [])
203
+ return [scope for scope in required if scope not in granted_set]
204
+
205
+
206
+ @dataclass
207
+ class Credentials:
208
+ app_key: str
209
+ refresh_token: str
210
+ access_token: str | None = None
211
+ expires_at: float | None = None
212
+ scope: list[str] = field(default_factory=list)
213
+ account_id: str | None = None
214
+ account_name: str | None = None
215
+ email: str | None = None
216
+ obtained_at: str | None = None
217
+
218
+ def to_dict(self) -> dict:
219
+ return {
220
+ "version": CREDENTIALS_VERSION,
221
+ "app_key": self.app_key,
222
+ "refresh_token": self.refresh_token,
223
+ "access_token": self.access_token,
224
+ "expires_at": self.expires_at,
225
+ "scope": list(self.scope),
226
+ "account_id": self.account_id,
227
+ "account_name": self.account_name,
228
+ "email": self.email,
229
+ "obtained_at": self.obtained_at,
230
+ }
231
+
232
+ @classmethod
233
+ def from_dict(cls, data: dict) -> Credentials:
234
+ return cls(
235
+ app_key=str(data["app_key"]),
236
+ refresh_token=str(data.get("refresh_token") or ""),
237
+ access_token=data.get("access_token"),
238
+ expires_at=float(data["expires_at"]) if data.get("expires_at") is not None else None,
239
+ scope=_split_scope(data.get("scope")),
240
+ account_id=data.get("account_id"),
241
+ account_name=data.get("account_name"),
242
+ email=data.get("email"),
243
+ obtained_at=data.get("obtained_at"),
244
+ )
245
+
246
+
247
+ def default_credentials_path() -> Path:
248
+ if os.name == "nt":
249
+ base = Path(os.environ.get("APPDATA") or Path.home())
250
+ return base / "waidrop" / "credentials.json"
251
+ xdg = os.environ.get("XDG_CONFIG_HOME")
252
+ base = Path(xdg) if xdg else Path.home() / ".config"
253
+ return base / "waidrop" / "credentials.json"
254
+
255
+
256
+ def resolve_credentials_path(args) -> Path:
257
+ explicit = getattr(args, "credentials_file", None) or os.environ.get("WAIDROP_CREDENTIALS")
258
+ if explicit:
259
+ return Path(str(explicit)).expanduser()
260
+ return default_credentials_path()
261
+
262
+
263
+ def load_credentials(path) -> Credentials | None:
264
+ if os.name != "nt":
265
+ try:
266
+ mode = stat.S_IMODE(os.stat(path).st_mode)
267
+ except FileNotFoundError:
268
+ return None
269
+ except OSError:
270
+ return None
271
+ if mode & 0o077:
272
+ raise UsageError(
273
+ f"credentials file {path} must not be readable or writable by "
274
+ f"group/others; run `chmod 600 {path}`"
275
+ )
276
+ try:
277
+ with open(path, encoding="utf-8") as handle:
278
+ data = json.load(handle)
279
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
280
+ return None
281
+ if not isinstance(data, dict) or data.get("version") != CREDENTIALS_VERSION:
282
+ return None
283
+ try:
284
+ return Credentials.from_dict(data)
285
+ except (KeyError, TypeError, ValueError):
286
+ return None
287
+
288
+
289
+ def save_credentials(path, creds: Credentials) -> None:
290
+ path = Path(path)
291
+ parent = path.parent
292
+ existed = parent.exists()
293
+ parent.mkdir(parents=True, exist_ok=True)
294
+ if not existed or parent == default_credentials_path().parent:
295
+ try:
296
+ os.chmod(parent, 0o700)
297
+ except OSError:
298
+ pass
299
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".credentials-", suffix=".tmp")
300
+ try:
301
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
302
+ json.dump(creds.to_dict(), handle, indent=2)
303
+ handle.write("\n")
304
+ os.chmod(tmp, 0o600)
305
+ os.replace(tmp, path)
306
+ except BaseException:
307
+ try:
308
+ os.unlink(tmp)
309
+ except OSError:
310
+ pass
311
+ raise
312
+
313
+
314
+ def delete_credentials(path) -> bool:
315
+ try:
316
+ Path(path).unlink()
317
+ return True
318
+ except FileNotFoundError:
319
+ return False
320
+ except OSError:
321
+ return False
@@ -0,0 +1,182 @@
1
+ """Interactive Dropbox authorization flows: paste-code and loopback.
2
+
3
+ The paste flow is the default: it needs no redirect URI registration and works
4
+ over SSH and on headless hosts (the user opens the URL anywhere and pastes the
5
+ code back). The loopback flow is optional and gives a smoother desktop
6
+ experience; its redirect URI must be registered in the App Console.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hmac
12
+ import secrets
13
+ import sys
14
+ import threading
15
+ import webbrowser
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+ from urllib.parse import parse_qs, urlparse
18
+
19
+ from .auth import (
20
+ CLI_SCOPES,
21
+ build_authorize_url,
22
+ exchange_code,
23
+ generate_pkce,
24
+ parse_token_document,
25
+ )
26
+ from .errors import RemoteError, UsageError
27
+
28
+ LOOPBACK_PORT = 53682
29
+ LOOPBACK_PATH = "/callback"
30
+
31
+
32
+ def _open_browser(url: str, open_browser: bool) -> None:
33
+ if not open_browser:
34
+ return
35
+ try:
36
+ webbrowser.open(url)
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def run_paste_flow(
42
+ *,
43
+ app_key: str,
44
+ token_url: str,
45
+ client_secret: str | None = None,
46
+ open_browser: bool = True,
47
+ code: str | None = None,
48
+ input_fn=input,
49
+ print_fn=print,
50
+ timeout: float = 60.0,
51
+ ) -> dict:
52
+ verifier, challenge = generate_pkce()
53
+ url = build_authorize_url(app_key, code_challenge=challenge, scopes=CLI_SCOPES)
54
+
55
+ print_fn("Connect WAiDrop to Dropbox:")
56
+ print_fn(" 1. Open this URL in a browser:")
57
+ print_fn(f" {url}")
58
+ print_fn(' 2. Click "Allow", then copy the authorization code Dropbox shows.')
59
+
60
+ if code is None and not sys.stdin.isatty():
61
+ raise UsageError(
62
+ "cannot prompt for the authorization code in a non-interactive session; "
63
+ "pass --code <CODE> or use --loopback"
64
+ )
65
+ _open_browser(url, open_browser)
66
+
67
+ if code is None:
68
+ code = input_fn(" Paste the authorization code: ").strip()
69
+ if not code:
70
+ raise UsageError("no authorization code provided")
71
+
72
+ document = exchange_code(
73
+ app_key=app_key,
74
+ code=code,
75
+ code_verifier=verifier,
76
+ token_url=token_url,
77
+ client_secret=client_secret,
78
+ timeout=timeout,
79
+ )
80
+ return parse_token_document(document)
81
+
82
+
83
+ def _result_page(ok: bool) -> str:
84
+ if ok:
85
+ body = "WAiDrop connected to Dropbox. You can close this tab and return to the terminal."
86
+ else:
87
+ body = "WAiDrop could not complete the connection. Check the terminal for details."
88
+ return f"<!doctype html><html><head><meta charset=utf-8></head><body><h1>WAiDrop</h1><p>{body}</p></body></html>"
89
+
90
+
91
+ def run_loopback_flow(
92
+ *,
93
+ app_key: str,
94
+ token_url: str,
95
+ port: int = LOOPBACK_PORT,
96
+ client_secret: str | None = None,
97
+ open_browser: bool = True,
98
+ timeout: float = 300.0,
99
+ print_fn=print,
100
+ ) -> dict:
101
+ verifier, challenge = generate_pkce()
102
+ state = secrets.token_urlsafe(24)
103
+ redirect_uri = f"http://127.0.0.1:{port}{LOOPBACK_PATH}"
104
+ url = build_authorize_url(
105
+ app_key, code_challenge=challenge, redirect_uri=redirect_uri, state=state, scopes=CLI_SCOPES
106
+ )
107
+
108
+ result: dict = {"code": None, "error": None, "state_ok": False}
109
+ done = threading.Event()
110
+
111
+ class Handler(BaseHTTPRequestHandler):
112
+ def log_message(self, *args): # silence request logging
113
+ return
114
+
115
+ def do_GET(self):
116
+ parsed = urlparse(self.path)
117
+ if parsed.path != LOOPBACK_PATH:
118
+ self.send_response(404)
119
+ self.end_headers()
120
+ return
121
+ query = parse_qs(parsed.query)
122
+ got_state = (query.get("state") or [""])[0]
123
+ state_ok = hmac.compare_digest(got_state, state)
124
+ result["state_ok"] = state_ok
125
+ if not state_ok:
126
+ self.send_response(400)
127
+ self.send_header("content-type", "text/html; charset=utf-8")
128
+ self.end_headers()
129
+ self.wfile.write(_result_page(False).encode("utf-8"))
130
+ return
131
+ if "code" in query:
132
+ result["code"] = query["code"][0]
133
+ elif "error" in query:
134
+ description = (query.get("error_description") or [""])[0]
135
+ result["error"] = f"{query['error'][0]} {description}".strip()
136
+ else:
137
+ result["error"] = "missing authorization code"
138
+ self.send_response(200)
139
+ self.send_header("content-type", "text/html; charset=utf-8")
140
+ self.end_headers()
141
+ self.wfile.write(_result_page(result["code"] is not None).encode("utf-8"))
142
+ done.set()
143
+
144
+ try:
145
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
146
+ except OSError as exc:
147
+ raise UsageError(
148
+ f"cannot bind the loopback callback on 127.0.0.1:{port} ({exc}); "
149
+ "pick another --port or use the paste flow"
150
+ ) from exc
151
+
152
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
153
+ server_thread.start()
154
+ try:
155
+ print_fn(
156
+ f"Waiting for Dropbox authorization at {redirect_uri} (timeout {int(timeout)}s)..."
157
+ )
158
+ _open_browser(url, open_browser)
159
+ if not done.wait(timeout):
160
+ raise UsageError(
161
+ "timed out waiting for Dropbox authorization; run again or use the paste flow"
162
+ )
163
+ finally:
164
+ server.shutdown()
165
+ server.server_close()
166
+ server_thread.join(timeout=2)
167
+
168
+ if not result["state_ok"]:
169
+ raise RemoteError("authorization state mismatch (possible CSRF); run `waidrop auth` again")
170
+ if result["error"]:
171
+ raise UsageError(f"Dropbox authorization failed: {result['error']}")
172
+
173
+ document = exchange_code(
174
+ app_key=app_key,
175
+ code=result["code"],
176
+ code_verifier=verifier,
177
+ token_url=token_url,
178
+ redirect_uri=redirect_uri,
179
+ client_secret=client_secret,
180
+ timeout=60.0,
181
+ )
182
+ return parse_token_document(document)