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.
waidrop_cli/crypto.py ADDED
@@ -0,0 +1,126 @@
1
+ """Client-side archive crypto (must match the extension's background/crypto.js).
2
+
3
+ Contract:
4
+ K = 32-byte master key (base64)
5
+ hashKey = HKDF-SHA256(ikm=K, salt=b"waidrop-v1", info=b"chat-hash", 32)
6
+ encKey = HKDF-SHA256(ikm=K, salt=b"waidrop-v1", info=b"object-encryption", 32)
7
+ mediaKey = HKDF-SHA256(ikm=K, salt=b"waidrop-v1", info=b"media-encryption", 32)
8
+ chatHash = lowerhex(HMAC-SHA256(hashKey, chatId))[:16]
9
+ mediaHash = lowerhex(HMAC-SHA256(hashKey, b"media:" + msgId))[:32]
10
+ object = b"WAAR" | 0x01 | nonce(12) | AES-256-GCM ciphertext+tag (AAD = first 5 bytes)
11
+ media = b"WAMR" | 0x01 | nonce(12) | AES-256-GCM ciphertext+tag (AAD = header + path)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import os
20
+
21
+ from cryptography.hazmat.primitives import hashes
22
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
24
+
25
+ MAGIC = b"WAAR"
26
+ VERSION = 1
27
+ MEDIA_MAGIC = b"WAMR"
28
+ MEDIA_VERSION = 1
29
+ SALT = b"waidrop-v1"
30
+
31
+
32
+ def decode_key(key_base64: str) -> bytes:
33
+ try:
34
+ raw = base64.b64decode(str(key_base64 or ""), validate=True)
35
+ except Exception as exc:
36
+ raise ValueError("encryption key is not valid base64") from exc
37
+ if len(raw) != 32:
38
+ raise ValueError("encryption key must be 32 bytes (base64)")
39
+ return raw
40
+
41
+
42
+ def is_valid_key(key_base64: str) -> bool:
43
+ try:
44
+ decode_key(key_base64)
45
+ return True
46
+ except ValueError:
47
+ return False
48
+
49
+
50
+ def derive_keys(key_base64: str) -> tuple[bytes, bytes]:
51
+ master = decode_key(key_base64)
52
+ hash_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=SALT, info=b"chat-hash").derive(
53
+ master
54
+ )
55
+ enc_key = HKDF(
56
+ algorithm=hashes.SHA256(), length=32, salt=SALT, info=b"object-encryption"
57
+ ).derive(master)
58
+ return hash_key, enc_key
59
+
60
+
61
+ def derive_media_key(key_base64: str) -> bytes:
62
+ master = decode_key(key_base64)
63
+ return HKDF(algorithm=hashes.SHA256(), length=32, salt=SALT, info=b"media-encryption").derive(
64
+ master
65
+ )
66
+
67
+
68
+ def chat_hash(hash_key: bytes, chat_id: str) -> str:
69
+ return hmac.new(hash_key, str(chat_id).encode("utf-8"), hashlib.sha256).hexdigest()[:16]
70
+
71
+
72
+ def media_hash(hash_key: bytes, msg_id: str) -> str:
73
+ return hmac.new(hash_key, f"media:{msg_id}".encode(), hashlib.sha256).hexdigest()[:32]
74
+
75
+
76
+ def decrypt_text(enc_key: bytes, envelope: bytes) -> str:
77
+ data = bytes(envelope)
78
+ header = len(MAGIC) + 1
79
+ if len(data) <= header + 12 + 16:
80
+ raise ValueError("encrypted object is too short")
81
+ if data[: len(MAGIC)] != MAGIC:
82
+ raise ValueError("bad envelope magic")
83
+ if data[len(MAGIC)] != VERSION:
84
+ raise ValueError("unsupported envelope version")
85
+ nonce = data[header : header + 12]
86
+ ciphertext = data[header + 12 :]
87
+ plaintext = AESGCM(enc_key).decrypt(nonce, ciphertext, data[:header])
88
+ return plaintext.decode("utf-8")
89
+
90
+
91
+ def encrypt_text(enc_key: bytes, text: str) -> bytes:
92
+ """Encrypt helper (tests/backup tooling); production only decrypts."""
93
+ nonce = os.urandom(12)
94
+ header = MAGIC + bytes([VERSION])
95
+ ciphertext = AESGCM(enc_key).encrypt(nonce, text.encode("utf-8"), header)
96
+ return header + nonce + ciphertext
97
+
98
+
99
+ def _media_aad(relative_path: str) -> bytes:
100
+ return MEDIA_MAGIC + bytes([MEDIA_VERSION]) + str(relative_path).encode("utf-8")
101
+
102
+
103
+ def decrypt_media(media_key: bytes, relative_path: str, envelope: bytes) -> bytes:
104
+ data = bytes(envelope)
105
+ header = len(MEDIA_MAGIC) + 1
106
+ if len(data) <= header + 12 + 16:
107
+ raise ValueError("media envelope is too short")
108
+ if data[: len(MEDIA_MAGIC)] != MEDIA_MAGIC:
109
+ raise ValueError("bad media envelope magic")
110
+ if data[len(MEDIA_MAGIC)] != MEDIA_VERSION:
111
+ raise ValueError("unsupported media envelope version")
112
+ nonce = data[header : header + 12]
113
+ ciphertext = data[header + 12 :]
114
+ return AESGCM(media_key).decrypt(nonce, ciphertext, _media_aad(relative_path))
115
+
116
+
117
+ def encrypt_media(
118
+ media_key: bytes, relative_path: str, payload: bytes, nonce: bytes | None = None
119
+ ) -> bytes:
120
+ """Encrypt helper (tests/backup tooling); production only decrypts."""
121
+ nonce = nonce or os.urandom(12)
122
+ if len(nonce) != 12:
123
+ raise ValueError("nonce must be 12 bytes")
124
+ header = MEDIA_MAGIC + bytes([MEDIA_VERSION])
125
+ ciphertext = AESGCM(media_key).encrypt(nonce, bytes(payload), _media_aad(relative_path))
126
+ return header + nonce + ciphertext
waidrop_cli/dropbox.py ADDED
@@ -0,0 +1,217 @@
1
+ """Minimal Dropbox API client (stdlib urllib)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ from .auth import (
12
+ EXPIRY_SKEW_SECONDS,
13
+ load_credentials,
14
+ parse_token_document,
15
+ refresh_access_token,
16
+ save_credentials,
17
+ )
18
+ from .config import DropboxConfig
19
+ from .errors import RemoteError
20
+
21
+
22
+ class DropboxClient:
23
+ def __init__(self, config: DropboxConfig):
24
+ self.config = config
25
+ self._access_token = config.token
26
+ self._expires_at: float | None = config.token_expires_at
27
+
28
+ # --- auth ---------------------------------------------------------------
29
+ def _refresh(self) -> None:
30
+ if not (self.config.refresh_token and self.config.app_key):
31
+ raise RemoteError(
32
+ "Dropbox access token expired and no refresh credentials are configured; "
33
+ "run `waidrop auth` to connect"
34
+ )
35
+ document = refresh_access_token(
36
+ app_key=self.config.app_key,
37
+ refresh_token=self.config.refresh_token,
38
+ token_url=self.config.token_url,
39
+ client_secret=self.config.app_secret or None,
40
+ timeout=self.config.timeout,
41
+ )
42
+ parsed = parse_token_document(document)
43
+ self._access_token = parsed["access_token"]
44
+ self._expires_at = parsed["expires_at"]
45
+ self._persist(parsed)
46
+
47
+ def _persist(self, parsed: dict) -> None:
48
+ """Write a refreshed access token back to the credentials file (if any)."""
49
+ if not (self.config.from_file and self.config.credentials_path):
50
+ return
51
+ creds = load_credentials(self.config.credentials_path)
52
+ if creds is None:
53
+ return
54
+ creds.access_token = parsed["access_token"]
55
+ creds.expires_at = parsed["expires_at"]
56
+ if parsed.get("refresh_token"):
57
+ creds.refresh_token = parsed["refresh_token"]
58
+ save_credentials(self.config.credentials_path, creds)
59
+
60
+ def _token(self) -> str:
61
+ expired = (
62
+ self._expires_at is not None and time.time() >= self._expires_at - EXPIRY_SKEW_SECONDS
63
+ )
64
+ if self._access_token and not expired:
65
+ return self._access_token
66
+ if self.config.refresh_token and self.config.app_key:
67
+ self._refresh()
68
+ return self._access_token or ""
69
+ if self._access_token:
70
+ return self._access_token
71
+ raise RemoteError("no Dropbox access token available")
72
+
73
+ # --- transport ----------------------------------------------------------
74
+ def _send(self, url: str, data: bytes, extra_headers: dict, retry: bool = True):
75
+ request = urllib.request.Request(
76
+ url,
77
+ data=data,
78
+ method="POST",
79
+ headers={"authorization": f"Bearer {self._token()}", **extra_headers},
80
+ )
81
+ try:
82
+ with urllib.request.urlopen(request, timeout=self.config.timeout) as response:
83
+ return response.read(), None
84
+ except urllib.error.HTTPError as exc:
85
+ if exc.code == 401 and retry and self.config.refresh_token and self.config.app_key:
86
+ detail = _body(exc)
87
+ if "missing_scope" not in detail:
88
+ self._refresh()
89
+ return self._send(url, data, extra_headers, retry=False)
90
+ return None, exc
91
+ except urllib.error.URLError as exc:
92
+ raise RemoteError(f"cannot reach Dropbox: {exc.reason}") from exc
93
+
94
+ def _rpc(self, path: str, payload: dict, allow_not_found: bool = False):
95
+ data, error = self._send(
96
+ self.config.api_base + path,
97
+ json.dumps(payload).encode("utf-8"),
98
+ {"content-type": "application/json"},
99
+ )
100
+ if error is None:
101
+ try:
102
+ return json.loads(data.decode("utf-8"))
103
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
104
+ raise RemoteError("Dropbox returned an invalid JSON response") from exc
105
+ detail = _body(error)
106
+ if error.code == 401:
107
+ raise RemoteError(
108
+ _scope_guidance(detail) or f"Dropbox authorization failed: HTTP 401 {detail}"
109
+ )
110
+ if error.code == 409 and allow_not_found and "not_found" in detail:
111
+ return None
112
+ if error.code == 429:
113
+ retry_after = error.headers.get("retry-after") if error.headers else None
114
+ raise RemoteError(f"Dropbox rate limit (retry after {retry_after or '?'}s)")
115
+ raise RemoteError(f"Dropbox API error HTTP {error.code}: {detail}")
116
+
117
+ # --- account / lifecycle ------------------------------------------------
118
+ def ensure_access_token(self) -> str:
119
+ return self._token()
120
+
121
+ def get_current_account(self) -> dict:
122
+ data, error = self._send(
123
+ self.config.api_base + "/users/get_current_account",
124
+ b"null",
125
+ {"content-type": "application/json"},
126
+ )
127
+ if error is None:
128
+ try:
129
+ return json.loads(data.decode("utf-8"))
130
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
131
+ raise RemoteError("Dropbox returned an invalid JSON response") from exc
132
+ detail = _body(error)
133
+ if error.code == 401:
134
+ raise RemoteError(
135
+ _scope_guidance(detail) or f"Dropbox authorization failed: HTTP 401 {detail}"
136
+ )
137
+ raise RemoteError(f"Dropbox account lookup failed: HTTP {error.code} {detail}")
138
+
139
+ def revoke(self) -> bool:
140
+ """Revoke the current access token (and its refresh token). Returns True on success."""
141
+ token = self._token()
142
+ request = urllib.request.Request(
143
+ self.config.revoke_url,
144
+ method="POST",
145
+ headers={"authorization": f"Bearer {token}"},
146
+ )
147
+ try:
148
+ with urllib.request.urlopen(request, timeout=self.config.timeout):
149
+ return True
150
+ except urllib.error.HTTPError as exc:
151
+ if exc.code in (400, 401):
152
+ return False
153
+ raise RemoteError(f"Dropbox token revoke failed: HTTP {exc.code} {_body(exc)}") from exc
154
+ except urllib.error.URLError as exc:
155
+ raise RemoteError(f"cannot reach Dropbox: {exc.reason}") from exc
156
+
157
+ # --- endpoints ----------------------------------------------------------
158
+ def list_folder(self, path: str, missing_ok: bool = True) -> list[dict]:
159
+ document = self._rpc(
160
+ "/files/list_folder", {"path": path, "recursive": False}, allow_not_found=missing_ok
161
+ )
162
+ if document is None:
163
+ return []
164
+ entries = list(document.get("entries") or [])
165
+ while document.get("has_more"):
166
+ document = self._rpc("/files/list_folder/continue", {"cursor": document.get("cursor")})
167
+ entries.extend(document.get("entries") or [])
168
+ return entries
169
+
170
+ def download(self, path: str) -> bytes | None:
171
+ data, error = self._send(
172
+ self.config.content_base + "/files/download",
173
+ b"",
174
+ {"dropbox-api-arg": json.dumps({"path": path})},
175
+ )
176
+ if error is None:
177
+ return data
178
+ detail = _body(error)
179
+ if error.code == 409 and "not_found" in detail:
180
+ return None
181
+ if error.code == 401:
182
+ raise RemoteError(
183
+ _scope_guidance(detail) or f"Dropbox authorization failed: HTTP 401 {detail}"
184
+ )
185
+ raise RemoteError(f"Dropbox download failed: HTTP {error.code} {detail}")
186
+
187
+
188
+ def _body(exc: urllib.error.HTTPError) -> str:
189
+ cached = getattr(exc, "_cached_body", None)
190
+ if cached is not None:
191
+ return cached
192
+ try:
193
+ detail = exc.read().decode("utf-8", errors="replace")[:300]
194
+ except Exception:
195
+ detail = ""
196
+ exc._cached_body = detail
197
+ return detail
198
+
199
+
200
+ def _scope_guidance(detail: str) -> str | None:
201
+ try:
202
+ document = json.loads(detail)
203
+ except json.JSONDecodeError:
204
+ return None
205
+ error = document.get("error")
206
+ summary = str(document.get("error_summary") or "")
207
+ if isinstance(error, dict) and (
208
+ error.get(".tag") == "missing_scope" or "missing_scope" in summary
209
+ ):
210
+ scope = error.get("required_scope")
211
+ if isinstance(scope, list):
212
+ scope = ", ".join(str(item) for item in scope)
213
+ return (
214
+ f"Dropbox token is missing required scope {scope or 'unknown'}; "
215
+ "enable it in the Dropbox app console and re-authorize"
216
+ )
217
+ return None
@@ -0,0 +1,132 @@
1
+ """Private environment files for archive credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import tempfile
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+
11
+ from .auth import resolve_credentials_path
12
+ from .errors import UsageError
13
+
14
+ ENV_KEY_PREFIX = "WAIDROP_"
15
+ MANAGED_KEYS = ("WAIDROP_KEY", "WAIDROP_ACCOUNT")
16
+
17
+
18
+ def default_env_path(args=None) -> Path:
19
+ credentials = resolve_credentials_path(args or SimpleNamespace(credentials_file=None))
20
+ return credentials.parent / "env"
21
+
22
+
23
+ def parse_env_text(text: str) -> dict[str, str]:
24
+ values: dict[str, str] = {}
25
+ for raw_line in text.splitlines():
26
+ line = raw_line.strip()
27
+ if not line or line.startswith("#"):
28
+ continue
29
+ if line.startswith("export "):
30
+ line = line[7:].lstrip()
31
+ key, separator, value = line.partition("=")
32
+ key = key.strip()
33
+ if not separator or not key or not key.startswith(ENV_KEY_PREFIX):
34
+ continue
35
+ value = value.strip()
36
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
37
+ value = value[1:-1]
38
+ values[key] = value
39
+ return values
40
+
41
+
42
+ def load_env_file(path: Path) -> dict[str, str]:
43
+ path = Path(path).expanduser()
44
+ try:
45
+ text = path.read_text(encoding="utf-8", errors="replace")
46
+ except FileNotFoundError:
47
+ return {}
48
+ except OSError:
49
+ return {}
50
+
51
+ if os.name != "nt":
52
+ try:
53
+ mode = stat.S_IMODE(path.stat().st_mode)
54
+ except OSError:
55
+ return {}
56
+ if mode & 0o077:
57
+ raise UsageError(
58
+ f"env file {path} must not be readable or writable by group/others; "
59
+ f"run `chmod 600 {path}`"
60
+ )
61
+
62
+ return parse_env_text(text)
63
+
64
+
65
+ def read_env_file_unchecked(path: Path) -> dict[str, str]:
66
+ """Read an env file without enforcing permissions (used before repairing it)."""
67
+ path = Path(path).expanduser()
68
+ try:
69
+ text = path.read_text(encoding="utf-8", errors="replace")
70
+ except FileNotFoundError:
71
+ return {}
72
+ except OSError as exc:
73
+ raise UsageError(f"could not read {path}: {exc}") from exc
74
+ return parse_env_text(text)
75
+
76
+
77
+ def write_env_file(path: Path, *, key: str, account: str) -> None:
78
+ path = Path(path).expanduser()
79
+ existing = _read_text_unchecked(path)
80
+ lines = _strip_managed_keys(existing)
81
+ if not lines:
82
+ lines.append("# WAiDrop CLI environment; keep this file private (chmod 600).")
83
+ elif lines[-1].strip():
84
+ lines.append("")
85
+ lines.append(f"WAIDROP_KEY={key}")
86
+ lines.append(f"WAIDROP_ACCOUNT={account}")
87
+ _atomic_write_private(path, "\n".join(lines).rstrip("\n") + "\n")
88
+
89
+
90
+ def _read_text_unchecked(path: Path) -> str:
91
+ try:
92
+ return path.read_text(encoding="utf-8", errors="replace")
93
+ except FileNotFoundError:
94
+ return ""
95
+ except OSError as exc:
96
+ raise UsageError(f"could not read {path}: {exc}") from exc
97
+
98
+
99
+ def _strip_managed_keys(text: str) -> list[str]:
100
+ lines: list[str] = []
101
+ for raw_line in text.splitlines():
102
+ parsed = parse_env_text(raw_line)
103
+ if any(name in parsed for name in MANAGED_KEYS):
104
+ continue
105
+ lines.append(raw_line)
106
+ while lines and not lines[-1].strip():
107
+ lines.pop()
108
+ return lines
109
+
110
+
111
+ def _atomic_write_private(path: Path, text: str) -> None:
112
+ parent = path.parent
113
+ existed = parent.exists()
114
+ parent.mkdir(parents=True, exist_ok=True)
115
+ if os.name != "nt" and (not existed or parent == default_env_path().parent):
116
+ try:
117
+ os.chmod(parent, 0o700)
118
+ except OSError:
119
+ pass
120
+
121
+ fd, tmp = tempfile.mkstemp(dir=str(parent), prefix=".env-", suffix=".tmp")
122
+ try:
123
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
124
+ handle.write(text)
125
+ os.chmod(tmp, 0o600)
126
+ os.replace(tmp, path)
127
+ except BaseException:
128
+ try:
129
+ os.unlink(tmp)
130
+ except OSError:
131
+ pass
132
+ raise
waidrop_cli/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Error types with stable CLI exit codes."""
2
+
3
+
4
+ class CliError(Exception):
5
+ exit_code = 1
6
+
7
+
8
+ class UsageError(CliError):
9
+ exit_code = 2
10
+
11
+
12
+ class NoMatchesError(CliError):
13
+ exit_code = 3
14
+
15
+
16
+ class GuardError(CliError):
17
+ exit_code = 4
18
+
19
+
20
+ class RemoteError(CliError):
21
+ exit_code = 5