gcli-control 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.
gcli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ gcli — remote access tool via npoint.io.
3
+ Encrypted command relay using AES-256-GCM over npoint.io JSON bins.
4
+ """
5
+ __version__ = "0.1.0"
6
+ __author__ = "gcli"
gcli/__main__.py ADDED
@@ -0,0 +1,104 @@
1
+ """
2
+ gcli — remote access tool via npoint.io.
3
+
4
+ Commands:
5
+ gcli host [--password P] [--bin ID] [--poll-interval N]
6
+ gcli ssh [--password P] [--bin ID]
7
+ gcli stop [--bin ID]
8
+ """
9
+ import argparse
10
+ import sys
11
+ import getpass
12
+ import logging
13
+
14
+ from .host import start_daemon, stop_daemon, DEFAULT_BIN_ID, DEFAULT_POLL
15
+ from .client import SSHSession
16
+ from .colors import banner, info, error
17
+
18
+ logger = logging.getLogger("gcli")
19
+
20
+
21
+ def cmd_host(args):
22
+ """Start the gcli host daemon."""
23
+ password = args.password
24
+ if not password:
25
+ password = getpass.getpass("Password: ")
26
+
27
+ print(info(f"[gcli] Starting host daemon (bin={args.bin})..."))
28
+ start_daemon(
29
+ password=password,
30
+ bin_id=args.bin,
31
+ poll_interval=args.poll_interval,
32
+ foreground=args.foreground,
33
+ )
34
+
35
+
36
+ def cmd_ssh(args):
37
+ """Connect to a remote gcli host."""
38
+ password = args.password
39
+ if not password:
40
+ password = getpass.getpass("Password: ")
41
+
42
+ print(banner())
43
+ session = SSHSession(
44
+ password=password,
45
+ bin_id=args.bin,
46
+ )
47
+ session.repl()
48
+
49
+
50
+ def cmd_stop(args):
51
+ """Stop a running gcli daemon."""
52
+ stop_daemon(bin_id=args.bin)
53
+
54
+
55
+ def main():
56
+ parser = argparse.ArgumentParser(
57
+ prog="gcli",
58
+ description="Remote access tool via npoint.io — encrypted command relay",
59
+ )
60
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
61
+
62
+ # ---- gcli host ----
63
+ host_parser = subparsers.add_parser("host", help="Start the background host daemon")
64
+ host_parser.add_argument("-p", "--password", default=None, help="Encryption password")
65
+ host_parser.add_argument("-b", "--bin", default=DEFAULT_BIN_ID, help="npoint.io bin ID")
66
+ host_parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL,
67
+ help="Poll interval in seconds (default: 2.0)")
68
+ host_parser.add_argument("--foreground", action="store_true",
69
+ help="Run in foreground (don't detach)")
70
+
71
+ # ---- gcli ssh ----
72
+ ssh_parser = subparsers.add_parser("ssh", help="Connect to a remote gcli host")
73
+ ssh_parser.add_argument("-p", "--password", default=None, help="Encryption password")
74
+ ssh_parser.add_argument("-b", "--bin", default=DEFAULT_BIN_ID, help="npoint.io bin ID")
75
+
76
+ # ---- gcli stop ----
77
+ stop_parser = subparsers.add_parser("stop", help="Stop the host daemon")
78
+ stop_parser.add_argument("-b", "--bin", default=DEFAULT_BIN_ID, help="npoint.io bin ID")
79
+
80
+ args = parser.parse_args()
81
+
82
+ if not args.command:
83
+ print(banner())
84
+ parser.print_help()
85
+ sys.exit(1)
86
+
87
+ try:
88
+ if args.command == "host":
89
+ print(banner())
90
+ cmd_host(args)
91
+ elif args.command == "ssh":
92
+ cmd_ssh(args)
93
+ elif args.command == "stop":
94
+ cmd_stop(args)
95
+ except KeyboardInterrupt:
96
+ print(f"\n{error('[gcli] Interrupted.')}")
97
+ sys.exit(130)
98
+ except Exception as e:
99
+ logger.error("Fatal error: %s", e)
100
+ sys.exit(1)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
gcli/client.py ADDED
@@ -0,0 +1,362 @@
1
+ """
2
+ gcli ssh — client that connects to a remote gcli host via npoint.
3
+ Sends encrypted commands, polls for results, provides interactive REPL.
4
+ """
5
+ import sys
6
+ import time
7
+ import uuid
8
+ import logging
9
+ from typing import Optional, List, Dict
10
+
11
+ # readline: available on Linux/macOS, needs pyreadline on Windows
12
+ try:
13
+ import readline # noqa: F401
14
+ except ImportError:
15
+ try:
16
+ import pyreadline as readline # noqa: F401
17
+ except ImportError:
18
+ readline = None # type: ignore
19
+
20
+ from .protocol import (
21
+ read_doc, append_command, claim_results, claim_all_results,
22
+ is_host_alive, init_doc,
23
+ )
24
+ from .colors import Style, prompt, success, error, warn, info, banner
25
+
26
+ logger = logging.getLogger("gcli.client")
27
+
28
+ DEFAULT_BIN_ID = "599ac3c39189fcc26e5a"
29
+ DEFAULT_POLL = 1.0 # seconds
30
+ DEFAULT_TIMEOUT = 30.0 # seconds for exec commands
31
+
32
+ BUILTIN_COMMANDS = [
33
+ ":help",
34
+ ":info",
35
+ ":ping",
36
+ ":upload <local_path> <remote_path>",
37
+ ":download <remote_path> <local_path>",
38
+ ":timeout <seconds>",
39
+ ":history",
40
+ ":exit",
41
+ ]
42
+
43
+
44
+ def _setup_completion():
45
+ """Set up readline tab-completion for built-in commands."""
46
+ if readline is None:
47
+ return
48
+
49
+ def completer(text, state):
50
+ if text.startswith(":"):
51
+ matches = [c for c in BUILTIN_COMMANDS if c.startswith(text)]
52
+ else:
53
+ matches = []
54
+ return matches[state] if state < len(matches) else None
55
+
56
+ readline.set_completer(completer)
57
+ readline.set_completer_delims(" \t")
58
+ readline.parse_and_bind("tab: complete")
59
+
60
+
61
+ class SSHSession:
62
+ """Interactive session to a remote gcli host."""
63
+
64
+ def __init__(self, password: str, bin_id: str = DEFAULT_BIN_ID,
65
+ poll_interval: float = DEFAULT_POLL):
66
+ self.password = password
67
+ self.bin_id = bin_id
68
+ self.poll_interval = poll_interval
69
+ self.seen_results: set = set()
70
+ self.connected = False
71
+ self.session_id = str(uuid.uuid4())
72
+ self.exec_timeout: float = DEFAULT_TIMEOUT
73
+ self.history: List[Dict] = [] # recent command/result pairs
74
+
75
+ def connect(self, timeout: float = 10.0) -> bool:
76
+ """
77
+ Try to connect to the host by sending a ping and waiting for a pong.
78
+ Returns True if host responded.
79
+ """
80
+ print(info(f"[gcli] Connecting to host via npoint (bin={self.bin_id})..."))
81
+ print(info("[gcli] Sending ping..."))
82
+
83
+ cmd_id = self._send("ping", {"type": "ping"})
84
+
85
+ deadline = time.time() + timeout
86
+ while time.time() < deadline:
87
+ try:
88
+ doc = read_doc(self.bin_id)
89
+ if not is_host_alive(doc):
90
+ time.sleep(self.poll_interval)
91
+ continue
92
+ result = claim_results(doc, self.password, cmd_id, self.seen_results)
93
+ if result and result.get("pong"):
94
+ print(success("[gcli] Host responded! Connected."))
95
+ self.connected = True
96
+ return True
97
+ except RuntimeError as e:
98
+ logger.debug("Read error during connect: %s", e)
99
+ time.sleep(self.poll_interval)
100
+
101
+ print(error("[gcli] Connection timed out. Is the host running?"))
102
+ return False
103
+
104
+ def repl(self) -> None:
105
+ """Interactive command loop."""
106
+ _setup_completion()
107
+
108
+ if not self.connected:
109
+ if not self.connect():
110
+ return
111
+
112
+ print(f"\n{info('[gcli ssh] Type commands to execute on the remote host.')}")
113
+ print(info("[gcli] Type :help for available commands"))
114
+ print()
115
+
116
+ try:
117
+ while self.connected:
118
+ try:
119
+ line = input(prompt("gcli")).strip()
120
+ except (EOFError, KeyboardInterrupt):
121
+ print(f"\n{warn('[gcli] Disconnecting...')}")
122
+ break
123
+
124
+ if not line:
125
+ continue
126
+
127
+ # Built-in commands
128
+ if line == ":exit" or line == ":quit":
129
+ self._send_disconnect()
130
+ break
131
+ elif line == ":help":
132
+ self._do_help()
133
+ elif line == ":info":
134
+ self._do_info()
135
+ elif line == ":ping":
136
+ self._do_ping()
137
+ elif line == ":history":
138
+ self._do_history()
139
+ elif line.startswith(":timeout"):
140
+ self._do_timeout(line)
141
+ elif line.startswith(":upload"):
142
+ self._do_upload(line)
143
+ elif line.startswith(":download"):
144
+ self._do_download(line)
145
+ else:
146
+ # Remote exec
147
+ self._do_exec(line)
148
+ except Exception as e:
149
+ logger.error("REPL error: %s", e)
150
+ finally:
151
+ self.connected = False
152
+ print(info("[gcli] Session closed."))
153
+
154
+ # -------------------------------------------------------------------
155
+ # Built-in command handlers
156
+ # -------------------------------------------------------------------
157
+
158
+ def _do_help(self) -> None:
159
+ """Show available commands."""
160
+ print(f"\n{Style.CYAN}{Style.BOLD}--- Available Commands ---{Style.RESET}")
161
+ print(f" {Style.GREEN}Any text{Style.RESET} Execute shell command on remote host")
162
+ print(f" {Style.GREEN}:help{Style.RESET} Show this help message")
163
+ print(f" {Style.GREEN}:info{Style.RESET} Show remote system info")
164
+ print(f" {Style.GREEN}:ping{Style.RESET} Ping remote host (show latency)")
165
+ print(f" {Style.GREEN}:upload <local> <remote>{Style.RESET} Upload a file to remote host")
166
+ print(f" {Style.GREEN}:download <remote> <local>{Style.RESET} Download a file from remote host")
167
+ print(f" {Style.GREEN}:timeout <seconds>{Style.RESET} Set exec command timeout (current: {self.exec_timeout}s)")
168
+ print(f" {Style.GREEN}:history{Style.RESET} Show recent command history")
169
+ print(f" {Style.GREEN}:exit{Style.RESET} Disconnect and close session")
170
+ print(f"{Style.CYAN}{Style.BOLD}--------------------------{Style.RESET}\n")
171
+
172
+ def _do_timeout(self, line: str) -> None:
173
+ """Set or display the exec timeout."""
174
+ parts = line.split()
175
+ if len(parts) == 1:
176
+ print(info(f"[gcli] Current timeout: {self.exec_timeout}s"))
177
+ return
178
+ try:
179
+ new_timeout = float(parts[1])
180
+ if new_timeout < 1:
181
+ print(error("[gcli] Timeout must be at least 1 second"))
182
+ return
183
+ self.exec_timeout = new_timeout
184
+ print(success(f"[gcli] Timeout set to {self.exec_timeout}s"))
185
+ except ValueError:
186
+ print(error("[gcli] Invalid timeout value. Usage: :timeout <seconds>"))
187
+
188
+ def _do_history(self) -> None:
189
+ """Show recent command history."""
190
+ if not self.history:
191
+ print(info("[gcli] No command history yet."))
192
+ return
193
+
194
+ print(f"\n{Style.CYAN}{Style.BOLD}--- Command History ---{Style.RESET}")
195
+ for i, entry in enumerate(self.history[-20:], 1):
196
+ cmd = entry.get("cmd", "")
197
+ ok = entry.get("ok", False)
198
+ ts = entry.get("ts", "")
199
+ status_icon = f"{Style.GREEN}OK{Style.RESET}" if ok else f"{Style.RED}FAIL{Style.RESET}"
200
+ print(f" {Style.DIM}{i:3d}{Style.RESET} [{status_icon}] {cmd}")
201
+ print(f"{Style.CYAN}{Style.BOLD}-----------------------{Style.RESET}\n")
202
+
203
+ # -------------------------------------------------------------------
204
+ # Command handlers
205
+ # -------------------------------------------------------------------
206
+
207
+ def _do_exec(self, cmd: str) -> None:
208
+ """Send a command for remote execution and display the result."""
209
+ t0 = time.time()
210
+ cmd_id = self._send("exec", {"type": "exec", "cmd": cmd, "timeout": int(self.exec_timeout)})
211
+ result = self._poll_result(cmd_id, timeout=self.exec_timeout + 5)
212
+ elapsed = (time.time() - t0) * 1000
213
+
214
+ if result is None:
215
+ print(error("[gcli] No response from host (timed out)"))
216
+ self.history.append({"cmd": cmd, "ok": False, "ts": time.strftime("%H:%M:%S")})
217
+ return
218
+
219
+ ok = result.get("ok", False)
220
+ self.history.append({"cmd": cmd, "ok": ok, "ts": time.strftime("%H:%M:%S")})
221
+
222
+ if ok:
223
+ stdout = result.get("stdout", "")
224
+ stderr = result.get("stderr", "")
225
+ if stdout:
226
+ print(stdout, end="")
227
+ if stderr:
228
+ print(stderr, end="", file=sys.stderr)
229
+ else:
230
+ err = result.get("error") or result.get("stderr", "unknown error")
231
+ print(error(f"[gcli] Error: {err}"))
232
+
233
+ def _do_info(self) -> None:
234
+ """Request remote system information."""
235
+ cmd_id = self._send("info", {"type": "info"})
236
+ result = self._poll_result(cmd_id)
237
+ if result is None:
238
+ print(error("[gcli] No response from host (timed out)"))
239
+ return
240
+ if result.get("ok"):
241
+ print(f"\n{Style.CYAN}{Style.BOLD}--- Remote System Info ---{Style.RESET}")
242
+ for k, v in result.items():
243
+ if k in ("ok", "cmd_id"):
244
+ continue
245
+ print(f" {Style.GREEN}{k:15s}{Style.RESET}: {v}")
246
+ print(f"{Style.CYAN}{Style.BOLD}--------------------------{Style.RESET}\n")
247
+ else:
248
+ print(error(f"[gcli] Info error: {result.get('error', 'unknown')}"))
249
+
250
+ def _do_ping(self) -> None:
251
+ """Send a ping and show latency."""
252
+ t0 = time.time()
253
+ cmd_id = self._send("ping", {"type": "ping"})
254
+ result = self._poll_result(cmd_id)
255
+ if result and result.get("pong"):
256
+ latency = (time.time() - t0) * 1000
257
+ print(success(f"[gcli] Pong! Round-trip: {latency:.0f}ms"))
258
+ else:
259
+ print(error("[gcli] No pong received"))
260
+
261
+ def _do_upload(self, line: str) -> None:
262
+ """
263
+ Upload a local file to the remote host.
264
+ Usage: :upload <local_path> <remote_path>
265
+ """
266
+ import base64
267
+ from pathlib import Path
268
+
269
+ parts = line.split(maxsplit=2)
270
+ if len(parts) < 3:
271
+ print(warn("Usage: :upload <local_path> <remote_path>"))
272
+ return
273
+
274
+ local_path = parts[1]
275
+ remote_path = parts[2]
276
+
277
+ try:
278
+ raw = Path(local_path).read_bytes()
279
+ data_b64 = base64.b64encode(raw).decode("ascii")
280
+ except FileNotFoundError:
281
+ print(error(f"[gcli] Local file not found: {local_path}"))
282
+ return
283
+ except Exception as e:
284
+ print(error(f"[gcli] Read error: {e}"))
285
+ return
286
+
287
+ print(info(f"[gcli] Uploading {local_path} -> {remote_path} ({len(raw)} bytes)..."))
288
+ cmd_id = self._send("upload", {
289
+ "type": "upload",
290
+ "path": remote_path,
291
+ "data": data_b64,
292
+ })
293
+ result = self._poll_result(cmd_id)
294
+ if result is None:
295
+ print(error("[gcli] Upload timed out"))
296
+ elif result.get("ok"):
297
+ print(success(f"[gcli] Upload complete: {result.get('bytes_written', '?')} bytes written"))
298
+ else:
299
+ print(error(f"[gcli] Upload failed: {result.get('error', 'unknown')}"))
300
+
301
+ def _do_download(self, line: str) -> None:
302
+ """
303
+ Download a file from the remote host.
304
+ Usage: :download <remote_path> <local_path>
305
+ """
306
+ import base64
307
+ from pathlib import Path
308
+
309
+ parts = line.split(maxsplit=2)
310
+ if len(parts) < 3:
311
+ print(warn("Usage: :download <remote_path> <local_path>"))
312
+ return
313
+
314
+ remote_path = parts[1]
315
+ local_path = parts[2]
316
+
317
+ print(info(f"[gcli] Downloading {remote_path} -> {local_path}..."))
318
+ cmd_id = self._send("download", {
319
+ "type": "download",
320
+ "path": remote_path,
321
+ })
322
+ result = self._poll_result(cmd_id)
323
+ if result is None:
324
+ print(error("[gcli] Download timed out"))
325
+ elif result.get("ok"):
326
+ data_b64 = result.get("data", "")
327
+ raw = base64.b64decode(data_b64)
328
+ Path(local_path).write_bytes(raw)
329
+ print(success(f"[gcli] Download complete: {len(raw)} bytes written to {local_path}"))
330
+ else:
331
+ print(error(f"[gcli] Download failed: {result.get('error', 'unknown')}"))
332
+
333
+ def _send_disconnect(self) -> None:
334
+ """Send a disconnect command to the host."""
335
+ cmd_id = self._send("disconnect", {"type": "disconnect"})
336
+ self._poll_result(cmd_id, timeout=3.0)
337
+ print(warn("[gcli] Disconnected."))
338
+
339
+ # -------------------------------------------------------------------
340
+ # Low-level send / poll
341
+ # -------------------------------------------------------------------
342
+
343
+ def _send(self, cmd_type: str, payload: dict) -> str:
344
+ """Encrypt and send a command. Returns the command id."""
345
+ payload["type"] = cmd_type
346
+ payload["id"] = payload.get("id") or str(uuid.uuid4())
347
+ cmd_id = append_command(self.bin_id, self.password, payload)
348
+ return cmd_id
349
+
350
+ def _poll_result(self, cmd_id: str, timeout: float = 10.0) -> Optional[dict]:
351
+ """Poll npoint for a result matching cmd_id. Returns decrypted payload or None."""
352
+ deadline = time.time() + timeout
353
+ while time.time() < deadline:
354
+ try:
355
+ doc = read_doc(self.bin_id)
356
+ result = claim_results(doc, self.password, cmd_id, self.seen_results)
357
+ if result is not None:
358
+ return result
359
+ except RuntimeError as e:
360
+ logger.debug("Read error during poll: %s", e)
361
+ time.sleep(self.poll_interval)
362
+ return None
gcli/colors.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ Terminal colour helpers for gcli.
3
+ Works on both Windows (via ctypes/win32) and Linux/macOS (ANSI escapes).
4
+ """
5
+ import sys
6
+ import os
7
+
8
+
9
+ def _setup_windows_console():
10
+ """Enable ANSI escape sequences on Windows 10+."""
11
+ if sys.platform == "win32":
12
+ try:
13
+ import ctypes
14
+ kernel32 = ctypes.windll.kernel32
15
+ # STD_OUTPUT_HANDLE = -11
16
+ handle = kernel32.GetStdHandle(-11)
17
+ mode = ctypes.c_ulong()
18
+ kernel32.GetConsoleMode(handle, ctypes.byref(mode))
19
+ # ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
20
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
21
+ except Exception:
22
+ pass
23
+
24
+
25
+ # Initialise on import
26
+ _setup_windows_console()
27
+
28
+ # Check if we should use colours
29
+ _NO_COLOR = os.environ.get("NO_COLOR") or not sys.stdout.isatty()
30
+
31
+
32
+ class Style:
33
+ RESET = "" if _NO_COLOR else "\033[0m"
34
+ BOLD = "" if _NO_COLOR else "\033[1m"
35
+ DIM = "" if _NO_COLOR else "\033[2m"
36
+
37
+ RED = "" if _NO_COLOR else "\033[91m"
38
+ GREEN = "" if _NO_COLOR else "\033[92m"
39
+ YELLOW = "" if _NO_COLOR else "\033[93m"
40
+ BLUE = "" if _NO_COLOR else "\033[94m"
41
+ MAGENTA = "" if _NO_COLOR else "\033[95m"
42
+ CYAN = "" if _NO_COLOR else "\033[96m"
43
+ WHITE = "" if _NO_COLOR else "\033[97m"
44
+
45
+ # Background
46
+ BG_RED = "" if _NO_COLOR else "\033[41m"
47
+ BG_GREEN = "" if _NO_COLOR else "\033[42m"
48
+
49
+
50
+ def coloured(text: str, style: str) -> str:
51
+ """Wrap text with colour style."""
52
+ return f"{style}{text}{Style.RESET}"
53
+
54
+
55
+ def prompt(text: str = "gcli") -> str:
56
+ """Return a coloured prompt string."""
57
+ return f"{Style.CYAN}{Style.BOLD}{text}>{Style.RESET} "
58
+
59
+
60
+ def success(text: str) -> str:
61
+ return f"{Style.GREEN}{text}{Style.RESET}"
62
+
63
+
64
+ def error(text: str) -> str:
65
+ return f"{Style.RED}{text}{Style.RESET}"
66
+
67
+
68
+ def warn(text: str) -> str:
69
+ return f"{Style.YELLOW}{text}{Style.RESET}"
70
+
71
+
72
+ def info(text: str) -> str:
73
+ return f"{Style.CYAN}{text}{Style.RESET}"
74
+
75
+
76
+ def banner() -> str:
77
+ """Return the gcli startup banner."""
78
+ return f"""
79
+ {Style.CYAN}{Style.BOLD} _____ _____ ____ __ __ ___ _ _ _ _
80
+ / ____| __/ ___|| \\/ |_ _| \\ | | / \\ | |
81
+ | | __| | | | __ | |\\/| || || \\| | / _ \\ | |
82
+ | |_| | |_| |___ || | | || || |\\ |/ ___ \\| |___
83
+ \\____|____/\\____/|_| |_|___|_| \\_/_/ \\_\\_____|
84
+ {Style.RESET}{Style.DIM} Remote access via npoint.io | Encrypted AES-256-GCM{Style.RESET}
85
+ """
gcli/crypto.py ADDED
@@ -0,0 +1,51 @@
1
+ """
2
+ AES-256-GCM encryption/decryption for gcli.
3
+ Password -> PBKDF2 -> 256-bit key -> AES-256-GCM.
4
+ """
5
+ import os
6
+ import json
7
+ import base64
8
+ import hashlib
9
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10
+
11
+ SALT = b"gcli-v1-salt-2024" # fixed salt (password provides secrecy)
12
+ ITERATIONS = 600_000 # OWASP 2023 recommendation for PBKDF2-SHA256
13
+
14
+
15
+ def derive_key(password: str) -> bytes:
16
+ """Derive a 256-bit key from a password using PBKDF2."""
17
+ return hashlib.pbkdf2_hmac(
18
+ "sha256",
19
+ password.encode("utf-8"),
20
+ SALT,
21
+ ITERATIONS,
22
+ dklen=32,
23
+ )
24
+
25
+
26
+ def encrypt(plaintext: str, password: str) -> str:
27
+ """Encrypt plaintext string -> base64 string. Format: nonce(12) + ciphertext + tag(16)."""
28
+ key = derive_key(password)
29
+ nonce = os.urandom(12)
30
+ aesgcm = AESGCM(key)
31
+ ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
32
+ return base64.b64encode(nonce + ct).decode("ascii")
33
+
34
+
35
+ def decrypt(blob: str, password: str) -> str:
36
+ """Decrypt base64 string -> plaintext string."""
37
+ key = derive_key(password)
38
+ raw = base64.b64decode(blob)
39
+ nonce, ct = raw[:12], raw[12:]
40
+ aesgcm = AESGCM(key)
41
+ return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
42
+
43
+
44
+ def encrypt_dict(data: dict, password: str) -> str:
45
+ """Encrypt a dict -> base64 string (JSON-serialised then encrypted)."""
46
+ return encrypt(json.dumps(data, separators=(",", ":")), password)
47
+
48
+
49
+ def decrypt_dict(blob: str, password: str) -> dict:
50
+ """Decrypt a base64 string -> dict."""
51
+ return json.loads(decrypt(blob, password))