diff --git a/client/src/settings_ui.py b/client/src/settings_ui.py index 9931ae8..054cebe 100644 --- a/client/src/settings_ui.py +++ b/client/src/settings_ui.py @@ -16,6 +16,7 @@ from PyQt5.QtWidgets import ( QFormLayout, QHeaderView, QHBoxLayout, + QLineEdit, QSizePolicy, QVBoxLayout, QWidget, @@ -210,6 +211,7 @@ class SettingsPage(QWidget): admin_gateway=None, android_device_service=None, update_service=None, + update_credential_store=None, ): super().__init__(parent) self.setObjectName("settingsPage") @@ -224,6 +226,7 @@ class SettingsPage(QWidget): admin_gateway=admin_gateway, android_device_service=android_device_service, update_service=update_service, + update_credential_store=update_credential_store, ) def _build_ui(self) -> None: @@ -301,10 +304,20 @@ class SettingsPage(QWidget): ) self.updateManifestUrlInput.setClearButtonEnabled(True) self.updateManifestUrlInput.setAccessibleName("在线更新清单地址") + self.updateUsernameInput = LineEdit(self) + self.updateUsernameInput.setClearButtonEnabled(True) + self.updateUsernameInput.setAccessibleName("在线更新账号") + self.updatePasswordInput = LineEdit(self) + self.updatePasswordInput.setEchoMode(QLineEdit.Password) + self.updatePasswordInput.setClearButtonEnabled(True) + self.updatePasswordInput.setAccessibleName("在线更新密码") + self.updatePasswordInput.setPlaceholderText("请输入密码") + self.updateSaveButton = PushButton(FIF.SAVE, "保存", self) + self.updateSaveButton.setAccessibleName("安全保存在线更新设置") self.updateCheckButton = PushButton(FIF.UPDATE, "检查更新", self) self.updateCheckButton.setAccessibleName("检查并下载软件更新") self.updateStatusLabel = CaptionLabel( - "尚未检查;请填写 HTTPS 更新清单地址", self + "尚未保存更新账号和密码", self ) self.updateStatusLabel.setAccessibleName("软件更新状态") self.updateStatusLabel.setWordWrap(True) @@ -424,13 +437,20 @@ class SettingsPage(QWidget): form.setVerticalSpacing(12) versionLabel = CaptionLabel("当前版本", card) manifestLabel = CaptionLabel("清单地址", card) + usernameLabel = CaptionLabel("账号", card) + passwordLabel = CaptionLabel("密码", card) manifestLabel.setBuddy(self.updateManifestUrlInput) + usernameLabel.setBuddy(self.updateUsernameInput) + passwordLabel.setBuddy(self.updatePasswordInput) form.addRow(versionLabel, self.currentVersionLabel) form.addRow(manifestLabel, self.updateManifestUrlInput) + form.addRow(usernameLabel, self.updateUsernameInput) + form.addRow(passwordLabel, self.updatePasswordInput) layout.addLayout(form) commandLayout = QHBoxLayout() commandLayout.addStretch(1) + commandLayout.addWidget(self.updateSaveButton) commandLayout.addWidget(self.updateCheckButton) layout.addLayout(commandLayout) return card diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index 2523ccf..aefc101 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -310,6 +310,7 @@ class SettingsPageEventBinder(QObject): admin_gateway: Optional[ClientRegistrationGateway] = None, android_device_service: Optional[AndroidDeviceService] = None, update_service=None, + update_credential_store=None, ): super().__init__(page) self._page = page @@ -349,6 +350,7 @@ class SettingsPageEventBinder(QObject): page, repository, service=update_service, + credential_store=update_credential_store, parent=self, ) self._client_service = CurrentClientService(repository) diff --git a/client/src/update_service.py b/client/src/update_service.py index b5e3651..a4fc695 100644 --- a/client/src/update_service.py +++ b/client/src/update_service.py @@ -6,6 +6,7 @@ from __future__ import annotations +import base64 import hashlib import json import os @@ -16,7 +17,7 @@ import urllib.error import urllib.parse import urllib.request import zipfile -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path, PurePosixPath from typing import Callable, Optional @@ -28,6 +29,10 @@ MAX_MANIFEST_BYTES = 1024 * 1024 MAX_UPDATE_BYTES = 500 * 1024 * 1024 MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024 UPDATE_MANIFEST_SETTING = "update.manifest_url" +UPDATE_USERNAME_SETTING = "update.username" +DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json" +DEFAULT_UPDATE_USERNAME = "admin" +_ALLOWED_HTTP_UPDATE_HOST = "cm.xiapi.com" _VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$") _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") @@ -56,6 +61,14 @@ class UpdateCancelled(UpdateError): """用户关闭页面后取消继续处理更新。""" +@dataclass(frozen=True) +class UpdateCredentials: + """仅在内存中使用的更新服务器 Basic Authentication 凭据。""" + + username: str + password: str = field(repr=False) + + @dataclass(frozen=True) class UpdateInfo: """清单中一份可下载更新的信息。""" @@ -93,12 +106,25 @@ def parse_version(version: str) -> tuple[int, int, int, int]: def validate_manifest_url(url: str) -> str: - """验证并返回只允许 HTTPS、且不含凭据的清单地址。""" + """验证更新地址;HTTP 只对白名单发布主机开放。""" normalized = url.strip() parsed = urllib.parse.urlsplit(normalized) - if parsed.scheme.lower() != "https" or not parsed.hostname: - raise UpdateConfigurationError("更新清单地址必须是有效的 HTTPS 地址") + scheme = parsed.scheme.lower() + try: + port = parsed.port + except ValueError as exc: + raise UpdateConfigurationError("更新清单地址端口无效") from exc + is_https = scheme == "https" and bool(parsed.hostname) + is_allowed_http = ( + scheme == "http" + and (parsed.hostname or "").lower() == _ALLOWED_HTTP_UPDATE_HOST + and port in {None, 80} + ) + if not is_https and not is_allowed_http: + raise UpdateConfigurationError( + "更新清单地址必须使用 HTTPS;HTTP 只允许固定发布服务器" + ) if parsed.username is not None or parsed.password is not None: raise UpdateConfigurationError("更新清单地址不能包含账号或密码") if parsed.query: @@ -110,24 +136,54 @@ def validate_manifest_url(url: str) -> str: def _origin(url: str) -> tuple[str, str, int]: parsed = urllib.parse.urlsplit(url) + scheme = parsed.scheme.lower() return ( - parsed.scheme.lower(), + scheme, (parsed.hostname or "").lower(), - parsed.port or 443, + parsed.port or (443 if scheme == "https" else 80), ) +def _ascii_request_url(url: str) -> str: + """只编码 URL 路径中的非 ASCII 字符,避免重复编码已有百分号。""" + + parsed = urllib.parse.urlsplit(url) + encoded_path = urllib.parse.quote(parsed.path, safe="/%") + return urllib.parse.urlunsplit( + (parsed.scheme, parsed.netloc, encoded_path, parsed.query, parsed.fragment) + ) + + +class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler): + """在发送认证头之前拒绝跨源重定向。""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + validated_url = validate_manifest_url(newurl) + if _origin(req.full_url) != _origin(validated_url): + raise UpdateConfigurationError("更新请求不允许跨服务器重定向") + return super().redirect_request( + req, + fp, + code, + msg, + headers, + _ascii_request_url(validated_url), + ) + + class UpdateService: """检查并把更新安全暂存到 ``data/update``。""" def __init__( self, update_directory: Optional[Path] = None, - urlopen: Callable = urllib.request.urlopen, + urlopen: Optional[Callable] = None, timeout_seconds: float = 10.0, ): self._update_directory = update_directory or (data_dir() / "update") - self._urlopen = urlopen + self._urlopen = urlopen or urllib.request.build_opener( + _SameOriginRedirectHandler() + ).open self._timeout_seconds = timeout_seconds @property @@ -139,6 +195,7 @@ class UpdateService: manifest_url: str, current_version: str = __version__, is_cancelled: Optional[Callable[[], bool]] = None, + credentials: Optional[UpdateCredentials] = None, ) -> UpdateCheckResult: """下载并解析清单,返回是否存在新版本。""" @@ -148,6 +205,7 @@ class UpdateService: configured_url, MAX_MANIFEST_BYTES, is_cancelled, + credentials, ) try: manifest = json.loads(content.decode("utf-8-sig")) @@ -168,6 +226,7 @@ class UpdateService: info: UpdateInfo, is_cancelled: Optional[Callable[[], bool]] = None, on_progress: Optional[Callable[[int], None]] = None, + credentials: Optional[UpdateCredentials] = None, ) -> Path: """下载、校验并安全解压更新,返回暂存的 ``app.new``。""" @@ -184,10 +243,7 @@ class UpdateService: bytes_written = 0 try: - request = urllib.request.Request( - info.update_url, - headers={"User-Agent": f"CMAutoBuy/{__version__}"}, - ) + request = self._make_request(info.update_url, credentials) with self._open(request) as response: final_url = validate_manifest_url(response.geturl()) if _origin(final_url) != _origin(info.manifest_url): @@ -294,11 +350,9 @@ class UpdateService: url: str, maximum_bytes: int, is_cancelled: Optional[Callable[[], bool]], + credentials: Optional[UpdateCredentials], ) -> tuple[bytes, str]: - request = urllib.request.Request( - url, - headers={"User-Agent": f"CMAutoBuy/{__version__}"}, - ) + request = self._make_request(url, credentials) with self._open(request) as response: final_url = validate_manifest_url(response.geturl()) declared_size = self._content_length(response) @@ -317,6 +371,22 @@ class UpdateService: chunks.append(block) return b"".join(chunks), final_url + @staticmethod + def _make_request( + url: str, + credentials: Optional[UpdateCredentials], + ) -> urllib.request.Request: + headers = {"User-Agent": f"CMAutoBuy/{__version__}"} + if credentials is not None: + username = credentials.username.strip() + if not username or not credentials.password: + raise UpdateConfigurationError("更新账号和密码不能为空") + raw = f"{username}:{credentials.password}".encode("utf-8") + headers["Authorization"] = "Basic " + base64.b64encode(raw).decode( + "ascii" + ) + return urllib.request.Request(_ascii_request_url(url), headers=headers) + def _open(self, request): try: return self._urlopen(request, timeout=self._timeout_seconds) diff --git a/client/src/update_ui_event.py b/client/src/update_ui_event.py index ade539e..69ab865 100644 --- a/client/src/update_ui_event.py +++ b/client/src/update_ui_event.py @@ -1,25 +1,34 @@ -"""设置页在线更新事件和 Qt 后台 Worker。""" +"""设置页在线更新配置、事件和 Qt 后台 Worker。""" from __future__ import annotations +import sqlite3 from typing import Optional -from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot +from PyQt5.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot from qfluentwidgets import MessageBox from .settings_repository import SettingsRepository from .update_service import ( + DEFAULT_UPDATE_MANIFEST_URL, + DEFAULT_UPDATE_USERNAME, UPDATE_MANIFEST_SETTING, + UPDATE_USERNAME_SETTING, UpdateCancelled, UpdateCheckResult, + UpdateCredentials, UpdateInfo, UpdateService, validate_manifest_url, ) +from .windows_credential_store import ( + CredentialStoreError, + WindowsCredentialStore, +) class UpdateCheckWorker(QObject): - """在线程中保存清单地址并检查新版本。""" + """在线程中使用内存凭据检查新版本。""" succeeded = pyqtSignal(object) failed = pyqtSignal(str) @@ -28,13 +37,13 @@ class UpdateCheckWorker(QObject): def __init__( self, service: UpdateService, - repository: SettingsRepository, manifest_url: str, + credentials: UpdateCredentials, ): super().__init__() self._service = service - self._repository = repository self._manifest_url = manifest_url + self._credentials = credentials self._cancelled = False def cancel(self) -> None: @@ -43,13 +52,10 @@ class UpdateCheckWorker(QObject): @pyqtSlot() def run(self) -> None: try: - normalized_url = validate_manifest_url(self._manifest_url) - if self._cancelled: - return - self._repository.set(UPDATE_MANIFEST_SETTING, normalized_url) result = self._service.check( - normalized_url, + self._manifest_url, is_cancelled=lambda: self._cancelled, + credentials=self._credentials, ) if not self._cancelled: self.succeeded.emit(result) @@ -59,21 +65,28 @@ class UpdateCheckWorker(QObject): if not self._cancelled: self.failed.emit(str(exc) or "检查更新失败") finally: + self._credentials = None self.completed.emit() class UpdateDownloadWorker(QObject): - """在线程中下载、校验并安全暂存更新。""" + """在线程中认证下载、校验并安全暂存更新。""" progressChanged = pyqtSignal(int) succeeded = pyqtSignal(str) failed = pyqtSignal(str) completed = pyqtSignal() - def __init__(self, service: UpdateService, update: UpdateInfo): + def __init__( + self, + service: UpdateService, + update: UpdateInfo, + credentials: UpdateCredentials, + ): super().__init__() self._service = service self._update = update + self._credentials = credentials self._cancelled = False def cancel(self) -> None: @@ -86,6 +99,7 @@ class UpdateDownloadWorker(QObject): self._update, is_cancelled=lambda: self._cancelled, on_progress=self.progressChanged.emit, + credentials=self._credentials, ) if not self._cancelled: self.succeeded.emit(self._update.version) @@ -95,56 +109,159 @@ class UpdateDownloadWorker(QObject): if not self._cancelled: self.failed.emit(str(exc) or "下载更新失败") finally: + self._credentials = None self.completed.emit() class UpdateUiEventBinder(QObject): - """管理设置页更新按钮、反馈和两个后台线程。""" + """管理更新配置、保存反馈和两个后台线程。""" def __init__( self, page, repository: SettingsRepository, service: Optional[UpdateService] = None, + credential_store=None, parent=None, ): super().__init__(parent or page) self._page = page self._repository = repository self._service = service or UpdateService() + self._credential_store_override = credential_store self._closing = False self._check_thread: Optional[QThread] = None self._check_worker: Optional[UpdateCheckWorker] = None self._download_thread: Optional[QThread] = None self._download_worker: Optional[UpdateDownloadWorker] = None - saved_url = repository.get(UPDATE_MANIFEST_SETTING, "") - page.updateManifestUrlInput.setText( - saved_url if isinstance(saved_url, str) else "" + saved_url_record = repository.get_record(UPDATE_MANIFEST_SETTING) + saved_username_record = repository.get_record(UPDATE_USERNAME_SETTING) + saved_url = ( + saved_url_record.value + if saved_url_record is not None and isinstance(saved_url_record.value, str) + else DEFAULT_UPDATE_MANIFEST_URL ) + saved_username = ( + saved_username_record.value + if saved_username_record is not None + and isinstance(saved_username_record.value, str) + else DEFAULT_UPDATE_USERNAME + ) + page.updateManifestUrlInput.setText(saved_url) + page.updateUsernameInput.setText(saved_username) + page.updatePasswordInput.clear() + self._saved_url = saved_url if saved_url_record is not None else "" + self._saved_username = ( + saved_username if saved_username_record is not None else "" + ) + + credential_ready = self._set_password_placeholder(saved_url) + page.updateSaveButton.clicked.connect(self.request_save) page.updateCheckButton.clicked.connect(self.request_check) - self._sync_button() + self._sync_controls() + + if self._saved_url and self._saved_username and credential_ready: + QTimer.singleShot(0, self.request_check) + + def _credential_store_for(self, manifest_url: str): + if self._credential_store_override is not None: + return self._credential_store_override + return WindowsCredentialStore.for_url(manifest_url) + + def _set_password_placeholder(self, manifest_url: str) -> bool: + try: + saved = self._credential_store_for(manifest_url).read() + except CredentialStoreError as exc: + self._page.updatePasswordInput.setPlaceholderText("凭据管理器不可用") + self._page.set_update_status(str(exc)) + return False + if saved is None: + self._page.updatePasswordInput.setPlaceholderText("请输入密码") + return False + self._page.updatePasswordInput.setPlaceholderText( + "密码已安全保存;留空表示不修改" + ) + return True + + @pyqtSlot() + def request_save(self) -> None: + """显式保存非敏感设置和 Windows 系统凭据。""" + + if self._closing or self._is_busy(): + return + manifest_url = self._page.updateManifestUrlInput.text().strip() + username = self._page.updateUsernameInput.text().strip() + password = self._page.updatePasswordInput.text() + self._page.updateManifestUrlInput.setText(manifest_url) + self._page.updateUsernameInput.setText(username) + + try: + normalized_url = validate_manifest_url(manifest_url) + except Exception as exc: + self._page.set_update_status(f"保存失败:{exc}") + self._page.updateManifestUrlInput.setFocus() + return + if not username: + self._page.set_update_status("保存失败:更新账号不能为空") + self._page.updateUsernameInput.setFocus() + return + + try: + credential_store = self._credential_store_for(normalized_url) + existing = credential_store.read() + if password: + credential_store.save(username, password) + elif existing is None: + self._page.set_update_status("保存失败:请填写更新密码") + self._page.updatePasswordInput.setFocus() + return + elif existing[0] != username: + self._page.set_update_status("账号已修改,请重新填写密码后保存") + self._page.updatePasswordInput.setFocus() + return + self._repository.set_many( + { + UPDATE_MANIFEST_SETTING: normalized_url, + UPDATE_USERNAME_SETTING: username, + } + ) + except (CredentialStoreError, OSError, sqlite3.Error, ValueError) as exc: + self._page.set_update_status(f"保存失败:{exc}") + return + + self._saved_url = normalized_url + self._saved_username = username + self._page.updatePasswordInput.clear() + self._set_password_placeholder(normalized_url) + self._page.set_update_status("更新设置已安全保存") @pyqtSlot() def request_check(self) -> None: - if self._closing or self._check_thread is not None or self._download_thread is not None: + if self._closing or self._is_busy(): return manifest_url = self._page.updateManifestUrlInput.text().strip() - self._page.updateManifestUrlInput.setText(manifest_url) - try: - validate_manifest_url(manifest_url) - except Exception as exc: - self._page.set_update_status(f"无法检查:{exc}") - self._page.updateManifestUrlInput.setFocus() + username = self._page.updateUsernameInput.text().strip() + if not self._saved_url or not self._saved_username: + self._page.set_update_status("尚未保存更新账号和密码,请填写后保存") + self._page.updatePasswordInput.setFocus() + return + if ( + manifest_url != self._saved_url + or username != self._saved_username + or self._page.updatePasswordInput.text() + ): + self._page.set_update_status("更新设置有修改,请先保存后再检查") + self._page.updateSaveButton.setFocus() + return + + credentials = self._read_credentials(username) + if credentials is None: return self._page.set_update_status("正在检查新版本…") thread = QThread(self) - worker = UpdateCheckWorker( - self._service, - self._repository, - manifest_url, - ) + worker = UpdateCheckWorker(self._service, manifest_url, credentials) worker.moveToThread(thread) thread.started.connect(worker.run) worker.succeeded.connect(self._on_check_succeeded) @@ -155,9 +272,26 @@ class UpdateUiEventBinder(QObject): thread.finished.connect(thread.deleteLater) self._check_thread = thread self._check_worker = worker - self._sync_button() + self._sync_controls() thread.start() + def _read_credentials(self, expected_username: str) -> Optional[UpdateCredentials]: + try: + saved = self._credential_store_for(self._saved_url).read() + except CredentialStoreError as exc: + self._page.set_update_status(f"无法读取更新密码:{exc}") + return None + if saved is None: + self._page.set_update_status("尚未保存更新密码,请填写后保存") + self._page.updatePasswordInput.setFocus() + return None + username, password = saved + if username != expected_username: + self._page.set_update_status("保存的账号不一致,请重新填写密码并保存") + self._page.updatePasswordInput.setFocus() + return None + return UpdateCredentials(username, password) + @pyqtSlot(object) def _on_check_succeeded(self, result: UpdateCheckResult) -> None: if self._closing: @@ -181,9 +315,7 @@ class UpdateUiEventBinder(QObject): dialog.cancelButton.setText("暂不下载") dialog.cancelButton.setFocus() if not dialog.exec(): - self._page.set_update_status( - f"发现新版本 {update.version},尚未下载" - ) + self._page.set_update_status(f"发现新版本 {update.version},尚未下载") return self._start_download(update) @@ -191,7 +323,7 @@ class UpdateUiEventBinder(QObject): def _on_check_failed(self, message: str) -> None: if not self._closing: self._page.set_update_status( - f"检查失败:{message};地址已保留,可以重试" + f"检查失败:{message};设置已保留,可以重试" ) @pyqtSlot() @@ -199,14 +331,17 @@ class UpdateUiEventBinder(QObject): self._check_worker = None self._check_thread = None if not self._closing: - self._sync_button() + self._sync_controls() def _start_download(self, update: UpdateInfo) -> None: if self._closing or self._download_thread is not None: return + credentials = self._read_credentials(self._saved_username) + if credentials is None: + return self._page.set_update_status(f"正在下载版本 {update.version}(0%)…") thread = QThread(self) - worker = UpdateDownloadWorker(self._service, update) + worker = UpdateDownloadWorker(self._service, update, credentials) worker.moveToThread(thread) thread.started.connect(worker.run) worker.progressChanged.connect(self._on_download_progress) @@ -218,7 +353,7 @@ class UpdateUiEventBinder(QObject): thread.finished.connect(thread.deleteLater) self._download_thread = thread self._download_worker = worker - self._sync_button() + self._sync_controls() thread.start() @pyqtSlot(int) @@ -245,16 +380,22 @@ class UpdateUiEventBinder(QObject): self._download_worker = None self._download_thread = None if not self._closing: - self._sync_button() + self._sync_controls() - def _sync_button(self) -> None: - busy = self._check_thread is not None or self._download_thread is not None - self._page.updateCheckButton.setEnabled(not self._closing and not busy) - self._page.updateManifestUrlInput.setEnabled(not self._closing and not busy) + def _is_busy(self) -> bool: + return self._check_thread is not None or self._download_thread is not None + + def _sync_controls(self) -> None: + enabled = not self._closing and not self._is_busy() + self._page.updateSaveButton.setEnabled(enabled) + self._page.updateCheckButton.setEnabled(enabled) + self._page.updateManifestUrlInput.setEnabled(enabled) + self._page.updateUsernameInput.setEnabled(enabled) + self._page.updatePasswordInput.setEnabled(enabled) @pyqtSlot() def shutdown(self) -> None: - """取消后续处理,断开业务结果并短暂等待线程退出。""" + """取消后续处理,断开业务结果并等待线程安全退出。""" if self._closing: return @@ -290,6 +431,5 @@ class UpdateUiEventBinder(QObject): pass if thread is not None and thread.isRunning(): thread.quit() - # 网络读超时是 10 秒;多等 1 秒,避免关闭窗口时销毁仍在运行的 QThread。 thread.wait(11000) - self._sync_button() + self._sync_controls() diff --git a/client/src/version.py b/client/src/version.py index e8077fe..6d6f646 100644 --- a/client/src/version.py +++ b/client/src/version.py @@ -3,4 +3,4 @@ 发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。 """ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/client/src/windows_credential_store.py b/client/src/windows_credential_store.py new file mode 100644 index 0000000..500d2ed --- /dev/null +++ b/client/src/windows_credential_store.py @@ -0,0 +1,168 @@ +"""使用 Windows 凭据管理器保存在线更新密码。 + +密码只在调用期间存在于内存,不进入 SQLite、日志或普通错误信息。本模块只使用 +Windows 自带 Credential API,不增加第三方依赖。 +""" + +from __future__ import annotations + +import ctypes +import sys +from ctypes import wintypes +from typing import Optional +from urllib.parse import urlsplit + + +UPDATE_CREDENTIAL_TARGET_PREFIX = "CMAutoBuy/update" +_CRED_TYPE_GENERIC = 1 +_CRED_PERSIST_LOCAL_MACHINE = 2 +_ERROR_NOT_FOUND = 1168 + + +class CredentialStoreError(RuntimeError): + """Windows 凭据管理器操作失败。""" + + +class _CredentialW(ctypes.Structure): + _fields_ = [ + ("Flags", wintypes.DWORD), + ("Type", wintypes.DWORD), + ("TargetName", wintypes.LPWSTR), + ("Comment", wintypes.LPWSTR), + ("LastWritten", wintypes.FILETIME), + ("CredentialBlobSize", wintypes.DWORD), + ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)), + ("Persist", wintypes.DWORD), + ("AttributeCount", wintypes.DWORD), + ("Attributes", wintypes.LPVOID), + ("TargetAlias", wintypes.LPWSTR), + ("UserName", wintypes.LPWSTR), + ] + + +class WindowsCredentialStore: + """读写当前 Windows 用户的通用凭据。""" + + def __init__(self, target: str): + self._target = target + if sys.platform != "win32": + self._api = None + return + api = ctypes.WinDLL("Advapi32.dll", use_last_error=True) + api.CredWriteW.argtypes = [ctypes.POINTER(_CredentialW), wintypes.DWORD] + api.CredWriteW.restype = wintypes.BOOL + api.CredReadW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.POINTER(_CredentialW)), + ] + api.CredReadW.restype = wintypes.BOOL + api.CredDeleteW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ] + api.CredDeleteW.restype = wintypes.BOOL + api.CredFree.argtypes = [wintypes.LPVOID] + api.CredFree.restype = None + self._api = api + + @property + def target(self) -> str: + """返回不含密码的系统凭据目标名。""" + + return self._target + + @classmethod + def for_url(cls, url: str) -> "WindowsCredentialStore": + """按 URL 源创建独立凭据,避免把一个服务器密码发送给另一个服务器。""" + + parsed = urlsplit(url) + scheme = parsed.scheme.lower() + host = (parsed.hostname or "").lower() + if not scheme or not host: + raise CredentialStoreError("无法为无效更新地址保存密码") + port = parsed.port or (443 if scheme == "https" else 80) + return cls(f"{UPDATE_CREDENTIAL_TARGET_PREFIX}/{scheme}/{host}/{port}") + + def save(self, username: str, password: str) -> None: + """保存账号和密码;密码为空时拒绝覆盖已有凭据。""" + + api = self._require_windows() + normalized_username = username.strip() + if not normalized_username: + raise CredentialStoreError("更新账号不能为空") + if not password: + raise CredentialStoreError("更新密码不能为空") + + password_bytes = password.encode("utf-16-le") + if len(password_bytes) > 2560: + raise CredentialStoreError("更新密码过长") + blob = ctypes.create_string_buffer(password_bytes) + credential = _CredentialW() + credential.Type = _CRED_TYPE_GENERIC + credential.TargetName = self._target + credential.CredentialBlobSize = len(password_bytes) + credential.CredentialBlob = ctypes.cast( + blob, + ctypes.POINTER(ctypes.c_ubyte), + ) + credential.Persist = _CRED_PERSIST_LOCAL_MACHINE + credential.UserName = normalized_username + if not api.CredWriteW(ctypes.byref(credential), 0): + raise self._system_error("保存更新凭据失败") + + def read(self) -> Optional[tuple[str, str]]: + """返回保存的 ``(账号, 密码)``;不存在时返回 ``None``。""" + + api = self._require_windows() + pointer = ctypes.POINTER(_CredentialW)() + if not api.CredReadW( + self._target, + _CRED_TYPE_GENERIC, + 0, + ctypes.byref(pointer), + ): + error_code = ctypes.get_last_error() + if error_code == _ERROR_NOT_FOUND: + return None + raise self._system_error("读取更新凭据失败", error_code) + try: + credential = pointer.contents + password_bytes = ctypes.string_at( + credential.CredentialBlob, + credential.CredentialBlobSize, + ) + password = password_bytes.decode("utf-16-le") + return credential.UserName or "", password + except (UnicodeDecodeError, ValueError) as exc: + raise CredentialStoreError("保存的更新凭据无法读取,请重新保存") from exc + finally: + api.CredFree(pointer) + + def exists(self) -> bool: + """返回是否已经保存更新凭据。""" + + return self.read() is not None + + def delete(self) -> bool: + """删除更新凭据;原本不存在时返回 ``False``。""" + + api = self._require_windows() + if api.CredDeleteW(self._target, _CRED_TYPE_GENERIC, 0): + return True + error_code = ctypes.get_last_error() + if error_code == _ERROR_NOT_FOUND: + return False + raise self._system_error("删除更新凭据失败", error_code) + + def _require_windows(self): + if self._api is None: + raise CredentialStoreError("更新密码只能保存到 Windows 凭据管理器") + return self._api + + @staticmethod + def _system_error(message: str, error_code: Optional[int] = None): + code = ctypes.get_last_error() if error_code is None else error_code + return CredentialStoreError(f"{message}(Windows 错误 {code})") diff --git a/client/test/test_update_service.py b/client/test/test_update_service.py index cfefc17..4049556 100644 --- a/client/test/test_update_service.py +++ b/client/test/test_update_service.py @@ -1,18 +1,23 @@ """在线更新清单、下载校验和安全解压测试。""" +import base64 import hashlib import io import json import tempfile import unittest +import urllib.request import zipfile from pathlib import Path from src.update_service import ( + DEFAULT_UPDATE_MANIFEST_URL, UnsafeUpdateArchiveError, UpdateConfigurationError, UpdateIntegrityError, + UpdateCredentials, UpdateService, + _SameOriginRedirectHandler, mark_current_version_healthy, parse_version, validate_manifest_url, @@ -41,9 +46,11 @@ class FakeUrlOpen: def __init__(self, responses): self.responses = responses self.requested_urls = [] + self.requested_headers = [] def __call__(self, request, timeout): self.requested_urls.append((request.full_url, timeout)) + self.requested_headers.append(dict(request.header_items())) content, final_url = self.responses[request.full_url] return FakeResponse(content, final_url, len(content)) @@ -107,6 +114,10 @@ class UpdateServiceTest(unittest.TestCase): validate_manifest_url(self.manifest_url), self.manifest_url, ) + self.assertEqual( + validate_manifest_url(DEFAULT_UPDATE_MANIFEST_URL), + DEFAULT_UPDATE_MANIFEST_URL, + ) for invalid in ( "http://updates.example.test/manifest.json", "https://user:pass@updates.example.test/manifest.json", @@ -118,6 +129,56 @@ class UpdateServiceTest(unittest.TestCase): ): validate_manifest_url(invalid) + def test_basic_auth_is_used_for_manifest_and_update_without_repr_leak(self): + update_content = make_update_zip() + service, opener = self.service_with(update_content) + credentials = UpdateCredentials("release-reader", "unit-test-password") + + result = service.check( + self.manifest_url, + "0.1.0", + credentials=credentials, + ) + service.download_and_stage(result.update, credentials=credentials) + + expected = "Basic " + base64.b64encode( + b"release-reader:unit-test-password" + ).decode("ascii") + self.assertEqual(len(opener.requested_headers), 2) + self.assertTrue( + all( + headers.get("Authorization") == expected + for headers in opener.requested_headers + ) + ) + self.assertNotIn("unit-test-password", repr(credentials)) + + def test_default_unicode_manifest_path_is_encoded_for_http_request(self): + request = UpdateService._make_request( + DEFAULT_UPDATE_MANIFEST_URL, + UpdateCredentials("release-reader", "unit-test-password"), + ) + + self.assertNotIn("——", request.full_url) + self.assertIn("%E2%80%94%E2%80%94", request.full_url) + + def test_authenticated_redirect_cannot_change_origin(self): + handler = _SameOriginRedirectHandler() + request = urllib.request.Request( + "https://updates.example.test/manifest.json", + headers={"Authorization": "Basic test"}, + ) + + with self.assertRaises(UpdateConfigurationError): + handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "https://other.example.test/manifest.json", + ) + def test_check_reports_newer_and_current_versions(self): update_content = make_update_zip() service, _opener = self.service_with(update_content) @@ -196,7 +257,7 @@ class UpdateServiceTest(unittest.TestCase): health = json.loads( (self.update_directory / "healthy.json").read_text(encoding="utf-8") ) - self.assertEqual(health["version"], "0.2.0") + self.assertEqual(health["version"], "0.2.1") if __name__ == "__main__": diff --git a/client/test/test_update_ui_event.py b/client/test/test_update_ui_event.py index d6251a3..6b2a2b2 100644 --- a/client/test/test_update_ui_event.py +++ b/client/test/test_update_ui_event.py @@ -1,4 +1,4 @@ -"""设置页在线更新 UI 和后台线程测试。""" +"""设置页认证更新配置、自动检查和后台线程测试。""" import os import tempfile @@ -11,38 +11,72 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt5.QtCore import QTimer from PyQt5.QtTest import QTest -from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QApplication, QLineEdit from src.mock_admin_gateway import MockAdminGateway from src.settings_repository import SettingsRepository from src.settings_ui import SettingsPage from src.update_service import ( + DEFAULT_UPDATE_MANIFEST_URL, + DEFAULT_UPDATE_USERNAME, UPDATE_MANIFEST_SETTING, + UPDATE_USERNAME_SETTING, UpdateCheckResult, UpdateInfo, ) MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json" +TEST_USERNAME = "release-reader" +TEST_PASSWORD = "unit-test-password" + + +class FakeCredentialStore: + def __init__(self, saved=None): + self.saved = saved + self.save_count = 0 + + def read(self): + return self.saved + + def save(self, username, password): + self.saved = (username, password) + self.save_count += 1 class FakeUpdateService: def __init__(self, result=None, delay=0.0, download_error=None): - self.result = result or UpdateCheckResult("0.2.0", "0.2.0", False) + self.result = result or UpdateCheckResult("0.2.1", "0.2.1", False) self.delay = delay self.download_error = download_error self.check_count = 0 self.download_count = 0 + self.check_credentials = None + self.download_credentials = None - def check(self, manifest_url, current_version="0.1.0", is_cancelled=None): + def check( + self, + manifest_url, + current_version="0.2.1", + is_cancelled=None, + credentials=None, + ): self.check_count += 1 self.manifest_url = manifest_url + self.check_credentials = credentials if self.delay: time.sleep(self.delay) return self.result - def download_and_stage(self, update, is_cancelled=None, on_progress=None): + def download_and_stage( + self, + update, + is_cancelled=None, + on_progress=None, + credentials=None, + ): self.download_count += 1 + self.download_credentials = credentials if on_progress is not None: on_progress(50) if self.download_error is not None: @@ -76,18 +110,19 @@ class UpdateUiEventTest(unittest.TestCase): def setUp(self): self.temporary_directory = tempfile.TemporaryDirectory() - self.repository = SettingsRepository( - Path(self.temporary_directory.name) / "client.db" - ) + self.db_path = Path(self.temporary_directory.name) / "client.db" + self.repository = SettingsRepository(self.db_path) + self.credential_store = FakeCredentialStore() def tearDown(self): self.temporary_directory.cleanup() - def _page(self, service): + def _page(self, service, credential_store=None): return SettingsPage( settings_repository=self.repository, admin_gateway=MockAdminGateway(), update_service=service, + update_credential_store=credential_store or self.credential_store, ) def _wait_until(self, predicate, timeout_ms=2000): @@ -97,24 +132,86 @@ class UpdateUiEventTest(unittest.TestCase): elapsed += 10 self.assertTrue(predicate(), "等待在线更新线程超时") - def test_update_card_loads_saved_url_and_current_version(self): - self.repository.set(UPDATE_MANIFEST_SETTING, MANIFEST_URL) + def _save_configuration(self, page): + page.updateManifestUrlInput.setText(MANIFEST_URL) + page.updateUsernameInput.setText(TEST_USERNAME) + page.updatePasswordInput.setText(TEST_PASSWORD) + page.updateSaveButton.click() + + def test_update_card_uses_safe_defaults_and_password_input(self): page = self._page(FakeUpdateService()) - self.assertEqual(page.currentVersionLabel.text(), "0.2.0") - self.assertEqual(page.updateManifestUrlInput.text(), MANIFEST_URL) - self.assertTrue(page.updateCheckButton.isEnabled()) + self.assertEqual(page.currentVersionLabel.text(), "0.2.1") + self.assertEqual( + page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL + ) + self.assertEqual(page.updateUsernameInput.text(), DEFAULT_UPDATE_USERNAME) + self.assertEqual(page.updatePasswordInput.text(), "") + self.assertEqual(page.updatePasswordInput.echoMode(), QLineEdit.Password) + self.assertTrue(page.updateSaveButton.isEnabled()) page.eventBinder.shutdown() page.deleteLater() - def test_http_url_is_rejected_before_starting_worker(self): + def test_save_uses_sqlite_for_non_secret_and_credential_store_for_password(self): + page = self._page(FakeUpdateService()) + + self._save_configuration(page) + + self.assertEqual( + self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL + ) + self.assertEqual(self.repository.get(UPDATE_USERNAME_SETTING), TEST_USERNAME) + self.assertEqual( + self.credential_store.saved, + (TEST_USERNAME, TEST_PASSWORD), + ) + self.assertNotIn(TEST_PASSWORD.encode(), self.db_path.read_bytes()) + self.assertEqual(page.updatePasswordInput.text(), "") + self.assertIn("已安全保存", page.updatePasswordInput.placeholderText()) + page.eventBinder.shutdown() + page.deleteLater() + + def test_blank_password_preserves_existing_credential(self): + self.repository.set_many( + { + UPDATE_MANIFEST_SETTING: MANIFEST_URL, + UPDATE_USERNAME_SETTING: TEST_USERNAME, + } + ) + store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD)) + page = self._page(FakeUpdateService(), store) + + page.updateSaveButton.click() + + self.assertEqual(store.save_count, 0) + self.assertEqual(store.saved, (TEST_USERNAME, TEST_PASSWORD)) + self.assertIn("安全保存", page.updateStatusLabel.text()) + page.eventBinder.shutdown() + page.deleteLater() + + def test_missing_password_stops_save_and_check(self): service = FakeUpdateService() page = self._page(service) - page.updateManifestUrlInput.setText("http://updates.example.test/manifest.json") + page.updateSaveButton.click() page.updateCheckButton.click() - self.assertIn("必须是有效的 HTTPS", page.updateStatusLabel.text()) + self.assertIn("密码", page.updateStatusLabel.text()) + self.assertEqual(service.check_count, 0) + page.eventBinder.shutdown() + page.deleteLater() + + def test_unapproved_http_url_is_rejected_when_saving(self): + service = FakeUpdateService() + page = self._page(service) + page.updateManifestUrlInput.setText( + "http://updates.example.test/manifest.json" + ) + page.updatePasswordInput.setText(TEST_PASSWORD) + + page.updateSaveButton.click() + + self.assertIn("HTTP 只允许固定发布服务器", page.updateStatusLabel.text()) self.assertEqual(service.check_count, 0) page.eventBinder.shutdown() page.deleteLater() @@ -122,7 +219,7 @@ class UpdateUiEventTest(unittest.TestCase): def test_slow_check_does_not_block_or_start_twice(self): service = FakeUpdateService(delay=0.08) page = self._page(service) - page.updateManifestUrlInput.setText(MANIFEST_URL) + self._save_configuration(page) timer_fired = [] QTimer.singleShot(10, lambda: timer_fired.append(True)) @@ -134,27 +231,46 @@ class UpdateUiEventTest(unittest.TestCase): ) self.assertEqual(service.check_count, 1) - self.assertEqual( - self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL - ) + self.assertEqual(service.check_credentials.username, TEST_USERNAME) + self.assertEqual(service.check_credentials.password, TEST_PASSWORD) self.assertIn("当前已是最新版本", page.updateStatusLabel.text()) page.eventBinder.shutdown() page.deleteLater() - def test_confirmed_new_version_downloads_and_stages(self): + def test_saved_configuration_checks_automatically_after_startup(self): + self.repository.set_many( + { + UPDATE_MANIFEST_SETTING: MANIFEST_URL, + UPDATE_USERNAME_SETTING: TEST_USERNAME, + } + ) + store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD)) + service = FakeUpdateService() + + page = self._page(service, store) + self._wait_until(lambda: service.check_count == 1) + self._wait_until( + lambda: page.eventBinder.updateEventBinder._check_thread is None + ) + + self.assertIn("当前已是最新版本", page.updateStatusLabel.text()) + page.eventBinder.shutdown() + page.deleteLater() + + def test_confirmed_new_version_downloads_with_saved_credentials(self): update = UpdateInfo( - version="0.2.0", + version="0.3.0", manifest_url=MANIFEST_URL, - update_url="https://updates.example.test/CMAutoBuy-0.2.0-update.zip", - file_name="CMAutoBuy-0.2.0-update.zip", + update_url="https://updates.example.test/CMAutoBuy-0.3.0-update.zip", + file_name="CMAutoBuy-0.3.0-update.zip", size=1024, sha256="0" * 64, ) service = FakeUpdateService( - UpdateCheckResult("0.1.0", "0.2.0", True, update) + UpdateCheckResult("0.2.1", "0.3.0", True, update) ) page = self._page(service) - page.updateManifestUrlInput.setText(MANIFEST_URL) + self._save_configuration(page) with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox): page.updateCheckButton.click() @@ -164,8 +280,9 @@ class UpdateUiEventTest(unittest.TestCase): ) self.assertEqual(service.download_count, 1) + self.assertEqual(service.download_credentials.username, TEST_USERNAME) + self.assertEqual(service.download_credentials.password, TEST_PASSWORD) self.assertIn("已准备好", page.updateStatusLabel.text()) - self.assertIn("重新启动", page.updateStatusLabel.text()) page.eventBinder.shutdown() page.deleteLater() diff --git a/client/test/test_windows_credential_store.py b/client/test/test_windows_credential_store.py new file mode 100644 index 0000000..2b4c7c7 --- /dev/null +++ b/client/test/test_windows_credential_store.py @@ -0,0 +1,26 @@ +"""Windows 更新凭据按服务器隔离的测试。""" + +import unittest + +from src.windows_credential_store import WindowsCredentialStore + + +class WindowsCredentialStoreTest(unittest.TestCase): + def test_different_origins_use_different_credential_targets(self): + http_store = WindowsCredentialStore.for_url( + "http://cm.xiapi.com/autobuy——manifest.json" + ) + https_store = WindowsCredentialStore.for_url( + "https://cm.xiapi.com/autobuy——manifest.json" + ) + other_store = WindowsCredentialStore.for_url( + "https://updates.example.test/manifest.json" + ) + + self.assertNotEqual(http_store.target, https_store.target) + self.assertNotEqual(https_store.target, other_store.target) + self.assertNotIn("@", http_store.target) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/client/00-glossary.md b/docs/client/00-glossary.md index 081bc67..de24368 100644 --- a/docs/client/00-glossary.md +++ b/docs/client/00-glossary.md @@ -84,6 +84,7 @@ Admin 想知道某台 Client 是不是卡死了,用"领取后超时重派"就 | 模态对话框 | 弹出来必须先处理它、否则动不了主窗口的窗口。**很打断人,只在必须做决定时用** | 高危确认 | | DPI / 显示缩放 | Windows 里"文字和应用的大小"那个百分比设置 | 需验证 100%~200% | | 高对比度 | Windows 的一种无障碍主题,颜色极简 | 需验证界面不能只靠颜色区分状态 | +| Windows 凭据管理器 | Windows 专门保存密码等秘密的系统存储,普通 SQLite 和日志不应代替它 | 保存在线更新密码,界面不回填明文 | ## 4. 自动化和安全 diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 33841be..642a70e 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -134,7 +134,7 @@ Client 应执行: 2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。 3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。 4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。 -5. **软件更新:**当前版本、HTTPS 更新清单地址、检查按钮和稳定状态文字。 +5. **软件更新:**当前版本、清单地址、账号、密码、保存/检查按钮和稳定状态文字。 密码和访问令牌不得以明文写入普通 SQLite 设置或日志。 @@ -214,13 +214,15 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用 `autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256 用于发现下载损坏,不等同于发布者身份认证。 -**在线更新:**操作人员在设置页填写完整 HTTPS 清单地址并主动检查。发现新版本后 -必须由用户确认下载;下载、校验和安全解压在后台线程完成,只写入 +**在线更新:**设置页默认显示已确认的发布清单地址和账号,密码由操作人员首次 +填写并保存到 Windows 凭据管理器;URL 和账号作为非敏感设置保存在 SQLite,密码 +不回填明文。保存完整配置后,Client 启动时在后台检查一次,也可手动检查。发现 +新版本后必须由用户确认下载;下载、校验和安全解压在后台线程完成,只写入 `data/update/`。程序不会强制退出,用户下次通过 `Launcher.exe` 启动时才替换 `app/`。Launcher 保留一份 `app.old/`,目录移动失败或新版本没有写入健康标记时 -恢复旧版本。更新包必须与清单同源;重定向后仍必须是 HTTPS;不允许忽略 TLS -证书错误,也不在更新地址中保存账号密码。 -更新地址还不得包含查询参数或片段,避免 token 一类凭据被持久化。 +恢复旧版本。更新包必须与清单同源且重定向不得跨源。一般地址只允许 HTTPS;当前 +固定发布主机按已确认方案临时允许默认端口 HTTP 和 Basic Authentication。URL +不得包含账号密码、查询参数或片段,认证密码不得写入源码、Git、SQLite 或日志。 `0.1.0` 的 Launcher 没有应用在线更新的能力,因此第一次升级到带在线更新的 `0.2.0` 仍需人工替换一次完整程序;从 `0.2.0` 开始才可使用上述流程。当前没有 diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index 2ac2853..bc7a126 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -145,8 +145,9 @@ client/ - Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。 - PDD Adapter 封装设备连接、页面识别、采集和采购。 - ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。 -- `UpdateService` 只负责 HTTPS 清单、下载校验和安全暂存;正在运行的主程序不替换 - 自身,目录替换与回退只由下一次启动的 `Launcher.exe` 执行。 +- `UpdateService` 负责认证清单、下载校验和安全暂存;URL/账号由 Repository 保存, + 密码只从 Windows 凭据管理器短暂读入内存。正在运行的主程序不替换自身,目录 + 替换与回退只由下一次启动的 `Launcher.exe` 执行。 ## 5. 线程模型 @@ -165,7 +166,7 @@ Qt 主线程 └── Outbox 重试,不重复执行 PDD 操作 更新工作线程 -├── 检查 HTTPS 清单 +├── 读取系统凭据并检查认证清单 └── 下载、SHA256 校验和安全解压到 data/update/ ``` @@ -293,14 +294,15 @@ def _on_finished(self, remote_task_id: str, result) -> None: ### 5.3 在线更新启动顺序 ```text -主程序:HTTPS 清单 → 用户确认 → 下载并校验 → data/update/app.new + pending.json +主程序:系统凭据 → 认证清单 → 用户确认 → 下载并校验 → app.new + pending.json Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序 主程序:窗口成功创建 → 写 healthy.json Launcher:健康标记正确则完成;提前退出则恢复 app.old ``` 更新 ZIP 只允许 `app/` 内容,拒绝绝对路径、`..`、反斜杠路径和符号链接。Launcher -不联网、不处理凭据,也不自更新。 +不联网、不处理凭据,也不自更新。启动自动检查只在 URL、账号和系统密码都已保存时 +执行,网络失败只更新设置页状态,不阻止主窗口使用。 ## 6. 任务引擎状态 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 688a468..1288eb2 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -299,8 +299,12 @@ class TaskTableModel(QAbstractTableModel): ### 软件更新 - 显示只读的当前版本; -- 清单地址使用带可见标签的单行输入框,只允许完整 HTTPS URL; -- “检查更新”同时保存已验证的非敏感清单地址;检查和下载期间按钮防重复; +- 清单地址、账号和密码分别使用带可见标签的单行输入框,密码框默认隐藏且不回填; +- “保存”把 URL/账号写入 SQLite,把密码写入 Windows 凭据管理器;已有密码时留空 + 表示不修改,修改账号时必须重新输入密码; +- “检查更新”只使用已经保存的配置;存在未保存修改时先提示保存;检查和下载期间 + 输入框及按钮禁用,防止并行任务和配置竞态; +- 已保存完整配置时启动后自动检查一次;没有新版或网络失败不弹模态框; - 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和 恢复建议; - 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启, @@ -322,6 +326,7 @@ class TaskTableModel(QAbstractTableModel): | 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 | | 普通任务成功 | 更新行和状态区,不弹“成功”对话框 | | 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 | +| 更新设置保存成功 | 状态区提示已安全保存,密码输入框清空并显示已保存占位文字 | | 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 | ### 9.1 错误提示模板(照抄即可) diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 32c0402..7c43a9d 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -187,8 +187,14 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置 ### 8.1 在线更新安全 -- 清单和更新包只允许 HTTPS,不提供忽略证书错误的开关;URL 不得包含账号密码、 - 查询参数或片段,避免凭据随地址写入 SQLite 或错误提示。 +- 一般清单和更新包只允许 HTTPS;按已确认部署方案,只有固定发布主机的默认端口 + 临时允许 HTTP Basic Authentication,任何其他 HTTP 地址仍拒绝。 +- URL 不得包含账号密码、查询参数或片段。URL 和账号可写 SQLite;密码只能写入 + Windows 凭据管理器,不得进入源码、Git、SQLite、日志、异常文本、工单或文档。 +- 不同协议、主机或端口使用不同的系统凭据目标;修改服务器地址后必须为新服务器 + 重新输入密码,不能把旧服务器密码自动发送到新地址。 +- HTTP Basic Authentication 不能防止同网段监听或中间人攻击;主机白名单只限制 + Client 误连范围,不能替代 TLS,发布服务器具备条件后应迁移到 HTTPS。 - 更新包必须与最终清单 URL 同源,并同时校验清单声明的字节大小和 SHA256。 - 清单、压缩包和解压后总大小均有限制;ZIP 只能包含安全的 `app/` 内容,拒绝路径 穿越、绝对路径、反斜杠路径和符号链接。