Files
cmautobuy/client/src/windows_credential_store.py
T

169 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""使用 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})")