diff --git a/pyproject.toml b/pyproject.toml index ce86e93..ccf6f0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,6 @@ dependencies = [ "requests>=2.32.4", "loguru>=0.7.0", "zstandard>=0.23.0", - "pymem>=1.14.0; sys_platform == 'win32'", - "yara-python>=4.5.4", "pilk>=0.2.4", ] diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py index 8015478..99a62bc 100644 --- a/src/wechat_decrypt_tool/chat_export_service.py +++ b/src/wechat_decrypt_tool/chat_export_service.py @@ -1461,7 +1461,7 @@ def _attach_offline_media( lock: threading.Lock, job: ExportJob, ) -> None: - # allow_process_key_extract is reserved for future: try to decrypt missing media using process memory keys + # allow_process_key_extract is reserved; this project does not extract keys from process (use wx_key instead). _ = allow_process_key_extract rt = str(msg.get("renderType") or "") diff --git a/src/wechat_decrypt_tool/media_helpers.py b/src/wechat_decrypt_tool/media_helpers.py index 450b10d..845f609 100644 --- a/src/wechat_decrypt_tool/media_helpers.py +++ b/src/wechat_decrypt_tool/media_helpers.py @@ -8,13 +8,9 @@ import os import re import sqlite3 import struct -import threading -from collections import Counter -from concurrent.futures import ThreadPoolExecutor from functools import lru_cache from pathlib import Path from typing import Any, Optional -from ctypes import wintypes from fastapi import HTTPException @@ -22,11 +18,6 @@ from .logging_config import get_logger logger = get_logger(__name__) -try: - import psutil # type: ignore -except Exception: - psutil = None - # 仓库根目录(用于定位 output/databases) _REPO_ROOT = Path(__file__).resolve().parents[2] @@ -1031,376 +1022,6 @@ def _detect_wechat_dat_version(data: bytes) -> int: return 2 return 0 - -def _extract_yyyymm_for_sort(p: Path) -> str: - m = re.search(r"(\d{4}-\d{2})", str(p)) - return m.group(1) if m else "0000-00" - - -@lru_cache(maxsize=16) -def _get_wechat_template_most_common_last2(weixin_root_str: str) -> Optional[bytes]: - try: - root = Path(weixin_root_str) - if not root.exists() or not root.is_dir(): - return None - except Exception: - return None - - try: - template_files = list(root.rglob("*_t.dat")) - except Exception: - template_files = [] - - if not template_files: - return None - - template_files.sort(key=_extract_yyyymm_for_sort, reverse=True) - last_bytes_list: list[bytes] = [] - for file in template_files[:16]: - try: - with open(file, "rb") as f: - f.seek(-2, 2) - b2 = f.read(2) - if b2 and len(b2) == 2: - last_bytes_list.append(b2) - except Exception: - continue - - if not last_bytes_list: - return None - return Counter(last_bytes_list).most_common(1)[0][0] - - -@lru_cache(maxsize=16) -def _find_wechat_xor_key(weixin_root_str: str) -> Optional[int]: - try: - root = Path(weixin_root_str) - if not root.exists() or not root.is_dir(): - return None - except Exception: - return None - - most_common = _get_wechat_template_most_common_last2(weixin_root_str) - if not most_common or len(most_common) != 2: - return None - x, y = most_common[0], most_common[1] - xor_key = x ^ 0xFF - if xor_key != (y ^ 0xD9): - return None - return xor_key - - -def _get_wechat_v2_ciphertext(weixin_root: Path, most_common_last2: bytes) -> Optional[bytes]: - try: - template_files = list(weixin_root.rglob("*_t.dat")) - except Exception: - return None - if not template_files: - return None - - template_files.sort(key=_extract_yyyymm_for_sort, reverse=True) - sig = b"\x07\x08V2\x08\x07" - - def try_read_ct(file: Path, require_last2: bool) -> Optional[bytes]: - try: - with open(file, "rb") as f: - if f.read(6) != sig: - return None - if require_last2 and most_common_last2 and len(most_common_last2) == 2: - try: - f.seek(-2, 2) - if f.read(2) != most_common_last2: - return None - except Exception: - return None - f.seek(0xF) - ct = f.read(16) - if ct and len(ct) == 16: - return ct - except Exception: - return None - return None - - # Prefer matching last2 bytes (older heuristic), but fall back to any V2 template like wx_key. - if most_common_last2 and len(most_common_last2) == 2: - for file in template_files: - ct = try_read_ct(file, require_last2=True) - if ct: - return ct - - for file in template_files: - ct = try_read_ct(file, require_last2=False) - if ct: - return ct - return None - - -def _verify_wechat_aes_key(ciphertext: bytes, key16: bytes) -> bool: - try: - from Crypto.Cipher import AES - - cipher = AES.new(key16[:16], AES.MODE_ECB) - plain = cipher.decrypt(ciphertext) - if plain.startswith(b"\xff\xd8\xff"): - return True - if plain.startswith(b"\x89PNG\r\n\x1a\n"): - return True - if plain.startswith(b"GIF87a") or plain.startswith(b"GIF89a"): - return True - if plain.startswith(b"wxgf"): - return True - if len(plain) >= 12 and plain.startswith(b"RIFF") and plain[8:12] == b"WEBP": - return True - if len(plain) >= 8 and plain[4:8] == b"ftyp": - return True - return False - except Exception: - return False - - -class _MEMORY_BASIC_INFORMATION(ctypes.Structure): - _fields_ = [ - ("BaseAddress", ctypes.c_void_p), - ("AllocationBase", ctypes.c_void_p), - ("AllocationProtect", ctypes.c_ulong), - ("RegionSize", ctypes.c_size_t), - ("State", ctypes.c_ulong), - ("Protect", ctypes.c_ulong), - ("Type", ctypes.c_ulong), - ] - - -def _find_weixin_pids() -> list[int]: - if psutil is None: - return [] - - preferred = ["weixin.exe", "wechat.exe", "wechatappex.exe", "wechatapp.exe"] - preferred_set = set(preferred) - pids_by_name: dict[str, list[int]] = {n: [] for n in preferred} - extra: list[int] = [] - - for p in psutil.process_iter(["pid", "name"]): - try: - name = (p.info.get("name") or "").lower() - pid = int(p.info.get("pid") or 0) - except Exception: - continue - if pid <= 0: - continue - - if name in preferred_set: - pids_by_name[name].append(pid) - continue - - if name.startswith("wechat") or name.startswith("weixin"): - extra.append(pid) - - ordered: list[int] = [] - for n in preferred: - ordered.extend(pids_by_name.get(n, [])) - ordered.extend(extra) - - seen: set[int] = set() - out: list[int] = [] - for pid in ordered: - if pid in seen: - continue - seen.add(pid) - out.append(pid) - return out - - -def _try_enable_windows_debug_privilege() -> None: - if os.name != "nt": - return - - try: - advapi32 = ctypes.windll.advapi32 - kernel32 = ctypes.windll.kernel32 - - TOKEN_ADJUST_PRIVILEGES = 0x0020 - TOKEN_QUERY = 0x0008 - SE_PRIVILEGE_ENABLED = 0x0002 - - class _LUID(ctypes.Structure): - _fields_ = [("LowPart", wintypes.DWORD), ("HighPart", wintypes.LONG)] - - class _LUID_AND_ATTRIBUTES(ctypes.Structure): - _fields_ = [("Luid", _LUID), ("Attributes", wintypes.DWORD)] - - class _TOKEN_PRIVILEGES(ctypes.Structure): - _fields_ = [("PrivilegeCount", wintypes.DWORD), ("Privileges", _LUID_AND_ATTRIBUTES * 1)] - - token = wintypes.HANDLE() - if not advapi32.OpenProcessToken( - kernel32.GetCurrentProcess(), - TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, - ctypes.byref(token), - ): - return - - try: - luid = _LUID() - if not advapi32.LookupPrivilegeValueW(None, "SeDebugPrivilege", ctypes.byref(luid)): - return - - tp = _TOKEN_PRIVILEGES() - tp.PrivilegeCount = 1 - tp.Privileges[0].Luid = luid - tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED - advapi32.AdjustTokenPrivileges(token, False, ctypes.byref(tp), 0, None, None) - finally: - kernel32.CloseHandle(token) - except Exception: - return - - -def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]: - _try_enable_windows_debug_privilege() - pids = _find_weixin_pids() - if not pids: - return None - - PROCESS_VM_READ = 0x0010 - PROCESS_QUERY_INFORMATION = 0x0400 - MEM_COMMIT = 0x1000 - MEM_PRIVATE = 0x20000 - - PAGE_NOACCESS = 0x01 - PAGE_READONLY = 0x02 - PAGE_READWRITE = 0x04 - PAGE_WRITECOPY = 0x08 - PAGE_EXECUTE_READ = 0x20 - PAGE_EXECUTE_READWRITE = 0x40 - PAGE_EXECUTE_WRITECOPY = 0x80 - PAGE_GUARD = 0x100 - - kernel32 = ctypes.windll.kernel32 - - OpenProcess = kernel32.OpenProcess - OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong] - OpenProcess.restype = ctypes.c_void_p - - ReadProcessMemory = kernel32.ReadProcessMemory - ReadProcessMemory.argtypes = [ - ctypes.c_void_p, - ctypes.c_void_p, - ctypes.c_void_p, - ctypes.c_size_t, - ctypes.POINTER(ctypes.c_size_t), - ] - ReadProcessMemory.restype = ctypes.c_bool - - VirtualQueryEx = kernel32.VirtualQueryEx - VirtualQueryEx.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] - VirtualQueryEx.restype = ctypes.c_size_t - - CloseHandle = kernel32.CloseHandle - CloseHandle.argtypes = [ctypes.c_void_p] - CloseHandle.restype = ctypes.c_bool - - readable_mask = ( - PAGE_READONLY - | PAGE_READWRITE - | PAGE_WRITECOPY - | PAGE_EXECUTE_READ - | PAGE_EXECUTE_READWRITE - | PAGE_EXECUTE_WRITECOPY - ) - - def is_readable(protect: int) -> bool: - if protect & PAGE_GUARD: - return False - if protect & PAGE_NOACCESS: - return False - return bool(protect & readable_mask) - - # Keep pattern consistent with wx_key: search for 16/32 lower/upper alpha-num strings with word-boundary-like guards. - # (Using 32 first reduces false positives in some builds.) - pattern = re.compile(rb"(?i)(? Optional[bytes]: - handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid) - if not handle: - return None - - stop = threading.Event() - result: list[Optional[bytes]] = [None] - - def read_mem(addr: int, size: int) -> Optional[bytes]: - buf = ctypes.create_string_buffer(size) - read = ctypes.c_size_t(0) - ok = ReadProcessMemory(handle, ctypes.c_void_p(addr), buf, size, ctypes.byref(read)) - if not ok or read.value <= 0: - return None - return buf.raw[: read.value] - - def scan_region(base: int, region_size: int) -> Optional[bytes]: - chunk = 4 * 1024 * 1024 - offset = 0 - tail = b"" - while offset < region_size and not stop.is_set(): - to_read = min(chunk, region_size - offset) - b = read_mem(base + offset, int(to_read)) - if not b: - # Don't abort the whole region on a single read failure (wx_key keeps scanning). - offset += to_read - tail = b"" - continue - data = tail + b - for m in pattern.finditer(data): - cand = m.group(1) - if len(cand) == 32: - # wx_key uses key[:16] to validate; keep that but also try the second half for compatibility. - candidates = [cand[:16], cand[16:]] - else: - candidates = [cand] - for cand16 in candidates: - if _verify_wechat_aes_key(ciphertext, cand16): - return cand16 - tail = data[-64:] if len(data) > 64 else data - offset += to_read - return None - - regions: list[tuple[int, int]] = [] - mbi = _MEMORY_BASIC_INFORMATION() - addr = 0 - try: - while VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)): - try: - if int(mbi.State) == MEM_COMMIT and int(mbi.Type) == MEM_PRIVATE: - protect = int(mbi.Protect) - if is_readable(protect): - base = int(mbi.BaseAddress) - size = int(mbi.RegionSize) - if size > 0: - # Skip extremely large regions to keep runtime bounded (same idea as wx_key). - if size <= 100 * 1024 * 1024: - regions.append((base, size)) - addr = int(mbi.BaseAddress) + int(mbi.RegionSize) - except Exception: - addr += 0x1000 - if addr <= 0: - break - - with ThreadPoolExecutor(max_workers=min(32, max(1, len(regions)))) as ex: - for found in ex.map(lambda r: scan_region(r[0], r[1]), regions): - if found: - result[0] = found - stop.set() - break - finally: - CloseHandle(handle) - - return result[0] - - for pid in pids: - found = scan_pid(pid) - if found: - return found - return None - - @lru_cache(maxsize=4096) def _fallback_search_media_by_file_id( weixin_root_str: str, @@ -1495,11 +1116,17 @@ def _fallback_search_media_by_file_id( return None -def _save_media_keys(account_dir: Path, xor_key: int, aes_key16: bytes) -> None: +def _save_media_keys(account_dir: Path, xor_key: int, aes_key16: Optional[bytes] = None) -> None: try: + aes_str = "" + if aes_key16: + try: + aes_str = aes_key16.decode("ascii", errors="ignore")[:16] + except Exception: + aes_str = "" payload = { "xor": int(xor_key), - "aes": aes_key16.decode("ascii", errors="ignore"), + "aes": aes_str, } (account_dir / "_media_keys.json").write_text( json.dumps(payload, ensure_ascii=False, indent=2), @@ -1650,17 +1277,13 @@ def _read_and_maybe_decrypt_media( # Try WeChat .dat v1/v2 decrypt. version = _detect_wechat_dat_version(data) if version in (0, 1, 2): - root = weixin_root - if root is None and account_dir is not None: - root = _resolve_account_wxid_dir(account_dir) - if root is None and account_dir is not None: - ds = _resolve_account_db_storage_dir(account_dir) - root = ds.parent if ds else None - - xor_key = _find_wechat_xor_key(str(root)) if root else None - if xor_key is None and account_dir is not None: + # 不在本项目内做任何密钥提取;仅使用用户保存的密钥(_media_keys.json)。 + xor_key: Optional[int] = None + aes_key16 = b"" + if account_dir is not None: try: keys2 = _load_media_keys(account_dir) + x2 = keys2.get("xor") if x2 is not None: xor_key = int(x2) @@ -1668,8 +1291,13 @@ def _read_and_maybe_decrypt_media( xor_key = None else: logger.debug("使用 _media_keys.json 中保存的 xor key") + + aes_str = str(keys2.get("aes") or "").strip() + if len(aes_str) >= 16: + aes_key16 = aes_str[:16].encode("ascii", errors="ignore") except Exception: xor_key = None + aes_key16 = b"" try: if version == 0 and xor_key is not None: out = _decrypt_wechat_dat_v3(data, xor_key) @@ -1707,41 +1335,24 @@ def _read_and_maybe_decrypt_media( mt1 = _detect_image_media_type(out[:32]) if mt1 != "application/octet-stream": return out, mt1 - elif version == 2 and xor_key is not None and account_dir is not None and root is not None: - keys = _load_media_keys(account_dir) - aes_str = str(keys.get("aes") or "").strip() - aes_key16 = aes_str.encode("ascii", errors="ignore")[:16] if aes_str else b"" - - if not aes_key16: - most_common = _get_wechat_template_most_common_last2(str(root)) - if most_common: - ct = _get_wechat_v2_ciphertext(Path(root), most_common) + elif version == 2 and xor_key is not None and aes_key16: + out = _decrypt_wechat_dat_v4(data, xor_key, aes_key16) + try: + out2, mtp2 = _try_strip_media_prefix(out) + if mtp2 != "application/octet-stream": + return out2, mtp2 + except Exception: + pass + if out.startswith(b"wxgf"): + converted = _wxgf_to_image_bytes(out) + if converted: + out = converted + logger.info(f"wxgf->image: {path} -> {len(out)} bytes") else: - ct = None - - if ct: - aes_key16 = _extract_wechat_aes_key_from_process(ct) or b"" - if aes_key16: - _save_media_keys(account_dir, xor_key, aes_key16) - - if aes_key16: - out = _decrypt_wechat_dat_v4(data, xor_key, aes_key16) - try: - out2, mtp2 = _try_strip_media_prefix(out) - if mtp2 != "application/octet-stream": - return out2, mtp2 - except Exception: - pass - if out.startswith(b"wxgf"): - converted = _wxgf_to_image_bytes(out) - if converted: - out = converted - logger.info(f"wxgf->image: {path} -> {len(out)} bytes") - else: - logger.info(f"wxgf->image failed: {path}") - mt2b = _detect_image_media_type(out[:32]) - if mt2b != "application/octet-stream": - return out, mt2b + logger.info(f"wxgf->image failed: {path}") + mt2b = _detect_image_media_type(out[:32]) + if mt2b != "application/octet-stream": + return out, mt2b except Exception: pass diff --git a/src/wechat_decrypt_tool/media_key_finder.py b/src/wechat_decrypt_tool/media_key_finder.py deleted file mode 100644 index d32cae2..0000000 --- a/src/wechat_decrypt_tool/media_key_finder.py +++ /dev/null @@ -1,250 +0,0 @@ -import ctypes -import json -import os -import re -import threading -from collections import Counter -from concurrent.futures import ThreadPoolExecutor -from ctypes import wintypes -from functools import lru_cache -from pathlib import Path -from typing import Any - -import pymem -import yara -from Crypto.Cipher import AES - -PROCESS_ALL_ACCESS = 0x1F0FFF -MEM_COMMIT = 0x1000 -MEM_PRIVATE = 0x20000 - -kernel32 = ctypes.windll.kernel32 - - -class MEMORY_BASIC_INFORMATION(ctypes.Structure): - _fields_ = [ - ("BaseAddress", ctypes.c_void_p), - ("AllocationBase", ctypes.c_void_p), - ("AllocationProtect", ctypes.c_ulong), - ("RegionSize", ctypes.c_size_t), - ("State", ctypes.c_ulong), - ("Protect", ctypes.c_ulong), - ("Type", ctypes.c_ulong), - ] - - -def _open_process(pid: int): - return kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) - - -def _read_process_memory(process_handle, address: int, size: int) -> bytes | None: - buffer = ctypes.create_string_buffer(size) - bytes_read = ctypes.c_size_t(0) - success = kernel32.ReadProcessMemory( - process_handle, - ctypes.c_void_p(address), - buffer, - size, - ctypes.byref(bytes_read), - ) - if not success: - return None - return buffer.raw - - -def _get_memory_regions(process_handle) -> list[tuple[int, int]]: - regions: list[tuple[int, int]] = [] - mbi = MEMORY_BASIC_INFORMATION() - address = 0 - while kernel32.VirtualQueryEx( - process_handle, - ctypes.c_void_p(address), - ctypes.byref(mbi), - ctypes.sizeof(mbi), - ): - if mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE: - regions.append((int(mbi.BaseAddress), int(mbi.RegionSize))) - address += int(mbi.RegionSize) - return regions - - -@lru_cache -def _verify(encrypted: bytes, key: bytes) -> bool: - aes_key = key[:16] - cipher = AES.new(aes_key, AES.MODE_ECB) - text = cipher.decrypt(encrypted) - if text.startswith(b"\xff\xd8\xff"): - return True - if text.startswith(b"\x89PNG\r\n\x1a\n"): - return True - if text.startswith(b"GIF87a") or text.startswith(b"GIF89a"): - return True - if text.startswith(b"wxgf"): - return True - if len(text) >= 12 and text.startswith(b"RIFF") and text[8:12] == b"WEBP": - return True - if len(text) >= 8 and text[4:8] == b"ftyp": - return True - return False - - -def _search_memory_chunk(process_handle, base_address: int, region_size: int, encrypted: bytes, rules): - memory = _read_process_memory(process_handle, base_address, region_size) - if not memory: - return None - - matches = rules.match(data=memory) - if matches: - for match in matches: - if match.rule == "AesKey": - for string in match.strings: - for instance in string.instances: - content = instance.matched_data[1:-1] - if _verify(encrypted, content): - return content[:16] - return None - - -def _get_aes_key(encrypted: bytes, pid: int) -> Any: - process_handle = _open_process(pid) - if not process_handle: - raise RuntimeError(f"无法打开进程 {pid}") - - rules_key = r""" - rule AesKey { - strings: - $pattern = /[^0-9a-z]([0-9a-z]{16}|[0-9a-z]{32})[^0-9a-z]/ nocase - condition: - $pattern - } - """ - rules = yara.compile(source=rules_key) - - process_infos = _get_memory_regions(process_handle) - - found_result = threading.Event() - result = [None] - - def process_chunk(args): - if found_result.is_set(): - return None - base_address, region_size = args - res = _search_memory_chunk(process_handle, base_address, region_size, encrypted, rules) - if res: - result[0] = res - found_result.set() - return res - - with ThreadPoolExecutor(max_workers=min(32, len(process_infos) or 1)) as executor: - executor.map(process_chunk, process_infos) - - kernel32.CloseHandle(process_handle) - return result[0] - - -def _dump_wechat_info_v4(encrypted: bytes, pid: int) -> bytes: - result = _get_aes_key(encrypted, pid) - if isinstance(result, bytes): - return result[:16] - raise RuntimeError("未找到 AES 密钥") - - -def _sort_template_files_by_date(template_files: list[Path]) -> list[Path]: - def get_date_from_path(filepath: Path) -> str: - match = re.search(r"(\d{4}-\d{2})", str(filepath)) - if match: - return match.group(1) - return "0000-00" - - return sorted(template_files, key=get_date_from_path, reverse=True) - - -def find_key( - weixin_dir: Path, - version: int = 4, - xor_key_: int | None = None, - aes_key_: bytes | None = None, -) -> tuple[int, bytes]: - if os.name != "nt": - raise RuntimeError("仅支持 Windows") - if version not in (3, 4): - raise RuntimeError("version must be 3 or 4") - - template_files = _sort_template_files_by_date(list(weixin_dir.rglob("*_t.dat"))) - if not template_files: - raise RuntimeError("未找到模板文件") - - last_bytes_list: list[bytes] = [] - for file in template_files[:16]: - try: - with open(file, "rb") as f: - f.seek(-2, 2) - last_bytes_list.append(f.read(2)) - except Exception: - continue - - if not last_bytes_list: - raise RuntimeError("对于 XOR, 未能成功读取任何模板文件") - - counter = Counter(last_bytes_list) - most_common = counter.most_common(1)[0][0] - - x, y = most_common - xor_key = x ^ 0xFF - if xor_key != (y ^ 0xD9): - raise RuntimeError("未能找到 XOR 密钥") - - if xor_key_ is not None: - if xor_key_ != xor_key: - raise RuntimeError("XOR 密钥校验失败") - return xor_key_, aes_key_ or b"" - - if version == 3: - return xor_key, b"cfcd208495d565ef" - - ciphertext: bytes | None = None - for file in template_files: - with open(file, "rb") as f: - if f.read(6) != b"\x07\x08V2\x08\x07": - continue - f.seek(-2, 2) - if f.read(2) != most_common: - continue - f.seek(0xF) - ciphertext = f.read(16) - break - - if not ciphertext: - raise RuntimeError("对于 AES, 未能成功读取任何模板文件") - - try: - pm = pymem.Pymem("Weixin.exe") - pid = pm.process_id - if not isinstance(pid, int): - raise RuntimeError("找不到微信进程") - except Exception: - raise RuntimeError("找不到微信进程") - - aes_key = _dump_wechat_info_v4(ciphertext, pid) - return xor_key, aes_key - - -CONFIG_FILE = "config.json" - - -def read_key_from_config() -> tuple[int, bytes]: - if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - key_dict = json.loads(f.read()) - x, y = key_dict["xor"], key_dict["aes"] - return x, y.encode()[:16] - return 0, b"" - - -def store_key(xor_k: int, aes_k: bytes) -> None: - key_dict = { - "xor": xor_k, - "aes": aes_k.decode(), - } - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - f.write(json.dumps(key_dict)) diff --git a/src/wechat_decrypt_tool/routers/chat_export.py b/src/wechat_decrypt_tool/routers/chat_export.py index cf7a847..cd70ef2 100644 --- a/src/wechat_decrypt_tool/routers/chat_export.py +++ b/src/wechat_decrypt_tool/routers/chat_export.py @@ -33,7 +33,7 @@ class ChatExportCreateRequest(BaseModel): ) allow_process_key_extract: bool = Field( False, - description="是否允许尝试从微信进程提取媒体密钥(预留;当前仅使用已存在的本地文件)", + description="预留字段:本项目不从微信进程提取媒体密钥,请使用 wx_key 获取并保存/批量解密", ) privacy_mode: bool = Field( False, diff --git a/src/wechat_decrypt_tool/routers/media.py b/src/wechat_decrypt_tool/routers/media.py index f8d5ef0..d2f3bdd 100644 --- a/src/wechat_decrypt_tool/routers/media.py +++ b/src/wechat_decrypt_tool/routers/media.py @@ -11,11 +11,7 @@ from ..media_helpers import ( _collect_all_dat_files, _decrypt_and_save_resource, _detect_image_media_type, - _extract_wechat_aes_key_from_process, - _find_wechat_xor_key, _get_resource_dir, - _get_wechat_template_most_common_last2, - _get_wechat_v2_ciphertext, _load_media_keys, _resolve_account_dir, _resolve_account_wxid_dir, @@ -29,11 +25,12 @@ logger = get_logger(__name__) router = APIRouter(route_class=PathFixRoute) -class MediaKeysRequest(BaseModel): - """媒体密钥请求模型""" +class MediaKeysSaveRequest(BaseModel): + """媒体密钥保存请求模型(用户手动提供)""" account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)") - force_extract: bool = Field(False, description="是否强制从微信进程重新提取密钥") + xor_key: str = Field(..., description="XOR密钥(十六进制格式,如 0xA5 或 A5)") + aes_key: Optional[str] = Field(None, description="AES密钥(可选,至少16字符,V4-V2需要)") class MediaDecryptRequest(BaseModel): @@ -44,105 +41,37 @@ class MediaDecryptRequest(BaseModel): aes_key: Optional[str] = Field(None, description="AES密钥(16字符ASCII字符串)") -@router.get("/api/media/keys", summary="获取图片解密密钥") -async def get_media_keys(account: Optional[str] = None, force_extract: bool = False): - """获取图片解密密钥(XOR和AES) - - 如果已缓存密钥且不强制提取,直接返回缓存的密钥。 - 否则尝试从微信进程中提取密钥。 - - 注意:提取AES密钥需要微信进程正在运行。 - """ - account_dir = _resolve_account_dir(account) - wxid_dir = _resolve_account_wxid_dir(account_dir) - - # 尝试加载已缓存的密钥 - cached_keys = _load_media_keys(account_dir) - if cached_keys and not force_extract: - xor_key = cached_keys.get("xor") - aes_key = cached_keys.get("aes") - if xor_key is not None and aes_key: - return { - "status": "success", - "source": "cache", - "xor_key": f"0x{int(xor_key):02X}", - "aes_key": str(aes_key)[:16] if aes_key else "", - "message": "已从缓存加载密钥", - } - - if not wxid_dir: - return { - "status": "error", - "message": "未找到微信数据目录,请确保已正确配置 db_storage_path", - } - - # 尝试提取XOR密钥 - xor_key = _find_wechat_xor_key(str(wxid_dir)) - if xor_key is None: - return { - "status": "error", - "message": "无法提取XOR密钥,请确保微信数据目录中存在 _t.dat 模板文件", - } - - # 尝试提取AES密钥(需要微信进程运行) - aes_key16: Optional[bytes] = None - aes_message = "" - - most_common = _get_wechat_template_most_common_last2(str(wxid_dir)) - if most_common: - ct = _get_wechat_v2_ciphertext(wxid_dir, most_common) - if ct: - aes_key16 = _extract_wechat_aes_key_from_process(ct) - if aes_key16: - aes_message = "已从微信进程提取AES密钥" - # 保存密钥到缓存 - _save_media_keys(account_dir, xor_key, aes_key16) - else: - aes_message = "无法从微信进程提取AES密钥(请确认微信正在运行,并尝试以管理员身份运行后端;可尝试打开朋友圈图片并点开大图 2-3 次后再提取)" - else: - aes_message = "未找到V2加密模板文件" - else: - aes_message = "未找到足够的模板文件用于提取AES密钥" - - return { - "status": "success", - "source": "extracted", - "xor_key": f"0x{xor_key:02X}", - "aes_key": aes_key16.decode("ascii", errors="ignore") if aes_key16 else "", - "message": f"XOR密钥提取成功。{aes_message}", - } - - @router.post("/api/media/keys", summary="保存图片解密密钥") -async def save_media_keys_api(request: MediaKeysRequest, xor_key: str, aes_key: str): +async def save_media_keys_api(request: MediaKeysSaveRequest): """手动保存图片解密密钥 参数: - xor_key: XOR密钥(十六进制格式,如 0xA5 或 A5) - - aes_key: AES密钥(16字符ASCII字符串) + - aes_key: AES密钥(可选,至少16个字符;V4-V2需要) """ account_dir = _resolve_account_dir(request.account) # 解析XOR密钥 try: - xor_hex = xor_key.strip().lower().replace("0x", "") + xor_hex = request.xor_key.strip().lower().replace("0x", "") xor_int = int(xor_hex, 16) except Exception: raise HTTPException(status_code=400, detail="XOR密钥格式无效,请使用十六进制格式如 0xA5") - # 验证AES密钥 - aes_str = aes_key.strip() - if len(aes_str) < 16: + # 验证AES密钥(可选) + aes_str = str(request.aes_key or "").strip() + if aes_str and len(aes_str) < 16: raise HTTPException(status_code=400, detail="AES密钥长度不足,需要至少16个字符") # 保存密钥 - _save_media_keys(account_dir, xor_int, aes_str[:16].encode("ascii", errors="ignore")) + aes_key16 = aes_str[:16].encode("ascii", errors="ignore") if aes_str else None + _save_media_keys(account_dir, xor_int, aes_key16) return { "status": "success", "message": "密钥已保存", "xor_key": f"0x{xor_int:02X}", - "aes_key": aes_str[:16], + "aes_key": aes_str[:16] if aes_str else "", } @@ -193,14 +122,10 @@ async def decrypt_all_media(request: MediaDecryptRequest): if len(aes_str) >= 16: aes_key16 = aes_str[:16].encode("ascii", errors="ignore") - # 如果仍然没有XOR密钥,尝试自动提取 - if xor_key_int is None: - xor_key_int = _find_wechat_xor_key(str(wxid_dir)) - if xor_key_int is None: raise HTTPException( status_code=400, - detail="未找到XOR密钥,请先调用 /api/media/keys 获取密钥或手动提供", + detail="未找到XOR密钥,请先使用 wx_key 获取并通过前端填写(或调用 /api/media/keys 保存)", ) # 收集所有.dat文件 @@ -353,12 +278,8 @@ async def decrypt_all_media_stream( if len(aes_str) >= 16: aes_key16 = aes_str[:16].encode("ascii", errors="ignore") - # 如果仍然没有XOR密钥,尝试自动提取 if xor_key_int is None: - xor_key_int = _find_wechat_xor_key(str(wxid_dir)) - - if xor_key_int is None: - yield f"data: {json.dumps({'type': 'error', 'message': '未找到XOR密钥,请先获取密钥'})}\n\n" + yield f"data: {json.dumps({'type': 'error', 'message': '未找到XOR密钥,请先使用 wx_key 获取并保存/填写'})}\n\n" return # 收集所有.dat文件 diff --git a/tools/extract_media_keys.py b/tools/extract_media_keys.py index 8a1aa23..cb8966c 100644 --- a/tools/extract_media_keys.py +++ b/tools/extract_media_keys.py @@ -1,99 +1,27 @@ #!/usr/bin/env python3 """ -提取微信 4.x 媒体解密密钥 (需要管理员权限运行) +已废弃:本项目不再提供任何密钥提取流程。 -用法: -1. 确保微信正在运行 -2. 以管理员身份运行 PowerShell -3. cd 到项目目录 -4. 运行: uv run python tools/extract_media_keys.py +请使用 wx_key 获取数据库/图片密钥: +https://github.com/ycccccccy/wx_key + +获取到图片密钥后,可在前端「图片密钥」步骤填写并保存, +或调用后端接口保存: + + POST /api/media/keys + body: { "xor_key": "0xA5", "aes_key": "xxxxxxxxxxxxxxxx" } """ +from __future__ import annotations + import sys -sys.path.insert(0, "src") - -import json -from pathlib import Path - -try: - from wechat_decrypt_tool.media_key_finder import find_key -except ImportError as e: - print(f"[ERROR] 无法导入 media_key_finder: {e}") - print("请确保 pymem, yara-python, pycryptodome 已安装") - sys.exit(1) - -# ========== 配置 ========== -REPO_ROOT = Path(__file__).resolve().parents[1] -OUTPUT_DB_DIR = REPO_ROOT / "output" / "databases" def main(): - print("=" * 60) - print("微信 4.x 媒体解密密钥提取工具") - print("=" * 60) - - # 1. 列出所有账号 - print("\n[1] 列出已解密账号...") - if not OUTPUT_DB_DIR.exists(): - print("[ERROR] output/databases 目录不存在") - sys.exit(1) - - accounts = [] - for p in OUTPUT_DB_DIR.iterdir(): - if p.is_dir() and (p / "_source.json").exists(): - accounts.append(p.name) - - if not accounts: - print("[ERROR] 没有找到已解密的账号") - sys.exit(1) - - print(f" 找到 {len(accounts)} 个账号") - - # 2. 处理每个账号 - for account in accounts: - print(f"\n[2] 处理账号: {account}") - account_dir = OUTPUT_DB_DIR / account - - # 读取 _source.json - source_json = account_dir / "_source.json" - with open(source_json, "r", encoding="utf-8") as f: - source = json.load(f) - - wxid_dir_str = source.get("wxid_dir", "") - if not wxid_dir_str: - print(" [SKIP] 没有 wxid_dir") - continue - - wxid_dir = Path(wxid_dir_str) - if not wxid_dir.exists(): - print(f" [SKIP] wxid_dir 不存在: {wxid_dir}") - continue - - # 使用 WxDatDecrypt 的 find_key 函数 - print(f" wxid_dir: {wxid_dir}") - print(" 正在提取密钥 (需要微信正在运行且有管理员权限)...") - - try: - xor_key, aes_key = find_key(wxid_dir, version=4) - - # 保存到 _media_keys.json - keys_file = account_dir / "_media_keys.json" - keys_data = { - "xor": xor_key, - "aes": aes_key.decode("ascii") if isinstance(aes_key, bytes) else str(aes_key), - } - with open(keys_file, "w", encoding="utf-8") as f: - json.dump(keys_data, f, indent=2) - print(f" [OK] 密钥已保存到: {keys_file}") - print(f" XOR key: {xor_key}") - print(f" AES key: {keys_data['aes']}") - except Exception as e: - print(f" [ERROR] 提取失败: {e}") - - print("\n" + "=" * 60) - print("完成!请重启后端服务以使密钥生效。") - print("=" * 60) + print("[DEPRECATED] 本项目不再提供密钥提取流程。") + print("请使用 wx_key 获取密钥:https://github.com/ycccccccy/wx_key") + return 1 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index e94853b..8d3ef00 100644 --- a/uv.lock +++ b/uv.lock @@ -427,15 +427,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] -[[package]] -name = "pymem" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/fd/1906f383fd9697c0da599580c58f5cd5af48edb55429f6ef994c447fb94e/pymem-1.14.0.tar.gz", hash = "sha256:29f6c32bcad0032888afabadf97d1e4c7757f88873de4d79f7f4c1df9b9e7ef1", size = 24890, upload-time = "2024-10-27T18:59:50.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/a5/23907a4b55d67cd4c0e4b9a37e32f5f1ffaf7e286dcf172a2a1bb9e12444/pymem-1.14.0-py3-none-any.whl", hash = "sha256:2b9cc64b49d0685f73d616ab1f638611f87e8d649869e7a556f050f677c42a7e", size = 29833, upload-time = "2024-10-27T18:59:48.911Z" }, -] - [[package]] name = "python-dotenv" version = "1.1.0" @@ -760,13 +751,11 @@ dependencies = [ { name = "pilk" }, { name = "psutil" }, { name = "pycryptodome" }, - { name = "pymem", marker = "sys_platform == 'win32'" }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, { name = "typing-extensions" }, { name = "uvicorn", extra = ["standard"] }, - { name = "yara-python" }, { name = "zstandard" }, ] @@ -780,13 +769,11 @@ requires-dist = [ { name = "pilk", specifier = ">=0.2.4" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pycryptodome", specifier = ">=3.23.0" }, - { name = "pymem", marker = "sys_platform == 'win32'", specifier = ">=1.14.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" }, { name = "requests", specifier = ">=2.32.4" }, { name = "typing-extensions", specifier = ">=4.8.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, - { name = "yara-python", specifier = ">=4.5.4" }, { name = "zstandard", specifier = ">=0.23.0" }, ] @@ -799,50 +786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] -[[package]] -name = "yara-python" -version = "4.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/38/347d1fcde4edabd338d5872ca5759ccfb95ff1cf5207dafded981fd08c4f/yara_python-4.5.4.tar.gz", hash = "sha256:4c682170f3d5cb3a73aa1bd0dc9ab1c0957437b937b7a83ff6d7ffd366415b9c", size = 551142, upload-time = "2025-05-27T14:15:49.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/17/f0bc4a643d8c3afb485c34b70fe1d161f3fe0459361d2eb3561d23cc16e1/yara_python-4.5.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e3e2a5575d61adc2b4ff2007737590783a43d16386b061ac12e6e70a82e5d1de", size = 2471737, upload-time = "2025-05-27T14:14:23.876Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/39c74fc2732b89d4a5a6ad272b2b60ec84b3aae53b120a9eccc742eec802/yara_python-4.5.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f79a27dbdafb79fc2dc03c7c3ba66751551e3e0b350ab69cc499870b78a6cb95", size = 208862, upload-time = "2025-05-27T14:14:25.941Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/ff38abffe3c5126da0b4f31471bc6df2e38f4f532a65941a1a8092cddb4f/yara_python-4.5.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:9d9acf6f8135bcee03f47b1096ad69f4a2788abe37dd070aab6e9dd816742ecc", size = 2478391, upload-time = "2025-05-27T14:14:27.802Z" }, - { url = "https://files.pythonhosted.org/packages/69/97/638f0f6920250dd4cc202f05d2b931c4aeb8ea916ae185cd15b9dacf9ae4/yara_python-4.5.4-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:0e762e6c5b47ddf30b0128ba723da46fcc2aa7959a252748497492cb452d1c84", size = 209304, upload-time = "2025-05-27T14:14:29.663Z" }, - { url = "https://files.pythonhosted.org/packages/2a/08/e9396374b8d4348f71db28dabbcbde21ceb0e68c604a4de82ab4f1c286e9/yara_python-4.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adcfac4b225e76ab6dcbeaf10101f0de2731fdbee51610dbc77b96e667e85a3a", size = 2242098, upload-time = "2025-05-27T14:14:31.001Z" }, - { url = "https://files.pythonhosted.org/packages/58/fa/b159db2afedef12d5de32b6eb7c087a78c29dc51fc5111475bbd96759744/yara_python-4.5.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a82c87038f0da2d90051bfd6449cf9a4b977a15ee8372f3512ce0a413ef822fd", size = 2324289, upload-time = "2025-05-27T14:14:32.852Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1e/8846d6c37e3cc5328ac90d888ac60599ed62f1ffcb7d68054ad60954df4a/yara_python-4.5.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a1721b61ee4e625a143e8e5bf32fa6774797c06724c45067f3e8919a8e5f8f3", size = 2329563, upload-time = "2025-05-27T14:14:34.758Z" }, - { url = "https://files.pythonhosted.org/packages/35/db/d6de595384e357366a8e7832876e5d9be56629e29e12d2a9325cc652e855/yara_python-4.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:57d80c7591bbc6d9e73934e0fa4cbbb35e3e733b2706c5fd6756edf495f42678", size = 2947661, upload-time = "2025-05-27T14:14:37.419Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/35d67095b0b000315545c59b2826166371d005f9815ca6c7c79a87e6c4cc/yara_python-4.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3872b5f5575d6f5077f86e2b8bcdfe8688f859a50854334a4085399331167abc", size = 2417932, upload-time = "2025-05-27T14:14:40.402Z" }, - { url = "https://files.pythonhosted.org/packages/8f/28/760d114cea3f160e3f862d747208a09150974a668a49c0cee23a943006c4/yara_python-4.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fd84af5b6da3429236b61f3ad8760fdc739d0e1d6a08b8f3d90cd375e71594df", size = 2638058, upload-time = "2025-05-27T14:14:42.141Z" }, - { url = "https://files.pythonhosted.org/packages/74/9d/e208dd3ffc38a163d18476d2f758c24de8ece67c9d59c654a6b5b400fd96/yara_python-4.5.4-cp311-cp311-win32.whl", hash = "sha256:491c9de854e4a47dfbef7b3a38686c574459779915be19dcf4421b65847a57ce", size = 1447300, upload-time = "2025-05-27T14:14:44.091Z" }, - { url = "https://files.pythonhosted.org/packages/85/ad/23ed18900f6024d5c1de567768c12a53c5759ac90d08624c87c816cd7249/yara_python-4.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:2a1bf52cb7b9178cc1ee2acd1697a0c8468af0c76aa1beffe22534bd4f62698b", size = 1825566, upload-time = "2025-05-27T14:14:45.879Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cc/deaf10b6b31ee81842176affce79d57c3b6df50e894cf6cdbb1f0eb12af2/yara_python-4.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ade234700c492bce0efda96c1cdcd763425016e40df4a8d30c4c4e6897be5ace", size = 2471771, upload-time = "2025-05-27T14:14:47.381Z" }, - { url = "https://files.pythonhosted.org/packages/b1/47/9227c56450be00db6a3a50ccf88ba201945ae14a34b80b3aae4607954159/yara_python-4.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e1dedd149be61992781f085b592d169d1d813f9b5ffc7c8c2b74e429b443414c", size = 208991, upload-time = "2025-05-27T14:14:48.832Z" }, - { url = "https://files.pythonhosted.org/packages/5b/0d/1f2e054f7ddf9fd4c873fccf63a08f2647b205398e11ea75cf44c161e702/yara_python-4.5.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:92b233aae320ee9e59728ee23f9faf4a423ae407d4768b47c8f0e472a34dbae2", size = 2478413, upload-time = "2025-05-27T14:14:50.205Z" }, - { url = "https://files.pythonhosted.org/packages/10/ab/96e2d06c909ba07941d6d303f23624e46751a54d6fd358069275f982168c/yara_python-4.5.4-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:1f238f10d26e4701559f73a69b22e1e192a6fa20abdd76f57a7054566780aa89", size = 209401, upload-time = "2025-05-27T14:14:52.017Z" }, - { url = "https://files.pythonhosted.org/packages/df/7d/e51ecb0db87094976904a52eb521e3faf9c18f7c889b9d5cf996ae3bb680/yara_python-4.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d29d0e137e0d77dd110186369276e88381f784bdc45b5932a2fb3463e2a1b1c7", size = 2245326, upload-time = "2025-05-27T14:14:53.338Z" }, - { url = "https://files.pythonhosted.org/packages/4c/5e/fe93d8609b8470148ebbae111f209c6f208bb834cee64046fce0532fcc70/yara_python-4.5.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d0039d734705b123494acad7a00b67df171dd5b1c16ff7b18ff07578efd4cd", size = 2327041, upload-time = "2025-05-27T14:14:54.915Z" }, - { url = "https://files.pythonhosted.org/packages/52/06/104c4daa22e34a7edb49051798126c37f6280d4f1ea7e8888b043314e72d/yara_python-4.5.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5eae935b05a9f8dc71df55a79c38f52abd93f8840310fe4e0d75fbd78284f24", size = 2332343, upload-time = "2025-05-27T14:14:56.424Z" }, - { url = "https://files.pythonhosted.org/packages/cd/38/4b788b8fe15faca08e4a52c0b3dc8787953115ce1811e7bf9439914b6a5b/yara_python-4.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:30fc7959394532c6e3f48faf59337f5da124f1630668258276b6cfa54e555a6e", size = 2949346, upload-time = "2025-05-27T14:14:57.924Z" }, - { url = "https://files.pythonhosted.org/packages/53/3a/c1d97172aa9672f381df78b4d3a9f60378f35431ff48f4a6c45037057e07/yara_python-4.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6d8c2acaf33931338fdb78aba8a68462b0151d833b2eeda712db87713ac2abf", size = 2421057, upload-time = "2025-05-27T14:15:00.118Z" }, - { url = "https://files.pythonhosted.org/packages/ba/cc/c6366d6d047f73594badd5444f6a32501e8b20ab1a7124837d305c08b42b/yara_python-4.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a3c7bc8cd0db5fb87ab579c755de83723030522f3c0cd5b3374044055a8ce6c6", size = 2639871, upload-time = "2025-05-27T14:15:02.63Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5c/edacbd11db432ac04a37c9e3a7c569986256d9659d1859c6f828268dfeed/yara_python-4.5.4-cp312-cp312-win32.whl", hash = "sha256:d12e57101683e9270738a1bccf676747f93e86b5bc529e7a7fb7adf94f20bd77", size = 1447403, upload-time = "2025-05-27T14:15:04.193Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e1/f6a72c155f3241360da890c218911d09bf63329eca9cfa1af64b1498339b/yara_python-4.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:bf14a8af06b2b980a889bdc3f9e8ccd6e703d2b3fa1c98da5fd3a1c3b551eb47", size = 1825743, upload-time = "2025-05-27T14:15:05.678Z" }, - { url = "https://files.pythonhosted.org/packages/74/7f/9bf4864fee85f86302d78d373c93793419c080222b5e18badadebb959263/yara_python-4.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:fe8ad189843c729eae74be3b8447a4753fac2cebe705e5e2a7280badfcc7e3b4", size = 2471811, upload-time = "2025-05-27T14:15:07.55Z" }, - { url = "https://files.pythonhosted.org/packages/44/58/dca36144c44b0613dbc39c70cc32ee0fc9db1ba9e5fa15f55657183f4229/yara_python-4.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:94e290d5035be23059d0475bff3eac8228acd51145bf0cabe355b1ddabab742b", size = 209044, upload-time = "2025-05-27T14:15:09.142Z" }, - { url = "https://files.pythonhosted.org/packages/8b/4c/997be6898cdda211cb601ae2af40a2dbd89a48ba9889168ddd3993636e97/yara_python-4.5.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:4537f8499d166d22a54739f440fb306f65b0438be2c6c4ecb2352ecb5adb5f1c", size = 2478416, upload-time = "2025-05-27T14:15:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/b6/29/91d5911d6decdd8a96bb891cacd8922569480ff1d4b47e7947b5dfc7f1d6/yara_python-4.5.4-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:ab5133a16e466db6fe9c1a08d1b171013507896175010fb85fc1b92da32e558c", size = 209441, upload-time = "2025-05-27T14:15:12.173Z" }, - { url = "https://files.pythonhosted.org/packages/87/9b/e21f534f33062f2e5f6dceec3cb4918f4923264b1609652adc0c83fe2bde/yara_python-4.5.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93f5f5aba88e2ed2aaebfbb697433a0c8020c6a6c6a711e900a29e9b512d5c3a", size = 2245135, upload-time = "2025-05-27T14:15:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/70/8e/3618d2473f1e97f3f91d13af5b1ed26381c74444f6cdebb849eb855b21ca/yara_python-4.5.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:473c52b53c39d5daedc1912bd8a82a1c88702a3e393688879d77f9ff5f396543", size = 2326864, upload-time = "2025-05-27T14:15:15.321Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/21477d4c83e7f267ff7d61a518eb1b2d3eb7877031c5c07bd1dc0a54eb95/yara_python-4.5.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d9a58b7dc87411a2443d2e0382a111bd892aef9f6db2a1ebb4a9215eef0db71", size = 2331976, upload-time = "2025-05-27T14:15:17.138Z" }, - { url = "https://files.pythonhosted.org/packages/9f/5d/2680831aa43d181a0cc023ba89132ff6f03a0532f220cde27bccd60d54e2/yara_python-4.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b9de86fbe8a646c0644df9e1396d6941dc6ed0f89be2807e6c52ab39161fd9f", size = 2949066, upload-time = "2025-05-27T14:15:18.822Z" }, - { url = "https://files.pythonhosted.org/packages/9e/76/e89ec354731b1872462fa9cfdfc6d4751c272b3f43fa55523a8f0fcdd48a/yara_python-4.5.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:973f0bc24470ac86b6009baf2800ad3eadfa4ab653b6546ba5c65e9239850f47", size = 2420758, upload-time = "2025-05-27T14:15:20.505Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/9499649cb2cb42592c13ca6a15163e0cbf132c2974394de171aa5f8b49e4/yara_python-4.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb0f0e7183165426b09e2b1235e70909e540ac18e2c6be96070dfe17d7db4d78", size = 2639628, upload-time = "2025-05-27T14:15:22.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/37/e8f2b9a2287070f39fa6a5afbcf1ed6762f60ab3b1fb08018732838ccc25/yara_python-4.5.4-cp313-cp313-win32.whl", hash = "sha256:7707b144c8fcdb30c069ea57b94799cd7601f694ba01b696bbd1832721f37fd0", size = 1447395, upload-time = "2025-05-27T14:15:25.237Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a0/40b0291c8b24d13daf0e26538c9f3a0d843c38c6446dd17f36335bdd5b5f/yara_python-4.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:5f1288448991d63c1f6351c9f6d112916b0177ceefaa27d1419427a6ff09f829", size = 1825779, upload-time = "2025-05-27T14:15:26.802Z" }, -] - [[package]] name = "zstandard" version = "0.25.0"