mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
00364fd645
Separate Galaxy authentication and local access policy from web presentation. Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""Local Galaxy credential owner. No transport or remote pairing is implied."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import stat
|
|
import tempfile
|
|
from collections.abc import Callable
|
|
|
|
|
|
class AccessStatus(StrEnum):
|
|
UNCONFIGURED = "unconfigured"
|
|
CONFIGURED_LOCAL = "configured_local"
|
|
LEGACY_IMPORT_AVAILABLE = "legacy_import_available"
|
|
UNAVAILABLE = "unavailable"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AccessResult:
|
|
status: AccessStatus
|
|
detail: str = ""
|
|
|
|
|
|
class GalaxyAccessOwner:
|
|
"""Own one versioned verifier in a private directory; never serves credentials."""
|
|
|
|
FILE = "access-v1.json"
|
|
N, R, P = 1 << 14, 8, 1
|
|
|
|
def __init__(self, root: Path, *, legacy_root: Path | None = None):
|
|
self.root = root
|
|
self.legacy_root = legacy_root
|
|
|
|
def _legacy_available(self) -> bool:
|
|
if self.legacy_root is None:
|
|
return False
|
|
paths = [self.legacy_root / name for name in ("glxyauth", "glxysession", "glxyslug")]
|
|
def metadata(path: Path) -> os.stat_result | None:
|
|
try:
|
|
return path.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
entries = [metadata(path) for path in paths]
|
|
present = [entry is not None for entry in entries]
|
|
if any(present) and not all(present):
|
|
raise ValueError("partial legacy credentials")
|
|
if all(present) and any(not stat.S_ISREG(entry.st_mode) for entry in entries if entry is not None):
|
|
raise ValueError("unsafe legacy credentials")
|
|
return all(present)
|
|
|
|
def legacy_available(self) -> bool | None:
|
|
try:
|
|
return self._legacy_available()
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
def _read(self) -> dict[str, object] | None:
|
|
try:
|
|
directory = self.root.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
if not stat.S_ISDIR(directory.st_mode) or directory.st_mode & 0o077:
|
|
raise ValueError("credential directory is unsafe")
|
|
path = self.root / self.FILE
|
|
try:
|
|
info = path.lstat()
|
|
except FileNotFoundError:
|
|
if any(entry.name.startswith(".access-") for entry in self.root.iterdir()):
|
|
raise ValueError("incomplete credential write") from None
|
|
return None
|
|
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_size > 4096:
|
|
raise ValueError("credential file is unsafe")
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
with os.fdopen(fd, "r", encoding="utf-8") as stream:
|
|
opened = os.fstat(stream.fileno())
|
|
if not stat.S_ISREG(opened.st_mode) or opened.st_mode & 0o077 or opened.st_size > 4096:
|
|
raise ValueError("credential file changed")
|
|
record = json.load(stream)
|
|
if not isinstance(record, dict) or set(record) != {"version", "algorithm", "salt", "verifier"} or \
|
|
record["version"] != 1 or record["algorithm"] != "scrypt-n16384-r8-p1":
|
|
raise ValueError("unsupported credential record")
|
|
if not all(isinstance(record[key], str) and re.fullmatch(f"[0-9a-f]{{{size}}}", record[key])
|
|
for key, size in (("salt", 32), ("verifier", 64))):
|
|
raise ValueError("invalid credential record")
|
|
return record
|
|
|
|
def status(self) -> AccessResult:
|
|
try:
|
|
record = self._read()
|
|
if record is not None:
|
|
return AccessResult(AccessStatus.CONFIGURED_LOCAL)
|
|
if self._legacy_available():
|
|
return AccessResult(AccessStatus.LEGACY_IMPORT_AVAILABLE)
|
|
return AccessResult(AccessStatus.UNCONFIGURED)
|
|
except (OSError, ValueError, UnicodeError, json.JSONDecodeError):
|
|
return AccessResult(AccessStatus.UNAVAILABLE, "Local credential state cannot be read")
|
|
|
|
def verify(self, password: str) -> bool:
|
|
return self.authenticate_generation(password) is not None
|
|
|
|
@staticmethod
|
|
def _generation(record: dict[str, object]) -> bytes:
|
|
# Internal session binding only; never send this digest to a browser.
|
|
return hashlib.sha256(bytes.fromhex(str(record["salt"])) + bytes.fromhex(str(record["verifier"]))).digest()
|
|
|
|
def current_generation(self) -> bytes | None:
|
|
try:
|
|
record = self._read()
|
|
if record is None:
|
|
return None
|
|
return self._generation(record)
|
|
except (OSError, ValueError, UnicodeError, json.JSONDecodeError):
|
|
return None
|
|
|
|
def authenticate_generation(self, password: str) -> bytes | None:
|
|
try:
|
|
record = self._read()
|
|
if record is None:
|
|
return None
|
|
derived = hashlib.scrypt(password.encode("utf-8"), salt=bytes.fromhex(str(record["salt"])),
|
|
n=self.N, r=self.R, p=self.P, dklen=32)
|
|
return self._generation(record) if hmac.compare_digest(derived, bytes.fromhex(str(record["verifier"]))) else None
|
|
except (OSError, ValueError, UnicodeError, json.JSONDecodeError):
|
|
return None
|
|
|
|
def _write(self, password: str, parked: Callable[[], bool], replace_generation: bytes | None = None) -> bool:
|
|
salt = secrets.token_bytes(16)
|
|
verifier = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=self.N, r=self.R, p=self.P, dklen=32)
|
|
record = {"version": 1, "algorithm": "scrypt-n16384-r8-p1", "salt": salt.hex(), "verifier": verifier.hex()}
|
|
if not parked():
|
|
return False
|
|
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
if not stat.S_ISDIR(self.root.lstat().st_mode) or self.root.stat().st_mode & 0o077:
|
|
return False
|
|
fd, temporary = tempfile.mkstemp(prefix=".access-", dir=self.root)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
json.dump(record, stream, separators=(",", ":"))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
if not parked():
|
|
return False
|
|
if replace_generation is None:
|
|
# A second writer cannot silently replace a record created after status().
|
|
os.link(temporary, self.root / self.FILE)
|
|
else:
|
|
if self.current_generation() != replace_generation:
|
|
return False
|
|
os.replace(temporary, self.root / self.FILE)
|
|
directory_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
return True
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
|
|
def configure(self, password: str, parked: Callable[[], bool]) -> bool:
|
|
if not 8 <= len(password) <= 255 or not parked() or self.status().status != AccessStatus.UNCONFIGURED:
|
|
return False
|
|
try:
|
|
return self._write(password, parked)
|
|
except (OSError, ValueError, UnicodeError):
|
|
return False
|
|
|
|
def replace_for_pairing(self, password: str, parked: Callable[[], bool]) -> bool:
|
|
"""Set a new remote login password from an authorized, parked local pairing flow."""
|
|
if not 8 <= len(password) <= 255 or not parked() or self.status().status != AccessStatus.CONFIGURED_LOCAL:
|
|
return False
|
|
generation = self.current_generation()
|
|
if generation is None:
|
|
return False
|
|
try:
|
|
return self._write(password, parked, replace_generation=generation)
|
|
except (OSError, ValueError, UnicodeError):
|
|
return False
|
|
|
|
def snapshot_for_pairing(self) -> dict[str, object] | None:
|
|
return self._read()
|
|
|
|
def restore_failed_pairing(self, previous: dict[str, object] | None, changed_generation: bytes) -> bool:
|
|
if self.current_generation() != changed_generation:
|
|
return False
|
|
path = self.root / self.FILE
|
|
if previous is None:
|
|
try:
|
|
path.unlink()
|
|
directory_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
try:
|
|
fd, temporary = tempfile.mkstemp(prefix=".access-", dir=self.root)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
json.dump(previous, stream, separators=(",", ":"))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
if self.current_generation() != changed_generation:
|
|
return False
|
|
os.replace(temporary, path)
|
|
directory_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
return True
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
except (OSError, ValueError, UnicodeError):
|
|
return False
|
|
|
|
def import_legacy(self, password: str, parked: Callable[[], bool]) -> bool:
|
|
"""Explicit migration needs the user's password; old SHA256 is never reused as a verifier."""
|
|
status = self.status().status
|
|
if not 6 <= len(password) <= 255 or not parked() or status not in \
|
|
(AccessStatus.LEGACY_IMPORT_AVAILABLE, AccessStatus.CONFIGURED_LOCAL):
|
|
return False
|
|
try:
|
|
if not self._legacy_available():
|
|
return False
|
|
assert self.legacy_root is not None
|
|
fd = os.open(self.legacy_root / "glxyauth", os.O_RDONLY | os.O_NOFOLLOW)
|
|
with os.fdopen(fd, "r", encoding="ascii") as stream:
|
|
if os.fstat(stream.fileno()).st_size > 128:
|
|
return False
|
|
old_hash = stream.read().strip()
|
|
if len(old_hash) != 64 or not re.fullmatch(r"[0-9a-f]{64}", old_hash):
|
|
return False
|
|
raw_matches = hmac.compare_digest(hashlib.sha256(password.encode()).hexdigest(), old_hash)
|
|
stripped_matches = hmac.compare_digest(hashlib.sha256(password.strip().encode()).hexdigest(), old_hash)
|
|
if not (raw_matches or stripped_matches):
|
|
return False
|
|
generation = self.current_generation() if status == AccessStatus.CONFIGURED_LOCAL else None
|
|
if status == AccessStatus.CONFIGURED_LOCAL and generation is None:
|
|
return False
|
|
return self._write(password if raw_matches else password.strip(), parked, replace_generation=generation)
|
|
except (OSError, ValueError, UnicodeError):
|
|
return False
|
|
|
|
def remove(self, parked: Callable[[], bool]) -> bool:
|
|
if not parked() or self.status().status != AccessStatus.CONFIGURED_LOCAL:
|
|
return False
|
|
try:
|
|
if not parked():
|
|
return False
|
|
(self.root / self.FILE).unlink()
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def legacy_galaxy_root() -> Path | None:
|
|
from openpilot.common.hardware import PC
|
|
from openpilot.common.hardware.hw import Paths
|
|
|
|
if os.environ.get("OPENPILOT_PREFIX"):
|
|
return None
|
|
if PC:
|
|
return Path(Paths.comma_home()) / "starpilot/data/galaxy"
|
|
return Path("/data/galaxy")
|
|
|
|
|
|
def default_owner() -> GalaxyAccessOwner:
|
|
"""Share the native UI credential location without importing UI modules."""
|
|
from openpilot.starpilot.storage import galaxy_storage_root
|
|
return GalaxyAccessOwner(galaxy_storage_root(), legacy_root=legacy_galaxy_root())
|