"""Windows 当前用户范围 DPAPI 封装;生产环境绝不降级为明文。""" from __future__ import annotations import ctypes from ctypes import wintypes import os import re from typing import Protocol from cmbuyer_client.core.errors import ProtectionError class SecretProtector(Protocol): def protect(self, plaintext: bytes, *, purpose: str) -> bytes: ... def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: ... class _DataBlob(ctypes.Structure): _fields_ = (("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte))) def _blob(data: bytes) -> tuple[_DataBlob, object]: buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) if data else (ctypes.c_ubyte * 1)() return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer class DpapiProtector: """使用 CryptProtectData/UI_FORBIDDEN;错误只暴露固定 reason code。""" _UI_FORBIDDEN = 0x1 _ENTROPY_PREFIX = b"cmbuyer-localstate-v1:" _PURPOSE_RE = re.compile( r"(?:device-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36}|" r"claim-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36})", flags=re.ASCII, ) def __init__(self) -> None: if os.name != "nt": raise ProtectionError("dpapi_requires_windows") self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) self._crypt32.CryptProtectData.argtypes = ( ctypes.POINTER(_DataBlob), wintypes.LPCWSTR, ctypes.POINTER(_DataBlob), wintypes.LPVOID, wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(_DataBlob), ) self._crypt32.CryptProtectData.restype = wintypes.BOOL self._crypt32.CryptUnprotectData.argtypes = ( ctypes.POINTER(_DataBlob), ctypes.POINTER(wintypes.LPWSTR), ctypes.POINTER(_DataBlob), wintypes.LPVOID, wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(_DataBlob), ) self._crypt32.CryptUnprotectData.restype = wintypes.BOOL self._kernel32.LocalFree.argtypes = (wintypes.HLOCAL,) self._kernel32.LocalFree.restype = wintypes.HLOCAL def protect(self, plaintext: bytes, *, purpose: str) -> bytes: if not isinstance(plaintext, bytes) or not plaintext: raise ProtectionError("invalid_plaintext") entropy = self._entropy(purpose) source, source_buffer = _blob(plaintext) entropy_blob, entropy_buffer = _blob(entropy) output = _DataBlob() if not self._crypt32.CryptProtectData( ctypes.byref(source), None, ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output) ): raise ProtectionError("dpapi_protect_failed") # ctypes 指针不持有底层 Python buffer;局部引用必须活到系统调用返回。 del source_buffer, entropy_buffer return self._take_output(output, "dpapi_protect_failed") def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: if not isinstance(ciphertext, bytes) or not ciphertext: raise ProtectionError("invalid_ciphertext") entropy = self._entropy(purpose) source, source_buffer = _blob(ciphertext) entropy_blob, entropy_buffer = _blob(entropy) output = _DataBlob() description = wintypes.LPWSTR() if not self._crypt32.CryptUnprotectData( ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output) ): raise ProtectionError("dpapi_unprotect_failed") del source_buffer, entropy_buffer if description: self._kernel32.LocalFree(ctypes.cast(description, wintypes.HLOCAL)) return self._take_output(output, "dpapi_unprotect_failed") def _take_output(self, output: _DataBlob, reason: str) -> bytes: if not output.pbData or output.cbData <= 0: raise ProtectionError(reason) try: return ctypes.string_at(output.pbData, output.cbData) finally: self._kernel32.LocalFree(ctypes.cast(output.pbData, wintypes.HLOCAL)) @classmethod def _entropy(cls, purpose: str) -> bytes: if not isinstance(purpose, str) or cls._PURPOSE_RE.fullmatch(purpose) is None: raise ProtectionError("invalid_protection_purpose") return cls._ENTROPY_PREFIX + purpose.encode("ascii")