Jarvis2345 commited on
Commit
2e7cef7
·
verified ·
1 Parent(s): 850b3bb

deploy(S4): Blender headless pipeline + WebAR client + backend fixes (part 2)

Browse files
Files changed (1) hide show
  1. modules/remote_unlock.py +392 -0
modules/remote_unlock.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """Unlock this PC's JARVIS session from the phone, from anywhere.
4
+
5
+ WHY THIS EXISTS
6
+ ---------------
7
+ core.commands.run_command() refuses every command until the session is activated
8
+ for the current boot (modules.activation_state). Satisfying that used to require
9
+ activation_system.py: press a hotkey AT the keyboard, read a code from Telegram,
10
+ type it into a tkinter window. That is a physical-presence control, so "control
11
+ my PC from any part of the world" was impossible by construction — and the
12
+ scheduled task that runs activation_system.py is disabled anyway, so the session
13
+ could not be activated at all. Every command relayed from the phone was silently
14
+ dropped.
15
+
16
+ WHAT THE EXE DOES, AND WHY IT COULD NOT BE REUSED
17
+ -------------------------------------------------
18
+ src-tauri/src/main.rs::cmd_unlock_system takes a password and checks it with
19
+ vault.rs::cmd_check_lock_password, which verifies an Argon2id hash read from
20
+ <app_data>/vault/master.hash. That file does not exist here — and it never can:
21
+ `master.hash` appears exactly once in the whole Rust source, in that read. There
22
+ is no cmd_set_lock_password, no writer, nothing. So the `if !path.exists()`
23
+ branch is the only branch that ever runs, and it returns Ok(true) — the exe's
24
+ unlock accepts ANY password, permanently, by construction. It is not a
25
+ credential this module can defer to.
26
+
27
+ WHERE THE CREDENTIAL LIVES INSTEAD
28
+ ----------------------------------
29
+ backend.services.usb_vault — the project's own encrypted secret store. Only a
30
+ PBKDF2-SHA256 hash is stored, never the password. That matters here: the vault
31
+ logs a warning that without VAULT_MASTER_PASSWORD its key sits in the same
32
+ database as the ciphertext, so anyone with the file can decrypt it. Storing a
33
+ salted hash means that even a fully decrypted vault yields no usable password.
34
+
35
+ (usb_vault was itself unimportable on this machine until the unconditional
36
+ `import wmi` in usb_monitor.py was made optional — every vault lookup here had
37
+ been silently answering "no such secret". Its vault_secrets table is empty.)
38
+
39
+ backend/vault/credential_vault.py — the Argon2id + AES-GCM store the Master
40
+ Vault Ledger advertises — is not an option either: it imports argon2.low_level
41
+ inside a try/except that sets hash_secret_raw = None, and _derive_key then
42
+ raises RuntimeError("argon2-cffi is not installed") on every store() and
43
+ retrieve(). argon2-cffi is not installed in this venv, so that class cannot
44
+ currently encrypt or decrypt anything at all.
45
+
46
+ That is also why this module does its own PBKDF2 instead of calling into either
47
+ one: the credential check has to keep working in the venv, under the system
48
+ Python that also runs the relay, and inside the frozen sidecar.
49
+
50
+ THE SECOND FACTOR IS THE POINT
51
+ ------------------------------
52
+ The cloud token already authenticates the request, and it is compiled into the
53
+ APK. The unlock password is a genuine second factor: an attacker who extracts
54
+ the token from the APK still cannot unlock the desktop. That is why the password
55
+ is never transmitted or stored in plaintext, and why attempts are rate-limited
56
+ with the same escalation the exe uses.
57
+ """
58
+
59
+ import base64
60
+ import hashlib
61
+ import hmac
62
+ import json
63
+ import os
64
+ import secrets
65
+ import subprocess
66
+ import time
67
+ from pathlib import Path
68
+ from typing import Any
69
+
70
+ ROOT = Path(__file__).resolve().parents[1]
71
+ STATE_PATH = ROOT / "data" / "remote_unlock_state.json"
72
+ UNLOCK_JSON = ROOT / "data" / "session_unlocked.json"
73
+
74
+ VAULT_KEY_NAME = "PC_UNLOCK_PASSWORD"
75
+
76
+ # PBKDF2 rather than Argon2 deliberately: hashlib is stdlib, so this works
77
+ # identically in the venv, under the system Python that also runs the relay, and
78
+ # inside the frozen sidecar. argon2-cffi is installed in none of them, and a
79
+ # credential check that depends on an optional package is a credential check
80
+ # that silently stops working.
81
+ _PBKDF2_ITERATIONS = 240_000
82
+ _SALT_BYTES = 16
83
+ _ALGO = "pbkdf2_sha256"
84
+
85
+ MIN_PASSWORD_LENGTH = 8
86
+
87
+ # Matches the exe's escalation in main.rs::cmd_unlock_system.
88
+ _SOFT_LOCK_AFTER = 6
89
+ _HARD_LOCK_AFTER = 9
90
+ _SOFT_LOCK_SECONDS = 60
91
+ _HARD_LOCK_SECONDS = 3600
92
+
93
+
94
+ # --------------------------------------------------------------------------- #
95
+ # vault access
96
+ # --------------------------------------------------------------------------- #
97
+ def _vault():
98
+ from backend.services import usb_vault
99
+ return usb_vault
100
+
101
+
102
+ def has_unlock_password() -> bool:
103
+ try:
104
+ return bool((_vault().get_secret(VAULT_KEY_NAME) or "").strip())
105
+ except Exception:
106
+ return False
107
+
108
+
109
+ def set_unlock_password(password: str) -> dict[str, Any]:
110
+ """Enrol (or change) the remote-unlock password. One-time setup."""
111
+ password = (password or "").strip()
112
+ if len(password) < MIN_PASSWORD_LENGTH:
113
+ return {"ok": False,
114
+ "message": f"Password must be at least {MIN_PASSWORD_LENGTH} characters."}
115
+ salt = secrets.token_bytes(_SALT_BYTES)
116
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt,
117
+ _PBKDF2_ITERATIONS)
118
+ stored = "$".join([
119
+ _ALGO,
120
+ str(_PBKDF2_ITERATIONS),
121
+ base64.b64encode(salt).decode("ascii"),
122
+ base64.b64encode(digest).decode("ascii"),
123
+ ])
124
+ try:
125
+ _vault().set_secret(VAULT_KEY_NAME, stored)
126
+ except Exception as exc:
127
+ return {"ok": False, "message": f"Could not write to the vault: {exc}"}
128
+ _reset_attempts()
129
+ return {"ok": True, "message": "Remote unlock password saved."}
130
+
131
+
132
+ def _verify_password(password: str) -> bool:
133
+ try:
134
+ stored = (_vault().get_secret(VAULT_KEY_NAME) or "").strip()
135
+ except Exception:
136
+ return False
137
+ if not stored:
138
+ return False
139
+ try:
140
+ algo, iterations, salt_b64, hash_b64 = stored.split("$")
141
+ if algo != _ALGO:
142
+ return False
143
+ salt = base64.b64decode(salt_b64)
144
+ expected = base64.b64decode(hash_b64)
145
+ except Exception:
146
+ return False
147
+ candidate = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"),
148
+ salt, int(iterations))
149
+ return hmac.compare_digest(candidate, expected)
150
+
151
+
152
+ # --------------------------------------------------------------------------- #
153
+ # attempt limiting (on disk: the relay gets restarted by its supervisor, and an
154
+ # attacker must not be able to reset the counter by making it crash)
155
+ # --------------------------------------------------------------------------- #
156
+ def _read_state() -> dict[str, Any]:
157
+ try:
158
+ data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
159
+ return data if isinstance(data, dict) else {}
160
+ except Exception:
161
+ return {}
162
+
163
+
164
+ def _write_state(state: dict[str, Any]) -> None:
165
+ try:
166
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
167
+ STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
168
+ except Exception:
169
+ pass
170
+
171
+
172
+ def _reset_attempts() -> None:
173
+ _write_state({"attempts": 0, "locked_until": 0})
174
+
175
+
176
+ def _lock_remaining() -> int:
177
+ state = _read_state()
178
+ remaining = float(state.get("locked_until") or 0) - time.time()
179
+ return int(remaining) if remaining > 0 else 0
180
+
181
+
182
+ def _record_failure() -> int:
183
+ state = _read_state()
184
+ attempts = int(state.get("attempts") or 0) + 1
185
+ state["attempts"] = attempts
186
+ if attempts >= _HARD_LOCK_AFTER:
187
+ state["locked_until"] = time.time() + _HARD_LOCK_SECONDS
188
+ elif attempts >= _SOFT_LOCK_AFTER:
189
+ state["locked_until"] = time.time() + _SOFT_LOCK_SECONDS
190
+ _write_state(state)
191
+ return attempts
192
+
193
+
194
+ # --------------------------------------------------------------------------- #
195
+ # unlock
196
+ # --------------------------------------------------------------------------- #
197
+ def _boot_id() -> int:
198
+ """Must match modules.activation_state._boot_id exactly, or the marker this
199
+ writes will not satisfy the gate it is meant to open."""
200
+ try:
201
+ import psutil # type: ignore
202
+ return int(psutil.boot_time())
203
+ except Exception:
204
+ return int(time.time() // 3600)
205
+
206
+
207
+ def is_unlocked() -> bool:
208
+ try:
209
+ from modules.activation_state import is_activated_for_this_boot
210
+ return bool(is_activated_for_this_boot())
211
+ except Exception:
212
+ return False
213
+
214
+
215
+ def _write_unlock_marker() -> None:
216
+ """Scoped to this boot on purpose: a reboot re-locks the machine, exactly as
217
+ the original design intended. Unlocking remotely must never be more
218
+ permanent than unlocking at the keyboard."""
219
+ UNLOCK_JSON.parent.mkdir(parents=True, exist_ok=True)
220
+ UNLOCK_JSON.write_text(json.dumps({
221
+ "unlocked_at": time.time(),
222
+ "boot_id": _boot_id(),
223
+ "source": "remote_unlock",
224
+ }), encoding="utf-8")
225
+
226
+
227
+ def status() -> dict[str, Any]:
228
+ if not has_unlock_password():
229
+ return {"ok": False, "enrolled": False, "unlocked": is_unlocked(),
230
+ "message": "No unlock password is set on this PC yet. Run "
231
+ "tools/set_unlock_password.py once on the desktop."}
232
+ if is_unlocked():
233
+ return {"ok": True, "enrolled": True, "unlocked": True,
234
+ "message": "This PC is already unlocked for this boot."}
235
+ locked = _lock_remaining()
236
+ if locked:
237
+ return {"ok": False, "enrolled": True, "unlocked": False,
238
+ "message": f"Too many wrong attempts. Try again in {locked} seconds."}
239
+ return {"ok": True, "enrolled": True, "unlocked": False,
240
+ "message": "This PC is locked. Send: unlock pc <your password>"}
241
+
242
+
243
+ def verify_and_unlock(password: str, launch_app: bool | None = None) -> dict[str, Any]:
244
+ """Check the password and, if it is right, unlock this boot.
245
+
246
+ NO WINDOW IS OPENED. Unlocking is a remote action — the user is not at the
247
+ machine, so popping the JARVIS GUI on an unattended desktop achieves nothing
248
+ and leaves a window sitting open on a PC nobody is looking at. What the user
249
+ actually wants after unlocking is a PC that is attached to the cloud and will
250
+ run commands, and that is what this reports on.
251
+
252
+ The relay process that calls this IS the cloud link, so by the time we get
253
+ here the PC is connected by construction — the command arrived over that very
254
+ socket. Everything the command stack needs runs in-process.
255
+
256
+ Set JARVIS_LAUNCH_ON_UNLOCK=1 (or pass launch_app=True) to also start the
257
+ desktop app, for anyone who does want the window.
258
+ """
259
+ if launch_app is None:
260
+ launch_app = os.environ.get("JARVIS_LAUNCH_ON_UNLOCK", "").strip().lower() in (
261
+ "1", "true", "yes")
262
+ if is_unlocked():
263
+ return {"ok": True, "already_unlocked": True,
264
+ "message": "This PC is already unlocked."}
265
+
266
+ if not has_unlock_password():
267
+ return {"ok": False,
268
+ "message": "No unlock password is set on this PC yet. Run "
269
+ "tools/set_unlock_password.py once on the desktop."}
270
+
271
+ locked = _lock_remaining()
272
+ if locked:
273
+ return {"ok": False,
274
+ "message": f"Too many wrong attempts. Try again in {locked} seconds."}
275
+
276
+ if not _verify_password(password):
277
+ attempts = _record_failure()
278
+ wait = _lock_remaining()
279
+ if wait:
280
+ return {"ok": False,
281
+ "message": f"Wrong password. Locked out for {wait} seconds."}
282
+ return {"ok": False,
283
+ "message": f"Wrong password. ({attempts} failed attempt(s).)"}
284
+
285
+ _reset_attempts()
286
+ _write_unlock_marker()
287
+
288
+ launched = _launch_jarvis() if launch_app else False
289
+ message = "PC unlocked and connected to your cloud. Send commands now."
290
+ if launched:
291
+ message = "PC unlocked and connected. JARVIS is opening on the desktop."
292
+ return {"ok": True, "launched": launched, "unlocked": True,
293
+ "message": message}
294
+
295
+
296
+ # --------------------------------------------------------------------------- #
297
+ # launching the desktop app
298
+ # --------------------------------------------------------------------------- #
299
+ def _jarvis_exe() -> Path | None:
300
+ """Installed app first, then a local release build.
301
+
302
+ Installed wins: it is what the user means by "the JARVIS app", and a build in
303
+ target/release can be stale or half-written mid-compile.
304
+ """
305
+ for path in (
306
+ Path(r"C:\Program Files\JARVIS-OS\JARVIS-OS.exe"),
307
+ ROOT / "src-tauri" / "target" / "release" / "JARVIS OS.exe",
308
+ ROOT / "src-tauri" / "target" / "release" / "JARVIS-OS.exe",
309
+ ):
310
+ try:
311
+ if path.exists():
312
+ return path
313
+ except Exception:
314
+ continue
315
+ return None
316
+
317
+
318
+ def jarvis_is_running() -> bool:
319
+ try:
320
+ import psutil # type: ignore
321
+ for proc in psutil.process_iter(["name"]):
322
+ if (proc.info.get("name") or "").lower() in ("jarvis-os.exe", "jarvis os.exe"):
323
+ return True
324
+ except Exception:
325
+ pass
326
+ return False
327
+
328
+
329
+ def _launch_jarvis() -> bool:
330
+ """Start the JARVIS desktop app. Already-running counts as success: the
331
+ caller asked for JARVIS to be up, and it is."""
332
+ if jarvis_is_running():
333
+ return True
334
+ exe = _jarvis_exe()
335
+ if exe is None:
336
+ return False
337
+ try:
338
+ # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP so the app outlives the
339
+ # relay that started it. The relay is restarted by its supervisor and
340
+ # must never take the user's desktop app down with it.
341
+ creationflags = (0x00000008 | 0x00000200) if os.name == "nt" else 0
342
+ subprocess.Popen(
343
+ [str(exe)],
344
+ cwd=str(exe.parent),
345
+ creationflags=creationflags,
346
+ stdout=subprocess.DEVNULL,
347
+ stderr=subprocess.DEVNULL,
348
+ close_fds=True,
349
+ )
350
+ return True
351
+ except Exception:
352
+ return False
353
+
354
+
355
+ # --------------------------------------------------------------------------- #
356
+ # command-surface parsing
357
+ # --------------------------------------------------------------------------- #
358
+ _STATUS_PHRASES = {
359
+ "unlock pc", "unlock my pc", "unlock the pc", "unlock computer",
360
+ "unlock my computer", "unlock jarvis", "unlock", "pc status", "unlock status",
361
+ }
362
+
363
+
364
+ def parse_unlock_command(text: str) -> tuple[str, str] | None:
365
+ """Recognise unlock phrases in a free-text command.
366
+
367
+ Returns ("status", "") | ("unlock", password) | None.
368
+
369
+ The relay matches this BEFORE handing the text to run_command(), because
370
+ run_command is precisely the thing that refuses everything while locked —
371
+ routing the unlock through it would deadlock.
372
+
373
+ Case is preserved for the password (passwords are case-sensitive); only the
374
+ command keywords are lowercased for matching.
375
+ """
376
+ if not text:
377
+ return None
378
+ raw = " ".join(str(text).strip().split())
379
+ if not raw:
380
+ return None
381
+ lowered = raw.lower()
382
+
383
+ if lowered in _STATUS_PHRASES:
384
+ return ("status", "")
385
+
386
+ for prefix in ("unlock pc ", "unlock my pc ", "unlock computer ",
387
+ "unlock jarvis ", "unlock "):
388
+ if lowered.startswith(prefix):
389
+ password = raw[len(prefix):].strip()
390
+ if password:
391
+ return ("unlock", password)
392
+ return None