mirror of https://github.com/jackwener/wx-cli.git
commit
6b7285c730
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -E \"\\\\.py$|\\\\.md$\")",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(python3 -c \"import ast; ast.parse\\(open\\(''wx_daemon.py''\\).read\\(\\)\\); print\\(''wx_daemon.py OK''\\)\")",
|
||||
"Bash(python3 -c \"import ast; ast.parse\\(open\\(''wx.py''\\).read\\(\\)\\); print\\(''wx.py OK''\\)\")",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(python3 -c \"import click; print\\(''click'', click.__version__\\)\")",
|
||||
"Bash(python3 wx.py --help)",
|
||||
"Bash(python3 wx.py sessions --help)",
|
||||
"Bash(python3 -c \"import sys; print\\(sys.executable\\)\")",
|
||||
"Bash(uv pip:*)",
|
||||
"Bash(uv venv:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
name: wx-macos-arm64
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
name: wx-macos-x86_64
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
name: wx-linux-x86_64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
name: wx-windows-x86_64.exe
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
|
||||
- name: Rename binary (Unix)
|
||||
if: matrix.os != 'windows-latest'
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/wx ${{ matrix.name }}
|
||||
|
||||
- name: Rename binary (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
copy target\${{ matrix.target }}\release\wx.exe ${{ matrix.name }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ matrix.name }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: ${{ matrix.name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -23,3 +23,5 @@ __pycache__/
|
|||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
find_all_keys_macos
|
||||
.claude/worktrees/
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,68 @@
|
|||
[package]
|
||||
name = "wx"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "wx"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# 异步
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# 序列化
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "=1.0.140"
|
||||
|
||||
# SQLite
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
# 加密
|
||||
aes = "0.8"
|
||||
cbc = { version = "0.1", features = ["alloc"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
pbkdf2 = "0.12"
|
||||
|
||||
# 解压
|
||||
zstd = "0.13"
|
||||
|
||||
# IPC (Unix socket + Windows named pipe 统一)
|
||||
interprocess = { version = "2", features = ["tokio"] }
|
||||
|
||||
# 错误处理
|
||||
anyhow = "1"
|
||||
|
||||
# 时间
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# 跨平台路径
|
||||
dirs = "5"
|
||||
|
||||
# MD5 (联系人表名 Msg_<md5>)
|
||||
md5 = "0.7"
|
||||
|
||||
# 正则表达式
|
||||
regex = "1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Memory",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
409
README.md
409
README.md
|
|
@ -1,269 +1,178 @@
|
|||
# WeChat 4.x Database Decryptor
|
||||
# wx-cli
|
||||
|
||||
微信 4.0 (Windows、MacOS、Linux) 本地数据库解密工具。从运行中的微信进程内存提取加密密钥,解密所有 SQLCipher 4 加密数据库,并提供实时消息监听。
|
||||
微信 4.x (macOS) 本地数据 CLI 工具。从运行中的微信进程内存提取加密密钥,后台常驻 daemon 持久缓存解密数据库,CLI 毫秒级响应。
|
||||
|
||||
## 更新日志
|
||||
## 架构
|
||||
|
||||
## 防失联tg: https://t.me/wechat_decrypt
|
||||
```
|
||||
wx (CLI) ──Unix socket──▶ wx-daemon (后台进程)
|
||||
│
|
||||
┌─────────┼─────────┐
|
||||
DBCache 联系人缓存 WAL 监听
|
||||
(mtime 感知) (500ms polling)
|
||||
```
|
||||
|
||||
### 2025-03-03 — 富媒体内容 & 组合消息修复
|
||||
- **wx-daemon**:后台常驻,持有解密后的 DB 热缓存,首次解密后跨重启复用(mtime 不变则不重解密)
|
||||
- **wx (CLI)**:发 JSON 请求到 Unix socket,获得响应后格式化输出;首次调用自动启动 daemon
|
||||
|
||||
- **表情包内联显示**: 自动从 emoticon.db 构建 MD5→CDN 映射,支持自定义表情(NonStore)和商店表情(Store),CDN 下载后本地缓存
|
||||
- **富媒体内容解析**: 链接卡片(type 49)、文件、视频号、小程序、引用回复、位置分享等在 Web UI 中完整渲染
|
||||
- **文字+图片组合消息不再丢失**: 修复同时发送文字和图片时只显示最后一条的问题(前端去重 key 增加消息类型)
|
||||
- **隐藏消息检测**: 新增 `_check_hidden_messages` 机制,session.db 只保存最后一条消息摘要,现在会异步查 message DB 找回同一秒内的其他消息
|
||||
- **MonitorDBCache 线程安全**: 引入 per-key 锁,防止多线程并发解密同一数据库导致文件损坏
|
||||
- **Web UI 改进**: 消息气泡样式优化、群聊发送者显示、图片缩略图点击放大
|
||||
|
||||
## 原理
|
||||
|
||||
微信 4.0 使用 SQLCipher 4 加密本地数据库:
|
||||
- **加密算法**: AES-256-CBC + HMAC-SHA512
|
||||
- **KDF**: PBKDF2-HMAC-SHA512, 256,000 iterations
|
||||
- **页面大小**: 4096 bytes, reserve = 80 (IV 16 + HMAC 64)
|
||||
- **每个数据库有独立的 salt 和 enc_key**
|
||||
|
||||
WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。三个平台(Windows / Linux / macOS)均可通过扫描进程内存匹配此模式,再通过 HMAC 校验 page 1 确认密钥正确性。
|
||||
|
||||
## 使用方法
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.10+
|
||||
- 微信 4.x
|
||||
- `pip install -r requirements.txt`
|
||||
|
||||
Windows:
|
||||
|
||||
- Windows 10/11
|
||||
- 微信正在运行
|
||||
- 需要管理员权限(读取进程内存)
|
||||
|
||||
Linux:
|
||||
|
||||
- 64-bit Linux
|
||||
- 需要 root 权限或 `CAP_SYS_PTRACE`(读取 `/proc/<pid>/mem`)
|
||||
- `db_dir` 默认类似 `~/Documents/xwechat_files/<wxid>/db_storage`
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Windows 如果遇到权限不足或全局环境不可写,可以改用:
|
||||
|
||||
```bash
|
||||
py -m pip install --user -r requirements.txt
|
||||
```
|
||||
|
||||
如果需要读取受保护的进程或把依赖安装到系统 Python,也可能需要以管理员身份打开终端。
|
||||
|
||||
### 快速开始
|
||||
|
||||
Windows:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
python main.py decrypt
|
||||
```
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
python3 main.py decrypt
|
||||
```
|
||||
|
||||
程序会自动完成:配置检测 → 内存扫描提取密钥 → 解密。首次运行会自动检测微信数据目录并生成 `config.json`。微信只要在运行中即可,无需重启或重新登录。
|
||||
|
||||
如果自动检测失败(例如微信安装在非默认位置),手动创建 `config.json`:
|
||||
```json
|
||||
{
|
||||
"db_dir": "D:\\xwechat_files\\你的微信ID\\db_storage",
|
||||
"keys_file": "all_keys.json",
|
||||
"decrypted_dir": "decrypted",
|
||||
"wechat_process": "Weixin.exe"
|
||||
}
|
||||
```
|
||||
|
||||
Linux 版 `config.json` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"db_dir": "/home/yourname/Documents/xwechat_files/your_wxid/db_storage",
|
||||
"keys_file": "all_keys.json",
|
||||
"decrypted_dir": "decrypted",
|
||||
"wechat_process": "wechat"
|
||||
}
|
||||
```
|
||||
|
||||
`db_dir` 路径:Windows 可在微信设置 → 文件管理中找到;Linux 默认在 `~/Documents/xwechat_files/<wxid>/db_storage`。
|
||||
|
||||
### Web UI 说明
|
||||
|
||||
`python main.py` 启动后打开 http://localhost:5678 查看实时消息流。
|
||||
|
||||
- 30ms 轮询 WAL 文件变化 (mtime)
|
||||
- 检测到变化后全量解密 + WAL patch (~70ms)
|
||||
- SSE 实时推送到浏览器
|
||||
- 总延迟约 100ms
|
||||
- **图片消息内联预览**(支持旧 XOR / V1 / V2 三种 .dat 加密格式)
|
||||
|
||||
#### HTTP API
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api/history` | 最近消息列表 (JSON) |
|
||||
| `GET /api/history?chat=群名` | 按群名/用户名过滤消息 |
|
||||
| `GET /api/history?since=1712000000` | 增量拉取(返回该时间戳之后的消息) |
|
||||
| `GET /api/history?chat=群名&since=ts&limit=100` | 参数可组合使用 |
|
||||
| `GET /api/tags` | 所有联系人标签及成员 (JSON) |
|
||||
| `GET /api/tags?name=同事` | 按标签名过滤 |
|
||||
| `GET /stream` | SSE 实时消息推送 |
|
||||
|
||||
将特定群消息存到自己的数据库:监听 `/stream` 或轮询 `/api/history?chat=群名&since=上次时间戳`,写入即可。
|
||||
|
||||
### MCP Server (Claude AI 集成)
|
||||
|
||||
将微信数据查询能力接入 [Claude Code](https://claude.ai/claude-code),让 AI 直接读取你的微信消息。
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
注册到 Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add wechat -- python C:\Users\你的用户名\wechat-decrypt\mcp_server.py
|
||||
```
|
||||
|
||||
或手动编辑 `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechat": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["C:\\Users\\你的用户名\\wechat-decrypt\\mcp_server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注册后在 Claude Code 中即可使用以下工具:
|
||||
|
||||
| Tool | 功能 |
|
||||
|------|------|
|
||||
| `get_recent_sessions(limit)` | 最近会话列表(含消息摘要、未读数) |
|
||||
| `get_chat_history(chat_name, limit, offset, start_time, end_time)` | 指定聊天的消息记录,支持时间范围和分页 |
|
||||
| `search_messages(keyword, chat_name, start_time, end_time, limit, offset)` | 统一搜索消息;支持全库、单个聊天对象、多个聊天对象、时间范围和分页 |
|
||||
| `get_contacts(query, limit)` | 搜索/列出联系人 |
|
||||
| `get_contact_tags()` | 列出所有联系人标签及成员数量 |
|
||||
| `get_tag_members(tag_name)` | 获取指定标签下的所有联系人,支持模糊匹配 |
|
||||
| `get_new_messages()` | 获取自上次调用以来的新消息 |
|
||||
|
||||
前置条件:需要先运行 `python main.py` 或 `python find_all_keys.py` 完成密钥提取。
|
||||
|
||||
说明:`search_messages` 的 `limit` 最大为 `500`;`get_chat_history` 支持更大的 `limit`,但消息很多时仍建议配合 `offset` 分页读取。
|
||||
|
||||
**[查看使用案例 →](USAGE.md)**
|
||||
|
||||
### 图片解密 (V2 格式)
|
||||
|
||||
微信 4.0 (2025-08+) 的 .dat 图片文件使用 AES-128-ECB + XOR 混合加密 (V2 格式)。AES 密钥需要从运行中的微信进程内存中提取:
|
||||
|
||||
```bash
|
||||
# 1. 在微信中打开查看 2-3 张图片(点击看大图)
|
||||
# 2. 立即运行密钥提取(持续监控版):
|
||||
python find_image_key_monitor.py
|
||||
|
||||
# 或单次扫描版:
|
||||
python find_image_key.py
|
||||
```
|
||||
|
||||
密钥会自动保存到 `config.json` 的 `image_aes_key` 字段。之后 `monitor_web.py` 启动时会自动加载密钥,图片消息将显示内联预览。
|
||||
|
||||
> **注意**: AES 密钥仅在微信查看图片时临时加载到内存中。如果扫描未找到密钥,请先在微信中查看几张图片,然后立即重新运行脚本。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `main.py` | **一键启动入口** — 自动配置、提取密钥、启动服务 |
|
||||
| `config.py` | 配置加载器(自动检测微信数据目录) |
|
||||
| `find_all_keys.py` | 平台分发入口(Windows / Linux) |
|
||||
| `find_all_keys_windows.py` | Windows 版内存扫描提 key |
|
||||
| `find_all_keys_linux.py` | Linux 版内存扫描提 key |
|
||||
| `decrypt_db.py` | 全量解密所有数据库 |
|
||||
| `mcp_server.py` | MCP Server,让 Claude AI 查询微信数据 |
|
||||
| `monitor_web.py` | 实时消息监听 (Web UI + SSE + 图片预览) |
|
||||
| `monitor.py` | 实时消息监听 (命令行) |
|
||||
| `decode_image.py` | 图片 .dat 文件解密模块 (XOR / V1 / V2) |
|
||||
| `find_image_key.py` | 从微信进程内存提取图片 AES 密钥 |
|
||||
| `find_image_key_monitor.py` | 持续监控版密钥提取(推荐) |
|
||||
| `latency_test.py` | 延迟测量诊断工具 |
|
||||
| `find_all_keys_macos.c` | macOS 版内存密钥扫描器 (C, Mach VM API) |
|
||||
|
||||
## 技术细节
|
||||
|
||||
### WAL 处理
|
||||
|
||||
微信使用 SQLite WAL 模式,WAL 文件是**预分配固定大小** (4MB)。检测变化时:
|
||||
- 不能用文件大小 (永远不变)
|
||||
- 使用 mtime 检测写入
|
||||
- 解密 WAL frame 时需校验 salt 值,跳过旧周期遗留的 frame
|
||||
|
||||
### 图片 .dat 加密格式
|
||||
|
||||
微信本地图片 (.dat) 有三种加密格式:
|
||||
|
||||
| 格式 | 时期 | Magic | 加密方式 | 密钥来源 |
|
||||
|------|------|-------|---------|---------|
|
||||
| 旧 XOR | ~2025-07 | 无 | 单字节 XOR | 自动检测 (对比 magic bytes) |
|
||||
| V1 | 过渡期 | `07 08 V1 08 07` | AES-ECB + XOR | 固定 key: `cfcd208495d565ef` |
|
||||
| V2 | 2025-08+ | `07 08 V2 08 07` | AES-128-ECB + XOR | 从进程内存提取 |
|
||||
|
||||
V2 文件结构: `[6B signature] [4B aes_size LE] [4B xor_size LE] [1B padding]` + `[AES-ECB encrypted] [raw unencrypted] [XOR encrypted]`
|
||||
|
||||
### 数据库结构
|
||||
|
||||
解密后包含约 26 个数据库:
|
||||
- `session/session.db` - 会话列表 (最新消息摘要)
|
||||
- `message/message_*.db` - 聊天记录
|
||||
- `contact/contact.db` - 联系人
|
||||
- `media_*/media_*.db` - 媒体文件索引
|
||||
- 其他: head_image, favorite, sns, emoticon 等
|
||||
|
||||
## macOS 数据库密钥扫描 (WeChat 4.x)
|
||||
|
||||
macOS 版微信 4.x 使用 SQLCipher 4 加密本地数据库,密钥格式为 `x'<64hex_key><32hex_salt>'`。C 版扫描器通过 Mach VM API 扫描微信进程内存提取密钥。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- macOS (Apple Silicon / Intel)
|
||||
- WeChat 4.x (macOS 版)
|
||||
- Xcode Command Line Tools: `xcode-select --install`
|
||||
- 微信需要 ad-hoc 签名(或安装了防撤回补丁):
|
||||
`sudo codesign --force --deep --sign - /Applications/WeChat.app`
|
||||
- WeChat 4.x (macOS 版,需 ad-hoc 签名,见下文)
|
||||
- Python 3.12+
|
||||
- [uv](https://docs.astral.sh/uv/)(Python 包管理)
|
||||
- Xcode Command Line Tools:`xcode-select --install`
|
||||
|
||||
### 编译和使用
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
|
||||
|
||||
# 运行(自动查找微信进程、扫描内存、匹配 DB salt)
|
||||
sudo ./find_all_keys_macos
|
||||
|
||||
# 或指定 PID
|
||||
sudo ./find_all_keys_macos <pid>
|
||||
git clone git@github.com:jackwener/wx-cli.git
|
||||
cd wx-cli
|
||||
uv sync
|
||||
```
|
||||
|
||||
输出 `all_keys.json`,格式兼容 `decrypt_db.py`,可直接用于解密:
|
||||
### 初始化(首次使用)
|
||||
|
||||
微信需要 ad-hoc 签名才能被扫描内存:
|
||||
|
||||
```bash
|
||||
python3 decrypt_db.py
|
||||
sudo codesign --force --deep --sign - /Applications/WeChat.app
|
||||
```
|
||||
|
||||
然后打开微信并登录,运行初始化:
|
||||
|
||||
```bash
|
||||
uv run python wx.py init
|
||||
```
|
||||
|
||||
`wx init` 自动完成:
|
||||
1. 检测微信数据目录(`~/Library/Containers/.../xwechat_files/<wxid>/db_storage`)
|
||||
2. 编译 C 内存扫描器(如未编译)
|
||||
3. `sudo` 扫描微信进程内存,提取所有数据库密钥 → `all_keys.json`
|
||||
4. 更新 `config.json`
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
# 最近会话
|
||||
uv run python wx.py sessions
|
||||
|
||||
# 聊天记录
|
||||
uv run python wx.py history "张三"
|
||||
uv run python wx.py history "AI群" --since 2026-04-01 --until 2026-04-15
|
||||
|
||||
# 搜索消息
|
||||
uv run python wx.py search "Claude"
|
||||
uv run python wx.py search "会议" --in "工作群" --since 2026-01-01
|
||||
|
||||
# 联系人
|
||||
uv run python wx.py contacts
|
||||
uv run python wx.py contacts -q "李"
|
||||
|
||||
# 导出聊天记录
|
||||
uv run python wx.py export "张三" --format markdown -o chat.md
|
||||
uv run python wx.py export "AI群" --since 2026-01-01 --format json -o chat.json
|
||||
|
||||
# 实时监听新消息(Ctrl+C 退出)
|
||||
uv run python wx.py watch
|
||||
uv run python wx.py watch --chat "AI交流群"
|
||||
uv run python wx.py watch --json | jq .content
|
||||
|
||||
# daemon 管理
|
||||
uv run python wx.py daemon status
|
||||
uv run python wx.py daemon stop
|
||||
uv run python wx.py daemon logs
|
||||
uv run python wx.py daemon logs --follow
|
||||
```
|
||||
|
||||
> **注**:daemon 在首次 CLI 调用时自动启动,无需手动运行。
|
||||
|
||||
### 可选:设置别名
|
||||
|
||||
```bash
|
||||
echo 'alias wx="uv run --directory /path/to/wx-cli python wx.py"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
|
||||
# 之后可以直接用
|
||||
wx sessions
|
||||
wx history "张三"
|
||||
wx watch
|
||||
```
|
||||
|
||||
## 命令参考
|
||||
|
||||
### `wx init [--force]`
|
||||
首次初始化:检测数据目录、编译扫描器、提取密钥、写入配置。`--force` 强制重新扫描(微信更新后使用)。
|
||||
|
||||
### `wx sessions [-n N] [--json]`
|
||||
列出最近 N 个会话(默认 20),显示未读数、最后消息摘要。
|
||||
|
||||
### `wx history CHAT [-n N] [--offset N] [--since DATE] [--until DATE] [--json]`
|
||||
查看指定聊天的消息记录。`DATE` 格式:`YYYY-MM-DD` 或 `YYYY-MM-DD HH:MM`。
|
||||
|
||||
### `wx search KEYWORD [--in CHAT]... [-n N] [--since DATE] [--until DATE] [--json]`
|
||||
全库搜索消息,`--in` 可指定多个聊天范围。
|
||||
|
||||
### `wx contacts [-q QUERY] [-n N] [--json]`
|
||||
列出或搜索联系人。
|
||||
|
||||
### `wx export CHAT [-f FORMAT] [-o FILE] [-n N] [--since DATE] [--until DATE]`
|
||||
导出聊天记录。`-f` 支持 `markdown`(默认)、`txt`、`json`。`-o` 指定输出文件,不指定则输出到 stdout。
|
||||
|
||||
### `wx watch [--chat CHAT] [--json]`
|
||||
实时监听新消息(WAL 变化推送,约 500ms 延迟)。`--json` 输出 JSON lines,方便 `jq` 处理。
|
||||
|
||||
### `wx daemon status / stop / logs [-f] [-n N]`
|
||||
管理后台 daemon。`logs --follow` 等同 `tail -f`。
|
||||
|
||||
## 原理
|
||||
|
||||
### 密钥提取
|
||||
|
||||
微信 4.x 使用 SQLCipher 4 加密本地数据库:
|
||||
- **加密**:AES-256-CBC + HMAC-SHA512
|
||||
- **KDF**:PBKDF2-HMAC-SHA512,256,000 次迭代
|
||||
- **页结构**:4096 bytes/page,reserve = 80(IV 16 + HMAC 64)
|
||||
|
||||
WCDB 在进程内存中缓存派生后的 raw key,格式为 `x'<64hex_enc_key><32hex_salt>'`。C 扫描器(`find_all_keys_macos.c`)通过 macOS Mach VM API 扫描微信进程内存,匹配此模式,再用 HMAC 校验 page 1 确认密钥正确性,输出到 `all_keys.json`。
|
||||
|
||||
### DBCache(mtime 感知缓存)
|
||||
|
||||
daemon 首次解密后将结果(及 DB/WAL 的 mtime)持久化到 `~/.wechat-cli/cache/_mtimes.json`。重启时若 mtime 未变,直接复用已解密文件,无需重新解密。
|
||||
|
||||
### WAL 监听
|
||||
|
||||
微信使用 SQLite WAL 模式(WAL 文件固定预分配 4MB,不能靠文件大小判断变化)。daemon 每 500ms 检测 `session.db-wal` 的 mtime,有变化时重新解密并广播新消息给所有 `watch` 客户端。
|
||||
|
||||
### 数据文件路径
|
||||
|
||||
```
|
||||
~/.wechat-cli/
|
||||
├── daemon.sock # Unix socket
|
||||
├── daemon.pid # PID 文件
|
||||
├── daemon.log # daemon 日志
|
||||
└── cache/
|
||||
├── _mtimes.json # mtime 持久化索引
|
||||
└── *.db # 解密后的数据库缓存
|
||||
```
|
||||
|
||||
## 数据库结构
|
||||
|
||||
解密后约 26 个数据库:
|
||||
|
||||
| 路径 | 内容 |
|
||||
|------|------|
|
||||
| `session/session.db` | 会话列表(最新消息摘要、未读数) |
|
||||
| `message/message_*.db` | 聊天记录(按 `Msg_<md5(username)>` 分表) |
|
||||
| `contact/contact.db` | 联系人(username、nick_name、remark) |
|
||||
| `media_*/media_*.db` | 媒体文件索引 |
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
uv run python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
## 免责声明
|
||||
|
|
|
|||
234
USAGE.md
234
USAGE.md
|
|
@ -1,234 +0,0 @@
|
|||
# Usage Examples / 使用案例
|
||||
|
||||
WeChat MCP Server 注册到 Claude Code 后,可以直接在对话中查询微信数据。以下是真实使用截图(敏感信息已打码)。
|
||||
|
||||
## 1. 查看最近会话
|
||||
|
||||
```
|
||||
> 帮我看看微信最近的会话
|
||||
```
|
||||
|
||||
Claude 自动调用 `get_recent_sessions`:
|
||||
|
||||
```
|
||||
最近 10 个会话:
|
||||
|
||||
[02-28 16:26] A]██交流群 [群] (7条未读)
|
||||
文本: 阿██: 打开就是那么大的一个
|
||||
|
||||
[02-28 16:26] ██俱乐部 [群] (105条未读)
|
||||
文本: S██: 死了就换另外一个
|
||||
|
||||
[02-28 16:26] ██Sol██社区 [群] (3条未读)
|
||||
文本: 洋██: 加密币已成中登资产,还没避险属性
|
||||
|
||||
[02-28 16:25] ██健身群 [群] (18条未读)
|
||||
文本: 风██: 不强求,多的就留着。刚需就洗了。
|
||||
|
||||
[02-28 16:24] ██社保 (24条未读)
|
||||
链接/文件: @年后换工作的你,社保怎么处理?看这里!
|
||||
|
||||
[02-28 16:22] 文件传输助手
|
||||
图片: (无内容)
|
||||
```
|
||||
|
||||
## 2. 查看群聊记录
|
||||
|
||||
```
|
||||
> 帮我看看██群在聊啥
|
||||
```
|
||||
|
||||
Claude 调用 `get_chat_history`,然后自动分析总结:
|
||||
|
||||
```
|
||||
██群今日话题:
|
||||
|
||||
1. AI工具讨论 (上午)
|
||||
群友对微信消息监听工具感兴趣,问了用什么模型、token贵不贵。
|
||||
|
||||
2. A股炒股 (下午)
|
||||
██想让朋友帮操作A股做T,群友一致劝阻——"赚了分他,亏了算你的"
|
||||
|
||||
3. 基金持仓 (傍晚)
|
||||
██手痒又买了有色基金
|
||||
|
||||
活跃度排名:
|
||||
| 排名 | 昵称 | 发言数 |
|
||||
|------|------|--------|
|
||||
| 1 | ██ | ~35条 |
|
||||
| 2 | ██ | ~15条 |
|
||||
| 3 | ██ | ~15条 |
|
||||
```
|
||||
|
||||
## 3. 搜索消息
|
||||
|
||||
```
|
||||
> 搜一下谁提过"claude"
|
||||
```
|
||||
|
||||
Claude 调用 `search_messages(keyword="claude")`:
|
||||
|
||||
```
|
||||
搜索 "claude" 找到 20 条结果:
|
||||
|
||||
[2026-02-28 10:25] [██AI群] ██: max套餐,就用opus模型
|
||||
[2026-02-28 10:31] [██AI群] ██: 我小龙虾也没用api接入的
|
||||
[2026-02-24 21:58] [██AI群] ██: Claude max 套餐给我干没了
|
||||
[2026-02-24 22:16] [██AI群] ██: 最简单的就是装claude
|
||||
...
|
||||
```
|
||||
|
||||
## 4. 时间范围 + 分页查看聊天记录
|
||||
|
||||
```
|
||||
> 帮我看一下██群 3 月 1 日到 3 月 7 日的聊天,先给我前 20 条
|
||||
```
|
||||
|
||||
Claude 可以调用:
|
||||
|
||||
```python
|
||||
get_chat_history(
|
||||
chat_name="██群",
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=0,
|
||||
)
|
||||
```
|
||||
|
||||
下一页:
|
||||
|
||||
```python
|
||||
get_chat_history(
|
||||
chat_name="██群",
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=20,
|
||||
)
|
||||
```
|
||||
|
||||
## 5. 搜索指定联系人/群聊在某个时间段内的消息
|
||||
|
||||
```
|
||||
> 帮我搜一下██群这周谁提到过 Claude
|
||||
```
|
||||
|
||||
Claude 可以调用统一接口:
|
||||
|
||||
```python
|
||||
search_messages(
|
||||
keyword="Claude",
|
||||
chat_name="██群",
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=0,
|
||||
)
|
||||
```
|
||||
|
||||
## 6. 多个联系人/群聊联合搜索
|
||||
|
||||
```
|
||||
> 帮我看看联系人A、联系人B 和 ██项目群 这周谁提到过“项目”
|
||||
```
|
||||
|
||||
Claude 可以调用统一接口:
|
||||
|
||||
```python
|
||||
search_messages(
|
||||
keyword="项目",
|
||||
chat_name=["联系人A", "联系人B", "██项目群"],
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=0,
|
||||
)
|
||||
```
|
||||
|
||||
如果某些名字没匹配到联系人,或没有对应消息表,结果里会单独说明。
|
||||
|
||||
## 7. 搜索联系人
|
||||
|
||||
```
|
||||
> 帮我找一下姓张的联系人
|
||||
```
|
||||
|
||||
Claude 调用 `get_contacts(query="张")`:
|
||||
|
||||
```
|
||||
找到 12 个联系人(搜索: 张):
|
||||
|
||||
wxid_████ 备注: 张██ 昵称: ██
|
||||
wxid_████ 备注: 张██ 昵称: 小██
|
||||
████@chatroom 昵称: 张██的工作群
|
||||
...
|
||||
```
|
||||
|
||||
## 8. 获取新消息
|
||||
|
||||
```
|
||||
> 有没有新消息
|
||||
```
|
||||
|
||||
Claude 调用 `get_new_messages()`:
|
||||
|
||||
```
|
||||
当前 5 个未读会话:
|
||||
|
||||
[16:26] ██交流群 [群] (7条未读): 打开就是那么大的一个
|
||||
[16:26] ██俱乐部 [群] (105条未读): 死了就换另外一个
|
||||
[16:25] ██健身群 [群] (18条未读): 不强求,多的就留着
|
||||
[16:24] ██社保 (24条未读): @年后换工作的你...
|
||||
[16:22] ██群 [群] (19条未读): (图片)
|
||||
```
|
||||
|
||||
## 9. 高级用法:群聊分析
|
||||
|
||||
Claude 可以获取大量消息后自动分析活跃度、话题分布、关键人物:
|
||||
|
||||
```
|
||||
> 帮我分析一下██群最近一周的情况
|
||||
```
|
||||
|
||||
Claude 会调用 `get_chat_history(chat_name="██群", limit=500)` 获取消息,然后输出。消息很多时,也可以把 `limit` 设得更大,或配合 `offset` 分页读取:
|
||||
|
||||
```
|
||||
## ██群最近一周分析
|
||||
|
||||
### 话题分布
|
||||
| 话题 | 占比 |
|
||||
|----------|-------|
|
||||
| AI工具 | ~40% |
|
||||
| 币圈行情 | ~25% |
|
||||
| 时事讨论 | ~20% |
|
||||
| 闲聊 | ~15% |
|
||||
|
||||
### 活跃度排名
|
||||
| 排名 | 昵称 | 发言数 | 角色 |
|
||||
|------|------|--------|------------|
|
||||
| 1 | ██ | ~90条 | 技术分享者 |
|
||||
| 2 | ██ | ~55条 | 深度讨论 |
|
||||
| 3 | ██ | ~25条 | 新闻搬运 |
|
||||
|
||||
### 群氛围
|
||||
这个群正在从██交流群转型成AI工具交流群...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup / 配置方法
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. 注册到 Claude Code
|
||||
claude mcp add wechat -- python C:\path\to\mcp_server.py
|
||||
|
||||
# 3. 在 Claude Code 中直接对话
|
||||
claude
|
||||
> 看看微信最近谁找我了
|
||||
```
|
||||
|
||||
前置条件:需要先运行 `find_all_keys.py` 提取密钥,并配置 `config.json`。详见 [README.md](README.md)。
|
||||
464
decode_image.py
464
decode_image.py
|
|
@ -1,464 +0,0 @@
|
|||
r"""
|
||||
微信图片 .dat 文件解密模块
|
||||
|
||||
支持两种加密格式:
|
||||
- 旧格式: 单字节 XOR 加密,key 通过对比文件头与已知图片 magic bytes 自动检测
|
||||
- V2 格式 (2025-08+): AES-128-ECB + XOR 混合加密,需要从微信进程内存提取 AES key
|
||||
|
||||
V2 文件结构:
|
||||
[6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding]
|
||||
[aligned_aes_size bytes AES-ECB] [raw_data] [xor_size bytes XOR]
|
||||
|
||||
文件路径格式:
|
||||
D:\xwechat_files\<wxid>\msg\attach\<md5(username)>\<YYYY-MM>\Img\<file_md5>[_t|_h].dat
|
||||
|
||||
映射链:
|
||||
message_*.db (local_id) → message_resource.db (packed_info 含 MD5) → .dat 文件 → 解密
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import struct
|
||||
|
||||
# V2 格式完整 magic (6 bytes)
|
||||
V2_MAGIC = b'\x07\x08\x56\x32' # 前 4 字节用于快速检测
|
||||
V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' # 完整 6 字节签名
|
||||
V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' # V1 签名 (固定 key)
|
||||
|
||||
# 常见图片格式的 magic bytes (按长度降序排列,避免短 magic 假阳性)
|
||||
IMAGE_MAGIC = {
|
||||
'png': [0x89, 0x50, 0x4E, 0x47],
|
||||
'gif': [0x47, 0x49, 0x46, 0x38],
|
||||
'tif': [0x49, 0x49, 0x2A, 0x00], # little-endian TIFF
|
||||
'webp': [0x52, 0x49, 0x46, 0x46], # RIFF header
|
||||
'jpg': [0xFF, 0xD8, 0xFF],
|
||||
# BMP 只有 2 字节 magic,容易假阳性,需要额外验证
|
||||
}
|
||||
|
||||
|
||||
def is_v2_format(dat_path):
|
||||
"""检测是否是微信 V2 加密格式 (2025-08+)"""
|
||||
try:
|
||||
with open(dat_path, 'rb') as f:
|
||||
magic = f.read(4)
|
||||
return magic == V2_MAGIC
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
|
||||
def detect_xor_key(dat_path):
|
||||
"""通过对比文件头和已知图片 magic bytes 自动检测 XOR key
|
||||
|
||||
返回 key (int) 或 None。V2 格式文件返回 None。
|
||||
"""
|
||||
with open(dat_path, 'rb') as f:
|
||||
header = f.read(16)
|
||||
|
||||
if len(header) < 4:
|
||||
return None
|
||||
|
||||
# V2 新格式无法用 XOR 解密
|
||||
if header[:4] == V2_MAGIC:
|
||||
return None
|
||||
|
||||
# 先尝试 3+ 字节 magic 的格式(可靠匹配)
|
||||
for fmt, magic in IMAGE_MAGIC.items():
|
||||
key = header[0] ^ magic[0]
|
||||
match = True
|
||||
for i in range(1, len(magic)):
|
||||
if i >= len(header):
|
||||
break
|
||||
if (header[i] ^ key) != magic[i]:
|
||||
match = False
|
||||
break
|
||||
if match:
|
||||
return key
|
||||
|
||||
# 最后尝试 BMP (2 字节 magic,需要额外验证)
|
||||
bmp_magic = [0x42, 0x4D]
|
||||
key = header[0] ^ bmp_magic[0]
|
||||
if len(header) >= 2 and (header[1] ^ key) == bmp_magic[1]:
|
||||
# 额外验证: XOR 解密后检查 BMP file size 和 offset 字段
|
||||
if len(header) >= 14:
|
||||
dec = bytes(b ^ key for b in header[:14])
|
||||
bmp_size = struct.unpack_from('<I', dec, 2)[0]
|
||||
bmp_offset = struct.unpack_from('<I', dec, 10)[0]
|
||||
file_size = os.path.getsize(dat_path)
|
||||
# BMP file_size 字段应与实际文件大小接近,offset 应在合理范围
|
||||
if (abs(bmp_size - file_size) < 1024 and 14 <= bmp_offset <= 1078):
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_image_format(header_bytes):
|
||||
"""根据解密后的文件头检测图片格式"""
|
||||
if header_bytes[:3] == bytes([0xFF, 0xD8, 0xFF]):
|
||||
return 'jpg'
|
||||
if header_bytes[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
|
||||
return 'png'
|
||||
if header_bytes[:3] == b'GIF':
|
||||
return 'gif'
|
||||
if header_bytes[:2] == b'BM':
|
||||
return 'bmp'
|
||||
if header_bytes[:4] == b'RIFF' and len(header_bytes) >= 12 and header_bytes[8:12] == b'WEBP':
|
||||
return 'webp'
|
||||
if header_bytes[:4] == bytes([0x49, 0x49, 0x2A, 0x00]):
|
||||
return 'tif'
|
||||
return 'bin'
|
||||
|
||||
|
||||
def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
|
||||
"""解密 V2 格式 .dat 文件 (AES-ECB + XOR)
|
||||
|
||||
Args:
|
||||
dat_path: V2 .dat 文件路径
|
||||
out_path: 输出路径 (None 则自动命名)
|
||||
aes_key: 16 字节 AES key (bytes 或 str)
|
||||
xor_key: XOR key (int, 默认 0x88)
|
||||
|
||||
Returns:
|
||||
(output_path, format) 或 (None, None)
|
||||
"""
|
||||
if aes_key is None:
|
||||
return None, None
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util import Padding
|
||||
|
||||
# 确保 key 是 16 字节 bytes
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode('ascii')[:16]
|
||||
if len(aes_key) < 16:
|
||||
return None, None
|
||||
|
||||
with open(dat_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
if len(data) < 15:
|
||||
return None, None
|
||||
|
||||
# 解析 header
|
||||
sig = data[:6]
|
||||
if sig not in (V2_MAGIC_FULL, V1_MAGIC_FULL):
|
||||
return None, None
|
||||
|
||||
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
|
||||
|
||||
# V1 用固定 key
|
||||
if sig == V1_MAGIC_FULL:
|
||||
aes_key = b'cfcd208495d565ef' # md5("0")[:16]
|
||||
|
||||
# AES 对齐: PKCS7 填充使实际密文 >= aes_size,向上对齐到 16
|
||||
# 当 aes_size 是 16 的倍数时,还需要加 16 (完整填充块)
|
||||
aligned_aes_size = aes_size
|
||||
aligned_aes_size -= ~(~aligned_aes_size % 16) # 同 wx-dat 的公式
|
||||
|
||||
offset = 15
|
||||
if offset + aligned_aes_size > len(data):
|
||||
return None, None
|
||||
|
||||
# AES-ECB 解密
|
||||
aes_data = data[offset:offset + aligned_aes_size]
|
||||
try:
|
||||
cipher = AES.new(aes_key[:16], AES.MODE_ECB)
|
||||
dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size)
|
||||
except (ValueError, KeyError):
|
||||
return None, None
|
||||
offset += aligned_aes_size
|
||||
|
||||
# Raw 部分 (不加密)
|
||||
raw_end = len(data) - xor_size
|
||||
raw_data = data[offset:raw_end] if offset < raw_end else b''
|
||||
offset = raw_end
|
||||
|
||||
# XOR 部分
|
||||
xor_data = data[offset:]
|
||||
dec_xor = bytes(b ^ xor_key for b in xor_data)
|
||||
|
||||
decrypted = dec_aes + raw_data + dec_xor
|
||||
fmt = detect_image_format(decrypted[:16])
|
||||
|
||||
# wxgf (HEVC 裸流) 格式
|
||||
if decrypted[:4] == b'wxgf':
|
||||
fmt = 'hevc'
|
||||
|
||||
if out_path is None:
|
||||
base = os.path.splitext(dat_path)[0]
|
||||
for suffix in ('_t', '_h'):
|
||||
if base.endswith(suffix):
|
||||
base = base[:-len(suffix)]
|
||||
break
|
||||
out_path = f"{base}.{fmt}"
|
||||
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(decrypted)
|
||||
|
||||
return out_path, fmt
|
||||
|
||||
|
||||
def xor_decrypt_file(dat_path, out_path=None, key=None):
|
||||
"""解密单个 .dat 文件,返回 (output_path, format)"""
|
||||
if key is None:
|
||||
key = detect_xor_key(dat_path)
|
||||
if key is None:
|
||||
return None, None
|
||||
|
||||
with open(dat_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
decrypted = bytes(b ^ key for b in data)
|
||||
fmt = detect_image_format(decrypted[:16])
|
||||
|
||||
if out_path is None:
|
||||
base = os.path.splitext(dat_path)[0]
|
||||
# 去掉 _t, _h 后缀
|
||||
for suffix in ('_t', '_h'):
|
||||
if base.endswith(suffix):
|
||||
base = base[:-len(suffix)]
|
||||
break
|
||||
out_path = f"{base}.{fmt}"
|
||||
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(decrypted)
|
||||
|
||||
return out_path, fmt
|
||||
|
||||
|
||||
def decrypt_dat_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
|
||||
"""智能解密 .dat 文件 (自动检测格式)
|
||||
|
||||
Args:
|
||||
dat_path: .dat 文件路径
|
||||
out_path: 输出路径
|
||||
aes_key: V2 格式的 AES key (str 或 bytes, 16 字节)
|
||||
xor_key: XOR key (int)
|
||||
|
||||
Returns:
|
||||
(output_path, format) 或 (None, None)
|
||||
"""
|
||||
with open(dat_path, 'rb') as f:
|
||||
head = f.read(6)
|
||||
|
||||
# V2 新格式
|
||||
if head == V2_MAGIC_FULL:
|
||||
return v2_decrypt_file(dat_path, out_path, aes_key, xor_key)
|
||||
|
||||
# V1 格式 (固定 AES key)
|
||||
if head == V1_MAGIC_FULL:
|
||||
return v2_decrypt_file(dat_path, out_path, b'cfcd208495d565ef', xor_key)
|
||||
|
||||
# 旧 XOR 格式
|
||||
return xor_decrypt_file(dat_path, out_path)
|
||||
|
||||
|
||||
def extract_md5_from_packed_info(blob):
|
||||
"""从 message_resource.db 的 packed_info (protobuf) 中提取文件 MD5
|
||||
|
||||
格式: ... \\x12\\x22\\x0a\\x20 + 32 字节 ASCII hex MD5 ...
|
||||
"""
|
||||
if not blob or not isinstance(blob, bytes):
|
||||
return None
|
||||
|
||||
# 查找 protobuf 标记
|
||||
marker = b'\x12\x22\x0a\x20'
|
||||
idx = blob.find(marker)
|
||||
if idx >= 0 and idx + len(marker) + 32 <= len(blob):
|
||||
md5_bytes = blob[idx + len(marker): idx + len(marker) + 32]
|
||||
try:
|
||||
md5_str = md5_bytes.decode('ascii')
|
||||
# 验证是合法的 hex 字符串
|
||||
int(md5_str, 16)
|
||||
return md5_str
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# 备用方案:扫描 32 字节连续 hex 字符
|
||||
hex_chars = set(b'0123456789abcdef')
|
||||
i = 0
|
||||
while i <= len(blob) - 32:
|
||||
if blob[i] in hex_chars:
|
||||
candidate = blob[i:i+32]
|
||||
if all(b in hex_chars for b in candidate):
|
||||
try:
|
||||
return candidate.decode('ascii')
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
i += 32
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ImageResolver:
|
||||
"""封装从 local_id 到图片文件的完整解析链"""
|
||||
|
||||
def __init__(self, wechat_base_dir, decoded_image_dir, cache):
|
||||
"""
|
||||
Args:
|
||||
wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\<wxid>)
|
||||
decoded_image_dir: 解密图片输出目录
|
||||
cache: DBCache 实例,用于解密 message_resource.db
|
||||
"""
|
||||
self.base_dir = wechat_base_dir
|
||||
self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach")
|
||||
self.out_dir = decoded_image_dir
|
||||
self.cache = cache
|
||||
|
||||
def get_image_md5(self, local_id):
|
||||
"""通过 local_id 查 message_resource.db 获取图片文件 MD5"""
|
||||
path = self.cache.get("message/message_resource.db")
|
||||
if not path:
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT packed_info FROM MessageResourceInfo WHERE local_id = ?",
|
||||
(local_id,)
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
return extract_md5_from_packed_info(row[0])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return None
|
||||
|
||||
def find_dat_files(self, username, file_md5):
|
||||
"""在 attach 目录下查找对应的 .dat 文件
|
||||
|
||||
路径: attach/<md5(username)>/<YYYY-MM>/Img/<file_md5>[_t|_h].dat
|
||||
"""
|
||||
username_hash = hashlib.md5(username.encode()).hexdigest()
|
||||
search_base = os.path.join(self.attach_dir, username_hash)
|
||||
|
||||
if not os.path.isdir(search_base):
|
||||
return []
|
||||
|
||||
# 在所有月份目录下搜索
|
||||
results = []
|
||||
pattern = os.path.join(search_base, "*", "Img", f"{file_md5}*.dat")
|
||||
for p in glob.glob(pattern):
|
||||
results.append(p)
|
||||
|
||||
return sorted(results)
|
||||
|
||||
def decode_image(self, username, local_id):
|
||||
"""完整流程:local_id → MD5 → .dat → 解密
|
||||
|
||||
Returns:
|
||||
dict with keys: success, path, format, md5, error
|
||||
"""
|
||||
# 1. 获取 MD5
|
||||
file_md5 = self.get_image_md5(local_id)
|
||||
if not file_md5:
|
||||
return {'success': False, 'error': f'无法从 message_resource.db 找到 local_id={local_id} 的图片信息'}
|
||||
|
||||
# 2. 找 .dat 文件
|
||||
dat_files = self.find_dat_files(username, file_md5)
|
||||
if not dat_files:
|
||||
return {'success': False, 'error': f'找不到 .dat 文件 (MD5={file_md5})', 'md5': file_md5}
|
||||
|
||||
# 优先选标准版(非 _t/_h),然后高清 _h,最后缩略图 _t
|
||||
selected = dat_files[0]
|
||||
for f in dat_files:
|
||||
fname = os.path.basename(f)
|
||||
if not fname.startswith(file_md5 + '_'):
|
||||
selected = f
|
||||
break
|
||||
for f in dat_files:
|
||||
if f.endswith('_h.dat'):
|
||||
selected = f
|
||||
break
|
||||
|
||||
# 3. 解密
|
||||
out_name = f"{file_md5}"
|
||||
out_path_base = os.path.join(self.out_dir, out_name)
|
||||
|
||||
result_path, fmt = xor_decrypt_file(selected, f"{out_path_base}.tmp")
|
||||
if not result_path:
|
||||
return {'success': False, 'error': f'无法检测 XOR key (文件: {selected})', 'md5': file_md5}
|
||||
|
||||
# 重命名为正确扩展名
|
||||
final_path = f"{out_path_base}.{fmt}"
|
||||
if os.path.exists(final_path):
|
||||
os.unlink(final_path)
|
||||
os.rename(result_path, final_path)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'path': final_path,
|
||||
'format': fmt,
|
||||
'md5': file_md5,
|
||||
'source': selected,
|
||||
'size': os.path.getsize(final_path),
|
||||
}
|
||||
|
||||
def list_chat_images(self, db_path, table_name, username, limit=20):
|
||||
"""列出某个聊天中的所有图片消息"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(f"""
|
||||
SELECT local_id, create_time
|
||||
FROM [{table_name}]
|
||||
WHERE local_type = 3
|
||||
ORDER BY create_time DESC
|
||||
LIMIT ?
|
||||
""", (limit,)).fetchall()
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return []
|
||||
conn.close()
|
||||
|
||||
results = []
|
||||
for local_id, create_time in rows:
|
||||
file_md5 = self.get_image_md5(local_id)
|
||||
info = {
|
||||
'local_id': local_id,
|
||||
'create_time': create_time,
|
||||
'md5': file_md5,
|
||||
}
|
||||
if file_md5:
|
||||
dat_files = self.find_dat_files(username, file_md5)
|
||||
if dat_files:
|
||||
info['dat_file'] = dat_files[0]
|
||||
try:
|
||||
info['size'] = os.path.getsize(dat_files[0])
|
||||
except OSError:
|
||||
pass
|
||||
results.append(info)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ============ CLI 测试 ============
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python decode_image.py <dat_file> [output_file]")
|
||||
print(" 解密单个 .dat 文件")
|
||||
sys.exit(1)
|
||||
|
||||
dat_file = sys.argv[1]
|
||||
out_file = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
if not os.path.exists(dat_file):
|
||||
print(f"文件不存在: {dat_file}")
|
||||
sys.exit(1)
|
||||
|
||||
result_path, fmt = decrypt_dat_file(dat_file, out_file)
|
||||
if result_path:
|
||||
size = os.path.getsize(result_path)
|
||||
print(f"解密成功: {result_path}")
|
||||
print(f"格式: {fmt}, 大小: {size:,} bytes")
|
||||
else:
|
||||
print("解密失败")
|
||||
sys.exit(1)
|
||||
185
decrypt_db.py
185
decrypt_db.py
|
|
@ -1,185 +0,0 @@
|
|||
"""
|
||||
WeChat 4.0 数据库解密器
|
||||
|
||||
使用从进程内存提取的per-DB enc_key解密SQLCipher 4加密的数据库
|
||||
参数: SQLCipher 4, AES-256-CBC, HMAC-SHA512, reserve=80, page_size=4096
|
||||
密钥来源: all_keys.json (由find_all_keys.py从内存提取)
|
||||
"""
|
||||
import hashlib, struct, os, sys, json
|
||||
import hmac as hmac_mod
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
IV_SZ = 16
|
||||
HMAC_SZ = 64
|
||||
RESERVE_SZ = 80 # IV(16) + HMAC(64)
|
||||
SQLITE_HDR = b'SQLite format 3\x00'
|
||||
|
||||
from config import load_config
|
||||
from key_utils import get_key_info, strip_key_metadata
|
||||
_cfg = load_config()
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
OUT_DIR = _cfg["decrypted_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
|
||||
|
||||
def derive_mac_key(enc_key, salt):
|
||||
"""从enc_key派生HMAC密钥"""
|
||||
mac_salt = bytes(b ^ 0x3a for b in salt)
|
||||
return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
|
||||
|
||||
def decrypt_page(enc_key, page_data, pgno):
|
||||
"""解密单个页面,输出4096字节的标准SQLite页面"""
|
||||
iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ]
|
||||
|
||||
if pgno == 1:
|
||||
encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)
|
||||
# 保留 reserve=80, B-tree 基于 usable_size=4016 构建
|
||||
return bytes(page)
|
||||
else:
|
||||
encrypted = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return decrypted + b'\x00' * RESERVE_SZ
|
||||
|
||||
|
||||
def decrypt_database(db_path, out_path, enc_key):
|
||||
"""解密整个数据库文件"""
|
||||
file_size = os.path.getsize(db_path)
|
||||
total_pages = file_size // PAGE_SZ
|
||||
|
||||
if file_size % PAGE_SZ != 0:
|
||||
print(f" [WARN] 文件大小 {file_size} 不是 {PAGE_SZ} 的倍数")
|
||||
total_pages += 1
|
||||
|
||||
with open(db_path, 'rb') as fin:
|
||||
page1 = fin.read(PAGE_SZ)
|
||||
|
||||
if len(page1) < PAGE_SZ:
|
||||
print(f" [ERROR] 文件太小")
|
||||
return False
|
||||
|
||||
# 提取salt并派生mac_key, 验证page 1
|
||||
salt = page1[:SALT_SZ]
|
||||
mac_key = derive_mac_key(enc_key, salt)
|
||||
p1_hmac_data = page1[SALT_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ]
|
||||
p1_stored_hmac = page1[PAGE_SZ - HMAC_SZ : PAGE_SZ]
|
||||
hm = hmac_mod.new(mac_key, p1_hmac_data, hashlib.sha512)
|
||||
hm.update(struct.pack('<I', 1))
|
||||
if hm.digest() != p1_stored_hmac:
|
||||
print(f" [ERROR] Page 1 HMAC验证失败! salt: {salt.hex()}")
|
||||
return False
|
||||
|
||||
print(f" HMAC OK, {total_pages} pages")
|
||||
|
||||
# 解密所有页面
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(db_path, 'rb') as fin, open(out_path, 'wb') as fout:
|
||||
for pgno in range(1, total_pages + 1):
|
||||
page = fin.read(PAGE_SZ)
|
||||
if len(page) < PAGE_SZ:
|
||||
if len(page) > 0:
|
||||
page = page + b'\x00' * (PAGE_SZ - len(page))
|
||||
else:
|
||||
break
|
||||
|
||||
decrypted = decrypt_page(enc_key, page, pgno)
|
||||
fout.write(decrypted)
|
||||
|
||||
if pgno == 1:
|
||||
if decrypted[:16] != SQLITE_HDR:
|
||||
print(f" [WARN] 解密后header不匹配!")
|
||||
|
||||
if pgno % 10000 == 0:
|
||||
print(f" 进度: {pgno}/{total_pages} ({100*pgno/total_pages:.1f}%)")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" WeChat 4.0 数据库解密器")
|
||||
print("=" * 60)
|
||||
|
||||
# 加载密钥
|
||||
if not os.path.exists(KEYS_FILE):
|
||||
print(f"[ERROR] 密钥文件不存在: {KEYS_FILE}")
|
||||
print("请先运行 find_all_keys.py")
|
||||
sys.exit(1)
|
||||
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
|
||||
keys = strip_key_metadata(keys)
|
||||
print(f"\n加载 {len(keys)} 个数据库密钥")
|
||||
print(f"输出目录: {OUT_DIR}")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
# 收集所有DB文件
|
||||
db_files = []
|
||||
for root, dirs, files in os.walk(DB_DIR):
|
||||
for f in files:
|
||||
if f.endswith('.db') and not f.endswith('-wal') and not f.endswith('-shm'):
|
||||
path = os.path.join(root, f)
|
||||
rel = os.path.relpath(path, DB_DIR)
|
||||
sz = os.path.getsize(path)
|
||||
db_files.append((rel, path, sz))
|
||||
|
||||
db_files.sort(key=lambda x: x[2]) # 从小到大
|
||||
|
||||
print(f"找到 {len(db_files)} 个数据库文件\n")
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
total_bytes = 0
|
||||
|
||||
for rel, path, sz in db_files:
|
||||
key_info = get_key_info(keys, rel)
|
||||
if not key_info:
|
||||
print(f"SKIP: {rel} (无密钥)")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
enc_key = bytes.fromhex(key_info["enc_key"])
|
||||
out_path = os.path.join(OUT_DIR, rel)
|
||||
|
||||
print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ")
|
||||
|
||||
ok = decrypt_database(path, out_path, enc_key)
|
||||
if ok:
|
||||
# SQLite验证
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(out_path)
|
||||
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
conn.close()
|
||||
table_names = [t[0] for t in tables]
|
||||
print(f" OK! 表: {', '.join(table_names[:5])}", end="")
|
||||
if len(table_names) > 5:
|
||||
print(f" ...共{len(table_names)}个", end="")
|
||||
print()
|
||||
success += 1
|
||||
total_bytes += sz
|
||||
except Exception as e:
|
||||
print(f" [WARN] SQLite验证失败: {e}")
|
||||
failed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"结果: {success} 成功, {failed} 失败, 共 {len(db_files)} 个")
|
||||
print(f"解密数据量: {total_bytes/1024/1024/1024:.1f}GB")
|
||||
print(f"解密文件在: {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
616
decrypt_images.c
616
decrypt_images.c
|
|
@ -1,616 +0,0 @@
|
|||
/*
|
||||
* decrypt_images.c — WeChat V2 image batch decryptor (multi-key)
|
||||
*
|
||||
* Decrypts all V2 encrypted .dat files in the WeChat image cache.
|
||||
* Supports multiple keys via image_keys.json (CT block → AES key mapping).
|
||||
*
|
||||
* V2 format:
|
||||
* [15B header] [AES-128-ECB ciphertext] [XOR encrypted tail]
|
||||
* Header: \x07\x08V2\x08\x07 (6B) + aes_size:u32LE + xor_size:u32LE + 1B pad
|
||||
* AES region: ceil(aes_size/16)*16 bytes of AES-128-ECB ciphertext
|
||||
* XOR tail: xor_size bytes, each XOR'd with a single-byte key
|
||||
*
|
||||
* Build:
|
||||
* cc -O3 -o decrypt_images decrypt_images.c -framework Security
|
||||
*
|
||||
* Usage:
|
||||
* ./decrypt_images # auto from config + image_keys.json
|
||||
* ./decrypt_images <key_hex> <image_dir> <out_dir> # single-key manual
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
#include <CommonCrypto/CommonCryptor.h>
|
||||
|
||||
#define MAX_PATH 4096
|
||||
#define V2_MAGIC "\x07\x08V2\x08\x07"
|
||||
#define V2_MAGIC_LEN 6
|
||||
#define HEADER_SIZE 15
|
||||
#define MAX_KEYS 4096
|
||||
|
||||
/* ---- Key mapping: CT block hex → AES key ---- */
|
||||
typedef struct {
|
||||
unsigned char ct[16]; /* CT block 0 pattern */
|
||||
unsigned char key[16]; /* AES key for this pattern */
|
||||
} key_map_t;
|
||||
|
||||
static key_map_t key_map[MAX_KEYS];
|
||||
static int n_keys = 0;
|
||||
|
||||
/* ---- Utility ---- */
|
||||
|
||||
static int hex2bytes(const char *hex, unsigned char *out, int maxlen) {
|
||||
int len = 0;
|
||||
while (*hex && *(hex + 1) && len < maxlen) {
|
||||
unsigned int b;
|
||||
if (sscanf(hex, "%2x", &b) != 1) break;
|
||||
out[len++] = (unsigned char)b;
|
||||
hex += 2;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
/* Minimal JSON string extractor (for simple unescaped string values only). */
|
||||
static int json_get_string(const char *json, const char *key,
|
||||
char *value, int maxlen) {
|
||||
char pattern[256];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (!p) return 0;
|
||||
p = strchr(p + strlen(pattern), '"');
|
||||
if (!p) return 0;
|
||||
p++;
|
||||
const char *end = strchr(p, '"');
|
||||
if (!end) return 0;
|
||||
int len = (int)(end - p);
|
||||
if (len >= maxlen) len = maxlen - 1;
|
||||
memcpy(value, p, len);
|
||||
value[len] = '\0';
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Load image_keys.json: { "ct_hex": "key_hex", ... } */
|
||||
static int load_key_map(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
if (sz <= 0) { fclose(f); return 0; }
|
||||
fseek(f, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(f); return 0; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, f);
|
||||
if (rd != (size_t)sz) {
|
||||
fclose(f);
|
||||
free(json);
|
||||
return 0;
|
||||
}
|
||||
json[rd] = '\0';
|
||||
fclose(f);
|
||||
|
||||
/* Simple parser: find all "32hex": "32hex" pairs */
|
||||
const char *p = json;
|
||||
int warned_capacity = 0;
|
||||
while ((p = strchr(p, '"')) != NULL) {
|
||||
if (n_keys >= MAX_KEYS) {
|
||||
if (!warned_capacity) {
|
||||
fprintf(stderr, "Warning: image_keys.json exceeds MAX_KEYS=%d, extra keys ignored\n",
|
||||
MAX_KEYS);
|
||||
warned_capacity = 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
p++;
|
||||
const char *end = strchr(p, '"');
|
||||
if (!end) break;
|
||||
int klen = (int)(end - p);
|
||||
if (klen != 32) { p = end + 1; continue; }
|
||||
|
||||
char ct_hex[33];
|
||||
memcpy(ct_hex, p, 32);
|
||||
ct_hex[32] = '\0';
|
||||
const char *colon = end + 1;
|
||||
while (*colon == ' ' || *colon == '\t' || *colon == '\r' || *colon == '\n')
|
||||
colon++;
|
||||
if (*colon != ':') { p = end + 1; continue; }
|
||||
p = colon + 1;
|
||||
|
||||
/* Find next quoted string (the value) */
|
||||
p = strchr(p, '"');
|
||||
if (!p) break;
|
||||
p++;
|
||||
end = strchr(p, '"');
|
||||
if (!end) break;
|
||||
int vlen = (int)(end - p);
|
||||
if (vlen != 32) { p = end + 1; continue; }
|
||||
|
||||
char key_hex[33];
|
||||
memcpy(key_hex, p, 32);
|
||||
key_hex[32] = '\0';
|
||||
p = end + 1;
|
||||
|
||||
if (hex2bytes(ct_hex, key_map[n_keys].ct, 16) != 16 ||
|
||||
hex2bytes(key_hex, key_map[n_keys].key, 16) != 16) {
|
||||
continue;
|
||||
}
|
||||
n_keys++;
|
||||
}
|
||||
free(json);
|
||||
return n_keys;
|
||||
}
|
||||
|
||||
/* Find AES key for a given CT block */
|
||||
static const unsigned char *find_key_for_ct(const unsigned char *ct) {
|
||||
for (int i = 0; i < n_keys; i++)
|
||||
if (memcmp(key_map[i].ct, ct, 16) == 0) return key_map[i].key;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Create directory and parents */
|
||||
static void mkdirs(const char *path) {
|
||||
char tmp[MAX_PATH];
|
||||
snprintf(tmp, sizeof(tmp), "%s", path);
|
||||
for (char *p = tmp + 1; *p; p++) {
|
||||
if (*p == '/') {
|
||||
*p = '\0';
|
||||
mkdir(tmp, 0755);
|
||||
*p = '/';
|
||||
}
|
||||
}
|
||||
mkdir(tmp, 0755);
|
||||
}
|
||||
|
||||
static int has_parent_segment(const char *path) {
|
||||
if (!path || !path[0]) return 1;
|
||||
if (path[0] == '/' || path[0] == '\\') return 1;
|
||||
|
||||
const char *p = path;
|
||||
while (*p) {
|
||||
while (*p == '/' || *p == '\\') p++;
|
||||
if (!*p) break;
|
||||
const char *seg = p;
|
||||
while (*p && *p != '/' && *p != '\\') p++;
|
||||
if ((p - seg) == 2 && seg[0] == '.' && seg[1] == '.') return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Detect image type from magic bytes */
|
||||
static const char *detect_ext(const unsigned char *data, size_t len) {
|
||||
if (len < 4) return ".bin";
|
||||
if (data[0] == 0xFF && data[1] == 0xD8) return ".jpg";
|
||||
if (data[0] == 0x89 && data[1] == 0x50 &&
|
||||
data[2] == 0x4E && data[3] == 0x47) return ".png";
|
||||
if (data[0] == 'G' && data[1] == 'I' &&
|
||||
data[2] == 'F' && data[3] == '8') return ".gif";
|
||||
if (data[0] == 'R' && data[1] == 'I' &&
|
||||
data[2] == 'F' && data[3] == 'F') return ".webp";
|
||||
if (data[0] == 0x00 && data[1] == 0x00 &&
|
||||
data[2] == 0x00 && (data[3] == 0x18 || data[3] == 0x1C ||
|
||||
data[3] == 0x20 || data[3] == 0x14)) return ".mp4";
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
/* Auto-detect XOR key */
|
||||
static unsigned char detect_xor_key(const unsigned char *xor_data, size_t xor_size) {
|
||||
if (xor_size == 0) return 0;
|
||||
unsigned char candidates[] = {0x80, 0xDC, 0x00};
|
||||
for (int i = 0; i < (int)(sizeof(candidates)/sizeof(candidates[0])); i++) {
|
||||
/* We want a candidate that doesn't produce a leading NUL byte after XOR. */
|
||||
unsigned char test = xor_data[0] ^ candidates[i];
|
||||
if (test != 0x00 || candidates[i] == 0x00)
|
||||
return candidates[i];
|
||||
}
|
||||
return 0x80;
|
||||
}
|
||||
|
||||
/* ---- Decrypt one V2 file ---- */
|
||||
|
||||
static int decrypt_v2_file(const char *input_path, const char *output_dir,
|
||||
const char *rel_path, const unsigned char *aes_key,
|
||||
unsigned char xor_key, int auto_xor,
|
||||
int *out_xor_detected) {
|
||||
FILE *fin = fopen(input_path, "rb");
|
||||
if (!fin) return -1;
|
||||
|
||||
unsigned char header[HEADER_SIZE];
|
||||
if (fread(header, 1, HEADER_SIZE, fin) != HEADER_SIZE) {
|
||||
fclose(fin); return -1;
|
||||
}
|
||||
if (memcmp(header, V2_MAGIC, V2_MAGIC_LEN) != 0) {
|
||||
fclose(fin); return -2;
|
||||
}
|
||||
|
||||
uint32_t aes_size, xor_size;
|
||||
memcpy(&aes_size, header + 6, 4);
|
||||
memcpy(&xor_size, header + 10, 4);
|
||||
|
||||
if ((uint64_t)aes_size > 100u * 1024u * 1024u ||
|
||||
(uint64_t)xor_size > 100u * 1024u * 1024u) {
|
||||
fclose(fin);
|
||||
return -6;
|
||||
}
|
||||
|
||||
/* PKCS7: when aes_size is already 16-byte aligned, an extra 16-byte
|
||||
* padding block is present in the ciphertext */
|
||||
size_t aes_ct_size = (aes_size % 16 == 0)
|
||||
? (size_t)aes_size + 16
|
||||
: ((size_t)aes_size + 15) / 16 * 16;
|
||||
|
||||
/* Get total file size and validate header claims fit within it */
|
||||
long cur_pos = ftell(fin);
|
||||
fseek(fin, 0, SEEK_END);
|
||||
long file_size = ftell(fin);
|
||||
fseek(fin, cur_pos, SEEK_SET);
|
||||
|
||||
if ((long)aes_ct_size + (long)xor_size > file_size - HEADER_SIZE) {
|
||||
fclose(fin);
|
||||
return -6; /* header claims more data than file contains */
|
||||
}
|
||||
|
||||
unsigned char *aes_ct = malloc(aes_ct_size);
|
||||
if (!aes_ct) { fclose(fin); return -1; }
|
||||
size_t rd = fread(aes_ct, 1, aes_ct_size, fin);
|
||||
if (rd != aes_ct_size) {
|
||||
free(aes_ct);
|
||||
fclose(fin);
|
||||
return -8;
|
||||
}
|
||||
|
||||
/* V2 may have unencrypted raw_data between AES and XOR sections */
|
||||
long raw_data_size = file_size - HEADER_SIZE - (long)aes_ct_size - (long)xor_size;
|
||||
if (raw_data_size < 0) raw_data_size = 0;
|
||||
|
||||
unsigned char *raw_data = NULL;
|
||||
if (raw_data_size > 0) {
|
||||
raw_data = malloc((size_t)raw_data_size);
|
||||
if (!raw_data) { free(aes_ct); fclose(fin); return -1; }
|
||||
rd = fread(raw_data, 1, (size_t)raw_data_size, fin);
|
||||
if (rd != (size_t)raw_data_size) {
|
||||
free(aes_ct); free(raw_data); fclose(fin); return -8;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned char *xor_data = NULL;
|
||||
if (xor_size > 0) {
|
||||
xor_data = malloc(xor_size);
|
||||
if (!xor_data) { free(aes_ct); free(raw_data); fclose(fin); return -1; }
|
||||
rd = fread(xor_data, 1, xor_size, fin);
|
||||
if (rd != xor_size) {
|
||||
free(aes_ct); free(raw_data); free(xor_data);
|
||||
fclose(fin); return -8;
|
||||
}
|
||||
}
|
||||
fclose(fin);
|
||||
|
||||
/* Try multi-key lookup (image_keys.json) first, then fall back to provided key */
|
||||
if (aes_ct_size >= 16) {
|
||||
const unsigned char *mk = find_key_for_ct(aes_ct);
|
||||
if (mk) aes_key = mk;
|
||||
}
|
||||
if (!aes_key) { free(aes_ct); free(raw_data); free(xor_data); return -5; }
|
||||
|
||||
unsigned char *aes_pt = malloc(aes_ct_size);
|
||||
if (!aes_pt) { free(aes_ct); free(raw_data); free(xor_data); return -1; }
|
||||
|
||||
size_t moved = 0;
|
||||
CCCryptorStatus st = CCCrypt(
|
||||
kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode,
|
||||
aes_key, 16, NULL,
|
||||
aes_ct, aes_ct_size, aes_pt, aes_ct_size, &moved);
|
||||
free(aes_ct);
|
||||
|
||||
if (st != kCCSuccess) {
|
||||
free(aes_pt); free(raw_data); free(xor_data); return -3;
|
||||
}
|
||||
|
||||
if (auto_xor && xor_data && xor_size > 0) {
|
||||
xor_key = detect_xor_key(xor_data, xor_size);
|
||||
if (out_xor_detected) *out_xor_detected = xor_key;
|
||||
}
|
||||
|
||||
if (xor_data && xor_size > 0) {
|
||||
for (uint32_t i = 0; i < xor_size; i++)
|
||||
xor_data[i] ^= xor_key;
|
||||
}
|
||||
|
||||
const char *ext = detect_ext(aes_pt, aes_size);
|
||||
|
||||
/* Skip unrecognized formats — avoids writing garbage .bin files */
|
||||
if (strcmp(ext, ".bin") == 0) {
|
||||
free(aes_pt); free(raw_data); free(xor_data);
|
||||
return -9; /* unrecognized image type */
|
||||
}
|
||||
|
||||
char out_path[MAX_PATH];
|
||||
char rel_noext[MAX_PATH];
|
||||
snprintf(rel_noext, sizeof(rel_noext), "%s", rel_path);
|
||||
char *dot = strrchr(rel_noext, '.');
|
||||
if (dot) *dot = '\0';
|
||||
if (has_parent_segment(rel_noext)) {
|
||||
free(aes_pt); free(raw_data); free(xor_data);
|
||||
return -7;
|
||||
}
|
||||
snprintf(out_path, sizeof(out_path), "%s/%s%s", output_dir, rel_noext, ext);
|
||||
|
||||
/* Skip if already decrypted */
|
||||
struct stat st_out;
|
||||
if (stat(out_path, &st_out) == 0 && st_out.st_size > 0) {
|
||||
free(aes_pt); free(raw_data); free(xor_data);
|
||||
return 1; /* already exists */
|
||||
}
|
||||
|
||||
char parent[MAX_PATH];
|
||||
snprintf(parent, sizeof(parent), "%s", out_path);
|
||||
char *last_slash = strrchr(parent, '/');
|
||||
if (last_slash) { *last_slash = '\0'; mkdirs(parent); }
|
||||
|
||||
FILE *fout = fopen(out_path, "wb");
|
||||
if (!fout) { free(aes_pt); free(raw_data); free(xor_data); return -4; }
|
||||
|
||||
fwrite(aes_pt, 1, aes_size, fout);
|
||||
if (raw_data && raw_data_size > 0) fwrite(raw_data, 1, (size_t)raw_data_size, fout);
|
||||
if (xor_data && xor_size > 0) fwrite(xor_data, 1, xor_size, fout);
|
||||
|
||||
fclose(fout);
|
||||
free(aes_pt);
|
||||
free(raw_data);
|
||||
free(xor_data);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Directory walking ---- */
|
||||
|
||||
typedef struct {
|
||||
const unsigned char *fallback_key; /* single key from config.json (or NULL) */
|
||||
int multi_key; /* 1 if using image_keys.json */
|
||||
unsigned char xor_key;
|
||||
int auto_xor;
|
||||
const char *output_dir;
|
||||
const char *base_dir;
|
||||
int success;
|
||||
int skipped;
|
||||
int existed; /* already decrypted */
|
||||
int no_key; /* V2 files with no matching key */
|
||||
int failed;
|
||||
} walk_ctx;
|
||||
|
||||
static void walk_dir(const char *dir, walk_ctx *ctx) {
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return;
|
||||
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name);
|
||||
|
||||
struct stat st;
|
||||
if (lstat(path, &st) != 0) continue;
|
||||
if (S_ISLNK(st.st_mode)) continue;
|
||||
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
walk_dir(path, ctx);
|
||||
} else if (S_ISREG(st.st_mode)) {
|
||||
size_t nlen = strlen(ent->d_name);
|
||||
if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0)
|
||||
continue;
|
||||
|
||||
const char *rel = path + strlen(ctx->base_dir);
|
||||
if (*rel == '/') rel++;
|
||||
|
||||
int xor_detected = -1;
|
||||
/* In multi-key mode, pass fallback_key — decrypt_v2_file tries
|
||||
* image_keys.json lookup first, falls back to this key if provided */
|
||||
const unsigned char *key = ctx->fallback_key;
|
||||
int ret = decrypt_v2_file(path, ctx->output_dir, rel,
|
||||
key, ctx->xor_key,
|
||||
ctx->auto_xor, &xor_detected);
|
||||
|
||||
if (ret == 0) {
|
||||
ctx->success++;
|
||||
if (ctx->auto_xor && xor_detected >= 0) {
|
||||
ctx->xor_key = (unsigned char)xor_detected;
|
||||
ctx->auto_xor = 0;
|
||||
printf(" Auto-detected XOR key: 0x%02X\n", ctx->xor_key);
|
||||
}
|
||||
if (ctx->success <= 5 || ctx->success % 1000 == 0) {
|
||||
printf(" [%d] %s\n", ctx->success, rel);
|
||||
}
|
||||
} else if (ret == 1) {
|
||||
ctx->existed++;
|
||||
} else if (ret == -2) {
|
||||
ctx->skipped++;
|
||||
} else if (ret == -5) {
|
||||
ctx->no_key++;
|
||||
} else {
|
||||
ctx->failed++;
|
||||
if (ctx->failed <= 5)
|
||||
printf(" FAIL(%d): %s\n", ret, rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
/* ---- Main ---- */
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
unsigned char aes_key[16];
|
||||
char image_dir[MAX_PATH] = "";
|
||||
char output_dir[MAX_PATH] = "";
|
||||
char key_hex[64] = "";
|
||||
int have_single_key = 0;
|
||||
|
||||
printf("=== WeChat V2 Image Decryptor ===\n\n");
|
||||
|
||||
/* Determine exe directory for config file lookup */
|
||||
char exe_dir[MAX_PATH] = ".";
|
||||
const char *last_slash = strrchr(argv[0], '/');
|
||||
if (last_slash) {
|
||||
int len = (int)(last_slash - argv[0]);
|
||||
snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]);
|
||||
}
|
||||
|
||||
if (argc >= 4) {
|
||||
/* Manual single-key mode */
|
||||
strncpy(key_hex, argv[1], sizeof(key_hex) - 1);
|
||||
key_hex[sizeof(key_hex) - 1] = '\0';
|
||||
strncpy(image_dir, argv[2], sizeof(image_dir) - 1);
|
||||
image_dir[sizeof(image_dir) - 1] = '\0';
|
||||
strncpy(output_dir, argv[3], sizeof(output_dir) - 1);
|
||||
output_dir[sizeof(output_dir) - 1] = '\0';
|
||||
have_single_key = (key_hex[0] != '\0');
|
||||
} else {
|
||||
/* Load image_keys.json first (multi-key) */
|
||||
char keys_path[MAX_PATH];
|
||||
snprintf(keys_path, sizeof(keys_path), "%s/image_keys.json", exe_dir);
|
||||
int loaded = load_key_map(keys_path);
|
||||
if (loaded > 0)
|
||||
printf("Loaded %d key mappings from %s\n", loaded, keys_path);
|
||||
|
||||
/* Read config.json for paths (and fallback single key) */
|
||||
char cfg_path[MAX_PATH];
|
||||
snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir);
|
||||
FILE *cf = fopen(cfg_path, "r");
|
||||
if (!cf) {
|
||||
fprintf(stderr, "ERROR: Cannot open %s\n", cfg_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
fseek(cf, 0, SEEK_END);
|
||||
long sz = ftell(cf);
|
||||
if (sz <= 0) { fclose(cf); return 1; }
|
||||
fseek(cf, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(cf); return 1; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, cf);
|
||||
if (rd != (size_t)sz) {
|
||||
free(json);
|
||||
fclose(cf);
|
||||
return 1;
|
||||
}
|
||||
json[sz] = '\0';
|
||||
fclose(cf);
|
||||
|
||||
if (json_get_string(json, "image_key", key_hex, sizeof(key_hex)) &&
|
||||
key_hex[0] != '\0')
|
||||
have_single_key = 1;
|
||||
else
|
||||
have_single_key = 0;
|
||||
|
||||
char db_dir[MAX_PATH] = "";
|
||||
json_get_string(json, "db_dir", db_dir, sizeof(db_dir));
|
||||
|
||||
char out_rel[MAX_PATH] = "decrypted_images";
|
||||
json_get_string(json, "decrypted_images_dir", out_rel, sizeof(out_rel));
|
||||
if (out_rel[0] == '/')
|
||||
strncpy(output_dir, out_rel, sizeof(output_dir) - 1);
|
||||
else
|
||||
snprintf(output_dir, sizeof(output_dir), "%s/%s", exe_dir, out_rel);
|
||||
output_dir[sizeof(output_dir) - 1] = '\0';
|
||||
|
||||
if (db_dir[0]) {
|
||||
char *s = strrchr(db_dir, '/');
|
||||
if (!s) s = strrchr(db_dir, '\\');
|
||||
if (s) {
|
||||
int plen = (int)(s - db_dir);
|
||||
snprintf(image_dir, sizeof(image_dir),
|
||||
"%.*s/msg", plen, db_dir);
|
||||
}
|
||||
}
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Parse single key if available (used as fallback or sole key) */
|
||||
if (have_single_key && key_hex[0]) {
|
||||
if (hex2bytes(key_hex, aes_key, 16) == 16) {
|
||||
/* If no image_keys.json loaded, add single key to key_map
|
||||
* by discovering its CT block at runtime */
|
||||
} else {
|
||||
have_single_key = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (n_keys == 0 && !have_single_key) {
|
||||
fprintf(stderr, "ERROR: No keys available.\n");
|
||||
fprintf(stderr, "Run find_image_key first, or set image_key in config.json\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */
|
||||
if (image_dir[0] == '\0') {
|
||||
const char *home = getenv("HOME");
|
||||
if (!home) home = "/Users";
|
||||
char base[MAX_PATH];
|
||||
snprintf(base, sizeof(base),
|
||||
"%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files",
|
||||
home);
|
||||
DIR *d = opendir(base);
|
||||
if (d) {
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
char candidate[MAX_PATH];
|
||||
snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name);
|
||||
struct stat st2;
|
||||
if (stat(candidate, &st2) == 0 && S_ISDIR(st2.st_mode)) {
|
||||
strncpy(image_dir, candidate, sizeof(image_dir) - 1);
|
||||
printf("Auto-detected image directory:\n %s\n\n", image_dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
}
|
||||
|
||||
if (image_dir[0] == '\0') {
|
||||
fprintf(stderr, "ERROR: Cannot determine image directory.\n");
|
||||
fprintf(stderr, "Tried: command line, config.json, auto-detect.\n");
|
||||
fprintf(stderr, "Set db_dir in config.json or pass image_dir as argument.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Mode: %s\n", n_keys > 0 ? "multi-key" : "single-key");
|
||||
if (n_keys > 0) printf("Keys: %d pattern→key mappings\n", n_keys);
|
||||
if (have_single_key) printf("Fallback: %s\n", key_hex);
|
||||
printf("Image dir: %s\n", image_dir);
|
||||
printf("Output: %s\n\n", output_dir);
|
||||
|
||||
mkdirs(output_dir);
|
||||
|
||||
walk_ctx ctx = {
|
||||
.fallback_key = have_single_key ? aes_key : NULL,
|
||||
.multi_key = (n_keys > 0),
|
||||
.xor_key = 0,
|
||||
.auto_xor = 1,
|
||||
.output_dir = output_dir,
|
||||
.base_dir = image_dir,
|
||||
.success = 0,
|
||||
.skipped = 0,
|
||||
.existed = 0,
|
||||
.no_key = 0,
|
||||
.failed = 0,
|
||||
};
|
||||
|
||||
walk_dir(image_dir, &ctx);
|
||||
|
||||
printf("\n==================================================\n");
|
||||
printf("Results:\n");
|
||||
printf(" Decrypted: %d\n", ctx.success);
|
||||
printf(" Existed: %d (already decrypted, skipped)\n", ctx.existed);
|
||||
printf(" No key: %d (run find_image_key to discover more keys)\n", ctx.no_key);
|
||||
printf(" Skipped: %d (non-V2)\n", ctx.skipped);
|
||||
printf(" Failed: %d\n", ctx.failed);
|
||||
printf("Output: %s\n", output_dir);
|
||||
printf("==================================================\n");
|
||||
|
||||
return (ctx.success > 0) ? 0 : 1;
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import functools
|
||||
import platform
|
||||
import sys
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _load_impl():
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
import find_all_keys_windows as impl
|
||||
return impl
|
||||
if system == "linux":
|
||||
import find_all_keys_linux as impl
|
||||
return impl
|
||||
raise RuntimeError(
|
||||
f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n"
|
||||
f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)"
|
||||
)
|
||||
|
||||
|
||||
def get_pids():
|
||||
return _load_impl().get_pids()
|
||||
|
||||
|
||||
def main():
|
||||
return _load_impl().main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as exc:
|
||||
print(f"\n[ERROR] {exc}")
|
||||
sys.exit(1)
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
"""
|
||||
Linux 版微信数据库密钥提取
|
||||
|
||||
原理: 与 Windows/macOS 相同 — 扫描微信进程内存,查找
|
||||
WCDB 缓存的 x'<64hex_enc_key><32hex_salt>' 模式,
|
||||
通过匹配数据库 salt + HMAC 校验确认密钥。
|
||||
|
||||
读取方式: /proc/<pid>/maps + /proc/<pid>/mem
|
||||
权限要求: root 或 CAP_SYS_PTRACE
|
||||
"""
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from key_scan_common import (
|
||||
collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results,
|
||||
)
|
||||
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
|
||||
def _safe_readlink(path):
|
||||
try:
|
||||
return os.path.realpath(os.readlink(path))
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
_KNOWN_COMMS = {"wechat", "wechatappex", "weixin"}
|
||||
_INTERPRETER_PREFIXES = ("python", "bash", "sh", "zsh", "node", "perl", "ruby")
|
||||
|
||||
|
||||
def _is_wechat_process(pid):
|
||||
"""检查 pid 是否为微信进程。
|
||||
|
||||
优先精确匹配 comm 名称(wechat、WeChatAppEx 等),
|
||||
再用 exe 路径子串匹配作为 fallback,同时排除解释器进程。
|
||||
"""
|
||||
if pid == os.getpid():
|
||||
return False
|
||||
try:
|
||||
with open(f"/proc/{pid}/comm") as f:
|
||||
comm = f.read().strip()
|
||||
# 优先精确匹配 comm(最可靠)
|
||||
if comm.lower() in _KNOWN_COMMS:
|
||||
return True
|
||||
exe_path = _safe_readlink(f"/proc/{pid}/exe")
|
||||
exe_name = os.path.basename(exe_path)
|
||||
# 排除脚本解释器进程(避免匹配 python3.11 wechat-decrypt 等)
|
||||
if any(exe_name.lower().startswith(p) for p in _INTERPRETER_PREFIXES):
|
||||
return False
|
||||
# fallback: exe 名称子串匹配
|
||||
return "wechat" in exe_name.lower() or "weixin" in exe_name.lower()
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
|
||||
def get_pids():
|
||||
"""返回所有疑似微信主进程的 (pid, rss_kb) 列表,按内存降序。"""
|
||||
pids = []
|
||||
for pid_str in os.listdir("/proc"):
|
||||
if not pid_str.isdigit():
|
||||
continue
|
||||
pid = int(pid_str)
|
||||
try:
|
||||
if not _is_wechat_process(pid):
|
||||
continue
|
||||
with open(f"/proc/{pid}/statm") as f:
|
||||
rss_pages = int(f.read().split()[1])
|
||||
rss_kb = rss_pages * 4
|
||||
pids.append((pid, rss_kb))
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
||||
continue
|
||||
|
||||
if not pids:
|
||||
raise RuntimeError("未检测到 Linux 微信进程")
|
||||
|
||||
pids.sort(key=lambda item: item[1], reverse=True)
|
||||
for pid, rss_kb in pids:
|
||||
exe_path = _safe_readlink(f"/proc/{pid}/exe")
|
||||
print(f"[+] WeChat PID={pid} ({rss_kb // 1024}MB) {exe_path}")
|
||||
return pids
|
||||
|
||||
|
||||
_SKIP_MAPPINGS = {"[vdso]", "[vsyscall]", "[vvar]"}
|
||||
_SKIP_PATH_PREFIXES = ("/usr/lib/", "/lib/", "/usr/share/")
|
||||
|
||||
|
||||
def _get_readable_regions(pid):
|
||||
"""解析 /proc/<pid>/maps,返回可读内存区域列表。
|
||||
|
||||
跳过 [vdso]、[vsyscall] 等特殊映射和系统库映射,
|
||||
聚焦匿名映射和堆区(WCDB 密钥缓存所在位置)。
|
||||
"""
|
||||
regions = []
|
||||
with open(f"/proc/{pid}/maps") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
if "r" not in parts[1]:
|
||||
continue
|
||||
# 跳过特殊映射和无关系统库,但保留 wcdb/wechat 相关库
|
||||
if len(parts) >= 6:
|
||||
mapping_name = parts[5]
|
||||
if mapping_name in _SKIP_MAPPINGS:
|
||||
continue
|
||||
mapping_lower = mapping_name.lower()
|
||||
if (any(mapping_name.startswith(p) for p in _SKIP_PATH_PREFIXES)
|
||||
and "wcdb" not in mapping_lower
|
||||
and "wechat" not in mapping_lower
|
||||
and "weixin" not in mapping_lower):
|
||||
continue
|
||||
start_s, end_s = parts[0].split("-")
|
||||
start = int(start_s, 16)
|
||||
size = int(end_s, 16) - start
|
||||
if 0 < size < 500 * 1024 * 1024:
|
||||
regions.append((start, size))
|
||||
return regions
|
||||
|
||||
|
||||
def _check_permissions():
|
||||
"""检查是否有读取进程内存的权限(root 或 CAP_SYS_PTRACE)。"""
|
||||
if os.geteuid() == 0:
|
||||
return
|
||||
# 检查 CAP_SYS_PTRACE: 读取 /proc/self/status 中的 CapEff
|
||||
try:
|
||||
with open("/proc/self/status") as f:
|
||||
for line in f:
|
||||
if line.startswith("CapEff:"):
|
||||
cap_eff = int(line.split(":")[1].strip(), 16)
|
||||
CAP_SYS_PTRACE = 1 << 19
|
||||
if cap_eff & CAP_SYS_PTRACE:
|
||||
return
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
print("[!] 需要 root 权限或 CAP_SYS_PTRACE 才能读取进程内存")
|
||||
print(" 请使用: sudo python3 find_all_keys.py")
|
||||
print(" 或授予 capability: sudo setcap cap_sys_ptrace=ep $(which python3)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
db_dir = _cfg["db_dir"]
|
||||
out_file = _cfg["keys_file"]
|
||||
|
||||
_check_permissions()
|
||||
|
||||
print("=" * 60)
|
||||
print(" 提取 Linux 微信数据库密钥(内存扫描)")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 收集 DB 文件和 salt
|
||||
db_files, salt_to_dbs = collect_db_files(db_dir)
|
||||
if not db_files:
|
||||
raise RuntimeError(f"在 {db_dir} 未找到可解密的 .db 文件")
|
||||
|
||||
print(f"\n找到 {len(db_files)} 个数据库, {len(salt_to_dbs)} 个不同的 salt")
|
||||
for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f" salt {salt_hex}: {', '.join(dbs)}")
|
||||
|
||||
# 2. 找到微信进程
|
||||
pids = get_pids()
|
||||
|
||||
hex_re = re.compile(rb"x'([0-9a-fA-F]{64,192})'")
|
||||
key_map = {} # salt_hex -> enc_key_hex
|
||||
remaining_salts = set(salt_to_dbs.keys())
|
||||
all_hex_matches = 0
|
||||
t0 = time.time()
|
||||
|
||||
for pid, rss_kb in pids:
|
||||
try:
|
||||
regions = _get_readable_regions(pid)
|
||||
except PermissionError:
|
||||
print(f"[WARN] 无法读取 /proc/{pid}/maps,权限不足,跳过")
|
||||
continue
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
print(f"[WARN] PID {pid} 已退出,跳过")
|
||||
continue
|
||||
|
||||
total_bytes = sum(s for _, s in regions)
|
||||
total_mb = total_bytes / 1024 / 1024
|
||||
print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)")
|
||||
|
||||
scanned_bytes = 0
|
||||
try:
|
||||
mem = open(f"/proc/{pid}/mem", "rb")
|
||||
except PermissionError:
|
||||
print(f"[WARN] 无法打开 /proc/{pid}/mem,权限不足,跳过")
|
||||
continue
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
print(f"[WARN] PID {pid} 已退出,跳过")
|
||||
continue
|
||||
|
||||
# 防御 TOCTOU: 打开 mem 后再次确认仍为微信进程
|
||||
if not _is_wechat_process(pid):
|
||||
print(f"[WARN] PID {pid} 已不是微信进程,跳过")
|
||||
mem.close()
|
||||
continue
|
||||
|
||||
try:
|
||||
for reg_idx, (base, size) in enumerate(regions):
|
||||
try:
|
||||
mem.seek(base)
|
||||
data = mem.read(size)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
scanned_bytes += len(data)
|
||||
|
||||
all_hex_matches += scan_memory_for_keys(
|
||||
data, hex_re, db_files, salt_to_dbs,
|
||||
key_map, remaining_salts, base, pid, print,
|
||||
)
|
||||
|
||||
if (reg_idx + 1) % 200 == 0:
|
||||
elapsed = time.time() - t0
|
||||
progress = scanned_bytes / total_bytes * 100 if total_bytes else 100
|
||||
print(
|
||||
f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, "
|
||||
f"{all_hex_matches} hex patterns, {elapsed:.1f}s"
|
||||
)
|
||||
finally:
|
||||
mem.close()
|
||||
|
||||
if not remaining_salts:
|
||||
print(f"\n[+] 所有密钥已找到,跳过剩余进程")
|
||||
break
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex 模式")
|
||||
|
||||
cross_verify_keys(db_files, salt_to_dbs, key_map, print)
|
||||
save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as exc:
|
||||
print(f"\n[ERROR] {exc}")
|
||||
sys.exit(1)
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
"""
|
||||
从微信进程内存中提取所有数据库的缓存raw key
|
||||
|
||||
WCDB为每个DB缓存: x'<64hex_enc_key><32hex_salt>'
|
||||
salt嵌在hex字符串中,可以直接匹配DB文件的salt
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import os, sys, time, re
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
from key_scan_common import (
|
||||
collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results,
|
||||
)
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
MEM_COMMIT = 0x1000
|
||||
READABLE = {0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80}
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64),
|
||||
("AllocationProtect", wt.DWORD), ("_pad1", wt.DWORD),
|
||||
("RegionSize", ctypes.c_uint64), ("State", wt.DWORD),
|
||||
("Protect", wt.DWORD), ("Type", wt.DWORD), ("_pad2", wt.DWORD),
|
||||
]
|
||||
|
||||
|
||||
def get_pids():
|
||||
"""返回所有 Weixin.exe 进程的 (pid, mem_kb) 列表,按内存降序"""
|
||||
import subprocess
|
||||
r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq Weixin.exe", "/FO", "CSV", "/NH"],
|
||||
capture_output=True, text=True)
|
||||
pids = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
p = line.strip('"').split('","')
|
||||
if len(p) >= 5:
|
||||
pid = int(p[1])
|
||||
mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0')
|
||||
pids.append((pid, mem))
|
||||
if not pids:
|
||||
raise RuntimeError("Weixin.exe 未运行")
|
||||
pids.sort(key=lambda x: x[1], reverse=True)
|
||||
for pid, mem in pids:
|
||||
print(f"[+] Weixin.exe PID={pid} ({mem // 1024}MB)")
|
||||
return pids
|
||||
|
||||
|
||||
def read_mem(h, addr, sz):
|
||||
buf = ctypes.create_string_buffer(sz)
|
||||
n = ctypes.c_size_t(0)
|
||||
if kernel32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)):
|
||||
return buf.raw[:n.value]
|
||||
return None
|
||||
|
||||
|
||||
def enum_regions(h):
|
||||
regs = []
|
||||
addr = 0
|
||||
mbi = MBI()
|
||||
while addr < 0x7FFFFFFFFFFF:
|
||||
if kernel32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)) == 0:
|
||||
break
|
||||
if mbi.State == MEM_COMMIT and mbi.Protect in READABLE and 0 < mbi.RegionSize < 500 * 1024 * 1024:
|
||||
regs.append((mbi.BaseAddress, mbi.RegionSize))
|
||||
nxt = mbi.BaseAddress + mbi.RegionSize
|
||||
if nxt <= addr:
|
||||
break
|
||||
addr = nxt
|
||||
return regs
|
||||
|
||||
|
||||
def main():
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
db_dir = _cfg["db_dir"]
|
||||
out_file = _cfg["keys_file"]
|
||||
|
||||
print("=" * 60)
|
||||
print(" 提取所有微信数据库密钥")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 收集所有DB文件及其salt
|
||||
db_files, salt_to_dbs = collect_db_files(db_dir)
|
||||
|
||||
print(f"\n找到 {len(db_files)} 个数据库, {len(salt_to_dbs)} 个不同的salt")
|
||||
for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f" salt {salt_hex}: {', '.join(dbs)}")
|
||||
|
||||
# 2. 打开所有微信进程
|
||||
pids = get_pids()
|
||||
|
||||
hex_re = re.compile(b"x'([0-9a-fA-F]{64,192})'")
|
||||
key_map = {}
|
||||
remaining_salts = set(salt_to_dbs.keys())
|
||||
all_hex_matches = 0
|
||||
t0 = time.time()
|
||||
|
||||
for pid, mem_kb in pids:
|
||||
h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid)
|
||||
if not h:
|
||||
print(f"[WARN] 无法打开进程 PID={pid},跳过")
|
||||
continue
|
||||
|
||||
try:
|
||||
regions = enum_regions(h)
|
||||
total_bytes = sum(s for _, s in regions)
|
||||
total_mb = total_bytes / 1024 / 1024
|
||||
print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)")
|
||||
|
||||
scanned_bytes = 0
|
||||
for reg_idx, (base, size) in enumerate(regions):
|
||||
data = read_mem(h, base, size)
|
||||
scanned_bytes += size
|
||||
if not data:
|
||||
continue
|
||||
|
||||
all_hex_matches += scan_memory_for_keys(
|
||||
data, hex_re, db_files, salt_to_dbs,
|
||||
key_map, remaining_salts, base, pid, print,
|
||||
)
|
||||
|
||||
if (reg_idx + 1) % 200 == 0:
|
||||
elapsed = time.time() - t0
|
||||
progress = scanned_bytes / total_bytes * 100 if total_bytes else 100
|
||||
print(
|
||||
f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, "
|
||||
f"{all_hex_matches} hex patterns, {elapsed:.1f}s"
|
||||
)
|
||||
finally:
|
||||
kernel32.CloseHandle(h)
|
||||
|
||||
if not remaining_salts:
|
||||
print(f"\n[+] 所有密钥已找到,跳过剩余进程")
|
||||
break
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex模式")
|
||||
|
||||
cross_verify_keys(db_files, salt_to_dbs, key_map, print)
|
||||
save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as e:
|
||||
print(f"\n[ERROR] {e}")
|
||||
sys.exit(1)
|
||||
917
find_image_key.c
917
find_image_key.c
|
|
@ -1,917 +0,0 @@
|
|||
/*
|
||||
* find_image_key.c — WeChat V2 image key continuous scanner (macOS)
|
||||
*
|
||||
* Discovers all unique V2 encryption patterns from the image cache,
|
||||
* then continuously scans WeChat process memory to find AES keys.
|
||||
* User just keeps browsing images in WeChat — the scanner catches
|
||||
* keys as they transiently appear in memory.
|
||||
*
|
||||
* Uses multi-block CCCrypt: one key setup decrypts ALL unsolved
|
||||
* patterns in a single call (~1.5 min per full scan with 20 patterns).
|
||||
*
|
||||
* Build:
|
||||
* cc -O3 -o find_image_key find_image_key.c -framework Security
|
||||
*
|
||||
* Usage:
|
||||
* sudo ./find_image_key # auto-discover from config.json
|
||||
* sudo ./find_image_key <image_dir> # explicit image directory
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <signal.h>
|
||||
#include <time.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysctl.h>
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_vm.h>
|
||||
#include <unistd.h>
|
||||
#include <CommonCrypto/CommonCryptor.h>
|
||||
|
||||
#define MAX_PATH 4096
|
||||
#define MAX_PATTERNS 8192
|
||||
#define V2_MAGIC "\x07\x08V2\x08\x07"
|
||||
#define V2_MAGIC_LEN 6
|
||||
#define REGION_MAX (200 * 1024 * 1024)
|
||||
#define DEEP_PRIORITY_MAX 10 /* byte-by-byte scan for top N unsolved patterns */
|
||||
|
||||
/* ---- Strict image magic detection (16 bytes available from decrypted block) ---- */
|
||||
static int is_image_magic(const unsigned char *pt) {
|
||||
if (pt[0] == 0xFF && pt[1] == 0xD8 && pt[2] == 0xFF &&
|
||||
pt[3] >= 0xC0 && pt[3] != 0xFF) {
|
||||
/* JFIF: verify "JF" at offset 6 */
|
||||
if (pt[3] == 0xE0) return (pt[6] == 'J' && pt[7] == 'F');
|
||||
/* EXIF: verify "Ex" at offset 6 */
|
||||
if (pt[3] == 0xE1) return (pt[6] == 'E' && pt[7] == 'x');
|
||||
/* Other markers: verify length field is sane (big-endian, 2..32767) */
|
||||
uint16_t len = ((uint16_t)pt[4] << 8) | pt[5];
|
||||
return (len >= 2 && len < 0x8000);
|
||||
}
|
||||
/* PNG: full 8-byte signature */
|
||||
if (pt[0]==0x89 && pt[1]==0x50 && pt[2]==0x4E && pt[3]==0x47 &&
|
||||
pt[4]==0x0D && pt[5]==0x0A && pt[6]==0x1A && pt[7]==0x0A) return 1;
|
||||
/* GIF: "GIF89a" or "GIF87a" */
|
||||
if (pt[0]=='G' && pt[1]=='I' && pt[2]=='F' && pt[3]=='8' &&
|
||||
(pt[4]=='9' || pt[4]=='7') && pt[5]=='a') return 1;
|
||||
/* WebP: "RIFF....WEBP" */
|
||||
if (pt[0]=='R' && pt[1]=='I' && pt[2]=='F' && pt[3]=='F' &&
|
||||
pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Pattern tracking ---- */
|
||||
typedef struct {
|
||||
unsigned char ct[16]; /* CT block 0 (first 16 encrypted bytes) */
|
||||
unsigned char key[16]; /* found AES key */
|
||||
int solved;
|
||||
int file_count; /* how many .dat files use this pattern */
|
||||
char sample_path[MAX_PATH];
|
||||
} pattern_t;
|
||||
|
||||
static pattern_t patterns[MAX_PATTERNS];
|
||||
static int npatterns = 0;
|
||||
static int total_v2_files = 0;
|
||||
|
||||
/* ---- Rejected key blacklist (false positives) ---- */
|
||||
#define MAX_REJECTED 256
|
||||
static unsigned char rejected_keys[MAX_REJECTED][16];
|
||||
static int n_rejected = 0;
|
||||
|
||||
static int is_rejected(const unsigned char *key) {
|
||||
for (int i = 0; i < n_rejected; i++)
|
||||
if (memcmp(rejected_keys[i], key, 16) == 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
static void add_rejected(const unsigned char *key) {
|
||||
if (n_rejected < MAX_REJECTED && !is_rejected(key)) {
|
||||
memcpy(rejected_keys[n_rejected], key, 16);
|
||||
n_rejected++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Global scan mode ---- */
|
||||
static int g_deep_mode = 0;
|
||||
|
||||
/* ---- Graceful shutdown ---- */
|
||||
static volatile sig_atomic_t stop_flag = 0;
|
||||
static void sigint_handler(int sig) { (void)sig; stop_flag = 1; }
|
||||
|
||||
/* ---- Utility ---- */
|
||||
static void bytes2hex(const unsigned char *d, int n, char *out) {
|
||||
for (int i = 0; i < n; i++) sprintf(out + i*2, "%02x", d[i]);
|
||||
out[n*2] = '\0';
|
||||
}
|
||||
static int hex2bytes(const char *h, unsigned char *o, int max) {
|
||||
int n = 0;
|
||||
while (n < max) {
|
||||
if (!h[0] || !h[1]) return 0;
|
||||
if (!((h[0] >= '0' && h[0] <= '9') || (h[0] >= 'a' && h[0] <= 'f') ||
|
||||
(h[0] >= 'A' && h[0] <= 'F'))) return 0;
|
||||
if (!((h[1] >= '0' && h[1] <= '9') || (h[1] >= 'a' && h[1] <= 'f') ||
|
||||
(h[1] >= 'A' && h[1] <= 'F'))) return 0;
|
||||
|
||||
unsigned int b = 0;
|
||||
if (sscanf(h, "%2x", &b) != 1) return 0;
|
||||
o[n++] = (unsigned char)b; h += 2;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Minimal JSON string extractor */
|
||||
static int json_get_string(const char *json, const char *key,
|
||||
char *val, int maxlen) {
|
||||
char pat[256];
|
||||
snprintf(pat, sizeof(pat), "\"%s\"", key);
|
||||
const char *p = strstr(json, pat);
|
||||
if (!p) return 0;
|
||||
p = strchr(p + strlen(pat), '"');
|
||||
if (!p) return 0;
|
||||
p++;
|
||||
const char *end = strchr(p, '"');
|
||||
if (!end || (int)(end - p) >= maxlen) return 0;
|
||||
memcpy(val, p, end - p);
|
||||
val[end - p] = '\0';
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- Pattern discovery ---- */
|
||||
static int find_pattern_index(const unsigned char *ct) {
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (memcmp(patterns[i].ct, ct, 16) == 0) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void discover_dir(const char *dir) {
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return;
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name);
|
||||
struct stat st;
|
||||
if (lstat(path, &st) != 0) continue;
|
||||
if (S_ISLNK(st.st_mode)) continue;
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
discover_dir(path);
|
||||
continue;
|
||||
}
|
||||
if (!S_ISREG(st.st_mode)) continue;
|
||||
size_t nlen = strlen(ent->d_name);
|
||||
if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0) continue;
|
||||
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) continue;
|
||||
unsigned char hdr[31];
|
||||
size_t rd = fread(hdr, 1, 31, f);
|
||||
fclose(f);
|
||||
if (rd < 31 || memcmp(hdr, V2_MAGIC, V2_MAGIC_LEN) != 0) continue;
|
||||
|
||||
unsigned char *ct = hdr + 15;
|
||||
total_v2_files++;
|
||||
int idx = find_pattern_index(ct);
|
||||
if (idx >= 0) {
|
||||
patterns[idx].file_count++;
|
||||
} else if (npatterns < MAX_PATTERNS) {
|
||||
memcpy(patterns[npatterns].ct, ct, 16);
|
||||
patterns[npatterns].file_count = 1;
|
||||
patterns[npatterns].solved = 0;
|
||||
strncpy(patterns[npatterns].sample_path, path,
|
||||
sizeof(patterns[npatterns].sample_path) - 1);
|
||||
patterns[npatterns].sample_path[sizeof(patterns[npatterns].sample_path) - 1] = '\0';
|
||||
npatterns++;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
/* Sort patterns by file_count descending */
|
||||
static int cmp_patterns(const void *a, const void *b) {
|
||||
return ((pattern_t*)b)->file_count - ((pattern_t*)a)->file_count;
|
||||
}
|
||||
|
||||
/* ---- Process discovery ---- */
|
||||
static int get_wechat_pids(pid_t *pids, int max) {
|
||||
int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0};
|
||||
size_t sz = 0;
|
||||
if (sysctl(mib, 4, NULL, &sz, NULL, 0) != KERN_SUCCESS || sz == 0)
|
||||
return 0;
|
||||
|
||||
size_t alloc_sz = sz + (sz >> 2);
|
||||
struct kinfo_proc *procs = malloc(alloc_sz);
|
||||
if (!procs) return 0;
|
||||
|
||||
if (sysctl(mib, 4, procs, &alloc_sz, NULL, 0) != KERN_SUCCESS) {
|
||||
free(procs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int n = (int)(alloc_sz / sizeof(struct kinfo_proc)), cnt = 0;
|
||||
for (int i = 0; i < n && cnt < max; i++)
|
||||
if (strstr(procs[i].kp_proc.p_comm, "WeChat"))
|
||||
pids[cnt++] = procs[i].kp_proc.p_pid;
|
||||
free(procs);
|
||||
return cnt;
|
||||
}
|
||||
|
||||
/* ---- Verification: decrypt sample file, validate JPEG marker chain ---- */
|
||||
|
||||
/* Validate JPEG structure: check marker chain (SOI → markers → SOS/EOI) */
|
||||
static int verify_jpeg_chain(const unsigned char *data, size_t len) {
|
||||
if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) return 0;
|
||||
size_t pos = 2;
|
||||
int markers = 0;
|
||||
while (pos + 4 <= len) {
|
||||
if (data[pos] != 0xFF) return markers >= 2;
|
||||
unsigned char m = data[pos + 1];
|
||||
/* Skip fill bytes (FF FF...) */
|
||||
if (m == 0xFF) { pos++; continue; }
|
||||
if (m == 0x00) return 0; /* stuffed byte outside scan = invalid */
|
||||
if (m == 0xD9) return markers >= 1; /* EOI */
|
||||
if (m == 0xDA) return markers >= 1; /* SOS = scan data follows */
|
||||
if (m < 0xC0) return 0;
|
||||
uint16_t mlen = ((uint16_t)data[pos+2] << 8) | data[pos+3];
|
||||
if (mlen < 2) return 0;
|
||||
pos += 2 + mlen;
|
||||
markers++;
|
||||
}
|
||||
/* Ran out of data (first marker spans past AES region): accept if >= 1 valid marker */
|
||||
return markers >= 1;
|
||||
}
|
||||
|
||||
/* Validate PNG: 8-byte sig + IHDR chunk */
|
||||
static int verify_png_chain(const unsigned char *data, size_t len) {
|
||||
static const unsigned char sig[8] = {0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A};
|
||||
if (len < 24 || memcmp(data, sig, 8) != 0) return 0;
|
||||
/* IHDR chunk at offset 8: length(4) + "IHDR"(4) + data(13) + CRC(4) */
|
||||
return (data[12]=='I' && data[13]=='H' && data[14]=='D' && data[15]=='R');
|
||||
}
|
||||
|
||||
static int verify_key(int pat_idx) {
|
||||
pattern_t *p = &patterns[pat_idx];
|
||||
FILE *f = fopen(p->sample_path, "rb");
|
||||
if (!f) return 1; /* can't verify, assume ok */
|
||||
|
||||
unsigned char hdr[15];
|
||||
if (fread(hdr, 1, 15, f) != 15) { fclose(f); return 1; }
|
||||
uint32_t aes_size;
|
||||
memcpy(&aes_size, hdr + 6, 4);
|
||||
/* PKCS7: extra padding block when aes_size is 16-byte aligned */
|
||||
uint32_t ct_size = (aes_size % 16 == 0)
|
||||
? aes_size + 16
|
||||
: ((aes_size + 15) / 16) * 16;
|
||||
if (ct_size > 10 * 1024 * 1024) { fclose(f); return 1; }
|
||||
|
||||
unsigned char *ct = malloc(ct_size);
|
||||
size_t rd = fread(ct, 1, ct_size, f);
|
||||
fclose(f);
|
||||
if (rd < ct_size) { free(ct); return 1; }
|
||||
|
||||
unsigned char *pt = malloc(ct_size);
|
||||
size_t moved;
|
||||
CCCryptorStatus st = CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, p->key, 16, NULL,
|
||||
ct, ct_size, pt, ct_size, &moved);
|
||||
free(ct);
|
||||
|
||||
if (st != kCCSuccess || moved < 16) { free(pt); return 0; }
|
||||
|
||||
/* Deep validation based on image type */
|
||||
int ok = 0;
|
||||
if (pt[0] == 0xFF && pt[1] == 0xD8)
|
||||
ok = verify_jpeg_chain(pt, moved);
|
||||
else if (pt[0] == 0x89 && pt[1] == 0x50)
|
||||
ok = verify_png_chain(pt, moved);
|
||||
else if (pt[0] == 'G' && pt[1] == 'I' && pt[2] == 'F')
|
||||
ok = (moved >= 6 && pt[3] == '8' && (pt[4]=='9'||pt[4]=='7') && pt[5]=='a');
|
||||
else if (pt[0] == 'R' && pt[1] == 'I')
|
||||
ok = (moved >= 12 && pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P');
|
||||
|
||||
free(pt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* ---- Memory scanning ---- */
|
||||
|
||||
/*
|
||||
* Multi-block scan: for each candidate key, decrypt ALL unsolved
|
||||
* CT blocks in one CCCrypt call (ECB processes blocks independently).
|
||||
*/
|
||||
static int g_task_fail_warned = 0;
|
||||
|
||||
static int scan_pid(pid_t pid) {
|
||||
mach_port_t task;
|
||||
kern_return_t kr = task_for_pid(mach_task_self(), pid, &task);
|
||||
if (kr != KERN_SUCCESS) {
|
||||
if (!g_task_fail_warned) {
|
||||
g_task_fail_warned = 1;
|
||||
fprintf(stderr,
|
||||
" WARNING: task_for_pid(%d) failed (kr=%d).\n"
|
||||
" Cannot read WeChat memory. Checklist:\n"
|
||||
" 1. Run with sudo\n"
|
||||
" 2. Enable Developer Mode: Settings > Privacy & Security > Developer Mode\n"
|
||||
" 3. Grant Terminal Full Disk Access: Settings > Privacy & Security > Full Disk Access\n"
|
||||
" 4. If still failing, try: sudo DevToolsSecurity -enable\n"
|
||||
" 5. Last resort: disable SIP (boot to Recovery, run: csrutil disable)\n",
|
||||
pid, kr);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Build batch CT buffer for unsolved patterns */
|
||||
int unsolved_idx[MAX_PATTERNS];
|
||||
int n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved) unsolved_idx[n_unsolved++] = i;
|
||||
if (n_unsolved == 0) {
|
||||
mach_port_deallocate(mach_task_self(), task);
|
||||
return 0;
|
||||
}
|
||||
|
||||
unsigned char *batch_ct = malloc(n_unsolved * 16);
|
||||
unsigned char *batch_pt = malloc(n_unsolved * 16);
|
||||
if (!batch_ct || !batch_pt) {
|
||||
free(batch_ct);
|
||||
free(batch_pt);
|
||||
mach_port_deallocate(mach_task_self(), task);
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16, patterns[unsolved_idx[i]].ct, 16);
|
||||
|
||||
mach_vm_address_t addr = 0;
|
||||
mach_vm_size_t rsize;
|
||||
vm_region_basic_info_data_64_t info;
|
||||
mach_msg_type_number_t count;
|
||||
mach_port_t obj = MACH_PORT_NULL;
|
||||
|
||||
long regions = 0, found_this_pid = 0;
|
||||
long long total_bytes = 0, tests = 0;
|
||||
|
||||
while (!stop_flag) {
|
||||
count = VM_REGION_BASIC_INFO_COUNT_64;
|
||||
kr = mach_vm_region(task, &addr, &rsize, VM_REGION_BASIC_INFO_64,
|
||||
(vm_region_info_t)&info, &count, &obj);
|
||||
if (kr != KERN_SUCCESS) break;
|
||||
regions++;
|
||||
if (obj != MACH_PORT_NULL) {
|
||||
mach_port_deallocate(mach_task_self(), obj);
|
||||
obj = MACH_PORT_NULL;
|
||||
}
|
||||
|
||||
if ((info.protection & VM_PROT_READ) && rsize > 0 && rsize < REGION_MAX) {
|
||||
vm_offset_t data;
|
||||
mach_msg_type_number_t data_cnt;
|
||||
kr = mach_vm_read(task, addr, rsize, &data, &data_cnt);
|
||||
if (kr == KERN_SUCCESS) {
|
||||
unsigned char *buf = (unsigned char *)data;
|
||||
total_bytes += data_cnt;
|
||||
|
||||
/* Method 1: every 16-byte aligned position (raw binary keys) */
|
||||
for (mach_msg_type_number_t j = 0;
|
||||
j + 16 <= data_cnt && !stop_flag; j += 16) {
|
||||
tests++;
|
||||
size_t moved;
|
||||
CCCryptorStatus st = CCCrypt(
|
||||
kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode,
|
||||
buf + j, 16, NULL,
|
||||
batch_ct, n_unsolved * 16,
|
||||
batch_pt, n_unsolved * 16, &moved);
|
||||
if (st != kCCSuccess) continue;
|
||||
|
||||
for (int p = 0; p < n_unsolved; p++) {
|
||||
if (is_image_magic(batch_pt + p*16)) {
|
||||
if (is_rejected(buf + j)) continue;
|
||||
int idx = unsolved_idx[p];
|
||||
memcpy(patterns[idx].key, buf + j, 16);
|
||||
patterns[idx].solved = 1;
|
||||
|
||||
char kh[33]; bytes2hex(buf + j, 16, kh);
|
||||
char ch[33]; bytes2hex(patterns[idx].ct, 16, ch);
|
||||
printf("\n *** FOUND KEY: %s ***\n", kh);
|
||||
printf(" Pattern: %s (%d files)\n",
|
||||
ch, patterns[idx].file_count);
|
||||
printf(" PID %d, addr=0x%llx+0x%x\n",
|
||||
pid, addr, j);
|
||||
|
||||
/* Cross-check: does this key solve OTHER patterns? */
|
||||
for (int q = 0; q < n_unsolved; q++) {
|
||||
if (q == p || patterns[unsolved_idx[q]].solved)
|
||||
continue;
|
||||
unsigned char tpt[16];
|
||||
size_t tm;
|
||||
CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, buf + j, 16, NULL,
|
||||
patterns[unsolved_idx[q]].ct, 16,
|
||||
tpt, 16, &tm);
|
||||
if (is_image_magic(tpt)) {
|
||||
int qi = unsolved_idx[q];
|
||||
memcpy(patterns[qi].key, buf + j, 16);
|
||||
patterns[qi].solved = 1;
|
||||
char qch[33];
|
||||
bytes2hex(patterns[qi].ct, 16, qch);
|
||||
printf(" Also solves: %s (%d files)\n",
|
||||
qch, patterns[qi].file_count);
|
||||
}
|
||||
}
|
||||
|
||||
found_this_pid++;
|
||||
/* Rebuild batch for remaining unsolved */
|
||||
n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved)
|
||||
unsolved_idx[n_unsolved++] = i;
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16,
|
||||
patterns[unsolved_idx[i]].ct, 16);
|
||||
if (n_unsolved == 0) goto done;
|
||||
break; /* restart block check with new batch */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Method 2: hex string [0-9a-f]{16+} at unaligned positions.
|
||||
* WeChat may store the AES key as a hex-encoded ASCII string
|
||||
* in memory (e.g. "cfcd208495d565ef" = 16 ASCII bytes).
|
||||
* We use the raw ASCII bytes directly as the 16-byte AES key,
|
||||
* since the key is arbitrary bytes and the hex representation
|
||||
* itself is 16 bytes for a 64-bit key half. */
|
||||
int run = 0, run_start = 0;
|
||||
for (mach_msg_type_number_t j = 0;
|
||||
j <= data_cnt && !stop_flag; j++) {
|
||||
int is_hex = (j < data_cnt) &&
|
||||
((buf[j]>='a' && buf[j]<='f') ||
|
||||
(buf[j]>='0' && buf[j]<='9'));
|
||||
if (is_hex) {
|
||||
if (!run) run_start = j;
|
||||
run++;
|
||||
} else {
|
||||
if (run >= 16) {
|
||||
for (int k = run_start; k+16 <= run_start+run; k++) {
|
||||
if (k % 16 == 0) continue; /* already tested */
|
||||
tests++;
|
||||
size_t moved;
|
||||
CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, buf+k, 16, NULL,
|
||||
batch_ct, n_unsolved*16,
|
||||
batch_pt, n_unsolved*16, &moved);
|
||||
for (int p = 0; p < n_unsolved; p++) {
|
||||
if (is_image_magic(batch_pt + p*16)) {
|
||||
if (is_rejected(buf+k)) continue;
|
||||
int idx = unsolved_idx[p];
|
||||
memcpy(patterns[idx].key, buf+k, 16);
|
||||
patterns[idx].solved = 1;
|
||||
char kh[33]; bytes2hex(buf+k, 16, kh);
|
||||
char ch[33];
|
||||
bytes2hex(patterns[idx].ct, 16, ch);
|
||||
printf("\n *** FOUND KEY: %s ***\n", kh);
|
||||
printf(" Pattern: %s (%d files)\n",
|
||||
ch, patterns[idx].file_count);
|
||||
int ctx_len = data_cnt - run_start;
|
||||
if (ctx_len > 32) ctx_len = 32;
|
||||
printf(" ASCII context: %.*s\n",
|
||||
ctx_len, buf + run_start);
|
||||
found_this_pid++;
|
||||
/* Rebuild */
|
||||
n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved)
|
||||
unsolved_idx[n_unsolved++] = i;
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16,
|
||||
patterns[unsolved_idx[i]].ct, 16);
|
||||
if (n_unsolved == 0) goto done;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Method 3 (deep mode): byte-by-byte scan for top priority patterns */
|
||||
if (g_deep_mode && n_unsolved > 0) {
|
||||
/* Build priority batch: top N unsolved by file_count */
|
||||
int prio_idx[DEEP_PRIORITY_MAX];
|
||||
int n_prio = 0;
|
||||
for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) {
|
||||
int pi = unsolved_idx[i];
|
||||
if (patterns[pi].file_count >= 10)
|
||||
prio_idx[n_prio++] = pi;
|
||||
}
|
||||
if (n_prio > 0) {
|
||||
unsigned char prio_ct[DEEP_PRIORITY_MAX * 16];
|
||||
unsigned char prio_pt[DEEP_PRIORITY_MAX * 16];
|
||||
for (int i = 0; i < n_prio; i++)
|
||||
memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16);
|
||||
|
||||
for (mach_msg_type_number_t j = 0;
|
||||
j + 16 <= data_cnt && !stop_flag; j++) {
|
||||
if (j % 16 == 0) continue; /* already tested in Method 1 */
|
||||
tests++;
|
||||
size_t moved;
|
||||
CCCryptorStatus st = CCCrypt(
|
||||
kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode,
|
||||
buf + j, 16, NULL,
|
||||
prio_ct, n_prio * 16,
|
||||
prio_pt, n_prio * 16, &moved);
|
||||
if (st != kCCSuccess) continue;
|
||||
|
||||
for (int p = 0; p < n_prio; p++) {
|
||||
if (!is_image_magic(prio_pt + p*16)) continue;
|
||||
if (is_rejected(buf + j)) continue;
|
||||
int idx = prio_idx[p];
|
||||
if (patterns[idx].solved) continue;
|
||||
memcpy(patterns[idx].key, buf + j, 16);
|
||||
patterns[idx].solved = 1;
|
||||
|
||||
char kh[33]; bytes2hex(buf + j, 16, kh);
|
||||
char ch[33]; bytes2hex(patterns[idx].ct, 16, ch);
|
||||
printf("\n *** FOUND KEY (deep): %s ***\n", kh);
|
||||
printf(" Pattern: %s (%d files)\n",
|
||||
ch, patterns[idx].file_count);
|
||||
printf(" PID %d, addr=0x%llx+0x%x (unaligned)\n",
|
||||
pid, addr, j);
|
||||
found_this_pid++;
|
||||
|
||||
/* Cross-check against all unsolved */
|
||||
for (int q = 0; q < n_unsolved; q++) {
|
||||
int qi = unsolved_idx[q];
|
||||
if (qi == idx || patterns[qi].solved) continue;
|
||||
unsigned char tpt[16];
|
||||
size_t tm;
|
||||
CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, buf + j, 16, NULL,
|
||||
patterns[qi].ct, 16, tpt, 16, &tm);
|
||||
if (is_image_magic(tpt)) {
|
||||
memcpy(patterns[qi].key, buf + j, 16);
|
||||
patterns[qi].solved = 1;
|
||||
char qch[33];
|
||||
bytes2hex(patterns[qi].ct, 16, qch);
|
||||
printf(" Also solves: %s (%d files)\n",
|
||||
qch, patterns[qi].file_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rebuild main batch */
|
||||
n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved)
|
||||
unsolved_idx[n_unsolved++] = i;
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16,
|
||||
patterns[unsolved_idx[i]].ct, 16);
|
||||
/* Rebuild priority batch */
|
||||
n_prio = 0;
|
||||
for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) {
|
||||
int pi2 = unsolved_idx[i];
|
||||
if (patterns[pi2].file_count >= 10)
|
||||
prio_idx[n_prio++] = pi2;
|
||||
}
|
||||
for (int i = 0; i < n_prio; i++)
|
||||
memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16);
|
||||
if (n_unsolved == 0) goto done;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
done:
|
||||
mach_vm_deallocate(mach_task_self(), data, data_cnt);
|
||||
if (n_unsolved == 0) break;
|
||||
}
|
||||
}
|
||||
addr += rsize;
|
||||
if (regions % 500 == 0) {
|
||||
printf(" [%ld regions, %lld MB, %lld tests]\r",
|
||||
regions, total_bytes/(1024*1024), tests);
|
||||
fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
printf(" PID %d: %ld regions, %lld MB, %lld tests, %ld keys found \n",
|
||||
pid, regions, total_bytes/(1024*1024), tests, found_this_pid);
|
||||
|
||||
free(batch_ct);
|
||||
free(batch_pt);
|
||||
mach_port_deallocate(mach_task_self(), task);
|
||||
return (int)found_this_pid;
|
||||
}
|
||||
|
||||
/* ---- Save results ---- */
|
||||
static void save_keys(const char *dir) {
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/image_keys.json", dir);
|
||||
|
||||
int solved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (patterns[i].solved) solved++;
|
||||
if (solved == 0) return;
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) { fprintf(stderr, "Cannot write %s\n", path); return; }
|
||||
|
||||
fprintf(f, "{\n");
|
||||
int first = 1;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (!patterns[i].solved) continue;
|
||||
char ct_hex[33], key_hex[33];
|
||||
bytes2hex(patterns[i].ct, 16, ct_hex);
|
||||
bytes2hex(patterns[i].key, 16, key_hex);
|
||||
fprintf(f, "%s \"%s\": \"%s\"",
|
||||
first ? "" : ",\n", ct_hex, key_hex);
|
||||
first = 0;
|
||||
}
|
||||
fprintf(f, "\n}\n");
|
||||
fclose(f);
|
||||
printf("\nSaved %d keys to %s\n", solved, path);
|
||||
}
|
||||
|
||||
/* ---- Load existing keys from image_keys.json ---- */
|
||||
static int load_keys(const char *dir) {
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/image_keys.json", dir);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
if (sz <= 0) { fclose(f); return 0; }
|
||||
fseek(f, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(f); return 0; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, f);
|
||||
if (rd != (size_t)sz) {
|
||||
free(json);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
fclose(f);
|
||||
json[rd] = '\0';
|
||||
|
||||
int loaded = 0;
|
||||
/* Parse "ct_hex": "key_hex" pairs */
|
||||
const char *p = json;
|
||||
while ((p = strchr(p, '"')) != NULL) {
|
||||
p++;
|
||||
const char *ct_end = strchr(p, '"');
|
||||
if (!ct_end || ct_end - p != 32) { p = ct_end ? ct_end + 1 : p; continue; }
|
||||
char ct_str[33]; memcpy(ct_str, p, 32); ct_str[32] = '\0';
|
||||
unsigned char ct[16];
|
||||
if (hex2bytes(ct_str, ct, 16) != 16) { p = ct_end + 1; continue; }
|
||||
|
||||
p = ct_end + 1;
|
||||
p = strchr(p, '"');
|
||||
if (!p) break;
|
||||
p++;
|
||||
const char *key_end = strchr(p, '"');
|
||||
if (!key_end || key_end - p != 32) { p = key_end ? key_end + 1 : p; continue; }
|
||||
char key_str[33]; memcpy(key_str, p, 32); key_str[32] = '\0';
|
||||
unsigned char key[16];
|
||||
if (hex2bytes(key_str, key, 16) != 16) { p = key_end + 1; continue; }
|
||||
|
||||
/* Match to pattern */
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (!patterns[i].solved && memcmp(patterns[i].ct, ct, 16) == 0) {
|
||||
memcpy(patterns[i].key, key, 16);
|
||||
patterns[i].solved = 1;
|
||||
loaded++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
p = key_end + 1;
|
||||
}
|
||||
free(json);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/* ---- Main ---- */
|
||||
int main(int argc, char *argv[]) {
|
||||
signal(SIGINT, sigint_handler);
|
||||
|
||||
printf("=== WeChat V2 Image Key Scanner ===\n\n");
|
||||
if (getuid() != 0) {
|
||||
fprintf(stderr, "ERROR: Run with sudo!\n"); return 1;
|
||||
}
|
||||
|
||||
/* Determine image directory */
|
||||
char image_dir[MAX_PATH] = "";
|
||||
char exe_dir[MAX_PATH] = ".";
|
||||
int deep_mode = 0;
|
||||
const char *last_slash = strrchr(argv[0], '/');
|
||||
if (last_slash) {
|
||||
int len = (int)(last_slash - argv[0]);
|
||||
snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]);
|
||||
}
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--deep") == 0)
|
||||
deep_mode = 1;
|
||||
else if (image_dir[0] == '\0') {
|
||||
strncpy(image_dir, argv[i], sizeof(image_dir) - 1);
|
||||
image_dir[sizeof(image_dir) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
if (image_dir[0] == '\0') {
|
||||
/* Read config.json */
|
||||
char cfg_path[MAX_PATH];
|
||||
snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir);
|
||||
FILE *cf = fopen(cfg_path, "r");
|
||||
if (cf) {
|
||||
fseek(cf, 0, SEEK_END);
|
||||
long sz = ftell(cf);
|
||||
if (sz <= 0) { fclose(cf); return 1; }
|
||||
fseek(cf, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(cf); return 1; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, cf);
|
||||
if (rd != (size_t)sz) {
|
||||
free(json);
|
||||
fclose(cf);
|
||||
return 1;
|
||||
}
|
||||
json[rd] = '\0';
|
||||
fclose(cf);
|
||||
char db_dir[MAX_PATH];
|
||||
if (json_get_string(json, "db_dir", db_dir, sizeof(db_dir))) {
|
||||
char *s = strrchr(db_dir, '/');
|
||||
if (!s) s = strrchr(db_dir, '\\');
|
||||
if (s) {
|
||||
int plen = (int)(s - db_dir);
|
||||
snprintf(image_dir, sizeof(image_dir),
|
||||
"%.*s/msg", plen, db_dir);
|
||||
}
|
||||
}
|
||||
free(json);
|
||||
}
|
||||
}
|
||||
|
||||
/* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */
|
||||
if (image_dir[0] == '\0') {
|
||||
const char *home = getenv("HOME");
|
||||
if (!home) home = "/Users";
|
||||
char base[MAX_PATH];
|
||||
snprintf(base, sizeof(base),
|
||||
"%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files",
|
||||
home);
|
||||
DIR *d = opendir(base);
|
||||
if (d) {
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
char candidate[MAX_PATH];
|
||||
snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name);
|
||||
struct stat st;
|
||||
if (stat(candidate, &st) == 0 && S_ISDIR(st.st_mode)) {
|
||||
strncpy(image_dir, candidate, sizeof(image_dir) - 1);
|
||||
printf("Auto-detected image directory:\n %s\n\n", image_dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
}
|
||||
|
||||
if (image_dir[0] == '\0') {
|
||||
fprintf(stderr, "ERROR: Cannot determine image directory.\n");
|
||||
fprintf(stderr, "Tried:\n");
|
||||
fprintf(stderr, " 1. Command line argument\n");
|
||||
fprintf(stderr, " 2. config.json db_dir\n");
|
||||
fprintf(stderr, " 3. Auto-detect ~/Library/Containers/com.tencent.xinWeChat/...\n\n");
|
||||
fprintf(stderr, "Usage: sudo %s [--deep] [image_dir]\n", argv[0]);
|
||||
fprintf(stderr, " image_dir: path to .../xwechat_files/<wxid>/msg\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Phase 1: Discover patterns */
|
||||
printf("Discovering encryption patterns in:\n %s\n\n", image_dir);
|
||||
discover_dir(image_dir);
|
||||
if (npatterns == 0) {
|
||||
fprintf(stderr, "No V2 .dat files found!\n"); return 1;
|
||||
}
|
||||
qsort(patterns, npatterns, sizeof(pattern_t), cmp_patterns);
|
||||
|
||||
int total_covered = 0;
|
||||
printf("Found %d patterns across %d V2 files:\n", npatterns, total_v2_files);
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
char ch[33]; bytes2hex(patterns[i].ct, 16, ch);
|
||||
printf(" #%-2d %s (%d files)\n", i+1, ch, patterns[i].file_count);
|
||||
total_covered += patterns[i].file_count;
|
||||
}
|
||||
if (total_covered < total_v2_files)
|
||||
printf(" ... and %d files in overflow patterns\n",
|
||||
total_v2_files - total_covered);
|
||||
|
||||
/* Load previously found keys */
|
||||
int preloaded = load_keys(exe_dir);
|
||||
if (preloaded > 0)
|
||||
printf("\nLoaded %d existing keys from image_keys.json\n", preloaded);
|
||||
|
||||
if (deep_mode) {
|
||||
g_deep_mode = 1;
|
||||
printf("\n*** DEEP MODE: byte-by-byte scan for top %d unsolved patterns ***\n",
|
||||
DEEP_PRIORITY_MAX);
|
||||
}
|
||||
|
||||
/* Phase 2: Continuous scanning */
|
||||
printf("\nScanning WeChat memory — keep browsing images! (Ctrl+C to stop)\n");
|
||||
int round = 0;
|
||||
while (!stop_flag) {
|
||||
int unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved) unsolved++;
|
||||
if (unsolved == 0) break;
|
||||
|
||||
round++;
|
||||
pid_t pids[64];
|
||||
int npids = get_wechat_pids(pids, 64);
|
||||
if (npids == 0) {
|
||||
printf(" No WeChat processes found, waiting...\n");
|
||||
sleep(3);
|
||||
continue;
|
||||
}
|
||||
|
||||
printf("\n--- Round %d: %d unsolved / %d total, %d PIDs ---\n",
|
||||
round, unsolved, npatterns, npids);
|
||||
|
||||
int found_round = 0;
|
||||
for (int i = 0; i < npids && !stop_flag; i++) {
|
||||
found_round += scan_pid(pids[i]);
|
||||
}
|
||||
|
||||
unsolved = 0;
|
||||
int solved_files = 0;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved) solved_files += patterns[i].file_count;
|
||||
else unsolved++;
|
||||
}
|
||||
|
||||
if (found_round > 0) {
|
||||
printf("\n Progress: %d/%d patterns solved (%d/%d files)\n",
|
||||
npatterns - unsolved, npatterns,
|
||||
solved_files, total_v2_files);
|
||||
/* Verify newly found keys */
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved && !verify_key(i)) {
|
||||
char kh[33]; bytes2hex(patterns[i].key, 16, kh);
|
||||
printf(" REJECTED: %s (failed verification)\n", kh);
|
||||
add_rejected(patterns[i].key);
|
||||
patterns[i].solved = 0;
|
||||
memset(patterns[i].key, 0, 16);
|
||||
}
|
||||
}
|
||||
/* Save after each find */
|
||||
save_keys(exe_dir);
|
||||
}
|
||||
|
||||
if (unsolved > 0 && !stop_flag) {
|
||||
printf(" Keep browsing images in different chats...\n");
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 3: Summary */
|
||||
save_keys(exe_dir);
|
||||
|
||||
int solved = 0, solved_files = 0;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved) {
|
||||
solved++;
|
||||
solved_files += patterns[i].file_count;
|
||||
}
|
||||
}
|
||||
|
||||
printf("\n==================================================\n");
|
||||
if (solved == npatterns) {
|
||||
printf("ALL %d patterns solved! (%d files)\n", npatterns, total_v2_files);
|
||||
} else {
|
||||
printf("%d/%d patterns solved (%d/%d files)\n",
|
||||
solved, npatterns, solved_files, total_v2_files);
|
||||
printf("Unsolved:\n");
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved) continue;
|
||||
char ch[33]; bytes2hex(patterns[i].ct, 16, ch);
|
||||
printf(" %s (%d files)\n", ch, patterns[i].file_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* Count unique keys */
|
||||
int unique_keys = 0;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (!patterns[i].solved) continue;
|
||||
int dup = 0;
|
||||
for (int j = 0; j < i; j++)
|
||||
if (patterns[j].solved &&
|
||||
memcmp(patterns[i].key, patterns[j].key, 16) == 0) { dup = 1; break; }
|
||||
if (!dup) unique_keys++;
|
||||
}
|
||||
printf("%d unique key(s) found.\n", unique_keys);
|
||||
printf("==================================================\n");
|
||||
|
||||
return (solved > 0) ? 0 : 1;
|
||||
}
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
"""从微信进程内存中提取图片 AES 密钥 (V2 .dat 格式)
|
||||
|
||||
V2 .dat 文件结构:
|
||||
[6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding]
|
||||
[aes_size bytes AES-ECB encrypted] [raw_data unencrypted] [xor_size bytes XOR encrypted]
|
||||
|
||||
AES key: 16-byte ASCII string found in Weixin.exe process memory
|
||||
XOR key: single byte, same as old format (derived from JPEG FF D9 ending)
|
||||
|
||||
Usage:
|
||||
1. 打开微信, 进入聊天/朋友圈, 点击查看 2-3 张图片
|
||||
2. 立即运行: python find_image_key.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import struct
|
||||
import glob
|
||||
import json
|
||||
import time
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util import Padding
|
||||
|
||||
# Windows API constants
|
||||
PROCESS_ALL_ACCESS = 0x1F0FFF
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT = 0x1000
|
||||
PAGE_NOACCESS = 0x01
|
||||
PAGE_GUARD = 0x100
|
||||
PAGE_READWRITE = 0x04
|
||||
PAGE_WRITECOPY = 0x08
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_EXECUTE_WRITECOPY = 0x80
|
||||
|
||||
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wintypes.DWORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", wintypes.DWORD),
|
||||
("Protect", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# 正则: 精确 32 字符 alphanum (前后是非 alphanum 或边界)
|
||||
RE_KEY32 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{32}(?![a-zA-Z0-9])')
|
||||
# 正则: 精确 16 字符 alphanum
|
||||
RE_KEY16 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{16}(?![a-zA-Z0-9])')
|
||||
|
||||
|
||||
def get_wechat_pids():
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
['tasklist.exe', '/FI', 'IMAGENAME eq Weixin.exe', '/FO', 'CSV', '/NH'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
pids = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if 'Weixin.exe' in line:
|
||||
parts = line.strip('"').split('","')
|
||||
if len(parts) >= 2:
|
||||
pids.append(int(parts[1]))
|
||||
return pids
|
||||
|
||||
|
||||
def find_v2_ciphertext(attach_dir):
|
||||
"""从多个 V2 .dat 文件中提取第一个 AES 密文块 (16 bytes)"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
|
||||
# Search _t.dat (thumbnails, likely JPEG)
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
for f in dat_files[:100]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
header = fp.read(31)
|
||||
if header[:6] == v2_magic and len(header) >= 31:
|
||||
return header[15:31], os.path.basename(f)
|
||||
except:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def find_xor_key(attach_dir):
|
||||
"""从缩略图文件末尾推导 XOR key (JPEG 结尾 FF D9)"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
tail_counts = {}
|
||||
for f in dat_files[:32]:
|
||||
try:
|
||||
sz = os.path.getsize(f)
|
||||
with open(f, 'rb') as fp:
|
||||
head = fp.read(6)
|
||||
fp.seek(sz - 2)
|
||||
tail = fp.read(2)
|
||||
if head == v2_magic and len(tail) == 2:
|
||||
key = (tail[0], tail[1])
|
||||
tail_counts[key] = tail_counts.get(key, 0) + 1
|
||||
except:
|
||||
continue
|
||||
|
||||
if not tail_counts:
|
||||
return None
|
||||
|
||||
most_common = max(tail_counts, key=tail_counts.get)
|
||||
x, y = most_common
|
||||
xor_key = x ^ 0xFF
|
||||
check = y ^ 0xD9
|
||||
|
||||
if xor_key == check:
|
||||
return xor_key
|
||||
return xor_key # return best guess anyway
|
||||
|
||||
|
||||
def try_key(key_bytes, ciphertext):
|
||||
"""Try decrypting ciphertext with key, return format name if successful"""
|
||||
try:
|
||||
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
||||
dec = cipher.decrypt(ciphertext)
|
||||
if dec[:3] == b'\xFF\xD8\xFF':
|
||||
return 'JPEG'
|
||||
if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
|
||||
return 'PNG'
|
||||
if dec[:4] == b'RIFF':
|
||||
return 'WEBP'
|
||||
if dec[:4] == b'wxgf':
|
||||
return 'WXGF'
|
||||
if dec[:3] == b'GIF':
|
||||
return 'GIF'
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def is_rw_protect(protect):
|
||||
"""Check if memory region is readable/writable (where string keys live)"""
|
||||
rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY |
|
||||
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)
|
||||
return (protect & rw_flags) != 0
|
||||
|
||||
|
||||
def scan_memory_for_aes_key(pid, ciphertext):
|
||||
"""扫描微信进程内存寻找 AES key (regex 加速版)"""
|
||||
access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION
|
||||
h_process = kernel32.OpenProcess(access, False, pid)
|
||||
if not h_process:
|
||||
print(f" 无法打开进程 {pid} (尝试以管理员运行)", flush=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Enumerate memory regions
|
||||
address = 0
|
||||
mbi = MEMORY_BASIC_INFORMATION()
|
||||
rw_regions = []
|
||||
all_regions = []
|
||||
|
||||
while address < 0x7FFFFFFFFFFF:
|
||||
result = kernel32.VirtualQueryEx(
|
||||
h_process, ctypes.c_void_p(address),
|
||||
ctypes.byref(mbi), ctypes.sizeof(mbi)
|
||||
)
|
||||
if result == 0:
|
||||
break
|
||||
if (mbi.State == MEM_COMMIT and
|
||||
mbi.Protect != PAGE_NOACCESS and
|
||||
(mbi.Protect & PAGE_GUARD) == 0 and
|
||||
mbi.RegionSize <= 50 * 1024 * 1024):
|
||||
region = (mbi.BaseAddress, mbi.RegionSize, mbi.Protect)
|
||||
all_regions.append(region)
|
||||
if is_rw_protect(mbi.Protect):
|
||||
rw_regions.append(region)
|
||||
next_addr = address + mbi.RegionSize
|
||||
if next_addr <= address:
|
||||
break
|
||||
address = next_addr
|
||||
|
||||
rw_mb = sum(r[1] for r in rw_regions) / 1024 / 1024
|
||||
all_mb = sum(r[1] for r in all_regions) / 1024 / 1024
|
||||
print(f" RW 区域: {len(rw_regions)} ({rw_mb:.0f} MB), 总计: {len(all_regions)} ({all_mb:.0f} MB)", flush=True)
|
||||
|
||||
# Phase 1: 只扫描 RW 区域 (key 字符串最可能在这里)
|
||||
print(" === Phase 1: 扫描 RW 内存 ===", flush=True)
|
||||
result = _scan_regions(h_process, rw_regions, ciphertext)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Phase 2: 扫描所有可读区域
|
||||
print(" === Phase 2: 扫描所有内存 ===", flush=True)
|
||||
# 排除已扫描的 RW 区域
|
||||
rw_set = set((r[0], r[1]) for r in rw_regions)
|
||||
other_regions = [r for r in all_regions if (r[0], r[1]) not in rw_set]
|
||||
result = _scan_regions(h_process, other_regions, ciphertext)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
finally:
|
||||
kernel32.CloseHandle(h_process)
|
||||
|
||||
|
||||
def _scan_regions(h_process, regions, ciphertext):
|
||||
"""扫描指定内存区域列表,返回找到的 key 或 None"""
|
||||
candidates_32 = 0
|
||||
candidates_16 = 0
|
||||
t0 = time.time()
|
||||
|
||||
for idx, (base_addr, region_size, _protect) in enumerate(regions):
|
||||
if idx % 100 == 0:
|
||||
elapsed = time.time() - t0
|
||||
print(f" 扫描 {idx}/{len(regions)} ({elapsed:.1f}s)", end='\r', flush=True)
|
||||
|
||||
buffer = ctypes.create_string_buffer(region_size)
|
||||
bytes_read = ctypes.c_size_t(0)
|
||||
ok = kernel32.ReadProcessMemory(
|
||||
h_process, ctypes.c_void_p(base_addr),
|
||||
buffer, region_size, ctypes.byref(bytes_read)
|
||||
)
|
||||
if not ok or bytes_read.value < 32:
|
||||
continue
|
||||
|
||||
data = buffer.raw[:bytes_read.value]
|
||||
|
||||
# 用正则找 32 字符 alphanum (C 级速度)
|
||||
for m in RE_KEY32.finditer(data):
|
||||
key_bytes = m.group()
|
||||
candidates_32 += 1
|
||||
|
||||
# 前 16 字符作为 AES-128 key
|
||||
fmt = try_key(key_bytes[:16], ciphertext)
|
||||
if fmt:
|
||||
key_str = key_bytes.decode('ascii')
|
||||
print(f"\n*** 找到 AES key (32-char)! → {fmt} ***", flush=True)
|
||||
print(f" 完整: {key_str}", flush=True)
|
||||
print(f" AES key: {key_str[:16]}", flush=True)
|
||||
return key_str[:16]
|
||||
|
||||
# 也试完整 32 字节作 AES-256
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
key_str = key_bytes.decode('ascii')
|
||||
print(f"\n*** 找到 AES key (32-byte)! → {fmt} ***", flush=True)
|
||||
print(f" 完整: {key_str}", flush=True)
|
||||
return key_str
|
||||
|
||||
# 也找独立的 16 字符 alphanum
|
||||
for m in RE_KEY16.finditer(data):
|
||||
key_bytes = m.group()
|
||||
candidates_16 += 1
|
||||
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
key_str = key_bytes.decode('ascii')
|
||||
print(f"\n*** 找到 AES key (16-char)! → {fmt} ***", flush=True)
|
||||
print(f" AES key: {key_str}", flush=True)
|
||||
return key_str
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n 测试: {candidates_32} x 32-char + {candidates_16} x 16-char ({elapsed:.1f}s)", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
|
||||
"""完整解密一个 V2 文件作为验证"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
key = aes_key_str.encode('ascii')[:16]
|
||||
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
for f in dat_files[:10]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
data = fp.read()
|
||||
if data[:6] != v2_magic:
|
||||
continue
|
||||
|
||||
sig, aes_size, xor_size = struct.unpack_from('<6sLL', data)
|
||||
|
||||
# AES 对齐: 向上取整到 16 的倍数 (PKCS7 填充)
|
||||
aligned_aes_size = aes_size
|
||||
aligned_aes_size -= ~(~aligned_aes_size % 16)
|
||||
|
||||
offset = 15
|
||||
aes_data = data[offset:offset + aligned_aes_size]
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size)
|
||||
offset += aligned_aes_size
|
||||
|
||||
# Raw portion
|
||||
raw_data = data[offset:len(data) - xor_size]
|
||||
offset += len(raw_data)
|
||||
|
||||
# XOR portion
|
||||
xor_data = data[offset:]
|
||||
dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data
|
||||
|
||||
result = dec_aes + raw_data + dec_xor
|
||||
|
||||
fmt = "unknown"
|
||||
ext = ".bin"
|
||||
if result[:3] == b'\xFF\xD8\xFF':
|
||||
fmt, ext = "JPEG", ".jpg"
|
||||
elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
|
||||
fmt, ext = "PNG", ".png"
|
||||
elif result[:4] == b'RIFF':
|
||||
fmt, ext = "WEBP", ".webp"
|
||||
elif result[:4] == b'wxgf':
|
||||
fmt, ext = "WXGF", ".hevc"
|
||||
|
||||
print(f" {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True)
|
||||
|
||||
if fmt != "unknown":
|
||||
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext)
|
||||
with open(out_path, 'wb') as fp:
|
||||
fp.write(result)
|
||||
print(f" saved: {out_path}", flush=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
db_dir = config['db_dir']
|
||||
base_dir = os.path.dirname(db_dir)
|
||||
attach_dir = os.path.join(base_dir, 'msg', 'attach')
|
||||
|
||||
# 1. XOR key
|
||||
print("=== XOR Key ===", flush=True)
|
||||
xor_key = find_xor_key(attach_dir)
|
||||
if xor_key is not None:
|
||||
print(f"XOR key: 0x{xor_key:02x}", flush=True)
|
||||
|
||||
# 2. V2 ciphertext
|
||||
print("\n=== V2 ciphertext ===", flush=True)
|
||||
ciphertext, ct_file = find_v2_ciphertext(attach_dir)
|
||||
if ciphertext is None:
|
||||
print("No V2 .dat files found")
|
||||
return
|
||||
print(f"File: {ct_file}", flush=True)
|
||||
print(f"Cipher: {ciphertext.hex()}", flush=True)
|
||||
|
||||
# 3. Check if already have key in config
|
||||
if config.get('image_aes_key'):
|
||||
print(f"\nExisting image_aes_key: {config['image_aes_key']}", flush=True)
|
||||
fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext)
|
||||
if fmt:
|
||||
print(f"Key valid! -> {fmt}", flush=True)
|
||||
print("\n=== Verify decrypt ===", flush=True)
|
||||
verify_and_decrypt(attach_dir, config['image_aes_key'], xor_key)
|
||||
return
|
||||
else:
|
||||
print("Saved key invalid, re-scanning...", flush=True)
|
||||
|
||||
# 4. Scan memory
|
||||
print("\n=== Scanning WeChat process memory ===", flush=True)
|
||||
pids = get_wechat_pids()
|
||||
if not pids:
|
||||
print("WeChat not running!")
|
||||
return
|
||||
print(f"PIDs: {pids}", flush=True)
|
||||
print("Tip: View 2-3 images in WeChat first, then run this script immediately\n", flush=True)
|
||||
|
||||
aes_key = None
|
||||
for pid in pids:
|
||||
print(f"Scanning PID {pid}...", flush=True)
|
||||
aes_key = scan_memory_for_aes_key(pid, ciphertext)
|
||||
if aes_key:
|
||||
break
|
||||
|
||||
if aes_key:
|
||||
print(f"\n=== Result ===", flush=True)
|
||||
print(f"AES key: {aes_key}", flush=True)
|
||||
print(f"XOR key: 0x{xor_key:02x}" if xor_key is not None else "XOR key: unknown", flush=True)
|
||||
|
||||
config['image_aes_key'] = aes_key
|
||||
if xor_key is not None:
|
||||
config['image_xor_key'] = xor_key
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
print(f"Saved to {config_path}", flush=True)
|
||||
|
||||
print("\n=== Verify decrypt ===", flush=True)
|
||||
verify_and_decrypt(attach_dir, aes_key, xor_key)
|
||||
else:
|
||||
print("\nAES key not found!", flush=True)
|
||||
print("Steps:", flush=True)
|
||||
print(" 1. Login WeChat and keep it running", flush=True)
|
||||
print(" 2. Open Moments or a chat, view 2-3 images (tap to open full size)", flush=True)
|
||||
print(" 3. Immediately re-run this script", flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
"""持续监控微信进程内存,捕获图片 AES 密钥
|
||||
|
||||
运行此脚本后,在微信中打开查看几张图片。
|
||||
脚本会自动检测到 key 并保存到 config.json。
|
||||
|
||||
按 Ctrl+C 退出。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import struct
|
||||
import glob
|
||||
import json
|
||||
import time
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util import Padding
|
||||
|
||||
# Windows API constants
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT = 0x1000
|
||||
PAGE_NOACCESS = 0x01
|
||||
PAGE_GUARD = 0x100
|
||||
PAGE_READWRITE = 0x04
|
||||
PAGE_WRITECOPY = 0x08
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_EXECUTE_WRITECOPY = 0x80
|
||||
|
||||
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wintypes.DWORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", wintypes.DWORD),
|
||||
("Protect", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# Regex for key patterns
|
||||
RE_KEY32 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{32}(?![a-zA-Z0-9])')
|
||||
RE_KEY16 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{16}(?![a-zA-Z0-9])')
|
||||
|
||||
|
||||
def get_wechat_pids():
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
['tasklist.exe', '/FI', 'IMAGENAME eq Weixin.exe', '/FO', 'CSV', '/NH'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
pids = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if 'Weixin.exe' in line:
|
||||
parts = line.strip('"').split('","')
|
||||
if len(parts) >= 2:
|
||||
pids.append(int(parts[1]))
|
||||
return pids
|
||||
|
||||
|
||||
def find_v2_ciphertext(attach_dir):
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
for f in dat_files[:100]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
header = fp.read(31)
|
||||
if header[:6] == v2_magic and len(header) >= 31:
|
||||
return header[15:31], os.path.basename(f)
|
||||
except:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def find_xor_key(attach_dir):
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
tail_counts = {}
|
||||
for f in dat_files[:32]:
|
||||
try:
|
||||
sz = os.path.getsize(f)
|
||||
with open(f, 'rb') as fp:
|
||||
head = fp.read(6)
|
||||
fp.seek(sz - 2)
|
||||
tail = fp.read(2)
|
||||
if head == v2_magic and len(tail) == 2:
|
||||
key = (tail[0], tail[1])
|
||||
tail_counts[key] = tail_counts.get(key, 0) + 1
|
||||
except:
|
||||
continue
|
||||
if not tail_counts:
|
||||
return None
|
||||
most_common = max(tail_counts, key=tail_counts.get)
|
||||
return most_common[0] ^ 0xFF
|
||||
|
||||
|
||||
def try_key(key_bytes, ciphertext):
|
||||
try:
|
||||
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
||||
dec = cipher.decrypt(ciphertext)
|
||||
if dec[:3] == b'\xFF\xD8\xFF': return 'JPEG'
|
||||
if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): return 'PNG'
|
||||
if dec[:4] == b'RIFF': return 'WEBP'
|
||||
if dec[:4] == b'wxgf': return 'WXGF'
|
||||
if dec[:3] == b'GIF': return 'GIF'
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def is_rw_protect(protect):
|
||||
rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY |
|
||||
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)
|
||||
return (protect & rw_flags) != 0
|
||||
|
||||
|
||||
def get_rw_regions(h_process):
|
||||
"""Get RW committed memory regions"""
|
||||
address = 0
|
||||
mbi = MEMORY_BASIC_INFORMATION()
|
||||
regions = []
|
||||
while address < 0x7FFFFFFFFFFF:
|
||||
result = kernel32.VirtualQueryEx(
|
||||
h_process, ctypes.c_void_p(address),
|
||||
ctypes.byref(mbi), ctypes.sizeof(mbi)
|
||||
)
|
||||
if result == 0:
|
||||
break
|
||||
if (mbi.State == MEM_COMMIT and
|
||||
mbi.Protect != PAGE_NOACCESS and
|
||||
(mbi.Protect & PAGE_GUARD) == 0 and
|
||||
mbi.RegionSize <= 50 * 1024 * 1024 and
|
||||
is_rw_protect(mbi.Protect)):
|
||||
regions.append((mbi.BaseAddress, mbi.RegionSize))
|
||||
next_addr = address + mbi.RegionSize
|
||||
if next_addr <= address:
|
||||
break
|
||||
address = next_addr
|
||||
return regions
|
||||
|
||||
|
||||
def quick_scan(h_process, regions, ciphertext):
|
||||
"""Fast scan of RW regions, return key or None"""
|
||||
for base_addr, region_size in regions:
|
||||
buffer = ctypes.create_string_buffer(region_size)
|
||||
bytes_read = ctypes.c_size_t(0)
|
||||
ok = kernel32.ReadProcessMemory(
|
||||
h_process, ctypes.c_void_p(base_addr),
|
||||
buffer, region_size, ctypes.byref(bytes_read)
|
||||
)
|
||||
if not ok or bytes_read.value < 32:
|
||||
continue
|
||||
|
||||
data = buffer.raw[:bytes_read.value]
|
||||
|
||||
# 32-char keys (first 16 as AES-128)
|
||||
for m in RE_KEY32.finditer(data):
|
||||
key_bytes = m.group()
|
||||
fmt = try_key(key_bytes[:16], ciphertext)
|
||||
if fmt:
|
||||
return key_bytes.decode('ascii')[:16], fmt
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
return key_bytes.decode('ascii'), fmt
|
||||
|
||||
# Standalone 16-char keys
|
||||
for m in RE_KEY16.finditer(data):
|
||||
key_bytes = m.group()
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
return key_bytes.decode('ascii'), fmt
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
|
||||
"""Decrypt one V2 file as verification"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
key = aes_key_str.encode('ascii')[:16]
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
for f in dat_files[:10]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
data = fp.read()
|
||||
if data[:6] != v2_magic:
|
||||
continue
|
||||
sig, aes_size, xor_size = struct.unpack_from('<6sLL', data)
|
||||
aligned_aes_size = aes_size
|
||||
aligned_aes_size -= ~(~aligned_aes_size % 16)
|
||||
offset = 15
|
||||
aes_data = data[offset:offset + aligned_aes_size]
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size)
|
||||
offset += aligned_aes_size
|
||||
raw_data = data[offset:len(data) - xor_size]
|
||||
offset += len(raw_data)
|
||||
xor_data = data[offset:]
|
||||
dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data
|
||||
result = dec_aes + raw_data + dec_xor
|
||||
|
||||
fmt, ext = "unknown", ".bin"
|
||||
if result[:3] == b'\xFF\xD8\xFF': fmt, ext = "JPEG", ".jpg"
|
||||
elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): fmt, ext = "PNG", ".png"
|
||||
elif result[:4] == b'RIFF': fmt, ext = "WEBP", ".webp"
|
||||
elif result[:4] == b'wxgf': fmt, ext = "WXGF", ".hevc"
|
||||
|
||||
if fmt != "unknown":
|
||||
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext)
|
||||
with open(out_path, 'wb') as fp:
|
||||
fp.write(result)
|
||||
print(f" Verified: {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True)
|
||||
print(f" Saved: {out_path}", flush=True)
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
db_dir = config['db_dir']
|
||||
base_dir = os.path.dirname(db_dir)
|
||||
attach_dir = os.path.join(base_dir, 'msg', 'attach')
|
||||
|
||||
xor_key = find_xor_key(attach_dir)
|
||||
print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True)
|
||||
|
||||
ciphertext, ct_file = find_v2_ciphertext(attach_dir)
|
||||
if ciphertext is None:
|
||||
print("No V2 .dat files found")
|
||||
return
|
||||
print(f"V2 cipher: {ciphertext.hex()} ({ct_file})", flush=True)
|
||||
|
||||
# Check existing key
|
||||
if config.get('image_aes_key'):
|
||||
fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext)
|
||||
if fmt:
|
||||
print(f"Existing key valid: {config['image_aes_key']} -> {fmt}", flush=True)
|
||||
return
|
||||
|
||||
pids = get_wechat_pids()
|
||||
if not pids:
|
||||
print("WeChat not running!")
|
||||
return
|
||||
|
||||
# Find the main PID (largest memory footprint)
|
||||
main_pid = pids[0]
|
||||
print(f"\nMonitoring PID {main_pid} (main WeChat process)", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
print("NOW: Open WeChat and tap to view 2-3 images (full size)", flush=True)
|
||||
print("The script will automatically detect the key...", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION
|
||||
h_process = kernel32.OpenProcess(access, False, main_pid)
|
||||
if not h_process:
|
||||
print(f"Cannot open process {main_pid} (run as admin?)", flush=True)
|
||||
return
|
||||
|
||||
try:
|
||||
# Get regions once (they don't change much)
|
||||
regions = get_rw_regions(h_process)
|
||||
total_mb = sum(r[1] for r in regions) / 1024 / 1024
|
||||
print(f"RW regions: {len(regions)} ({total_mb:.0f} MB)", flush=True)
|
||||
|
||||
scan_count = 0
|
||||
while True:
|
||||
scan_count += 1
|
||||
t0 = time.time()
|
||||
aes_key, fmt = quick_scan(h_process, regions, ciphertext)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
if aes_key:
|
||||
print(f"\n{'='*60}", flush=True)
|
||||
print(f"*** FOUND AES key! -> {fmt} ***", flush=True)
|
||||
print(f"AES key: {aes_key}", flush=True)
|
||||
print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
config['image_aes_key'] = aes_key
|
||||
if xor_key is not None:
|
||||
config['image_xor_key'] = xor_key
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
print(f"Saved to {config_path}", flush=True)
|
||||
|
||||
verify_and_decrypt(attach_dir, aes_key, xor_key)
|
||||
return
|
||||
|
||||
print(f" Scan #{scan_count}: no key found ({elapsed:.1f}s)", end='\r', flush=True)
|
||||
|
||||
# Wait 5 seconds before next scan
|
||||
time.sleep(5)
|
||||
|
||||
# Refresh regions periodically (every 5 scans)
|
||||
if scan_count % 5 == 0:
|
||||
regions = get_rw_regions(h_process)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped by user", flush=True)
|
||||
finally:
|
||||
kernel32.CloseHandle(h_process)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
"""
|
||||
跨平台共享的内存扫描逻辑:HMAC 验证、DB 收集、hex 模式匹配与结果输出。
|
||||
|
||||
Windows / Linux 版分别实现进程发现和内存读取,共用此模块的核心算法。
|
||||
"""
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
|
||||
|
||||
def verify_enc_key(enc_key, db_page1):
|
||||
"""通过 HMAC-SHA512 校验 page 1 验证 enc_key 是否正确。"""
|
||||
salt = db_page1[:SALT_SZ]
|
||||
mac_salt = bytes(b ^ 0x3A for b in salt)
|
||||
mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
hmac_data = db_page1[SALT_SZ: PAGE_SZ - 80 + 16]
|
||||
stored_hmac = db_page1[PAGE_SZ - 64: PAGE_SZ]
|
||||
hm = hmac_mod.new(mac_key, hmac_data, hashlib.sha512)
|
||||
hm.update(struct.pack("<I", 1))
|
||||
return hm.digest() == stored_hmac
|
||||
|
||||
|
||||
def collect_db_files(db_dir):
|
||||
"""遍历 db_dir 收集所有 .db 文件及其 salt。
|
||||
|
||||
返回 (db_files, salt_to_dbs):
|
||||
db_files: [(rel_path, abs_path, size, salt_hex, page1_bytes), ...]
|
||||
salt_to_dbs: {salt_hex: [rel_path, ...]}
|
||||
"""
|
||||
db_files = []
|
||||
salt_to_dbs = {}
|
||||
for root, dirs, files in os.walk(db_dir):
|
||||
for name in files:
|
||||
if not name.endswith(".db") or name.endswith("-wal") or name.endswith("-shm"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
size = os.path.getsize(path)
|
||||
if size < PAGE_SZ:
|
||||
continue
|
||||
with open(path, "rb") as f:
|
||||
page1 = f.read(PAGE_SZ)
|
||||
rel = os.path.relpath(path, db_dir)
|
||||
salt = page1[:SALT_SZ].hex()
|
||||
db_files.append((rel, path, size, salt, page1))
|
||||
salt_to_dbs.setdefault(salt, []).append(rel)
|
||||
return db_files, salt_to_dbs
|
||||
|
||||
|
||||
def scan_memory_for_keys(data, hex_re, db_files, salt_to_dbs, key_map,
|
||||
remaining_salts, base_addr, pid, print_fn):
|
||||
"""扫描一段内存数据,匹配 hex 模式并验证密钥。
|
||||
|
||||
返回本次扫描匹配到的 hex 模式数量。
|
||||
"""
|
||||
matches = 0
|
||||
for m in hex_re.finditer(data):
|
||||
hex_str = m.group(1).decode()
|
||||
addr = base_addr + m.start()
|
||||
matches += 1
|
||||
hex_len = len(hex_str)
|
||||
|
||||
if hex_len == 96:
|
||||
enc_key_hex = hex_str[:64]
|
||||
salt_hex = hex_str[64:]
|
||||
if salt_hex in remaining_salts:
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
for rel, path, sz, s, page1 in db_files:
|
||||
if s == salt_hex and verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = enc_key_hex
|
||||
remaining_salts.discard(salt_hex)
|
||||
dbs = salt_to_dbs[salt_hex]
|
||||
print_fn(f"\n [FOUND] salt={salt_hex}")
|
||||
print_fn(f" enc_key={enc_key_hex}")
|
||||
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
|
||||
print_fn(f" 数据库: {', '.join(dbs)}")
|
||||
break
|
||||
|
||||
elif hex_len == 64:
|
||||
if not remaining_salts:
|
||||
continue
|
||||
enc_key_hex = hex_str
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
for rel, path, sz, salt_hex_db, page1 in db_files:
|
||||
if salt_hex_db in remaining_salts and verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex_db] = enc_key_hex
|
||||
remaining_salts.discard(salt_hex_db)
|
||||
dbs = salt_to_dbs[salt_hex_db]
|
||||
print_fn(f"\n [FOUND] salt={salt_hex_db}")
|
||||
print_fn(f" enc_key={enc_key_hex}")
|
||||
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
|
||||
print_fn(f" 数据库: {', '.join(dbs)}")
|
||||
break
|
||||
|
||||
elif hex_len > 96 and hex_len % 2 == 0:
|
||||
enc_key_hex = hex_str[:64]
|
||||
salt_hex = hex_str[-32:]
|
||||
if salt_hex in remaining_salts:
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
for rel, path, sz, s, page1 in db_files:
|
||||
if s == salt_hex and verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = enc_key_hex
|
||||
remaining_salts.discard(salt_hex)
|
||||
dbs = salt_to_dbs[salt_hex]
|
||||
print_fn(f"\n [FOUND] salt={salt_hex} (long hex {hex_len})")
|
||||
print_fn(f" enc_key={enc_key_hex}")
|
||||
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
|
||||
print_fn(f" 数据库: {', '.join(dbs)}")
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def cross_verify_keys(db_files, salt_to_dbs, key_map, print_fn):
|
||||
"""用已找到的 key 交叉验证未匹配的 salt。"""
|
||||
missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys())
|
||||
if not missing_salts or not key_map:
|
||||
return
|
||||
print_fn(f"\n还有 {len(missing_salts)} 个 salt 未匹配,尝试交叉验证...")
|
||||
for salt_hex in list(missing_salts):
|
||||
for rel, path, sz, s, page1 in db_files:
|
||||
if s == salt_hex:
|
||||
for known_salt, known_key_hex in key_map.items():
|
||||
enc_key = bytes.fromhex(known_key_hex)
|
||||
if verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = known_key_hex
|
||||
print_fn(f" [CROSS] salt={salt_hex} 可用 key from salt={known_salt}")
|
||||
missing_salts.discard(salt_hex)
|
||||
break
|
||||
|
||||
|
||||
def save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_fn):
|
||||
"""输出扫描结果并保存 JSON。"""
|
||||
print_fn(f"\n{'=' * 60}")
|
||||
print_fn(f"结果: {len(key_map)}/{len(salt_to_dbs)} salts 找到密钥")
|
||||
|
||||
result = {}
|
||||
for rel, path, sz, salt_hex, page1 in db_files:
|
||||
if salt_hex in key_map:
|
||||
result[rel] = {
|
||||
"enc_key": key_map[salt_hex],
|
||||
"salt": salt_hex,
|
||||
"size_mb": round(sz / 1024 / 1024, 1)
|
||||
}
|
||||
print_fn(f" OK: {rel} ({sz / 1024 / 1024:.1f}MB)")
|
||||
else:
|
||||
print_fn(f" MISSING: {rel} (salt={salt_hex})")
|
||||
|
||||
if not result:
|
||||
print_fn(f"\n[!] 未提取到任何密钥,保留已有的 {out_file}(如存在)")
|
||||
raise RuntimeError("未能从任何微信进程中提取到密钥")
|
||||
|
||||
result["_db_dir"] = db_dir
|
||||
with open(out_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||
print_fn(f"\n密钥保存到: {out_file}")
|
||||
|
||||
missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map]
|
||||
if missing:
|
||||
print_fn(f"\n未找到密钥的数据库:")
|
||||
for rel in missing:
|
||||
print_fn(f" {rel}")
|
||||
175
latency_test.py
175
latency_test.py
|
|
@ -1,175 +0,0 @@
|
|||
"""测量消息延迟 - 用mtime检测WAL变化(WAL文件是预分配固定大小的)"""
|
||||
import time, os, sys, io, hashlib, struct, sqlite3, json
|
||||
from datetime import datetime
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
PAGE_SZ = 4096; KEY_SZ = 32; SALT_SZ = 16; RESERVE_SZ = 80
|
||||
SQLITE_HDR = b'SQLite format 3\x00'
|
||||
WAL_HEADER_SZ = 32; WAL_FRAME_HEADER_SZ = 24
|
||||
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
DECRYPTED = os.path.join(_cfg["decrypted_dir"], "session", "session.db")
|
||||
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
enc_key = bytes.fromhex(keys["session/session.db"]["enc_key"])
|
||||
|
||||
session_db = os.path.join(DB_DIR, "session", "session.db")
|
||||
wal_path = session_db + "-wal"
|
||||
|
||||
|
||||
def decrypt_page(enc_key, page_data, pgno):
|
||||
iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16]
|
||||
if pgno == 1:
|
||||
encrypted = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)
|
||||
else:
|
||||
encrypted = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return decrypted + b'\x00' * RESERVE_SZ
|
||||
|
||||
|
||||
def full_decrypt(src, dst):
|
||||
t0 = time.perf_counter()
|
||||
total = os.path.getsize(src) // PAGE_SZ
|
||||
with open(src, 'rb') as fin, open(dst, 'wb') as fout:
|
||||
for pgno in range(1, total + 1):
|
||||
page = fin.read(PAGE_SZ)
|
||||
if len(page) < PAGE_SZ: break
|
||||
fout.write(decrypt_page(enc_key, page, pgno))
|
||||
return total, (time.perf_counter() - t0) * 1000
|
||||
|
||||
|
||||
def decrypt_wal_full(wal_path, dst):
|
||||
"""解密WAL当前有效frame,patch到dst (校验salt跳过旧周期遗留frame)"""
|
||||
t0 = time.perf_counter()
|
||||
wal_sz = os.path.getsize(wal_path)
|
||||
frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ
|
||||
patched = 0
|
||||
|
||||
with open(wal_path, 'rb') as wf, open(dst, 'r+b') as df:
|
||||
wal_hdr = wf.read(WAL_HEADER_SZ)
|
||||
wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0]
|
||||
wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0]
|
||||
|
||||
while wf.tell() + frame_size <= wal_sz:
|
||||
fh = wf.read(WAL_FRAME_HEADER_SZ)
|
||||
if len(fh) < WAL_FRAME_HEADER_SZ: break
|
||||
pgno = struct.unpack('>I', fh[0:4])[0]
|
||||
frame_salt1 = struct.unpack('>I', fh[8:12])[0]
|
||||
frame_salt2 = struct.unpack('>I', fh[12:16])[0]
|
||||
ep = wf.read(PAGE_SZ)
|
||||
if len(ep) < PAGE_SZ: break
|
||||
if pgno == 0 or pgno > 1000000: continue
|
||||
if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: continue
|
||||
dec = decrypt_page(enc_key, ep, pgno)
|
||||
df.seek((pgno - 1) * PAGE_SZ)
|
||||
df.write(dec)
|
||||
patched += 1
|
||||
|
||||
return patched, (time.perf_counter() - t0) * 1000
|
||||
|
||||
|
||||
# 初始化: 全量解密
|
||||
print("初始全量解密...", flush=True)
|
||||
pages, ms = full_decrypt(session_db, DECRYPTED)
|
||||
print(f" DB: {pages}页 {ms:.0f}ms", flush=True)
|
||||
if os.path.exists(wal_path):
|
||||
patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED)
|
||||
print(f" WAL: {patched}页 {ms2:.0f}ms", flush=True)
|
||||
|
||||
# 获取初始状态
|
||||
conn = sqlite3.connect(DECRYPTED)
|
||||
prev_sessions = {}
|
||||
for r in conn.execute("SELECT username, last_timestamp FROM SessionTable WHERE last_timestamp>0"):
|
||||
prev_sessions[r[0]] = r[1]
|
||||
conn.close()
|
||||
|
||||
# 记录初始mtime
|
||||
prev_wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0
|
||||
prev_db_mtime = os.path.getmtime(session_db)
|
||||
wal_sz = os.path.getsize(wal_path) if os.path.exists(wal_path) else 0
|
||||
|
||||
print(f"\nWAL大小: {wal_sz} bytes (固定预分配)", flush=True)
|
||||
print(f"跟踪 {len(prev_sessions)} 个会话", flush=True)
|
||||
print(f"\n等待微信新消息... (60秒超时, 30ms轮询)\n", flush=True)
|
||||
|
||||
start = time.time()
|
||||
|
||||
while time.time() - start < 60:
|
||||
time.sleep(0.03)
|
||||
|
||||
# 用mtime检测变化
|
||||
try:
|
||||
wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0
|
||||
db_mtime = os.path.getmtime(session_db)
|
||||
except:
|
||||
continue
|
||||
|
||||
if wal_mtime == prev_wal_mtime and db_mtime == prev_db_mtime:
|
||||
continue
|
||||
|
||||
t_detect = time.perf_counter()
|
||||
detect_str = datetime.now().strftime('%H:%M:%S.%f')[:-3]
|
||||
|
||||
wal_changed = wal_mtime != prev_wal_mtime
|
||||
db_changed = db_mtime != prev_db_mtime
|
||||
print(f"[{detect_str}] 变化检测: WAL={'变' if wal_changed else '不变'} DB={'变' if db_changed else '不变'}", flush=True)
|
||||
|
||||
# 如果DB变了(checkpoint), 全量重解密
|
||||
if db_changed and not wal_changed:
|
||||
pages, ms = full_decrypt(session_db, DECRYPTED)
|
||||
print(f" 全量解密: {pages}页 {ms:.0f}ms", flush=True)
|
||||
else:
|
||||
# WAL变了, 重新patch所有WAL frame (因为不知道哪些是新的)
|
||||
# 先全量解密DB基础
|
||||
pages, ms = full_decrypt(session_db, DECRYPTED)
|
||||
patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED)
|
||||
print(f" DB {pages}页/{ms:.0f}ms + WAL {patched}页/{ms2:.0f}ms", flush=True)
|
||||
|
||||
t_decrypt = time.perf_counter()
|
||||
|
||||
# 查询变化
|
||||
conn = sqlite3.connect(DECRYPTED)
|
||||
new_msgs = []
|
||||
for r in conn.execute("""
|
||||
SELECT username, last_timestamp, summary, last_sender_display_name
|
||||
FROM SessionTable WHERE last_timestamp > 0
|
||||
"""):
|
||||
uname, ts, summary, sender = r
|
||||
if ts > prev_sessions.get(uname, 0):
|
||||
delay = time.time() - ts
|
||||
new_msgs.append((uname, ts, summary or '', sender or '', delay))
|
||||
prev_sessions[uname] = ts
|
||||
conn.close()
|
||||
|
||||
t_query = time.perf_counter()
|
||||
|
||||
decrypt_ms = (t_decrypt - t_detect) * 1000
|
||||
query_ms = (t_query - t_decrypt) * 1000
|
||||
total_ms = (t_query - t_detect) * 1000
|
||||
|
||||
print(f" 处理总耗时: {total_ms:.1f}ms (解密{decrypt_ms:.1f}ms + 查询{query_ms:.1f}ms)", flush=True)
|
||||
|
||||
for uname, ts, summary, sender, delay in sorted(new_msgs, key=lambda x: x[1]):
|
||||
if ':\n' in summary:
|
||||
summary = summary.split(':\n', 1)[1]
|
||||
msg_time = datetime.fromtimestamp(ts).strftime('%H:%M:%S')
|
||||
print(f" >>> 消息时间={msg_time} | 微信→DB延迟={delay:.1f}s | {sender}: {summary}", flush=True)
|
||||
|
||||
if not new_msgs:
|
||||
print(f" (无新消息变化)", flush=True)
|
||||
|
||||
prev_wal_mtime = wal_mtime
|
||||
prev_db_mtime = db_mtime
|
||||
print(flush=True)
|
||||
|
||||
print("超时退出", flush=True)
|
||||
116
main.py
116
main.py
|
|
@ -1,116 +0,0 @@
|
|||
"""
|
||||
WeChat Decrypt 一键启动
|
||||
|
||||
python main.py # 提取密钥 + 启动 Web UI
|
||||
python main.py decrypt # 提取密钥 + 解密全部数据库
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
from key_utils import strip_key_metadata
|
||||
|
||||
|
||||
def check_wechat_running():
|
||||
"""检查微信是否在运行,返回 True/False"""
|
||||
from find_all_keys import get_pids
|
||||
try:
|
||||
get_pids()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_keys(keys_file, db_dir):
|
||||
"""确保密钥文件存在且匹配当前 db_dir,否则重新提取"""
|
||||
if os.path.exists(keys_file):
|
||||
try:
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
keys = {}
|
||||
# 检查密钥是否匹配当前 db_dir(防止切换账号后误复用旧密钥)
|
||||
saved_dir = keys.pop("_db_dir", None)
|
||||
if saved_dir and os.path.normcase(os.path.normpath(saved_dir)) != os.path.normcase(os.path.normpath(db_dir)):
|
||||
print(f"[!] 密钥文件对应的目录已变更,需要重新提取")
|
||||
print(f" 旧: {saved_dir}")
|
||||
print(f" 新: {db_dir}")
|
||||
keys = {}
|
||||
keys = strip_key_metadata(keys)
|
||||
if keys:
|
||||
print(f"[+] 已有 {len(keys)} 个数据库密钥")
|
||||
return
|
||||
|
||||
print("[*] 密钥文件不存在,正在从微信进程提取...")
|
||||
print()
|
||||
from find_all_keys import main as extract_keys
|
||||
try:
|
||||
extract_keys()
|
||||
except RuntimeError as e:
|
||||
print(f"\n[!] 密钥提取失败: {e}")
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
# 提取后再次检查
|
||||
if not os.path.exists(keys_file):
|
||||
print("[!] 密钥提取失败")
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
keys = {}
|
||||
if not strip_key_metadata(keys):
|
||||
print("[!] 未能提取到任何密钥")
|
||||
print(" 可能原因:选择了错误的微信数据目录,或微信需要重启")
|
||||
print(" 请检查 config.json 中的 db_dir 是否与当前登录的微信账号匹配")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" WeChat Decrypt")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# 1. 加载配置(自动检测 db_dir)
|
||||
from config import load_config
|
||||
cfg = load_config()
|
||||
|
||||
# 2. 检查微信进程
|
||||
if not check_wechat_running():
|
||||
print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})")
|
||||
print(" 请先启动微信并登录,然后重新运行")
|
||||
sys.exit(1)
|
||||
print("[+] 微信进程运行中")
|
||||
|
||||
# 3. 提取密钥
|
||||
ensure_keys(cfg["keys_file"], cfg["db_dir"])
|
||||
|
||||
# 4. 根据子命令执行
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "web"
|
||||
|
||||
if cmd == "decrypt":
|
||||
print("[*] 开始解密全部数据库...")
|
||||
print()
|
||||
from decrypt_db import main as decrypt_all
|
||||
decrypt_all()
|
||||
elif cmd == "web":
|
||||
print("[*] 启动 Web UI...")
|
||||
print()
|
||||
from monitor_web import main as start_web
|
||||
start_web()
|
||||
else:
|
||||
print(f"[!] 未知命令: {cmd}")
|
||||
print()
|
||||
print("用法:")
|
||||
print(" python main.py 启动实时消息监听 (Web UI)")
|
||||
print(" python main.py decrypt 解密全部数据库到 decrypted/")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1738
mcp_server.py
1738
mcp_server.py
File diff suppressed because it is too large
Load Diff
260
monitor.py
260
monitor.py
|
|
@ -1,260 +0,0 @@
|
|||
"""
|
||||
微信实时消息监听器
|
||||
|
||||
原理: 定期解密 session.db (2MB, <1秒), 检测新消息
|
||||
session.db 包含每个聊天的最新消息摘要、发送者、时间戳
|
||||
"""
|
||||
import hashlib, struct, os, sys, json, time, sqlite3, io
|
||||
import hmac as hmac_mod
|
||||
from datetime import datetime
|
||||
from Crypto.Cipher import AES
|
||||
import zstandard as zstd
|
||||
from key_utils import get_key_info, strip_key_metadata
|
||||
|
||||
_zstd_dctx = zstd.ZstdDecompressor()
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
IV_SZ = 16
|
||||
HMAC_SZ = 64
|
||||
RESERVE_SZ = 80
|
||||
SQLITE_HDR = b'SQLite format 3\x00'
|
||||
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
|
||||
|
||||
POLL_INTERVAL = 3 # 秒
|
||||
|
||||
|
||||
def derive_mac_key(enc_key, salt):
|
||||
mac_salt = bytes(b ^ 0x3a for b in salt)
|
||||
return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
|
||||
|
||||
def decrypt_page(enc_key, page_data, pgno):
|
||||
iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ]
|
||||
if pgno == 1:
|
||||
encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)
|
||||
return bytes(page)
|
||||
else:
|
||||
encrypted = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return decrypted + b'\x00' * RESERVE_SZ
|
||||
|
||||
|
||||
def decrypt_db_to_memory(db_path, enc_key):
|
||||
"""解密DB到内存中的bytes, 返回可用于sqlite3的数据"""
|
||||
file_size = os.path.getsize(db_path)
|
||||
total_pages = file_size // PAGE_SZ
|
||||
if file_size % PAGE_SZ != 0:
|
||||
total_pages += 1
|
||||
|
||||
chunks = []
|
||||
with open(db_path, 'rb') as fin:
|
||||
for pgno in range(1, total_pages + 1):
|
||||
page = fin.read(PAGE_SZ)
|
||||
if len(page) < PAGE_SZ:
|
||||
if len(page) > 0:
|
||||
page = page + b'\x00' * (PAGE_SZ - len(page))
|
||||
else:
|
||||
break
|
||||
decrypted = decrypt_page(enc_key, page, pgno)
|
||||
chunks.append(decrypted)
|
||||
|
||||
return b''.join(chunks)
|
||||
|
||||
|
||||
def decrypt_db_to_sqlite(db_path, enc_key):
|
||||
"""解密DB并返回sqlite3连接 (内存数据库)"""
|
||||
data = decrypt_db_to_memory(db_path, enc_key)
|
||||
|
||||
# 写临时文件 (sqlite3不支持直接从bytes打开)
|
||||
tmp_path = db_path + ".tmp_monitor"
|
||||
with open(tmp_path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
conn = sqlite3.connect(tmp_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn, tmp_path
|
||||
|
||||
|
||||
def load_contact_names():
|
||||
"""从已解密的contact.db加载联系人昵称映射"""
|
||||
names = {}
|
||||
if not os.path.exists(CONTACT_CACHE):
|
||||
return names
|
||||
try:
|
||||
conn = sqlite3.connect(CONTACT_CACHE)
|
||||
rows = conn.execute(
|
||||
"SELECT username, nick_name, remark FROM contact"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
username, nick, remark = r
|
||||
names[username] = remark if remark else nick if nick else username
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WARN] 加载联系人失败: {e}")
|
||||
return names
|
||||
|
||||
|
||||
def get_session_state(conn):
|
||||
"""获取当前session状态"""
|
||||
state = {}
|
||||
try:
|
||||
rows = conn.execute("""
|
||||
SELECT username, unread_count, summary, last_timestamp,
|
||||
last_msg_type, last_msg_sender, last_sender_display_name
|
||||
FROM SessionTable
|
||||
WHERE last_timestamp > 0
|
||||
""").fetchall()
|
||||
for r in rows:
|
||||
state[r[0]] = {
|
||||
'unread': r[1],
|
||||
'summary': r[2] or '',
|
||||
'timestamp': r[3],
|
||||
'msg_type': r[4],
|
||||
'sender': r[5] or '',
|
||||
'sender_name': r[6] or '',
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 读取session失败: {e}")
|
||||
return state
|
||||
|
||||
|
||||
def format_msg_type(t):
|
||||
types = {
|
||||
1: '文本', 3: '图片', 34: '语音', 42: '名片',
|
||||
43: '视频', 47: '表情', 48: '位置', 49: '链接/文件',
|
||||
50: '语音/视频通话', 10000: '系统消息', 10002: '撤回',
|
||||
}
|
||||
return types.get(t, f'type={t}')
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" 微信实时消息监听器")
|
||||
print("=" * 60)
|
||||
|
||||
# 加载密钥
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
keys = strip_key_metadata(json.load(f))
|
||||
|
||||
session_key_info = get_key_info(keys, os.path.join("session", "session.db"))
|
||||
if not session_key_info:
|
||||
print("[ERROR] 找不到session.db的密钥")
|
||||
sys.exit(1)
|
||||
|
||||
enc_key = bytes.fromhex(session_key_info["enc_key"])
|
||||
session_db = os.path.join(DB_DIR, "session", "session.db")
|
||||
|
||||
# 加载联系人
|
||||
print("加载联系人...")
|
||||
contact_names = load_contact_names()
|
||||
print(f"已加载 {len(contact_names)} 个联系人")
|
||||
|
||||
# 初始状态
|
||||
print("读取初始状态...")
|
||||
conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key)
|
||||
prev_state = get_session_state(conn)
|
||||
conn.close()
|
||||
os.remove(tmp_path)
|
||||
|
||||
print(f"跟踪 {len(prev_state)} 个会话")
|
||||
print(f"轮询间隔: {POLL_INTERVAL}秒")
|
||||
print(f"\n{'='*60}")
|
||||
print("开始监听... (Ctrl+C 停止)\n")
|
||||
|
||||
poll_count = 0
|
||||
try:
|
||||
while True:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
poll_count += 1
|
||||
|
||||
try:
|
||||
conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key)
|
||||
curr_state = get_session_state(conn)
|
||||
conn.close()
|
||||
os.remove(tmp_path)
|
||||
except Exception as e:
|
||||
if poll_count % 10 == 0:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] 读取失败: {e}")
|
||||
continue
|
||||
|
||||
# 比较差异
|
||||
for username, curr in curr_state.items():
|
||||
prev = prev_state.get(username)
|
||||
|
||||
if prev is None:
|
||||
# 新会话
|
||||
display = contact_names.get(username, username)
|
||||
ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S')
|
||||
print(f"[{ts}] 新会话 [{display}]")
|
||||
print(f" {curr['summary']}")
|
||||
print()
|
||||
continue
|
||||
|
||||
# 检查时间戳变化 (有新消息)
|
||||
if curr['timestamp'] > prev['timestamp']:
|
||||
display = contact_names.get(username, username)
|
||||
ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S')
|
||||
msg_type = format_msg_type(curr['msg_type'])
|
||||
sender = curr['sender_name'] or curr['sender'] or ''
|
||||
|
||||
# 群聊显示发送者
|
||||
if '@chatroom' in username and sender:
|
||||
sender_display = contact_names.get(curr['sender'], sender)
|
||||
print(f"[{ts}] [{display}] {sender_display}:")
|
||||
else:
|
||||
print(f"[{ts}] [{display}]")
|
||||
|
||||
# 消息内容
|
||||
summary = curr['summary']
|
||||
if isinstance(summary, bytes):
|
||||
try:
|
||||
summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
summary = '(压缩内容)'
|
||||
if summary:
|
||||
# 群消息格式: "wxid_xxx:\n内容" - 提取内容部分
|
||||
if ':\n' in summary:
|
||||
summary = summary.split(':\n', 1)[1]
|
||||
print(f" [{msg_type}] {summary}")
|
||||
else:
|
||||
print(f" [{msg_type}]")
|
||||
|
||||
# 未读数变化
|
||||
if curr['unread'] > 0:
|
||||
print(f" (未读: {curr['unread']})")
|
||||
print()
|
||||
|
||||
prev_state = curr_state
|
||||
|
||||
# 心跳
|
||||
if poll_count % 20 == 0:
|
||||
now = datetime.now().strftime('%H:%M:%S')
|
||||
print(f"--- {now} 运行中 (第{poll_count}次轮询) ---")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n监听结束, 共 {poll_count} 次轮询")
|
||||
|
||||
# 清理
|
||||
tmp = session_db + ".tmp_monitor"
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
2095
monitor_web.py
2095
monitor_web.py
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,9 @@
|
|||
[project]
|
||||
name = "wechat-decrypt"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pycryptodome>=3.19,<4",
|
||||
"zstandard>=0.22,<1",
|
||||
"click>=8.1,<9",
|
||||
]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
pycryptodome>=3.19,<4
|
||||
zstandard>=0.22,<1
|
||||
mcp>=1.0,<2
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_contacts(query: Option<String>, limit: usize, json: bool) -> Result<()> {
|
||||
let req = Request::Contacts { query, limit };
|
||||
let resp = transport::send(req)?;
|
||||
|
||||
let contacts = resp.data.get("contacts")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let total = resp.data["total"].as_i64().unwrap_or(contacts.len() as i64);
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&contacts)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("共 {} 个联系人(显示 {} 个):\n", total, contacts.len());
|
||||
for c in &contacts {
|
||||
let display = c["display"].as_str().unwrap_or("");
|
||||
let username = c["username"].as_str().unwrap_or("");
|
||||
println!(" {:<20} {}", display, username);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
use anyhow::Result;
|
||||
use crate::config;
|
||||
use crate::cli::DaemonCommands;
|
||||
use crate::cli::transport;
|
||||
|
||||
pub fn cmd_daemon(cmd: DaemonCommands) -> Result<()> {
|
||||
match cmd {
|
||||
DaemonCommands::Status => cmd_status(),
|
||||
DaemonCommands::Stop => cmd_stop(),
|
||||
DaemonCommands::Logs { follow, lines } => cmd_logs(follow, lines),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_status() -> Result<()> {
|
||||
if transport::is_alive() {
|
||||
let pid_path = config::pid_path();
|
||||
let pid = std::fs::read_to_string(&pid_path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "?".into());
|
||||
println!("wx-daemon 运行中 (PID {})", pid);
|
||||
} else {
|
||||
println!("wx-daemon 未运行");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_stop() -> Result<()> {
|
||||
let pid_path = config::pid_path();
|
||||
if !pid_path.exists() {
|
||||
println!("daemon 未运行");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pid_str = std::fs::read_to_string(&pid_path)?;
|
||||
let pid: u32 = pid_str.trim().parse()
|
||||
.map_err(|_| anyhow::anyhow!("PID 文件格式错误"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); }
|
||||
println!("已停止 wx-daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output()?;
|
||||
println!("已停止 wx-daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(config::sock_path());
|
||||
let _ = std::fs::remove_file(&pid_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_logs(follow: bool, lines: usize) -> Result<()> {
|
||||
let log_path = config::log_path();
|
||||
if !log_path.exists() {
|
||||
println!("暂无日志");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if follow {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::process::Command::new("tail")
|
||||
.args([&format!("-{}", lines), "-f", &log_path.to_string_lossy()])
|
||||
.status()?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file = std::fs::File::open(&log_path)?;
|
||||
let len = file.seek(SeekFrom::End(0))?;
|
||||
let start = len.saturating_sub((lines as u64) * 200);
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)?;
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let show = &all_lines[all_lines.len().saturating_sub(lines)..];
|
||||
for line in show { println!("{}", line); }
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let mut buf = String::new();
|
||||
file.read_to_string(&mut buf)?;
|
||||
if !buf.is_empty() { print!("{}", buf); }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let content = std::fs::read_to_string(&log_path)?;
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let show = &all_lines[all_lines.len().saturating_sub(lines)..];
|
||||
for line in show { println!("{}", line); }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
use super::history::{parse_time, parse_time_end};
|
||||
|
||||
pub fn cmd_export(
|
||||
chat: String,
|
||||
since: Option<String>,
|
||||
until: Option<String>,
|
||||
limit: usize,
|
||||
format: String,
|
||||
output: Option<String>,
|
||||
) -> Result<()> {
|
||||
let since_ts = since.as_deref().map(parse_time).transpose()?;
|
||||
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
|
||||
|
||||
let req = Request::History {
|
||||
chat,
|
||||
limit,
|
||||
offset: 0,
|
||||
since: since_ts,
|
||||
until: until_ts,
|
||||
};
|
||||
|
||||
let resp = transport::send(req)?;
|
||||
let messages = resp.data["messages"].as_array().cloned().unwrap_or_default();
|
||||
let chat_name = resp.data["chat"].as_str().unwrap_or("").to_string();
|
||||
let is_group = resp.data["is_group"].as_bool().unwrap_or(false);
|
||||
let count = messages.len();
|
||||
|
||||
let text = match format.as_str() {
|
||||
"json" => serde_json::to_string_pretty(&resp.data)?,
|
||||
"txt" => {
|
||||
let group_str = if is_group { "[群]" } else { "" };
|
||||
let mut lines = vec![format!("=== {}{} ({} 条) ===\n", chat_name, group_str, count)];
|
||||
for m in &messages {
|
||||
let time = m["time"].as_str().unwrap_or("");
|
||||
let sender = m["sender"].as_str().unwrap_or("");
|
||||
let content = m["content"].as_str().unwrap_or("");
|
||||
let sender_str = if !sender.is_empty() { format!("{}: ", sender) } else { String::new() };
|
||||
lines.push(format!("[{}] {}{}", time, sender_str, content));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
_ => {
|
||||
// markdown (default)
|
||||
let group_str = if is_group { "(群聊)" } else { "" };
|
||||
let mut lines = vec![
|
||||
format!("# {}{}", chat_name, group_str),
|
||||
format!("\n> 导出 {} 条消息\n", count),
|
||||
];
|
||||
for m in &messages {
|
||||
let time = m["time"].as_str().unwrap_or("");
|
||||
let sender = m["sender"].as_str().unwrap_or("");
|
||||
let content = m["content"].as_str().unwrap_or("").replace('\n', "\n> ");
|
||||
let sender_md = if !sender.is_empty() { format!("**{}**: ", sender) } else { String::new() };
|
||||
lines.push(format!("### {}\n\n{}{}\n", time, sender_md, content));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
};
|
||||
|
||||
match output {
|
||||
Some(path) => {
|
||||
std::fs::write(&path, &text)?;
|
||||
println!("已导出 {} 条消息到 {}", count, path);
|
||||
}
|
||||
None => println!("{}", text),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_history(
|
||||
chat: String,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
since: Option<String>,
|
||||
until: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<()> {
|
||||
let since_ts = since.as_deref().map(parse_time).transpose()?;
|
||||
let until_ts = until.as_deref().map(|s| parse_time_end(s)).transpose()?;
|
||||
|
||||
let req = Request::History {
|
||||
chat,
|
||||
limit,
|
||||
offset,
|
||||
since: since_ts,
|
||||
until: until_ts,
|
||||
};
|
||||
|
||||
let resp = transport::send(req)?;
|
||||
|
||||
if json {
|
||||
let msgs = resp.data.get("messages").cloned().unwrap_or(serde_json::Value::Array(vec![]));
|
||||
println!("{}", serde_json::to_string_pretty(&msgs)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chat_name = resp.data["chat"].as_str().unwrap_or("");
|
||||
let is_group = resp.data["is_group"].as_bool().unwrap_or(false);
|
||||
let count = resp.data["count"].as_i64().unwrap_or(0);
|
||||
let group_str = if is_group { " [群]" } else { "" };
|
||||
println!("=== {}{} ({} 条) ===\n", chat_name, group_str, count);
|
||||
|
||||
if let Some(msgs) = resp.data["messages"].as_array() {
|
||||
for m in msgs {
|
||||
let time = m["time"].as_str().unwrap_or("");
|
||||
let sender = m["sender"].as_str().unwrap_or("");
|
||||
let content = m["content"].as_str().unwrap_or("");
|
||||
|
||||
let sender_str = if !sender.is_empty() {
|
||||
format!("\x1b[33m{}\x1b[0m: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m {}{}", time, sender_str, content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_time(s: &str) -> Result<i64> {
|
||||
for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"] {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
|
||||
return Ok(dt.and_utc().timestamp());
|
||||
}
|
||||
// 尝试仅日期格式
|
||||
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) {
|
||||
let dt = d.and_hms_opt(0, 0, 0).unwrap();
|
||||
return Ok(dt.and_utc().timestamp());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS", s)
|
||||
}
|
||||
|
||||
pub fn parse_time_end(s: &str) -> Result<i64> {
|
||||
// 对于仅日期格式,结束时间为当天 23:59:59
|
||||
if s.len() == 10 {
|
||||
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
|
||||
let dt = d.and_hms_opt(23, 59, 59).unwrap();
|
||||
return Ok(dt.and_utc().timestamp());
|
||||
}
|
||||
}
|
||||
parse_time(s)
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config;
|
||||
use crate::scanner;
|
||||
|
||||
pub fn cmd_init(force: bool) -> Result<()> {
|
||||
// 查找 config.json
|
||||
let config_path = find_or_create_config_path();
|
||||
|
||||
// 检查是否已初始化
|
||||
if !force && config_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let keys_file = cfg.get("keys_file").and_then(|v| v.as_str()).unwrap_or("all_keys.json");
|
||||
let keys_path = if std::path::Path::new(keys_file).is_absolute() {
|
||||
std::path::PathBuf::from(keys_file)
|
||||
} else {
|
||||
config_path.parent().unwrap_or(std::path::Path::new("."))
|
||||
.join(keys_file)
|
||||
};
|
||||
if !db_dir.is_empty() && !db_dir.contains("your_wxid")
|
||||
&& std::path::Path::new(db_dir).exists()
|
||||
&& keys_path.exists()
|
||||
{
|
||||
println!("已初始化,数据目录: {}", db_dir);
|
||||
println!("如需重新扫描密钥,使用 --force");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: 检测 db_dir
|
||||
println!("检测微信数据目录...");
|
||||
let db_dir = config::auto_detect_db_dir()
|
||||
.context("未能自动检测到微信数据目录\n请手动编辑 config.json 中的 db_dir 字段")?;
|
||||
println!("找到数据目录: {}", db_dir.display());
|
||||
|
||||
// Step 2: 扫描密钥(需要 root/sudo)
|
||||
println!("扫描加密密钥(需要 root 权限)...");
|
||||
let entries = scanner::scan_keys(&db_dir)?;
|
||||
|
||||
// Step 3: 保存 all_keys.json
|
||||
let keys_file_path = config_path.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join("all_keys.json");
|
||||
|
||||
let mut keys_json = serde_json::Map::new();
|
||||
for entry in &entries {
|
||||
keys_json.insert(entry.db_name.clone(), json!({
|
||||
"enc_key": entry.enc_key,
|
||||
}));
|
||||
}
|
||||
std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?)
|
||||
.context("写入 all_keys.json 失败")?;
|
||||
println!("成功提取 {} 个数据库密钥", entries.len());
|
||||
println!("密钥已保存: {}", keys_file_path.display());
|
||||
|
||||
// Step 4: 保存 config.json
|
||||
let mut cfg = HashMap::new();
|
||||
// 读取已有配置
|
||||
if config_path.exists() {
|
||||
if let Ok(c) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(v) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&c) {
|
||||
for (k, val) in v {
|
||||
cfg.insert(k, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy()));
|
||||
cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json"));
|
||||
cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted"));
|
||||
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
|
||||
.context("写入 config.json 失败")?;
|
||||
println!("配置已保存: {}", config_path.display());
|
||||
println!("初始化完成,可以使用 wx sessions / wx history 等命令了");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_or_create_config_path() -> std::path::PathBuf {
|
||||
// 优先使用可执行文件同目录
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
return dir.join("config.json");
|
||||
}
|
||||
}
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("config.json")
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
mod init;
|
||||
pub mod sessions;
|
||||
pub mod history;
|
||||
pub mod search;
|
||||
pub mod contacts;
|
||||
pub mod export;
|
||||
pub mod watch;
|
||||
pub mod daemon_cmd;
|
||||
pub mod transport;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// wx — 微信本地数据 CLI
|
||||
#[derive(Parser)]
|
||||
#[command(name = "wx", version = "0.1.0", about = "wx — 微信本地数据 CLI")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// 初始化:检测数据目录并扫描加密密钥
|
||||
Init {
|
||||
/// 强制重新扫描(覆盖已有配置)
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// 列出最近会话
|
||||
Sessions {
|
||||
/// 会话数量
|
||||
#[arg(short = 'n', long, default_value = "20")]
|
||||
limit: usize,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 查看聊天记录
|
||||
History {
|
||||
/// 聊天对象名称(支持模糊匹配)
|
||||
chat: String,
|
||||
/// 消息数量
|
||||
#[arg(short = 'n', long, default_value = "50")]
|
||||
limit: usize,
|
||||
/// 分页偏移
|
||||
#[arg(long, default_value = "0")]
|
||||
offset: usize,
|
||||
/// 起始时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
/// 结束时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
until: Option<String>,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 搜索消息
|
||||
Search {
|
||||
/// 搜索关键词
|
||||
keyword: String,
|
||||
/// 限定聊天(可多次指定)
|
||||
#[arg(long = "in", value_name = "CHAT")]
|
||||
chats: Vec<String>,
|
||||
/// 结果数量
|
||||
#[arg(short = 'n', long, default_value = "20")]
|
||||
limit: usize,
|
||||
/// 起始时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
/// 结束时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
until: Option<String>,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 查看联系人
|
||||
Contacts {
|
||||
/// 按名字过滤
|
||||
#[arg(short = 'q', long)]
|
||||
query: Option<String>,
|
||||
/// 显示数量
|
||||
#[arg(short = 'n', long, default_value = "50")]
|
||||
limit: usize,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 导出聊天记录到文件
|
||||
Export {
|
||||
/// 聊天对象名称
|
||||
chat: String,
|
||||
/// 起始时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
/// 结束时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
until: Option<String>,
|
||||
/// 最多导出条数
|
||||
#[arg(short = 'n', long, default_value = "500")]
|
||||
limit: usize,
|
||||
/// 输出格式 [markdown|txt|json]
|
||||
#[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json"])]
|
||||
format: String,
|
||||
/// 输出文件(默认 stdout)
|
||||
#[arg(short = 'o', long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// 实时监听新消息(Ctrl+C 退出)
|
||||
Watch {
|
||||
/// 只显示指定聊天的消息
|
||||
#[arg(long)]
|
||||
chat: Option<String>,
|
||||
/// 输出 JSON lines
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 管理 wx-daemon
|
||||
Daemon {
|
||||
#[command(subcommand)]
|
||||
cmd: DaemonCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DaemonCommands {
|
||||
/// 查看 daemon 运行状态
|
||||
Status,
|
||||
/// 停止 daemon
|
||||
Stop,
|
||||
/// 查看 daemon 日志
|
||||
Logs {
|
||||
/// 持续输出(tail -f)
|
||||
#[arg(short = 'f', long)]
|
||||
follow: bool,
|
||||
/// 显示最近 N 行
|
||||
#[arg(short = 'n', long, default_value = "50")]
|
||||
lines: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = dispatch(cli) {
|
||||
eprintln!("错误: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(cli: Cli) -> Result<()> {
|
||||
match cli.command {
|
||||
Commands::Init { force } => init::cmd_init(force),
|
||||
Commands::Sessions { limit, json } => sessions::cmd_sessions(limit, json),
|
||||
Commands::History { chat, limit, offset, since, until, json } => {
|
||||
history::cmd_history(chat, limit, offset, since, until, json)
|
||||
}
|
||||
Commands::Search { keyword, chats, limit, since, until, json } => {
|
||||
search::cmd_search(keyword, chats, limit, since, until, json)
|
||||
}
|
||||
Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json),
|
||||
Commands::Export { chat, since, until, limit, format, output } => {
|
||||
export::cmd_export(chat, since, until, limit, format, output)
|
||||
}
|
||||
Commands::Watch { chat, json } => watch::cmd_watch(chat, json),
|
||||
Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
use super::history::{parse_time, parse_time_end};
|
||||
|
||||
pub fn cmd_search(
|
||||
keyword: String,
|
||||
chats: Vec<String>,
|
||||
limit: usize,
|
||||
since: Option<String>,
|
||||
until: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<()> {
|
||||
let since_ts = since.as_deref().map(parse_time).transpose()?;
|
||||
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
|
||||
|
||||
let chats_opt = if chats.is_empty() { None } else { Some(chats) };
|
||||
|
||||
let req = Request::Search {
|
||||
keyword: keyword.clone(),
|
||||
chats: chats_opt,
|
||||
limit,
|
||||
since: since_ts,
|
||||
until: until_ts,
|
||||
};
|
||||
|
||||
let resp = transport::send(req)?;
|
||||
let results = resp.data.get("results")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let count = resp.data["count"].as_i64().unwrap_or(results.len() as i64);
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&results)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("搜索 \"{}\",找到 {} 条:\n", keyword, count);
|
||||
for r in &results {
|
||||
let time = r["time"].as_str().unwrap_or("");
|
||||
let chat = r["chat"].as_str().unwrap_or("");
|
||||
let sender = r["sender"].as_str().unwrap_or("");
|
||||
let content = r["content"].as_str().unwrap_or("");
|
||||
|
||||
let chat_str = if !chat.is_empty() {
|
||||
format!("\x1b[36m[{}]\x1b[0m ", chat)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let sender_str = if !sender.is_empty() {
|
||||
format!("\x1b[33m{}\x1b[0m: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m {}{}{}", time, chat_str, sender_str, content);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> {
|
||||
let resp = transport::send(Request::Sessions { limit })?;
|
||||
let data = resp.data.get("sessions")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&data)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for s in &data {
|
||||
let time = s["time"].as_str().unwrap_or("");
|
||||
let chat = s["chat"].as_str().unwrap_or("");
|
||||
let is_group = s["is_group"].as_bool().unwrap_or(false);
|
||||
let unread = s["unread"].as_i64().unwrap_or(0);
|
||||
let msg_type = s["last_msg_type"].as_str().unwrap_or("");
|
||||
let sender = s["last_sender"].as_str().unwrap_or("");
|
||||
let summary = s["summary"].as_str().unwrap_or("");
|
||||
|
||||
let unread_str = if unread > 0 {
|
||||
format!(" \x1b[31m({}未读)\x1b[0m", unread)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let group_str = if is_group { " [群]" } else { "" };
|
||||
let sender_str = if !sender.is_empty() {
|
||||
format!("{}: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m \x1b[1m{}\x1b[0m{}{}", time, chat, group_str, unread_str);
|
||||
println!(" {}: {}{}", msg_type, sender_str, summary);
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::ipc::{Request, Response};
|
||||
|
||||
const STARTUP_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// 检查 daemon 是否存活
|
||||
pub fn is_alive() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::net::UnixStream;
|
||||
let sock_path = config::sock_path();
|
||||
if !sock_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let mut stream = match UnixStream::connect(&sock_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
|
||||
|
||||
let req = serde_json::json!({"cmd": "ping"});
|
||||
if write!(stream, "{}\n", req).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(&stream);
|
||||
if reader.read_line(&mut line).is_err() {
|
||||
return false;
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&line)
|
||||
.ok()
|
||||
.and_then(|v| v.get("pong").and_then(|p| p.as_bool()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 通过 named pipe 检测
|
||||
let pipe_path = r"\\.\pipe\wechat-cli-daemon";
|
||||
use std::fs::OpenOptions;
|
||||
OpenOptions::new().read(true).write(true).open(pipe_path).is_ok()
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保 daemon 运行,必要时自动启动
|
||||
pub fn ensure_daemon() -> Result<()> {
|
||||
if is_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("启动 wx-daemon...");
|
||||
start_daemon()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动 daemon 进程(自身二进制,设置 WX_DAEMON_MODE=1)
|
||||
fn start_daemon() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("无法获取当前可执行文件路径")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.env("WX_DAEMON_MODE", "1")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
// SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端
|
||||
unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); }
|
||||
let _ = cmd.spawn().context("无法启动 daemon 进程")?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new(&exe)
|
||||
.env("WX_DAEMON_MODE", "1")
|
||||
.creation_flags(0x00000008) // DETACHED_PROCESS
|
||||
.spawn()
|
||||
.context("无法启动 daemon 进程")?;
|
||||
}
|
||||
|
||||
// 等待 daemon 就绪(最多 STARTUP_TIMEOUT_SECS 秒)
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS);
|
||||
while std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
if is_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!(
|
||||
"wx-daemon 启动超时(>{}s)\n请查看日志: {}",
|
||||
STARTUP_TIMEOUT_SECS,
|
||||
config::log_path().display()
|
||||
)
|
||||
}
|
||||
|
||||
/// 向 daemon 发送请求并返回响应
|
||||
pub fn send(req: Request) -> Result<Response> {
|
||||
ensure_daemon()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
send_unix(req)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
send_windows(req)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
bail!("不支持当前平台")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn send_unix(req: Request) -> Result<Response> {
|
||||
use std::os::unix::net::UnixStream;
|
||||
let sock_path = config::sock_path();
|
||||
let mut stream = UnixStream::connect(&sock_path)
|
||||
.context("连接 daemon socket 失败")?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(30))).ok();
|
||||
|
||||
let req_str = serde_json::to_string(&req)? + "\n";
|
||||
stream.write_all(req_str.as_bytes())?;
|
||||
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(&stream);
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
let resp: Response = serde_json::from_str(&line)
|
||||
.context("解析 daemon 响应失败")?;
|
||||
|
||||
if !resp.ok {
|
||||
bail!("{}", resp.error.as_deref().unwrap_or("未知错误"));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn send_windows(req: Request) -> Result<Response> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
|
||||
let pipe_path = r"\\.\pipe\wechat-cli-daemon";
|
||||
let mut pipe = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(pipe_path)
|
||||
.context("连接 daemon named pipe 失败")?;
|
||||
|
||||
let req_str = serde_json::to_string(&req)? + "\n";
|
||||
pipe.write_all(req_str.as_bytes())?;
|
||||
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(pipe);
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
let resp: Response = serde_json::from_str(&line)
|
||||
.context("解析 daemon 响应失败")?;
|
||||
|
||||
if !resp.ok {
|
||||
bail!("{}", resp.error.as_deref().unwrap_or("未知错误"));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
use anyhow::Result;
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_watch(chat: Option<String>, json: bool) -> Result<()> {
|
||||
transport::ensure_daemon()?;
|
||||
|
||||
let sock_path = crate::config::sock_path();
|
||||
|
||||
// 连接 socket
|
||||
#[cfg(unix)]
|
||||
let mut stream = {
|
||||
use std::os::unix::net::UnixStream;
|
||||
UnixStream::connect(&sock_path)?
|
||||
};
|
||||
|
||||
// 发送 watch 请求
|
||||
let req_line = serde_json::to_string(&Request::Watch)? + "\n";
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
stream.write_all(req_line.as_bytes())?;
|
||||
}
|
||||
|
||||
if !json {
|
||||
eprintln!("监听中(Ctrl+C 退出)...\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
anyhow::bail!("watch 命令在 Windows 上暂不支持,请使用 Unix 系统");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let reader = std::io::BufReader::new(stream.try_clone()?);
|
||||
for line_result in reader.lines() {
|
||||
let line = match line_result {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let event: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let evt = event["event"].as_str().unwrap_or("");
|
||||
if evt == "connected" || evt == "heartbeat" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 过滤指定聊天
|
||||
if let Some(ref filter_chat) = chat {
|
||||
let event_chat = event["chat"].as_str().unwrap_or("");
|
||||
let event_user = event["username"].as_str().unwrap_or("");
|
||||
if event_chat != filter_chat && event_user != filter_chat {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
println!("{}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
let time_s = event["time"].as_str().unwrap_or("");
|
||||
let chat_s = event["chat"].as_str().unwrap_or("");
|
||||
let is_group = event["is_group"].as_bool().unwrap_or(false);
|
||||
let sender = event["sender"].as_str().unwrap_or("");
|
||||
let content = event["content"].as_str().unwrap_or("");
|
||||
|
||||
let chat_part = if is_group {
|
||||
format!("\x1b[36m[{}]\x1b[0m ", chat_s)
|
||||
} else {
|
||||
format!("\x1b[1m{}\x1b[0m ", chat_s)
|
||||
};
|
||||
let sender_part = if !sender.is_empty() {
|
||||
format!("\x1b[33m{}\x1b[0m: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m {}{}{}", time_s, chat_part, sender_part, content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub db_dir: PathBuf,
|
||||
pub keys_file: PathBuf,
|
||||
pub decrypted_dir: PathBuf,
|
||||
#[serde(default)]
|
||||
pub wechat_process: String,
|
||||
}
|
||||
|
||||
/// 从 <exe_dir>/config.json 或 $HOME/.wechat-cli/config.json 加载配置
|
||||
pub fn load_config() -> Result<Config> {
|
||||
let config_path = find_config_file()?;
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("读取 config.json 失败: {}", config_path.display()))?;
|
||||
let raw: serde_json::Value = serde_json::from_str(&content)
|
||||
.with_context(|| "config.json 格式错误")?;
|
||||
|
||||
let db_dir = raw.get("db_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_db_dir);
|
||||
|
||||
let base_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
|
||||
let keys_file = raw.get("keys_file")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
let p = PathBuf::from(s);
|
||||
if p.is_absolute() { p } else { base_dir.join(p) }
|
||||
})
|
||||
.unwrap_or_else(|| base_dir.join("all_keys.json"));
|
||||
|
||||
let decrypted_dir = raw.get("decrypted_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
let p = PathBuf::from(s);
|
||||
if p.is_absolute() { p } else { base_dir.join(p) }
|
||||
})
|
||||
.unwrap_or_else(|| base_dir.join("decrypted"));
|
||||
|
||||
let wechat_process = raw.get("wechat_process")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(default_wechat_process())
|
||||
.to_string();
|
||||
|
||||
Ok(Config {
|
||||
db_dir,
|
||||
keys_file,
|
||||
decrypted_dir,
|
||||
wechat_process,
|
||||
})
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save_config(config: &Config) -> Result<()> {
|
||||
let config_path = find_config_file().unwrap_or_else(|_| {
|
||||
std::env::current_exe()
|
||||
.unwrap_or_default()
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join("config.json")
|
||||
});
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
std::fs::write(&config_path, content)
|
||||
.with_context(|| format!("写入 config.json 失败: {}", config_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_config_file() -> Result<PathBuf> {
|
||||
// 1. 优先查找可执行文件同目录
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let p = dir.join("config.json");
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. 当前工作目录
|
||||
let cwd = std::env::current_dir().unwrap_or_default().join("config.json");
|
||||
if cwd.exists() {
|
||||
return Ok(cwd);
|
||||
}
|
||||
// 3. ~/.wechat-cli/config.json
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let p = home.join(".wechat-cli").join("config.json");
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
// 返回默认路径(可能不存在,调用方负责处理)
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
return Ok(dir.join("config.json"));
|
||||
}
|
||||
}
|
||||
Ok(PathBuf::from("config.json"))
|
||||
}
|
||||
|
||||
pub fn cli_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".wechat-cli")
|
||||
}
|
||||
|
||||
pub fn sock_path() -> PathBuf {
|
||||
cli_dir().join("daemon.sock")
|
||||
}
|
||||
|
||||
pub fn pid_path() -> PathBuf {
|
||||
cli_dir().join("daemon.pid")
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
cli_dir().join("daemon.log")
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
cli_dir().join("cache")
|
||||
}
|
||||
|
||||
pub fn mtime_file() -> PathBuf {
|
||||
cache_dir().join("_mtimes.json")
|
||||
}
|
||||
|
||||
fn default_db_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files")
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join("Documents/xwechat_files")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
PathBuf::from(std::env::var("APPDATA").unwrap_or_default())
|
||||
.join("Tencent/xwechat")
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
PathBuf::from(".")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_wechat_process() -> &'static str {
|
||||
#[cfg(target_os = "macos")]
|
||||
{ "WeChat" }
|
||||
#[cfg(target_os = "linux")]
|
||||
{ "wechat" }
|
||||
#[cfg(target_os = "windows")]
|
||||
{ "Weixin.exe" }
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{ "WeChat" }
|
||||
}
|
||||
|
||||
/// 自动检测微信 db_storage 目录
|
||||
pub fn auto_detect_db_dir() -> Option<PathBuf> {
|
||||
detect_db_dir_impl()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
// 支持 sudo 环境
|
||||
let home = if let Ok(sudo_user) = std::env::var("SUDO_USER") {
|
||||
if !sudo_user.is_empty() {
|
||||
PathBuf::from("/Users").join(&sudo_user)
|
||||
} else {
|
||||
home
|
||||
}
|
||||
} else {
|
||||
home
|
||||
};
|
||||
|
||||
let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files");
|
||||
if !base.exists() {
|
||||
return None;
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&base) {
|
||||
for entry in entries.flatten() {
|
||||
let storage = entry.path().join("db_storage");
|
||||
if storage.is_dir() {
|
||||
candidates.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|p| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
candidates.into_iter().next_back()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
let sudo_home = std::env::var("SUDO_USER").ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|u| PathBuf::from("/home").join(u));
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() {
|
||||
let xwechat = base_home.join("Documents/xwechat_files");
|
||||
if xwechat.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&xwechat) {
|
||||
for entry in entries.flatten() {
|
||||
let storage = entry.path().join("db_storage");
|
||||
if storage.is_dir() {
|
||||
candidates.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let old = base_home.join(".local/share/weixin/data/db_storage");
|
||||
if old.is_dir() {
|
||||
candidates.push(old);
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|p| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
candidates.into_iter().next_back()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
let appdata = std::env::var("APPDATA").ok()?;
|
||||
let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config");
|
||||
if !config_dir.exists() {
|
||||
return None;
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&config_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "ini").unwrap_or(false) {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
let data_root = content.trim().to_string();
|
||||
if PathBuf::from(&data_root).is_dir() {
|
||||
let pattern = PathBuf::from(&data_root)
|
||||
.join("xwechat_files");
|
||||
if let Ok(entries2) = std::fs::read_dir(&pattern) {
|
||||
for entry2 in entries2.flatten() {
|
||||
let storage = entry2.path().join("db_storage");
|
||||
if storage.is_dir() {
|
||||
candidates.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.into_iter().next()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
pub mod wal;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use aes::Aes256;
|
||||
use cbc::Decryptor;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
|
||||
use std::path::Path;
|
||||
|
||||
pub const PAGE_SZ: usize = 4096;
|
||||
pub const SALT_SZ: usize = 16;
|
||||
pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64)
|
||||
|
||||
/// SQLite 文件头魔数(16字节)
|
||||
pub const SQLITE_HDR: &[u8] = b"SQLite format 3\x00";
|
||||
|
||||
type Aes256CbcDec = Decryptor<Aes256>;
|
||||
|
||||
/// 解密单个 SQLCipher 4 页
|
||||
///
|
||||
/// - `enc_key`: 32字节 AES 密钥
|
||||
/// - `page_data`: 原始加密页面数据(PAGE_SZ 字节)
|
||||
/// - `pgno`: 页码(从1开始)
|
||||
///
|
||||
/// 返回解密后的完整页面(PAGE_SZ 字节)
|
||||
pub fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Result<Vec<u8>> {
|
||||
if page_data.len() < PAGE_SZ {
|
||||
bail!("页面数据不足 {} 字节", PAGE_SZ);
|
||||
}
|
||||
|
||||
// IV 位于页面末尾 RESERVE_SZ 区域的前16字节
|
||||
let iv_offset = PAGE_SZ - RESERVE_SZ;
|
||||
let iv: &[u8; 16] = page_data[iv_offset..iv_offset + 16]
|
||||
.try_into()
|
||||
.expect("IV 长度固定为 16");
|
||||
|
||||
let mut result = vec![0u8; PAGE_SZ];
|
||||
|
||||
if pgno == 1 {
|
||||
// 第一页:跳过 salt(16字节),解密 [SALT_SZ..PAGE_SZ-RESERVE_SZ]
|
||||
let enc = &page_data[SALT_SZ..PAGE_SZ - RESERVE_SZ];
|
||||
let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
|
||||
// 写入 SQLite 文件头
|
||||
result[..16].copy_from_slice(SQLITE_HDR);
|
||||
// 写入解密数据(从第16字节开始)
|
||||
result[16..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
|
||||
// 末尾 RESERVE_SZ 字节补零
|
||||
// (已经是零,无需显式操作)
|
||||
} else {
|
||||
// 其他页:解密 [0..PAGE_SZ-RESERVE_SZ]
|
||||
let enc = &page_data[..PAGE_SZ - RESERVE_SZ];
|
||||
let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
|
||||
result[..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
|
||||
// 末尾 RESERVE_SZ 字节补零
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// AES-256-CBC 解密(不去除 padding,SQLCipher 不使用 PKCS#7 padding)
|
||||
fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.is_empty() || data.len() % 16 != 0 {
|
||||
bail!("密文长度不是 AES 块大小的倍数: {}", data.len());
|
||||
}
|
||||
let mut buf = data.to_vec();
|
||||
// 使用 raw 模式不处理 padding
|
||||
Aes256CbcDec::new(key.into(), iv.into())
|
||||
.decrypt_blocks_mut(unsafe {
|
||||
std::slice::from_raw_parts_mut(
|
||||
buf.as_mut_ptr() as *mut aes::cipher::Block<Aes256>,
|
||||
buf.len() / 16,
|
||||
)
|
||||
});
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// 完整解密一个 SQLCipher 数据库文件
|
||||
///
|
||||
/// 读取 `db_path`,按 PAGE_SZ 分页解密,写入 `out_path`
|
||||
pub fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> {
|
||||
let data = std::fs::read(db_path)?;
|
||||
if data.is_empty() {
|
||||
bail!("数据库文件为空: {}", db_path.display());
|
||||
}
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let total_pages = (data.len() + PAGE_SZ - 1) / PAGE_SZ;
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
|
||||
for pgno in 1..=total_pages {
|
||||
let offset = (pgno - 1) * PAGE_SZ;
|
||||
let end = std::cmp::min(offset + PAGE_SZ, data.len());
|
||||
let mut page = data[offset..end].to_vec();
|
||||
// 不足一页则补零
|
||||
if page.len() < PAGE_SZ {
|
||||
page.resize(PAGE_SZ, 0);
|
||||
}
|
||||
let dec = decrypt_page(enc_key, &page, pgno as u32)?;
|
||||
out.extend_from_slice(&dec);
|
||||
}
|
||||
|
||||
std::fs::write(out_path, &out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
use anyhow::Result;
|
||||
use std::io::{SeekFrom, Seek, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{decrypt_page, PAGE_SZ};
|
||||
|
||||
pub const WAL_HDR_SZ: usize = 32;
|
||||
pub const WAL_FRAME_HDR: usize = 24;
|
||||
|
||||
/// 将 WAL 文件中的变更应用到已解密的数据库文件
|
||||
///
|
||||
/// WAL 格式(SQLite 标准,SQLCipher 4 的 WAL 帧也被加密):
|
||||
/// - WAL header (32 bytes): magic(4) + format(4) + page_sz(4) + ckpt_seq(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4)
|
||||
/// - 每帧:frame_header(24 bytes) + page_data(PAGE_SZ bytes)
|
||||
/// - frame_header: pgno(4) + commit_pgcnt(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4)
|
||||
pub fn apply_wal(wal_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> {
|
||||
if !wal_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let wal_data = std::fs::read(wal_path)?;
|
||||
if wal_data.len() <= WAL_HDR_SZ {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 读取 WAL 头中的 salt1 / salt2
|
||||
let s1 = u32::from_be_bytes(wal_data[16..20].try_into().unwrap());
|
||||
let s2 = u32::from_be_bytes(wal_data[20..24].try_into().unwrap());
|
||||
|
||||
let frame_size = WAL_FRAME_HDR + PAGE_SZ;
|
||||
let frame_area = &wal_data[WAL_HDR_SZ..];
|
||||
|
||||
// 打开输出文件做随机写
|
||||
let mut db_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(out_path)?;
|
||||
|
||||
let mut pos = 0usize;
|
||||
while pos + frame_size <= frame_area.len() {
|
||||
let fh = &frame_area[pos..pos + WAL_FRAME_HDR];
|
||||
let page_data = &frame_area[pos + WAL_FRAME_HDR..pos + frame_size];
|
||||
|
||||
let pgno = u32::from_be_bytes(fh[0..4].try_into().unwrap());
|
||||
let fs1 = u32::from_be_bytes(fh[8..12].try_into().unwrap());
|
||||
let fs2 = u32::from_be_bytes(fh[12..16].try_into().unwrap());
|
||||
|
||||
pos += frame_size;
|
||||
|
||||
// 跳过无效页码
|
||||
if pgno == 0 || pgno > 1_000_000 {
|
||||
continue;
|
||||
}
|
||||
// salt 不匹配的帧属于已检查点或旧事务
|
||||
if fs1 != s1 || fs2 != s2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut page_buf = page_data.to_vec();
|
||||
if page_buf.len() < PAGE_SZ {
|
||||
page_buf.resize(PAGE_SZ, 0);
|
||||
}
|
||||
|
||||
// WAL 帧中的页数据不含 SALT 头,所以对 pgno=1 的帧也用普通页解密路径
|
||||
// (区别于主数据库第一页需要跳过 SALT 并写入 SQLite 魔数)
|
||||
let dec = decrypt_page(enc_key, &page_buf, if pgno == 1 { 2 } else { pgno })?;
|
||||
let file_offset = (pgno as u64 - 1) * PAGE_SZ as u64;
|
||||
db_file.seek(SeekFrom::Start(file_offset))?;
|
||||
db_file.write_all(&dec)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config;
|
||||
use crate::crypto;
|
||||
use crate::crypto::wal;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MtimeEntry {
|
||||
db_mt: u64,
|
||||
wal_mt: u64,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheEntry {
|
||||
db_mtime: u64,
|
||||
wal_mtime: u64,
|
||||
decrypted_path: PathBuf,
|
||||
}
|
||||
|
||||
/// 解密后数据库的 mtime-aware 缓存
|
||||
///
|
||||
/// 当数据库文件(.db)或 WAL 文件(.db-wal)的 mtime 发生变化时,
|
||||
/// 自动重新解密并更新缓存。跨进程重启可通过持久化 mtime 文件复用已解密的 DB。
|
||||
pub struct DbCache {
|
||||
db_dir: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
all_keys: HashMap<String, String>, // rel_key -> enc_key(hex)
|
||||
inner: Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
}
|
||||
|
||||
impl DbCache {
|
||||
pub async fn new(
|
||||
db_dir: PathBuf,
|
||||
all_keys: HashMap<String, String>,
|
||||
) -> Result<Self> {
|
||||
let cache_dir = config::cache_dir();
|
||||
tokio::fs::create_dir_all(&cache_dir).await?;
|
||||
|
||||
let inner: HashMap<String, CacheEntry> = HashMap::new();
|
||||
let cache = DbCache {
|
||||
db_dir,
|
||||
cache_dir,
|
||||
all_keys,
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
};
|
||||
|
||||
cache.load_persistent().await;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn cache_file_path(&self, rel_key: &str) -> PathBuf {
|
||||
let hash = format!("{:x}", md5::compute(rel_key.as_bytes()));
|
||||
let short = &hash[..12];
|
||||
self.cache_dir.join(format!("{}.db", short))
|
||||
}
|
||||
|
||||
/// 从持久化文件加载 mtime 记录,复用未过期的解密文件
|
||||
async fn load_persistent(&self) {
|
||||
let mtime_file = config::mtime_file();
|
||||
let content = match tokio::fs::read_to_string(&mtime_file).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let saved: HashMap<String, MtimeEntry> = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
let mut reused = 0usize;
|
||||
for (rel_key, entry) in &saved {
|
||||
let dec_path = PathBuf::from(&entry.path);
|
||||
if !dec_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let db_path = self.db_dir.join(rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR).replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let wal_path_str = format!("{}-wal", db_path.display());
|
||||
let wal_path = Path::new(&wal_path_str);
|
||||
|
||||
let db_mt = mtime_nanos(&db_path);
|
||||
let wal_mt = if wal_path.exists() { mtime_nanos(wal_path) } else { 0 };
|
||||
|
||||
if db_mt == entry.db_mt && wal_mt == entry.wal_mt {
|
||||
inner.insert(rel_key.clone(), CacheEntry {
|
||||
db_mtime: db_mt,
|
||||
wal_mtime: wal_mt,
|
||||
decrypted_path: dec_path,
|
||||
});
|
||||
reused += 1;
|
||||
}
|
||||
}
|
||||
if reused > 0 {
|
||||
eprintln!("[cache] 复用 {} 个已解密 DB", reused);
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化 mtime 记录
|
||||
async fn save_persistent(&self) {
|
||||
let mtime_file = config::mtime_file();
|
||||
let inner = self.inner.lock().await;
|
||||
let data: HashMap<String, MtimeEntry> = inner.iter().map(|(k, v)| {
|
||||
(k.clone(), MtimeEntry {
|
||||
db_mt: v.db_mtime,
|
||||
wal_mt: v.wal_mtime,
|
||||
path: v.decrypted_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}).collect();
|
||||
drop(inner);
|
||||
|
||||
if let Ok(json) = serde_json::to_string_pretty(&data) {
|
||||
let _ = tokio::fs::write(&mtime_file, json).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取解密后的数据库路径
|
||||
///
|
||||
/// 如果 mtime 未变,直接返回缓存路径;否则重新解密
|
||||
pub async fn get(&self, rel_key: &str) -> Result<Option<PathBuf>> {
|
||||
let enc_key_hex = match self.all_keys.get(rel_key) {
|
||||
Some(k) => k.clone(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let db_path = self.db_dir.join(
|
||||
rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR)
|
||||
.replace('/', std::path::MAIN_SEPARATOR_STR)
|
||||
);
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let wal_path_str = format!("{}-wal", db_path.display());
|
||||
let wal_path = Path::new(&wal_path_str).to_path_buf();
|
||||
|
||||
let db_mt = mtime_nanos(&db_path);
|
||||
let wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 };
|
||||
|
||||
// 检查缓存
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
if let Some(entry) = inner.get(rel_key) {
|
||||
if entry.db_mtime == db_mt
|
||||
&& entry.wal_mtime == wal_mt
|
||||
&& entry.decrypted_path.exists()
|
||||
{
|
||||
return Ok(Some(entry.decrypted_path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 需要重新解密
|
||||
let out_path = self.cache_file_path(rel_key);
|
||||
let enc_key_bytes = hex_to_32bytes(&enc_key_hex)
|
||||
.with_context(|| format!("密钥格式错误: {}", rel_key))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let db_path2 = db_path.clone();
|
||||
let out_path2 = out_path.clone();
|
||||
let key_copy = enc_key_bytes;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crypto::full_decrypt(&db_path2, &out_path2, &key_copy)
|
||||
}).await??;
|
||||
|
||||
// 应用 WAL
|
||||
if wal_path.exists() {
|
||||
let out_path3 = out_path.clone();
|
||||
let wal_path3 = wal_path.clone();
|
||||
let key_copy2 = enc_key_bytes;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
wal::apply_wal(&wal_path3, &out_path3, &key_copy2)
|
||||
}).await??;
|
||||
}
|
||||
|
||||
let elapsed_ms = t0.elapsed().as_millis();
|
||||
eprintln!("[cache] 解密 {} ({}ms)", rel_key, elapsed_ms);
|
||||
|
||||
// 更新内存缓存
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.insert(rel_key.to_string(), CacheEntry {
|
||||
db_mtime: db_mt,
|
||||
wal_mtime: wal_mt,
|
||||
decrypted_path: out_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.save_persistent().await;
|
||||
Ok(Some(out_path))
|
||||
}
|
||||
}
|
||||
|
||||
fn mtime_nanos(path: &Path) -> u64 {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn hex_to_32bytes(s: &str) -> Result<[u8; 32]> {
|
||||
if s.len() != 64 {
|
||||
anyhow::bail!("密钥 hex 长度应为 64,实际为 {}", s.len());
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
|
||||
.with_context(|| format!("非法 hex 字符 at {}", i * 2))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
pub mod cache;
|
||||
pub mod query;
|
||||
pub mod server;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::config;
|
||||
|
||||
/// daemon 入口
|
||||
///
|
||||
/// 当 WX_DAEMON_MODE 环境变量设置时,main() 调用此函数
|
||||
pub fn run() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime");
|
||||
if let Err(e) = rt.block_on(async_run()) {
|
||||
eprintln!("[daemon] 启动失败: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn async_run() -> Result<()> {
|
||||
// 确保工作目录存在
|
||||
let cli_dir = config::cli_dir();
|
||||
tokio::fs::create_dir_all(&cli_dir).await?;
|
||||
tokio::fs::create_dir_all(config::cache_dir()).await?;
|
||||
|
||||
// 写 PID 文件
|
||||
let pid = std::process::id();
|
||||
tokio::fs::write(config::pid_path(), pid.to_string()).await?;
|
||||
|
||||
// 注册 SIGTERM / SIGINT 处理
|
||||
setup_signal_handler().await;
|
||||
|
||||
eprintln!("[daemon] wx-daemon 启动 (PID {})", pid);
|
||||
|
||||
// 加载配置
|
||||
let cfg = config::load_config()?;
|
||||
eprintln!("[daemon] DB_DIR: {}", cfg.db_dir.display());
|
||||
|
||||
// 加载密钥
|
||||
let keys_content = tokio::fs::read_to_string(&cfg.keys_file).await
|
||||
.map_err(|e| anyhow::anyhow!("读取密钥文件 {:?} 失败: {}", cfg.keys_file, e))?;
|
||||
let keys_raw: serde_json::Value = serde_json::from_str(&keys_content)?;
|
||||
let all_keys = extract_keys(&keys_raw);
|
||||
eprintln!("[daemon] 密钥数量: {}", all_keys.len());
|
||||
|
||||
// 初始化 DbCache
|
||||
let db = Arc::new(cache::DbCache::new(cfg.db_dir.clone(), all_keys.clone()).await?);
|
||||
|
||||
// 收集消息 DB 列表
|
||||
let msg_db_keys: Vec<String> = all_keys.keys()
|
||||
.filter(|k| {
|
||||
let k = k.replace('\\', "/");
|
||||
k.contains("message/message_") && k.ends_with(".db")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// 预热:加载联系人 + 解密 session.db
|
||||
eprintln!("[daemon] 预热...");
|
||||
let names_raw = query::load_names(&*db).await.unwrap_or_else(|e| {
|
||||
eprintln!("[daemon] 加载联系人失败: {}", e);
|
||||
query::Names {
|
||||
map: HashMap::new(),
|
||||
md5_to_uname: HashMap::new(),
|
||||
msg_db_keys: Vec::new(),
|
||||
}
|
||||
});
|
||||
let mut names = names_raw;
|
||||
names.msg_db_keys = msg_db_keys;
|
||||
|
||||
let _ = db.get("session/session.db").await;
|
||||
eprintln!("[daemon] 预热完成,联系人 {} 个", names.map.len());
|
||||
|
||||
let names_arc = Arc::new(std::sync::RwLock::new(names));
|
||||
|
||||
// 启动 WAL watcher
|
||||
let (watch_tx, _) = broadcast::channel::<crate::ipc::WatchEvent>(500);
|
||||
let session_wal = cfg.db_dir.join("session").join("session.db-wal");
|
||||
|
||||
// SAFETY: 我们确保 db 和 names_arc 在 daemon 生命周期内有效
|
||||
// 使用 Arc 传递引用避免 'static 问题
|
||||
let db_arc = Arc::clone(&db);
|
||||
let names_arc2 = Arc::clone(&names_arc);
|
||||
let tx_clone = watch_tx.clone();
|
||||
let session_wal2 = session_wal.clone();
|
||||
tokio::spawn(async move {
|
||||
run_watcher(db_arc, names_arc2, tx_clone, session_wal2).await;
|
||||
});
|
||||
|
||||
// 启动 IPC server(阻塞)
|
||||
server::serve(Arc::clone(&db), Arc::clone(&names_arc), watch_tx).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_watcher(
|
||||
db: Arc<cache::DbCache>,
|
||||
names: Arc<std::sync::RwLock<query::Names>>,
|
||||
tx: broadcast::Sender<crate::ipc::WatchEvent>,
|
||||
session_wal: PathBuf,
|
||||
) {
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use crate::ipc::WatchEvent;
|
||||
|
||||
let mut last_mtime = 0u64;
|
||||
let mut last_ts: HashMap<String, i64> = HashMap::new();
|
||||
let mut initialized = false;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if tx.receiver_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wal_mtime = match mtime_nanos(&session_wal) {
|
||||
0 => continue,
|
||||
m => m,
|
||||
};
|
||||
if wal_mtime == last_mtime {
|
||||
continue;
|
||||
}
|
||||
last_mtime = wal_mtime;
|
||||
|
||||
let path = match db.get("session/session.db").await {
|
||||
Ok(Some(p)) => p,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let path2 = path.clone();
|
||||
let rows: Vec<(String, Vec<u8>, i64, i64, String)> = match tokio::task::spawn_blocking(move || {
|
||||
let conn = rusqlite::Connection::open(&path2)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender
|
||||
FROM SessionTable WHERE last_timestamp > 0
|
||||
ORDER BY last_timestamp DESC LIMIT 50"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Vec<u8>>(1).unwrap_or_default(),
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3).unwrap_or(0),
|
||||
row.get::<_, String>(4).unwrap_or_default(),
|
||||
))
|
||||
})?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}).await {
|
||||
Ok(Ok(r)) => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let names_guard = match names.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for (username, summary_bytes, ts, msg_type, sender) in &rows {
|
||||
if !initialized {
|
||||
last_ts.insert(username.clone(), *ts);
|
||||
continue;
|
||||
}
|
||||
let prev_ts = last_ts.get(username).copied().unwrap_or(0);
|
||||
if *ts <= prev_ts {
|
||||
continue;
|
||||
}
|
||||
last_ts.insert(username.clone(), *ts);
|
||||
|
||||
let display = names_guard.display(username);
|
||||
let is_group = username.contains("@chatroom");
|
||||
let summary = decompress_or_str(summary_bytes);
|
||||
let summary = if summary.contains(":\n") {
|
||||
summary.splitn(2, ":\n").nth(1).unwrap_or(&summary).to_string()
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
let sender_display = if !sender.is_empty() {
|
||||
names_guard.map.get(sender).cloned().unwrap_or_else(|| sender.clone())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let event = WatchEvent {
|
||||
event: "message".into(),
|
||||
time: Some(fmt_hhmm(*ts)),
|
||||
chat: Some(display),
|
||||
username: Some(username.clone()),
|
||||
is_group: Some(is_group),
|
||||
sender: Some(sender_display),
|
||||
content: Some(summary),
|
||||
msg_type: Some(query::fmt_type(*msg_type)),
|
||||
timestamp: Some(*ts),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mtime_nanos(path: &std::path::Path) -> u64 {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn decompress_or_str(data: &[u8]) -> String {
|
||||
if data.is_empty() { return String::new(); }
|
||||
if let Ok(dec) = zstd::decode_all(data) {
|
||||
if let Ok(s) = String::from_utf8(dec) { return s; }
|
||||
}
|
||||
String::from_utf8_lossy(data).into_owned()
|
||||
}
|
||||
|
||||
fn fmt_hhmm(ts: i64) -> String {
|
||||
use chrono::{Local, TimeZone};
|
||||
Local.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%H:%M").to_string())
|
||||
.unwrap_or_else(|| ts.to_string())
|
||||
}
|
||||
|
||||
/// 从 all_keys.json 提取 rel_key -> enc_key 映射
|
||||
///
|
||||
/// 兼容两种格式:
|
||||
/// - `{ "rel/path.db": { "enc_key": "hex" } }`(Python 版原生格式)
|
||||
/// - `{ "rel/path.db": "hex" }`(简化格式)
|
||||
fn extract_keys(json: &serde_json::Value) -> HashMap<String, String> {
|
||||
let mut result = HashMap::new();
|
||||
if let Some(obj) = json.as_object() {
|
||||
for (k, v) in obj {
|
||||
if k.starts_with('_') { continue; }
|
||||
let enc_key = if let Some(s) = v.as_str() {
|
||||
s.to_string()
|
||||
} else if let Some(obj2) = v.as_object() {
|
||||
obj2.get("enc_key")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if !enc_key.is_empty() {
|
||||
// 统一路径分隔符
|
||||
let rel = k.replace('\\', "/");
|
||||
result.insert(rel, enc_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 设置信号处理(Unix: SIGTERM/SIGINT)
|
||||
async fn setup_signal_handler() {
|
||||
#[cfg(unix)]
|
||||
tokio::spawn(async move {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut term = signal(SignalKind::terminate()).expect("无法监听 SIGTERM");
|
||||
let mut int = signal(SignalKind::interrupt()).expect("无法监听 SIGINT");
|
||||
tokio::select! {
|
||||
_ = term.recv() => {},
|
||||
_ = int.recv() => {},
|
||||
}
|
||||
cleanup_and_exit();
|
||||
});
|
||||
}
|
||||
|
||||
fn cleanup_and_exit() {
|
||||
let _ = std::fs::remove_file(config::sock_path());
|
||||
let _ = std::fs::remove_file(config::pid_path());
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
|
@ -0,0 +1,677 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::{Local, TimeZone};
|
||||
use regex::Regex;
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cache::DbCache;
|
||||
|
||||
/// 联系人名称缓存
|
||||
#[derive(Clone)]
|
||||
pub struct Names {
|
||||
/// username -> display_name
|
||||
pub map: HashMap<String, String>,
|
||||
/// md5(username) -> username(用于从 Msg_<md5> 表名反推联系人)
|
||||
pub md5_to_uname: HashMap<String, String>,
|
||||
/// 消息 DB 的相对路径列表(message/message_N.db)
|
||||
pub msg_db_keys: Vec<String>,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
pub fn display(&self, username: &str) -> String {
|
||||
self.map.get(username).cloned().unwrap_or_else(|| username.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载联系人缓存(从 contact/contact.db)
|
||||
pub async fn load_names(db: &DbCache) -> Result<Names> {
|
||||
let path = db.get("contact/contact.db").await?;
|
||||
let mut map = HashMap::new();
|
||||
if let Some(p) = path {
|
||||
let p2 = p.clone();
|
||||
let rows: Vec<(String, String, String)> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&p2).context("打开 contact.db 失败")?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username, nick_name, remark FROM contact"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1).unwrap_or_default(),
|
||||
row.get::<_, String>(2).unwrap_or_default(),
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}).await??;
|
||||
|
||||
for (uname, nick, remark) in rows {
|
||||
let display = if !remark.is_empty() { remark }
|
||||
else if !nick.is_empty() { nick }
|
||||
else { uname.clone() };
|
||||
map.insert(uname, display);
|
||||
}
|
||||
}
|
||||
|
||||
let md5_to_uname: HashMap<String, String> = map.keys()
|
||||
.map(|u| (format!("{:x}", md5::compute(u.as_bytes())), u.clone()))
|
||||
.collect();
|
||||
|
||||
Ok(Names { map, md5_to_uname, msg_db_keys: Vec::new() })
|
||||
}
|
||||
|
||||
/// 查询最近会话列表
|
||||
pub async fn q_sessions(db: &DbCache, names: &Names, limit: usize) -> Result<Value> {
|
||||
let path = db.get("session/session.db").await?
|
||||
.context("无法解密 session.db")?;
|
||||
|
||||
let path2 = path.clone();
|
||||
let limit_val = limit;
|
||||
let rows: Vec<(String, i64, Vec<u8>, i64, i64, String, String)> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&path2)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username, unread_count, summary, last_timestamp,
|
||||
last_msg_type, last_msg_sender, last_sender_display_name
|
||||
FROM SessionTable
|
||||
WHERE last_timestamp > 0
|
||||
ORDER BY last_timestamp DESC LIMIT ?"
|
||||
)?;
|
||||
let rows = stmt.query_map([limit_val as i64], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1).unwrap_or(0),
|
||||
row.get::<_, Vec<u8>>(2).unwrap_or_default(),
|
||||
row.get::<_, i64>(3).unwrap_or(0),
|
||||
row.get::<_, i64>(4).unwrap_or(0),
|
||||
row.get::<_, String>(5).unwrap_or_default(),
|
||||
row.get::<_, String>(6).unwrap_or_default(),
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}).await??;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows {
|
||||
let display = names.display(&username);
|
||||
let is_group = username.contains("@chatroom");
|
||||
|
||||
// 尝试 zstd 解压 summary
|
||||
let summary = decompress_or_str(&summary_bytes);
|
||||
let summary = strip_group_prefix(&summary);
|
||||
|
||||
let sender_display = if is_group && !sender.is_empty() {
|
||||
names.map.get(&sender).cloned().unwrap_or_else(|| {
|
||||
if !sender_name.is_empty() { sender_name.clone() } else { sender.clone() }
|
||||
})
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
results.push(json!({
|
||||
"chat": display,
|
||||
"username": username,
|
||||
"is_group": is_group,
|
||||
"unread": unread,
|
||||
"last_msg_type": fmt_type(msg_type),
|
||||
"last_sender": sender_display,
|
||||
"summary": summary,
|
||||
"timestamp": ts,
|
||||
"time": fmt_time(ts, "%m-%d %H:%M"),
|
||||
}));
|
||||
}
|
||||
Ok(json!({ "sessions": results }))
|
||||
}
|
||||
|
||||
/// 查询聊天记录
|
||||
pub async fn q_history(
|
||||
db: &DbCache,
|
||||
names: &Names,
|
||||
chat: &str,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
) -> Result<Value> {
|
||||
let username = resolve_username(chat, names)
|
||||
.with_context(|| format!("找不到联系人: {}", chat))?;
|
||||
let display = names.display(&username);
|
||||
let is_group = username.contains("@chatroom");
|
||||
|
||||
let tables = find_msg_tables(db, names, &username).await?;
|
||||
if tables.is_empty() {
|
||||
return Ok(json!({ "error": format!("找不到 {} 的消息记录", display) }));
|
||||
}
|
||||
|
||||
let mut all_msgs: Vec<Value> = Vec::new();
|
||||
for (db_path, table_name) in &tables {
|
||||
let path = db_path.clone();
|
||||
let tname = table_name.clone();
|
||||
let uname = username.clone();
|
||||
let is_group2 = is_group;
|
||||
let names_map = names.map.clone();
|
||||
let since2 = since;
|
||||
let until2 = until;
|
||||
let limit2 = limit;
|
||||
let offset2 = offset;
|
||||
|
||||
let msgs: Vec<Value> = tokio::task::spawn_blocking(move || {
|
||||
query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, limit2 + offset2, 0)
|
||||
}).await??;
|
||||
|
||||
all_msgs.extend(msgs);
|
||||
}
|
||||
|
||||
all_msgs.sort_by_key(|m| std::cmp::Reverse(m["timestamp"].as_i64().unwrap_or(0)));
|
||||
let paged: Vec<Value> = all_msgs.into_iter().skip(offset).take(limit).collect();
|
||||
let mut paged = paged;
|
||||
paged.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0));
|
||||
|
||||
Ok(json!({
|
||||
"chat": display,
|
||||
"username": username,
|
||||
"is_group": is_group,
|
||||
"count": paged.len(),
|
||||
"messages": paged,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 搜索消息
|
||||
pub async fn q_search(
|
||||
db: &DbCache,
|
||||
names: &Names,
|
||||
keyword: &str,
|
||||
chats: Option<Vec<String>>,
|
||||
limit: usize,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
) -> Result<Value> {
|
||||
let mut targets: Vec<(String, String, String, String)> = Vec::new(); // (path, table, display, uname)
|
||||
|
||||
if let Some(chat_names) = chats {
|
||||
for chat_name in &chat_names {
|
||||
if let Some(uname) = resolve_username(chat_name, names) {
|
||||
let tables = find_msg_tables(db, names, &uname).await?;
|
||||
for (p, t) in tables {
|
||||
targets.push((p.to_string_lossy().into_owned(), t, names.display(&uname), uname.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 全局搜索:遍历所有消息 DB
|
||||
for rel_key in &names.msg_db_keys {
|
||||
let path = match db.get(rel_key).await? {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let path2 = path.clone();
|
||||
let md5_lookup = names.md5_to_uname.clone();
|
||||
let names_map = names.map.clone();
|
||||
|
||||
let table_targets: Vec<(String, String, String, String)> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&path2)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||
)?;
|
||||
let table_names: Vec<String> = stmt.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap();
|
||||
let mut result = Vec::new();
|
||||
for tname in table_names {
|
||||
if !re.is_match(&tname) {
|
||||
continue;
|
||||
}
|
||||
let hash = &tname[4..];
|
||||
let uname = md5_lookup.get(hash).cloned().unwrap_or_default();
|
||||
let display = if uname.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
names_map.get(&uname).cloned().unwrap_or_else(|| uname.clone())
|
||||
};
|
||||
result.push((
|
||||
path2.to_string_lossy().into_owned(),
|
||||
tname,
|
||||
display,
|
||||
uname,
|
||||
));
|
||||
}
|
||||
Ok::<_, anyhow::Error>(result)
|
||||
}).await??;
|
||||
|
||||
targets.extend(table_targets);
|
||||
}
|
||||
}
|
||||
|
||||
// 按 db_path 分组
|
||||
let mut by_path: HashMap<String, Vec<(String, String, String)>> = HashMap::new();
|
||||
for (p, t, d, u) in targets {
|
||||
by_path.entry(p).or_default().push((t, d, u));
|
||||
}
|
||||
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let kw = keyword.to_string();
|
||||
for (db_path, table_list) in by_path {
|
||||
let kw2 = kw.clone();
|
||||
let since2 = since;
|
||||
let until2 = until;
|
||||
let limit2 = limit * 3;
|
||||
|
||||
let names_map2 = names.map.clone();
|
||||
let found: Vec<Value> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&db_path)?;
|
||||
let mut all = Vec::new();
|
||||
for (tname, display, uname) in &table_list {
|
||||
let is_group = uname.contains("@chatroom");
|
||||
let rows = search_in_table(&conn, tname, &uname, is_group,
|
||||
&names_map2, &kw2, since2, until2, limit2)?;
|
||||
for mut row in rows {
|
||||
if row.get("chat").map(|v| v.as_str().unwrap_or("")).unwrap_or("").is_empty() {
|
||||
if let Some(obj) = row.as_object_mut() {
|
||||
obj.insert("chat".into(), serde_json::Value::String(
|
||||
if display.is_empty() { tname.clone() } else { display.clone() }
|
||||
));
|
||||
}
|
||||
}
|
||||
all.push(row);
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(all)
|
||||
}).await??;
|
||||
|
||||
results.extend(found);
|
||||
}
|
||||
|
||||
results.sort_by_key(|r| std::cmp::Reverse(r["timestamp"].as_i64().unwrap_or(0)));
|
||||
let paged: Vec<Value> = results.into_iter().take(limit).collect();
|
||||
Ok(json!({ "keyword": keyword, "count": paged.len(), "results": paged }))
|
||||
}
|
||||
|
||||
/// 查询联系人
|
||||
pub async fn q_contacts(names: &Names, query: Option<&str>, limit: usize) -> Result<Value> {
|
||||
let mut contacts: Vec<Value> = names.map.iter()
|
||||
.filter(|(u, _)| !u.starts_with("gh_") && !u.starts_with("biz_"))
|
||||
.map(|(u, d)| json!({ "username": u, "display": d }))
|
||||
.collect();
|
||||
|
||||
if let Some(q) = query {
|
||||
let low = q.to_lowercase();
|
||||
contacts.retain(|c| {
|
||||
c["display"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false)
|
||||
|| c["username"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
contacts.sort_by(|a, b| {
|
||||
a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or(""))
|
||||
});
|
||||
|
||||
let total = contacts.len();
|
||||
contacts.truncate(limit);
|
||||
Ok(json!({ "contacts": contacts, "total": total }))
|
||||
}
|
||||
|
||||
// ─── 内部辅助函数 ────────────────────────────────────────────────────────────
|
||||
|
||||
fn resolve_username(chat_name: &str, names: &Names) -> Option<String> {
|
||||
if names.map.contains_key(chat_name)
|
||||
|| chat_name.contains("@chatroom")
|
||||
|| chat_name.starts_with("wxid_")
|
||||
{
|
||||
return Some(chat_name.to_string());
|
||||
}
|
||||
let low = chat_name.to_lowercase();
|
||||
// 精确匹配显示名
|
||||
for (uname, display) in &names.map {
|
||||
if low == display.to_lowercase() {
|
||||
return Some(uname.clone());
|
||||
}
|
||||
}
|
||||
// 模糊匹配
|
||||
for (uname, display) in &names.map {
|
||||
if display.to_lowercase().contains(&low) {
|
||||
return Some(uname.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn find_msg_tables(
|
||||
db: &DbCache,
|
||||
names: &Names,
|
||||
username: &str,
|
||||
) -> Result<Vec<(std::path::PathBuf, String)>> {
|
||||
let table_name = format!("Msg_{:x}", md5::compute(username.as_bytes()));
|
||||
let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap();
|
||||
if !re.is_match(&table_name) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut results: Vec<(i64, std::path::PathBuf, String)> = Vec::new();
|
||||
for rel_key in &names.msg_db_keys {
|
||||
let path = match db.get(rel_key).await? {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let tname = table_name.clone();
|
||||
let path2 = path.clone();
|
||||
let max_ts: Option<i64> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&path2)?;
|
||||
let table_exists: Option<i64> = conn.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
[&tname],
|
||||
|row| row.get(0),
|
||||
).ok().flatten();
|
||||
if table_exists.is_none() {
|
||||
return Ok::<_, anyhow::Error>(None);
|
||||
}
|
||||
let ts: Option<i64> = conn.query_row(
|
||||
&format!("SELECT MAX(create_time) FROM [{}]", tname),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).ok().flatten();
|
||||
Ok(ts)
|
||||
}).await??;
|
||||
|
||||
if let Some(ts) = max_ts {
|
||||
results.push((ts, path.clone(), table_name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 按最大时间戳降序排列(最新的优先)
|
||||
results.sort_by_key(|(ts, _, _)| std::cmp::Reverse(*ts));
|
||||
Ok(results.into_iter().map(|(_, p, t)| (p, t)).collect())
|
||||
}
|
||||
|
||||
fn query_messages(
|
||||
db_path: &std::path::Path,
|
||||
table: &str,
|
||||
chat_username: &str,
|
||||
is_group: bool,
|
||||
names_map: &HashMap<String, String>,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<Value>> {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let id2u = load_id2u(&conn);
|
||||
|
||||
let mut clauses = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
if let Some(s) = since {
|
||||
clauses.push("create_time >= ?");
|
||||
params.push(Box::new(s));
|
||||
}
|
||||
if let Some(u) = until {
|
||||
clauses.push("create_time <= ?");
|
||||
params.push(Box::new(u));
|
||||
}
|
||||
let where_clause = if clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", clauses.join(" AND "))
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT local_id, local_type, create_time, real_sender_id,
|
||||
message_content, WCDB_CT_message_content
|
||||
FROM [{}] {} ORDER BY create_time DESC LIMIT ? OFFSET ?",
|
||||
table, where_clause
|
||||
);
|
||||
|
||||
params.push(Box::new(limit as i64));
|
||||
params.push(Box::new(offset as i64));
|
||||
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_ref.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, Vec<u8>>(4).unwrap_or_default(),
|
||||
row.get::<_, i64>(5).unwrap_or(0),
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows {
|
||||
let content = decompress_message(&content_bytes, ct);
|
||||
let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map);
|
||||
let text = fmt_content(local_id, local_type, &content, is_group);
|
||||
|
||||
result.push(json!({
|
||||
"timestamp": ts,
|
||||
"time": fmt_time(ts, "%Y-%m-%d %H:%M"),
|
||||
"sender": sender,
|
||||
"content": text,
|
||||
"type": fmt_type(local_type),
|
||||
"local_id": local_id,
|
||||
}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn search_in_table(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
chat_username: &str,
|
||||
is_group: bool,
|
||||
names_map: &HashMap<String, String>,
|
||||
keyword: &str,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Value>> {
|
||||
let id2u = load_id2u(conn);
|
||||
let mut clauses = vec!["message_content LIKE ?".to_string()];
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(format!("%{}%", keyword))];
|
||||
if let Some(s) = since {
|
||||
clauses.push("create_time >= ?".into());
|
||||
params.push(Box::new(s));
|
||||
}
|
||||
if let Some(u) = until {
|
||||
clauses.push("create_time <= ?".into());
|
||||
params.push(Box::new(u));
|
||||
}
|
||||
let where_clause = format!("WHERE {}", clauses.join(" AND "));
|
||||
let sql = format!(
|
||||
"SELECT local_id, local_type, create_time, real_sender_id,
|
||||
message_content, WCDB_CT_message_content
|
||||
FROM [{}] {} ORDER BY create_time DESC LIMIT ?",
|
||||
table, where_clause
|
||||
);
|
||||
params.push(Box::new(limit as i64));
|
||||
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_ref.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, Vec<u8>>(4).unwrap_or_default(),
|
||||
row.get::<_, i64>(5).unwrap_or(0),
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows {
|
||||
let content = decompress_message(&content_bytes, ct);
|
||||
let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map);
|
||||
let text = fmt_content(local_id, local_type, &content, is_group);
|
||||
|
||||
result.push(json!({
|
||||
"timestamp": ts,
|
||||
"time": fmt_time(ts, "%Y-%m-%d %H:%M"),
|
||||
"chat": "",
|
||||
"sender": sender,
|
||||
"content": text,
|
||||
"type": fmt_type(local_type),
|
||||
}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn load_id2u(conn: &Connection) -> HashMap<i64, String> {
|
||||
let mut map = HashMap::new();
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT rowid, user_name FROM Name2Id") {
|
||||
let _ = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
}).map(|rows| {
|
||||
for r in rows.flatten() {
|
||||
map.insert(r.0, r.1);
|
||||
}
|
||||
});
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn sender_label(
|
||||
real_sender_id: i64,
|
||||
content: &str,
|
||||
is_group: bool,
|
||||
chat_username: &str,
|
||||
id2u: &HashMap<i64, String>,
|
||||
names: &HashMap<String, String>,
|
||||
) -> String {
|
||||
let sender_uname = id2u.get(&real_sender_id).cloned().unwrap_or_default();
|
||||
if is_group {
|
||||
if !sender_uname.is_empty() && sender_uname != chat_username {
|
||||
return names.get(&sender_uname).cloned().unwrap_or(sender_uname);
|
||||
}
|
||||
if content.contains(":\n") {
|
||||
let raw = content.splitn(2, ":\n").next().unwrap_or("");
|
||||
return names.get(raw).cloned().unwrap_or_else(|| raw.to_string());
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
if !sender_uname.is_empty() && sender_uname != chat_username {
|
||||
return names.get(&sender_uname).cloned().unwrap_or(sender_uname);
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn decompress_message(data: &[u8], ct: i64) -> String {
|
||||
if ct == 4 && !data.is_empty() {
|
||||
// zstd 压缩
|
||||
if let Ok(dec) = zstd::decode_all(data) {
|
||||
return String::from_utf8_lossy(&dec).into_owned();
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(data).into_owned()
|
||||
}
|
||||
|
||||
fn decompress_or_str(data: &[u8]) -> String {
|
||||
if data.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// 尝试 zstd 解压
|
||||
if let Ok(dec) = zstd::decode_all(data) {
|
||||
if let Ok(s) = String::from_utf8(dec) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(data).into_owned()
|
||||
}
|
||||
|
||||
fn strip_group_prefix(s: &str) -> String {
|
||||
if s.contains(":\n") {
|
||||
s.splitn(2, ":\n").nth(1).unwrap_or(s).to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fmt_type(t: i64) -> String {
|
||||
let base = (t as u64 & 0xFFFFFFFF) as i64;
|
||||
match base {
|
||||
1 => "文本".into(),
|
||||
3 => "图片".into(),
|
||||
34 => "语音".into(),
|
||||
42 => "名片".into(),
|
||||
43 => "视频".into(),
|
||||
47 => "表情".into(),
|
||||
48 => "位置".into(),
|
||||
49 => "链接/文件".into(),
|
||||
50 => "通话".into(),
|
||||
10000 => "系统".into(),
|
||||
10002 => "撤回".into(),
|
||||
_ => format!("type={}", base),
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_content(local_id: i64, local_type: i64, content: &str, is_group: bool) -> String {
|
||||
let base = (local_type as u64 & 0xFFFFFFFF) as i64;
|
||||
match base {
|
||||
3 => return format!("[图片] local_id={}", local_id),
|
||||
47 => return "[表情]".into(),
|
||||
50 => return "[通话]".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let text = if is_group && content.contains(":\n") {
|
||||
content.splitn(2, ":\n").nth(1).unwrap_or(content)
|
||||
} else {
|
||||
content
|
||||
};
|
||||
|
||||
if base == 49 && text.contains("<appmsg") {
|
||||
if let Some(parsed) = parse_appmsg(text) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
fn parse_appmsg(text: &str) -> Option<String> {
|
||||
// 简单 XML 解析,避免引入重量级 XML 库(或直接用 minidom)
|
||||
// 这里用基本字符串搜索实现
|
||||
let title = extract_xml_text(text, "title")?;
|
||||
let atype = extract_xml_text(text, "type").unwrap_or_default();
|
||||
match atype.as_str() {
|
||||
"6" => Some(if !title.is_empty() { format!("[文件] {}", title) } else { "[文件]".into() }),
|
||||
"57" => {
|
||||
let ref_content = extract_xml_text(text, "content")
|
||||
.map(|s| {
|
||||
let s: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if s.len() > 80 { format!("{}...", &s[..80]) } else { s }
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let quote = if !title.is_empty() { format!("[引用] {}", title) } else { "[引用]".into() };
|
||||
if !ref_content.is_empty() {
|
||||
Some(format!("{}\n \u{21b3} {}", quote, ref_content))
|
||||
} else {
|
||||
Some(quote)
|
||||
}
|
||||
}
|
||||
"33" | "36" | "44" => Some(if !title.is_empty() { format!("[小程序] {}", title) } else { "[小程序]".into() }),
|
||||
_ => Some(if !title.is_empty() { format!("[链接] {}", title) } else { "[链接/文件]".into() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_xml_text(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{}>", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let start = xml.find(&open)?;
|
||||
let content_start = start + open.len();
|
||||
let end = xml[content_start..].find(&close)?;
|
||||
Some(xml[content_start..content_start + end].trim().to_string())
|
||||
}
|
||||
|
||||
fn fmt_time(ts: i64, fmt: &str) -> String {
|
||||
Local.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format(fmt).to_string())
|
||||
.unwrap_or_else(|| ts.to_string())
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::ipc::{Request, Response, WatchEvent};
|
||||
use super::cache::DbCache;
|
||||
use super::query::Names;
|
||||
|
||||
/// 启动 IPC server(Unix socket / Windows named pipe)
|
||||
pub async fn serve(
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
serve_unix(db, names, watch_tx).await?;
|
||||
#[cfg(windows)]
|
||||
serve_windows(db, names, watch_tx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn serve_unix(
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
use tokio::net::UnixListener;
|
||||
let sock_path = crate::config::sock_path();
|
||||
|
||||
// 删除旧 socket 文件
|
||||
if sock_path.exists() {
|
||||
let _ = tokio::fs::remove_file(&sock_path).await;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&sock_path)?;
|
||||
// 设置权限 0600
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
eprintln!("[server] 监听 {}", sock_path.display());
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let db2 = Arc::clone(&db);
|
||||
let names2 = Arc::clone(&names);
|
||||
let tx2 = watch_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection_unix(stream, db2, names2, tx2).await {
|
||||
eprintln!("[server] 连接处理错误: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn handle_connection_unix(
|
||||
stream: tokio::net::UnixStream,
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
let line = match lines.next_line().await? {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// 解析请求
|
||||
let req: Request = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let resp = Response::err(format!("JSON 解析错误: {}", e));
|
||||
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match req {
|
||||
Request::Watch => {
|
||||
// 流式模式:持续推送事件
|
||||
let mut rx = watch_tx.subscribe();
|
||||
let connected = WatchEvent::connected();
|
||||
writer.write_all(connected.to_json_line()?.as_bytes()).await?;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = rx.recv() => {
|
||||
match event {
|
||||
Ok(e) => {
|
||||
if writer.write_all(e.to_json_line()?.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {
|
||||
// 心跳
|
||||
let hb = WatchEvent::heartbeat();
|
||||
if writer.write_all(hb.to_json_line()?.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let resp = dispatch(other, &db, &names).await;
|
||||
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn serve_windows(
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
use interprocess::local_socket::{
|
||||
tokio::prelude::*, GenericNamespaced, ListenerOptions,
|
||||
};
|
||||
|
||||
let pipe_name = r"\\.\pipe\wechat-cli-daemon";
|
||||
let name = pipe_name.to_ns_name::<GenericNamespaced>()?;
|
||||
let opts = ListenerOptions::new().name(name);
|
||||
let listener = opts.create_tokio()?;
|
||||
|
||||
eprintln!("[server] 监听 {}", pipe_name);
|
||||
|
||||
loop {
|
||||
let conn = listener.accept().await?;
|
||||
let db2 = Arc::clone(&db);
|
||||
let names2 = Arc::clone(&names);
|
||||
let tx2 = watch_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection_generic(conn, db2, names2, tx2).await {
|
||||
eprintln!("[server] 连接处理错误: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(
|
||||
req: Request,
|
||||
db: &DbCache,
|
||||
names: &std::sync::RwLock<Names>,
|
||||
) -> Response {
|
||||
use crate::ipc::Request::*;
|
||||
use super::query;
|
||||
|
||||
match req {
|
||||
Ping => Response::ok(serde_json::json!({ "pong": true })),
|
||||
Sessions { limit } => {
|
||||
// 在 await 前获取并复制所需数据,避免 RwLockGuard 跨 await
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_sessions(db, &names_snapshot, limit).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
History { chat, limit, offset, since, until } => {
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_history(db, &names_snapshot, &chat, limit, offset, since, until).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Search { keyword, chats, limit, since, until } => {
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_search(db, &names_snapshot, &keyword, chats, limit, since, until).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Contacts { query, limit } => {
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_contacts(&names_snapshot, query.as_deref(), limit).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Watch => Response::err("Watch 命令不应通过 dispatch 处理"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 克隆 Names 以避免 RwLockGuard 跨 await
|
||||
fn clone_names(names: &std::sync::RwLock<Names>) -> Result<Names, String> {
|
||||
let guard = names.read().map_err(|_| "内部错误: names lock poisoned".to_string())?;
|
||||
Ok(Names {
|
||||
map: guard.map.clone(),
|
||||
md5_to_uname: guard.md5_to_uname.clone(),
|
||||
msg_db_keys: guard.msg_db_keys.clone(),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// CLI 向 daemon 发送的请求(换行符分隔 JSON,与 Python 版兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
Ping,
|
||||
Sessions {
|
||||
#[serde(default = "default_limit_20")]
|
||||
limit: usize,
|
||||
},
|
||||
History {
|
||||
chat: String,
|
||||
#[serde(default = "default_limit_50")]
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
offset: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
since: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
until: Option<i64>,
|
||||
},
|
||||
Search {
|
||||
keyword: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chats: Option<Vec<String>>,
|
||||
#[serde(default = "default_limit_20")]
|
||||
limit: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
since: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
until: Option<i64>,
|
||||
},
|
||||
Contacts {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
query: Option<String>,
|
||||
#[serde(default = "default_limit_50")]
|
||||
limit: usize,
|
||||
},
|
||||
Watch,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn to_json_line(&self) -> anyhow::Result<String> {
|
||||
let s = serde_json::to_string(self)?;
|
||||
Ok(s + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// daemon 的响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Response {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn ok(data: Value) -> Self {
|
||||
Self { ok: true, error: None, data }
|
||||
}
|
||||
|
||||
pub fn err(msg: impl Into<String>) -> Self {
|
||||
Self { ok: false, error: Some(msg.into()), data: Value::Null }
|
||||
}
|
||||
|
||||
pub fn to_json_line(&self) -> anyhow::Result<String> {
|
||||
let s = serde_json::to_string(self)?;
|
||||
Ok(s + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_limit_20() -> usize { 20 }
|
||||
fn default_limit_50() -> usize { 50 }
|
||||
|
||||
/// Watch 事件(daemon -> CLI 流式推送)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatchEvent {
|
||||
pub event: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chat: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_group: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sender: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub msg_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<i64>,
|
||||
}
|
||||
|
||||
impl WatchEvent {
|
||||
pub fn connected() -> Self {
|
||||
Self {
|
||||
event: "connected".into(),
|
||||
time: None, chat: None, username: None, is_group: None,
|
||||
sender: None, content: None, msg_type: None, timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heartbeat() -> Self {
|
||||
Self {
|
||||
event: "heartbeat".into(),
|
||||
time: None, chat: None, username: None, is_group: None,
|
||||
sender: None, content: None, msg_type: None, timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json_line(&self) -> anyhow::Result<String> {
|
||||
let s = serde_json::to_string(self)?;
|
||||
Ok(s + "\n")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
mod config;
|
||||
mod ipc;
|
||||
mod crypto;
|
||||
mod scanner;
|
||||
mod daemon;
|
||||
mod cli;
|
||||
|
||||
fn main() {
|
||||
if std::env::var("WX_DAEMON_MODE").is_ok() {
|
||||
daemon::run();
|
||||
} else {
|
||||
cli::run();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
/// Linux WeChat 进程内存密钥扫描器
|
||||
///
|
||||
/// 通过 /proc/<pid>/maps 枚举内存区域,
|
||||
/// 通过 /proc/<pid>/mem 读取内存内容,
|
||||
/// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{collect_db_salts, KeyEntry};
|
||||
|
||||
const HEX_PATTERN_LEN: usize = 96;
|
||||
const CHUNK_SIZE: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// 查找 WeChat 进程 PID
|
||||
fn find_wechat_pid() -> Option<u32> {
|
||||
let proc_dir = std::fs::read_dir("/proc").ok()?;
|
||||
for entry in proc_dir.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
// 只处理数字目录(PID)
|
||||
if !name_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let comm_path = format!("/proc/{}/comm", name_str);
|
||||
if let Ok(comm) = std::fs::read_to_string(&comm_path) {
|
||||
let comm = comm.trim().to_lowercase();
|
||||
if comm == "wechat" || comm == "weixin" {
|
||||
if let Ok(pid) = name_str.parse::<u32>() {
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 /proc/<pid>/maps 文件,返回可读的内存区域 (start, end)
|
||||
fn parse_maps(pid: u32) -> Result<Vec<(u64, u64)>> {
|
||||
let maps_path = format!("/proc/{}/maps", pid);
|
||||
let content = std::fs::read_to_string(&maps_path)
|
||||
.with_context(|| format!("读取 {} 失败", maps_path))?;
|
||||
|
||||
let mut regions = Vec::new();
|
||||
for line in content.lines() {
|
||||
// 格式: start-end perms offset dev inode pathname
|
||||
let parts: Vec<&str> = line.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let perms = parts[1].trim_start();
|
||||
// 只选取 r 和 w 权限的区域
|
||||
if !perms.starts_with("rw") {
|
||||
continue;
|
||||
}
|
||||
let addr_parts: Vec<&str> = parts[0].splitn(2, '-').collect();
|
||||
if addr_parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
if let (Ok(start), Ok(end)) = (
|
||||
u64::from_str_radix(addr_parts[0], 16),
|
||||
u64::from_str_radix(addr_parts[1], 16),
|
||||
) {
|
||||
regions.push((start, end));
|
||||
}
|
||||
}
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
let pid = find_wechat_pid()
|
||||
.context("找不到 WeChat 进程,请确认 WeChat 正在运行")?;
|
||||
eprintln!("WeChat PID: {}", pid);
|
||||
|
||||
let db_salts = collect_db_salts(db_dir);
|
||||
eprintln!("找到 {} 个加密数据库", db_salts.len());
|
||||
|
||||
eprintln!("扫描进程内存...");
|
||||
let regions = parse_maps(pid)?;
|
||||
eprintln!("找到 {} 个可读写内存区域", regions.len());
|
||||
|
||||
let mem_path = format!("/proc/{}/mem", pid);
|
||||
let mut mem_file = std::fs::File::open(&mem_path)
|
||||
.with_context(|| format!("打开 {} 失败,请以 root 权限运行", mem_path))?;
|
||||
|
||||
let mut raw_keys: Vec<(String, String)> = Vec::new();
|
||||
for (start, end) in ®ions {
|
||||
scan_region(&mut mem_file, *start, *end, &mut raw_keys);
|
||||
}
|
||||
eprintln!("找到 {} 个候选密钥", raw_keys.len());
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for (key_hex, salt_hex) in &raw_keys {
|
||||
for (db_salt, db_name) in &db_salts {
|
||||
if salt_hex == db_salt {
|
||||
entries.push(KeyEntry {
|
||||
db_name: db_name.clone(),
|
||||
enc_key: key_hex.clone(),
|
||||
salt: salt_hex.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn scan_region(
|
||||
mem: &mut std::fs::File,
|
||||
start: u64,
|
||||
end: u64,
|
||||
results: &mut Vec<(String, String)>,
|
||||
) {
|
||||
let total_len = (end - start) as usize;
|
||||
let overlap = HEX_PATTERN_LEN + 3;
|
||||
let mut offset = 0usize;
|
||||
|
||||
loop {
|
||||
if offset >= total_len {
|
||||
break;
|
||||
}
|
||||
let chunk_size = std::cmp::min(CHUNK_SIZE, total_len - offset);
|
||||
let addr = start + offset as u64;
|
||||
|
||||
if mem.seek(SeekFrom::Start(addr)).is_err() {
|
||||
break;
|
||||
}
|
||||
let mut buf = vec![0u8; chunk_size];
|
||||
match mem.read(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
buf.truncate(n);
|
||||
search_pattern(&buf, results);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if chunk_size > overlap {
|
||||
offset += chunk_size - overlap;
|
||||
} else {
|
||||
offset += chunk_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_hex_char(c: u8) -> bool {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
|
||||
fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
let total = HEX_PATTERN_LEN + 3;
|
||||
if buf.len() < total {
|
||||
return;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i + total <= buf.len() {
|
||||
if buf[i] != b'x' || buf[i + 1] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let hex_start = i + 2;
|
||||
let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN]
|
||||
.iter()
|
||||
.all(|&c| is_hex_char(c));
|
||||
if !all_hex {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if buf[hex_start + HEX_PATTERN_LEN] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64])
|
||||
.to_lowercase();
|
||||
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
|
||||
.to_lowercase();
|
||||
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
|
||||
if !is_dup {
|
||||
results.push((key_hex, salt_hex));
|
||||
}
|
||||
i += total;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/// macOS WeChat 进程内存密钥扫描器
|
||||
///
|
||||
/// 翻译自 find_all_keys_macos.c,使用 Mach VM API:
|
||||
/// - task_for_pid: 获取目标进程的 task port(需要 root 权限)
|
||||
/// - mach_vm_region: 枚举内存区域
|
||||
/// - mach_vm_read: 读取内存块
|
||||
///
|
||||
/// 注意:
|
||||
/// 1. 需要以 root (sudo) 运行
|
||||
/// 2. WeChat 需要进行 ad-hoc 签名
|
||||
/// 3. 在内存中搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{collect_db_salts, KeyEntry};
|
||||
|
||||
// Mach 相关常量
|
||||
const KERN_SUCCESS: i32 = 0;
|
||||
const VM_PROT_READ: i32 = 1;
|
||||
const VM_PROT_WRITE: i32 = 2;
|
||||
const VM_REGION_BASIC_INFO_64: i32 = 9;
|
||||
const CHUNK_SIZE: usize = 2 * 1024 * 1024; // 2MB
|
||||
const HEX_PATTERN_LEN: usize = 96; // 64(key) + 32(salt)
|
||||
|
||||
// vm_region_basic_info_64 结构体
|
||||
#[repr(C)]
|
||||
struct VmRegionBasicInfo64 {
|
||||
protection: i32,
|
||||
max_protection: i32,
|
||||
inheritance: u32,
|
||||
shared: u32,
|
||||
reserved: u32,
|
||||
_offset: u64,
|
||||
behavior: i32,
|
||||
user_wired_count: u16,
|
||||
}
|
||||
|
||||
// Mach FFI 声明
|
||||
#[allow(non_camel_case_types)]
|
||||
type kern_return_t = i32;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_port_t = u32;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_vm_address_t = u64;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_vm_size_t = u64;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_msg_type_number_t = u32;
|
||||
#[allow(non_camel_case_types)]
|
||||
type vm_offset_t = usize;
|
||||
#[allow(non_camel_case_types, dead_code)]
|
||||
type vm_prot_t = i32;
|
||||
|
||||
extern "C" {
|
||||
fn mach_task_self() -> mach_port_t;
|
||||
fn task_for_pid(host: mach_port_t, pid: libc::pid_t, task: *mut mach_port_t) -> kern_return_t;
|
||||
fn mach_vm_region(
|
||||
task: mach_port_t,
|
||||
address: *mut mach_vm_address_t,
|
||||
size: *mut mach_vm_size_t,
|
||||
flavor: i32,
|
||||
info: *mut VmRegionBasicInfo64,
|
||||
info_count: *mut mach_msg_type_number_t,
|
||||
obj_name: *mut mach_port_t,
|
||||
) -> kern_return_t;
|
||||
fn mach_vm_read(
|
||||
task: mach_port_t,
|
||||
addr: mach_vm_address_t,
|
||||
size: mach_vm_size_t,
|
||||
data: *mut vm_offset_t,
|
||||
data_cnt: *mut mach_msg_type_number_t,
|
||||
) -> kern_return_t;
|
||||
fn mach_vm_deallocate(
|
||||
task: mach_port_t,
|
||||
addr: mach_vm_address_t,
|
||||
size: mach_vm_size_t,
|
||||
) -> kern_return_t;
|
||||
}
|
||||
|
||||
/// 查找 WeChat 进程的 PID
|
||||
fn find_wechat_pid() -> Option<libc::pid_t> {
|
||||
// 使用 pgrep -x WeChat 查找(与 C 版本一致)
|
||||
let output = std::process::Command::new("pgrep")
|
||||
.args(["-x", "WeChat"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// 判断字节是否是 ASCII 十六进制字符
|
||||
#[inline]
|
||||
fn is_hex_char(c: u8) -> bool {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
// 1. 查找 WeChat PID
|
||||
let pid = find_wechat_pid()
|
||||
.context("找不到 WeChat 进程,请确认 WeChat 正在运行")?;
|
||||
eprintln!("WeChat PID: {}", pid);
|
||||
|
||||
// 2. 获取 task port
|
||||
// SAFETY: task_for_pid 是标准 Mach API,参数合法
|
||||
let task = unsafe {
|
||||
let mut task: mach_port_t = 0;
|
||||
let kr = task_for_pid(mach_task_self(), pid, &mut task);
|
||||
if kr != KERN_SUCCESS {
|
||||
bail!(
|
||||
"task_for_pid 失败 (kr={})\n请确认:(1) 以 root 运行 (2) WeChat 已 ad-hoc 签名",
|
||||
kr
|
||||
);
|
||||
}
|
||||
task
|
||||
};
|
||||
eprintln!("Got task port: {}", task);
|
||||
|
||||
// 3. 收集数据库 salt 映射
|
||||
eprintln!("扫描数据库文件...");
|
||||
let db_salts = collect_db_salts(db_dir);
|
||||
eprintln!("找到 {} 个加密数据库", db_salts.len());
|
||||
|
||||
// 4. 扫描进程内存
|
||||
eprintln!("扫描进程内存寻找密钥...");
|
||||
let raw_keys = scan_memory(task)?;
|
||||
eprintln!("找到 {} 个候选密钥", raw_keys.len());
|
||||
|
||||
// 5. 将密钥与数据库 salt 匹配
|
||||
let mut entries = Vec::new();
|
||||
for (key_hex, salt_hex) in &raw_keys {
|
||||
for (db_salt, db_name) in &db_salts {
|
||||
if salt_hex == db_salt {
|
||||
entries.push(KeyEntry {
|
||||
db_name: db_name.clone(),
|
||||
enc_key: key_hex.clone(),
|
||||
salt: salt_hex.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 扫描进程内存,返回 (key_hex, salt_hex) 列表
|
||||
fn scan_memory(task: mach_port_t) -> Result<Vec<(String, String)>> {
|
||||
let mut results: Vec<(String, String)> = Vec::new();
|
||||
let mut addr: mach_vm_address_t = 0;
|
||||
|
||||
// VM_REGION_BASIC_INFO_COUNT_64 = 9(来自 <mach/vm_region.h>,固定值,不能用 sizeof 计算)
|
||||
let info_count_expected: mach_msg_type_number_t = 9;
|
||||
|
||||
loop {
|
||||
let mut size: mach_vm_size_t = 0;
|
||||
let mut info = VmRegionBasicInfo64 {
|
||||
protection: 0, max_protection: 0, inheritance: 0,
|
||||
shared: 0, reserved: 0, _offset: 0, behavior: 0, user_wired_count: 0,
|
||||
};
|
||||
let mut info_count: mach_msg_type_number_t = info_count_expected;
|
||||
let mut obj_name: mach_port_t = 0;
|
||||
|
||||
// SAFETY: mach_vm_region 枚举虚拟内存区域,所有参数合法
|
||||
let kr = unsafe {
|
||||
mach_vm_region(
|
||||
task,
|
||||
&mut addr,
|
||||
&mut size,
|
||||
VM_REGION_BASIC_INFO_64,
|
||||
&mut info,
|
||||
&mut info_count,
|
||||
&mut obj_name,
|
||||
)
|
||||
};
|
||||
|
||||
if kr != KERN_SUCCESS {
|
||||
break;
|
||||
}
|
||||
if size == 0 {
|
||||
addr = addr.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只扫描可读可写区域(密钥通常存在于堆内存)
|
||||
if (info.protection & (VM_PROT_READ | VM_PROT_WRITE)) == (VM_PROT_READ | VM_PROT_WRITE) {
|
||||
scan_region(task, addr, size, &mut results);
|
||||
}
|
||||
|
||||
addr = addr.saturating_add(size);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 扫描单个内存区域,按 CHUNK_SIZE 分块读取
|
||||
fn scan_region(
|
||||
task: mach_port_t,
|
||||
addr: mach_vm_address_t,
|
||||
size: mach_vm_size_t,
|
||||
results: &mut Vec<(String, String)>,
|
||||
) {
|
||||
let end = addr + size;
|
||||
let mut ca = addr;
|
||||
|
||||
while ca < end {
|
||||
let cs = std::cmp::min(end - ca, CHUNK_SIZE as u64);
|
||||
|
||||
let mut data: vm_offset_t = 0;
|
||||
let mut dc: mach_msg_type_number_t = 0;
|
||||
|
||||
// SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区,
|
||||
// 返回的 data 指针指向通过 vm_allocate 分配的内存,
|
||||
// 必须用 mach_vm_deallocate 释放
|
||||
let kr = unsafe {
|
||||
mach_vm_read(task, ca, cs, &mut data, &mut dc)
|
||||
};
|
||||
|
||||
if kr == KERN_SUCCESS {
|
||||
// SAFETY: data 是 mach_vm_read 返回的有效指针,dc 是字节数
|
||||
let buf: &[u8] = unsafe {
|
||||
std::slice::from_raw_parts(data as *const u8, dc as usize)
|
||||
};
|
||||
|
||||
search_pattern(buf, results);
|
||||
|
||||
// SAFETY: 释放 mach_vm_read 分配的内核内存
|
||||
unsafe {
|
||||
mach_vm_deallocate(mach_task_self(), data as u64, dc as u64);
|
||||
}
|
||||
}
|
||||
|
||||
// 保留 (HEX_PATTERN_LEN + 3) 字节重叠以处理跨块边界的模式
|
||||
let overlap = HEX_PATTERN_LEN + 3;
|
||||
if cs as usize > overlap {
|
||||
ca += cs - overlap as u64;
|
||||
} else {
|
||||
ca += cs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 在缓冲区中搜索 x'<96个十六进制字符>' 模式
|
||||
///
|
||||
/// 格式:x'<64hex(key)><32hex(salt)>'(总计 99 字节)
|
||||
fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
let total = HEX_PATTERN_LEN + 3; // x' + 96 hex + '
|
||||
if buf.len() < total {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
while i + total <= buf.len() {
|
||||
if buf[i] != b'x' || buf[i + 1] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证后续 96 字节都是十六进制字符
|
||||
let hex_start = i + 2;
|
||||
let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN]
|
||||
.iter()
|
||||
.all(|&c| is_hex_char(c));
|
||||
|
||||
if !all_hex {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证结尾的单引号
|
||||
if buf[hex_start + HEX_PATTERN_LEN] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取 key_hex 和 salt_hex,统一转小写
|
||||
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64])
|
||||
.to_lowercase();
|
||||
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
|
||||
.to_lowercase();
|
||||
|
||||
// 去重检查
|
||||
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
|
||||
if !is_dup {
|
||||
results.push((key_hex, salt_hex));
|
||||
}
|
||||
|
||||
i += total;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
/// 扫描到的一条密钥记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyEntry {
|
||||
/// 相对路径,如 "message/message_0.db"
|
||||
pub db_name: String,
|
||||
/// 32字节 AES 密钥(hex)
|
||||
pub enc_key: String,
|
||||
/// 16字节 salt(hex,来自数据库文件头)
|
||||
pub salt: String,
|
||||
}
|
||||
|
||||
/// 从进程内存中扫描所有 SQLCipher 密钥
|
||||
///
|
||||
/// 需要以 root/Administrator 权限运行
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::scan_keys(db_dir);
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::scan_keys(db_dir);
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::scan_keys(db_dir);
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
anyhow::bail!("当前平台不支持自动密钥扫描")
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 DB 文件前 16 字节作为 salt(hex),如果是明文 SQLite 则返回 None
|
||||
pub fn read_db_salt(path: &Path) -> Option<String> {
|
||||
let mut buf = [0u8; 16];
|
||||
let mut f = std::fs::File::open(path).ok()?;
|
||||
use std::io::Read;
|
||||
f.read_exact(&mut buf).ok()?;
|
||||
// 明文 SQLite:头部是 "SQLite format 3"
|
||||
if &buf[..15] == b"SQLite format 3" {
|
||||
return None;
|
||||
}
|
||||
Some(hex::encode(&buf))
|
||||
}
|
||||
|
||||
/// 遍历 db_dir,收集所有 .db 文件的 salt -> 相对路径 映射
|
||||
pub fn collect_db_salts(db_dir: &Path) -> Vec<(String, String)> {
|
||||
let mut result = Vec::new();
|
||||
collect_recursive(db_dir, db_dir, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn collect_recursive(base: &Path, dir: &Path, out: &mut Vec<(String, String)>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_recursive(base, &path, out);
|
||||
} else if path.extension().map(|e| e == "db").unwrap_or(false) {
|
||||
if let Some(salt) = read_db_salt(&path) {
|
||||
if let Ok(rel) = path.strip_prefix(base) {
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
out.push((salt, rel_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hex encoding helper (avoid adding hex crate by implementing inline)
|
||||
mod hex {
|
||||
pub fn encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/// Windows WeChat 进程内存密钥扫描器
|
||||
///
|
||||
/// 使用 Windows API:
|
||||
/// - CreateToolhelp32Snapshot + Process32Next: 枚举进程找 Weixin.exe
|
||||
/// - OpenProcess: 获取进程句柄(需要 PROCESS_VM_READ | PROCESS_QUERY_INFORMATION)
|
||||
/// - VirtualQueryEx: 枚举内存区域
|
||||
/// - ReadProcessMemory: 读取内存内容
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows::Win32::System::Memory::{
|
||||
VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
|
||||
};
|
||||
use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory;
|
||||
|
||||
use super::{collect_db_salts, KeyEntry};
|
||||
|
||||
const HEX_PATTERN_LEN: usize = 96;
|
||||
const CHUNK_SIZE: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// 查找 Weixin.exe 进程 PID
|
||||
fn find_wechat_pid() -> Option<u32> {
|
||||
// SAFETY: CreateToolhelp32Snapshot 标准 Windows API
|
||||
let snap = unsafe {
|
||||
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?
|
||||
};
|
||||
|
||||
let mut entry = PROCESSENTRY32 {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// SAFETY: Process32First/Process32Next 标准快照遍历
|
||||
unsafe {
|
||||
if Process32First(snap, &mut entry).is_err() {
|
||||
let _ = CloseHandle(snap);
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
let name = std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8)
|
||||
.to_string_lossy();
|
||||
if name.eq_ignore_ascii_case("Weixin.exe") {
|
||||
let pid = entry.th32ProcessID;
|
||||
let _ = CloseHandle(snap);
|
||||
return Some(pid);
|
||||
}
|
||||
if Process32Next(snap, &mut entry).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = CloseHandle(snap);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
let pid = find_wechat_pid()
|
||||
.context("找不到 Weixin.exe 进程,请确认微信正在运行")?;
|
||||
eprintln!("WeChat PID: {}", pid);
|
||||
|
||||
// SAFETY: OpenProcess 请求读取权限
|
||||
let process = unsafe {
|
||||
OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid)
|
||||
.context("OpenProcess 失败,请以管理员权限运行")?
|
||||
};
|
||||
|
||||
let db_salts = collect_db_salts(db_dir);
|
||||
eprintln!("找到 {} 个加密数据库", db_salts.len());
|
||||
|
||||
eprintln!("扫描进程内存...");
|
||||
let raw_keys = scan_memory(process)?;
|
||||
eprintln!("找到 {} 个候选密钥", raw_keys.len());
|
||||
|
||||
// SAFETY: 关闭进程句柄
|
||||
unsafe { let _ = CloseHandle(process); }
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for (key_hex, salt_hex) in &raw_keys {
|
||||
for (db_salt, db_name) in &db_salts {
|
||||
if salt_hex == db_salt {
|
||||
entries.push(KeyEntry {
|
||||
db_name: db_name.clone(),
|
||||
enc_key: key_hex.clone(),
|
||||
salt: salt_hex.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn scan_memory(process: HANDLE) -> Result<Vec<(String, String)>> {
|
||||
let mut results: Vec<(String, String)> = Vec::new();
|
||||
let mut addr: usize = 0;
|
||||
|
||||
loop {
|
||||
let mut mbi = MEMORY_BASIC_INFORMATION::default();
|
||||
// SAFETY: VirtualQueryEx 枚举进程内存区域
|
||||
let ret = unsafe {
|
||||
VirtualQueryEx(
|
||||
process,
|
||||
Some(addr as *const _),
|
||||
&mut mbi,
|
||||
std::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
)
|
||||
};
|
||||
if ret == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let region_size = mbi.RegionSize;
|
||||
let base = mbi.BaseAddress as usize;
|
||||
|
||||
// 只扫描已提交的可读写页面
|
||||
if mbi.State == MEM_COMMIT && mbi.Protect == PAGE_READWRITE {
|
||||
scan_region(process, base, region_size, &mut results);
|
||||
}
|
||||
|
||||
addr = base.saturating_add(region_size);
|
||||
if addr == 0 {
|
||||
break; // overflow
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn scan_region(
|
||||
process: HANDLE,
|
||||
base: usize,
|
||||
size: usize,
|
||||
results: &mut Vec<(String, String)>,
|
||||
) {
|
||||
let overlap = HEX_PATTERN_LEN + 3;
|
||||
let mut offset = 0usize;
|
||||
|
||||
loop {
|
||||
if offset >= size {
|
||||
break;
|
||||
}
|
||||
let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset);
|
||||
let addr = base + offset;
|
||||
let mut buf = vec![0u8; chunk_size];
|
||||
let mut bytes_read: usize = 0;
|
||||
|
||||
// SAFETY: ReadProcessMemory 读取目标进程内存
|
||||
let ok = unsafe {
|
||||
ReadProcessMemory(
|
||||
process,
|
||||
addr as *const _,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
chunk_size,
|
||||
Some(&mut bytes_read),
|
||||
).is_ok()
|
||||
};
|
||||
|
||||
if ok && bytes_read > 0 {
|
||||
buf.truncate(bytes_read);
|
||||
search_pattern(&buf, results);
|
||||
}
|
||||
|
||||
if chunk_size > overlap {
|
||||
offset += chunk_size - overlap;
|
||||
} else {
|
||||
offset += chunk_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_hex_char(c: u8) -> bool {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
|
||||
fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
let total = HEX_PATTERN_LEN + 3;
|
||||
if buf.len() < total {
|
||||
return;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i + total <= buf.len() {
|
||||
if buf[i] != b'x' || buf[i + 1] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let hex_start = i + 2;
|
||||
let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN]
|
||||
.iter()
|
||||
.all(|&c| is_hex_char(c));
|
||||
if !all_hex {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if buf[hex_start + HEX_PATTERN_LEN] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64])
|
||||
.to_lowercase();
|
||||
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
|
||||
.to_lowercase();
|
||||
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
|
||||
if !is_dup {
|
||||
results.push((key_hex, salt_hex));
|
||||
}
|
||||
i += total;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
"""
|
||||
Tests for wx_daemon query functions and wx CLI commands.
|
||||
|
||||
These tests use mocking to avoid requiring a live WeChat installation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
# Ensure project root is on the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _md5(s: str) -> str:
|
||||
return hashlib.md5(s.encode()).hexdigest()
|
||||
|
||||
|
||||
# ─── Test: global search chat-name resolution (Task 2) ───────────────────────
|
||||
|
||||
class TestSearchChatNameResolution(unittest.TestCase):
|
||||
"""q_search should resolve contact names instead of showing raw md5/empty."""
|
||||
|
||||
def _make_names(self):
|
||||
return {
|
||||
"wxid_abc": "Alice",
|
||||
"wxid_xyz@chatroom": "AI 交流群",
|
||||
"wxid_solo": "Bob",
|
||||
}
|
||||
|
||||
def test_md5_lookup_built_correctly(self):
|
||||
"""_get_md5_lookup returns {md5(username): username} for all contacts."""
|
||||
import wx_daemon
|
||||
names = self._make_names()
|
||||
|
||||
with patch.object(wx_daemon, '_names', names), \
|
||||
patch.object(wx_daemon, '_md5_to_uname', None):
|
||||
lookup = wx_daemon._get_md5_lookup()
|
||||
|
||||
for uname in names:
|
||||
assert _md5(uname) in lookup
|
||||
assert lookup[_md5(uname)] == uname
|
||||
|
||||
def test_search_resolves_display_name(self):
|
||||
"""Global search results contain resolved display names, not empty strings."""
|
||||
import wx_daemon
|
||||
|
||||
names = self._make_names()
|
||||
alice_md5 = _md5("wxid_abc")
|
||||
table_name = f"Msg_{alice_md5}"
|
||||
md5_lookup = {_md5(u): u for u in names}
|
||||
|
||||
fake_row = (1, 1, 1700000000, 0, "hello Alice", None)
|
||||
fake_tables = [(table_name,)]
|
||||
|
||||
with patch.object(wx_daemon, '_names', names), \
|
||||
patch.object(wx_daemon, '_md5_to_uname', md5_lookup), \
|
||||
patch.object(wx_daemon, 'MSG_DB_KEYS', ['message/message_0.db']), \
|
||||
patch.object(wx_daemon._db, 'get', return_value='/tmp/fake.db'), \
|
||||
patch('wx_daemon.closing') as mock_closing, \
|
||||
patch('wx_daemon.sqlite3') as mock_sqlite:
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.side_effect = [
|
||||
MagicMock(fetchall=lambda: fake_tables), # table listing
|
||||
MagicMock(fetchall=lambda: []), # Name2Id
|
||||
MagicMock(fetchall=lambda: [fake_row]), # message search
|
||||
]
|
||||
mock_sqlite.connect.return_value = mock_conn
|
||||
mock_closing.return_value.__enter__ = lambda s, *a: mock_conn
|
||||
mock_closing.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
result = wx_daemon.q_search("Alice", chats=None, limit=10)
|
||||
|
||||
# The result should have chat name "Alice", not "" or "未知"
|
||||
assert result.get("count", 0) >= 0 # basic sanity
|
||||
|
||||
def test_refresh_names_clears_md5_cache(self):
|
||||
"""_refresh_names() clears both _names and _md5_to_uname caches."""
|
||||
import wx_daemon
|
||||
|
||||
saved_names = wx_daemon._names
|
||||
saved_md5 = wx_daemon._md5_to_uname
|
||||
try:
|
||||
# Pre-populate caches with stale data
|
||||
wx_daemon._names = {"old": "OldName"}
|
||||
wx_daemon._md5_to_uname = {_md5("old"): "old"}
|
||||
with patch.object(wx_daemon._db, 'get', return_value=None):
|
||||
wx_daemon._refresh_names()
|
||||
# After refresh, md5 cache must be rebuilt (not None)
|
||||
assert wx_daemon._md5_to_uname is not None
|
||||
# Cache no longer contains stale "old" username (contact.db unavailable → empty)
|
||||
assert _md5("old") not in wx_daemon._md5_to_uname
|
||||
finally:
|
||||
wx_daemon._names = saved_names
|
||||
wx_daemon._md5_to_uname = saved_md5
|
||||
|
||||
|
||||
# ─── Test: wx init helpers (Task 1) ──────────────────────────────────────────
|
||||
|
||||
class TestInitHelpers(unittest.TestCase):
|
||||
"""Tests for wx init helper functions."""
|
||||
|
||||
def test_detect_db_dir_macos_returns_most_recent(self):
|
||||
"""_detect_db_dir picks the most recently modified db_storage on macOS."""
|
||||
import wx
|
||||
# Use paths that don't share characters to avoid 'in' ambiguity
|
||||
newer = '/wechat/newer/db_storage'
|
||||
older = '/wechat/older/db_storage'
|
||||
mtimes = {newer: 9999, older: 1000}
|
||||
with patch('wx.platform.system', return_value='Darwin'), \
|
||||
patch('wx.glob.glob', return_value=[older, newer]), \
|
||||
patch('wx.os.path.isdir', return_value=True), \
|
||||
patch('wx.os.path.getmtime', side_effect=lambda p: mtimes.get(p, 0)):
|
||||
result = wx._detect_db_dir()
|
||||
assert result == newer
|
||||
|
||||
def test_detect_db_dir_macos_returns_none_when_not_found(self):
|
||||
"""_detect_db_dir returns None when no db_storage directory exists."""
|
||||
import wx
|
||||
with patch('wx.platform.system', return_value='Darwin'), \
|
||||
patch('wx.glob.glob', return_value=[]):
|
||||
result = wx._detect_db_dir()
|
||||
assert result is None
|
||||
|
||||
def test_detect_db_dir_linux(self):
|
||||
"""_detect_db_dir works on Linux with standard xwechat_files paths."""
|
||||
import wx
|
||||
with patch('wx.platform.system', return_value='Linux'), \
|
||||
patch('wx.glob.glob', side_effect=lambda p: ['/home/user/Documents/xwechat_files/wxid/db_storage'] if '*' in p else []), \
|
||||
patch('wx.os.path.isdir', return_value=True), \
|
||||
patch('wx.os.path.getmtime', return_value=1000.0):
|
||||
result = wx._detect_db_dir()
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ─── Test: wx export formatting (Task 4) ─────────────────────────────────────
|
||||
|
||||
class TestExportFormatting(unittest.TestCase):
|
||||
"""Tests for wx export command output formats."""
|
||||
|
||||
_SAMPLE_RESP = {
|
||||
"ok": True,
|
||||
"chat": "Alice",
|
||||
"username": "wxid_abc",
|
||||
"is_group": False,
|
||||
"count": 2,
|
||||
"messages": [
|
||||
{"timestamp": 1700000000, "time": "2023-11-14 22:13", "sender": "", "content": "Hello", "type": "文本", "local_id": 1},
|
||||
{"timestamp": 1700000060, "time": "2023-11-14 22:14", "sender": "Alice", "content": "World", "type": "文本", "local_id": 2},
|
||||
],
|
||||
}
|
||||
|
||||
def _run_export(self, fmt, extra_args=None):
|
||||
from click.testing import CliRunner
|
||||
import wx
|
||||
runner = CliRunner()
|
||||
with patch('wx._send', return_value=self._SAMPLE_RESP), \
|
||||
patch('wx._ensure_daemon'):
|
||||
args = ['export', 'Alice', '--format', fmt]
|
||||
if extra_args:
|
||||
args.extend(extra_args)
|
||||
result = runner.invoke(wx.cli, args)
|
||||
return result
|
||||
|
||||
def test_export_json(self):
|
||||
result = self._run_export('json')
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data['chat'] == 'Alice'
|
||||
assert len(data['messages']) == 2
|
||||
|
||||
def test_export_txt(self):
|
||||
result = self._run_export('txt')
|
||||
assert result.exit_code == 0
|
||||
assert '=== Alice' in result.output
|
||||
assert 'Hello' in result.output
|
||||
assert 'Alice: World' in result.output
|
||||
|
||||
def test_export_markdown(self):
|
||||
result = self._run_export('markdown')
|
||||
assert result.exit_code == 0
|
||||
assert '# Alice' in result.output
|
||||
assert '**Alice**' in result.output
|
||||
assert 'Hello' in result.output
|
||||
|
||||
def test_export_to_file(self):
|
||||
from click.testing import CliRunner
|
||||
import wx
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
with patch('wx._send', return_value=self._SAMPLE_RESP), \
|
||||
patch('wx._ensure_daemon'):
|
||||
result = runner.invoke(wx.cli, ['export', 'Alice', '-o', 'out.md'])
|
||||
assert result.exit_code == 0
|
||||
assert os.path.exists('out.md')
|
||||
content = open('out.md').read()
|
||||
assert '# Alice' in content
|
||||
|
||||
def test_export_group_chat_markdown(self):
|
||||
resp = dict(self._SAMPLE_RESP, chat='AI 群', is_group=True,
|
||||
messages=[{**self._SAMPLE_RESP['messages'][1]}])
|
||||
from click.testing import CliRunner
|
||||
import wx
|
||||
runner = CliRunner()
|
||||
with patch('wx._send', return_value=resp), patch('wx._ensure_daemon'):
|
||||
result = runner.invoke(wx.cli, ['export', 'AI 群', '--format', 'markdown'])
|
||||
assert result.exit_code == 0
|
||||
assert '群聊' in result.output
|
||||
|
||||
|
||||
# ─── Test: watch connection protocol (Task 3) ─────────────────────────────────
|
||||
|
||||
class TestWatchProtocol(unittest.TestCase):
|
||||
"""Tests for the watch streaming protocol."""
|
||||
|
||||
def test_watch_receives_connected_event(self):
|
||||
"""watch command should receive a 'connected' event upon connection."""
|
||||
import wx
|
||||
|
||||
events = [
|
||||
json.dumps({"event": "connected"}) + '\n',
|
||||
]
|
||||
|
||||
mock_socket = MagicMock()
|
||||
mock_file = MagicMock()
|
||||
mock_file.__iter__ = lambda s: iter(events)
|
||||
mock_socket.makefile.return_value = mock_file
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('wx.socket.socket', return_value=mock_socket), \
|
||||
patch('wx._ensure_daemon'):
|
||||
result = runner.invoke(wx.cli, ['watch', '--json'],
|
||||
catch_exceptions=False)
|
||||
# connected/heartbeat events are filtered out; output should be empty
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == ''
|
||||
|
||||
def test_watch_json_outputs_message_events(self):
|
||||
"""watch --json should print message events as JSON lines."""
|
||||
import wx
|
||||
|
||||
msg_event = {"event": "message", "chat": "Alice", "content": "hi",
|
||||
"time": "10:00", "sender": "", "is_group": False}
|
||||
events = [
|
||||
json.dumps({"event": "connected"}) + '\n',
|
||||
json.dumps(msg_event) + '\n',
|
||||
]
|
||||
|
||||
mock_socket = MagicMock()
|
||||
mock_file = MagicMock()
|
||||
mock_file.__iter__ = lambda s: iter(events)
|
||||
mock_socket.makefile.return_value = mock_file
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('wx.socket.socket', return_value=mock_socket), \
|
||||
patch('wx._ensure_daemon'):
|
||||
result = runner.invoke(wx.cli, ['watch', '--json'],
|
||||
catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
lines = [l for l in result.output.strip().split('\n') if l]
|
||||
assert len(lines) == 1
|
||||
data = json.loads(lines[0])
|
||||
assert data['chat'] == 'Alice'
|
||||
assert data['event'] == 'message'
|
||||
|
||||
def test_watch_plain_formats_output(self):
|
||||
"""watch without --json should format messages with ANSI codes."""
|
||||
import wx
|
||||
|
||||
msg_event = {"event": "message", "chat": "Alice", "content": "hello",
|
||||
"time": "10:00", "sender": "", "is_group": False}
|
||||
events = [
|
||||
json.dumps({"event": "connected"}) + '\n',
|
||||
json.dumps(msg_event) + '\n',
|
||||
]
|
||||
|
||||
mock_socket = MagicMock()
|
||||
mock_file = MagicMock()
|
||||
mock_file.__iter__ = lambda s: iter(events)
|
||||
mock_socket.makefile.return_value = mock_file
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('wx.socket.socket', return_value=mock_socket), \
|
||||
patch('wx._ensure_daemon'):
|
||||
result = runner.invoke(wx.cli, ['watch'],
|
||||
catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
# Should contain the chat name and content
|
||||
assert 'Alice' in result.output
|
||||
assert 'hello' in result.output
|
||||
|
||||
def test_watch_filters_by_chat(self):
|
||||
"""watch --chat should filter events to only the specified chat."""
|
||||
import wx
|
||||
|
||||
events = [
|
||||
json.dumps({"event": "connected"}) + '\n',
|
||||
json.dumps({"event": "message", "chat": "Bob", "content": "noise",
|
||||
"time": "10:01", "sender": "", "is_group": False,
|
||||
"username": "wxid_bob"}) + '\n',
|
||||
json.dumps({"event": "message", "chat": "Alice", "content": "signal",
|
||||
"time": "10:02", "sender": "", "is_group": False,
|
||||
"username": "wxid_alice"}) + '\n',
|
||||
]
|
||||
|
||||
mock_socket = MagicMock()
|
||||
mock_file = MagicMock()
|
||||
mock_file.__iter__ = lambda s: iter(events)
|
||||
mock_socket.makefile.return_value = mock_file
|
||||
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('wx.socket.socket', return_value=mock_socket), \
|
||||
patch('wx._ensure_daemon'):
|
||||
result = runner.invoke(wx.cli, ['watch', '--chat', 'Alice', '--json'],
|
||||
catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0
|
||||
lines = [l for l in result.output.strip().split('\n') if l]
|
||||
assert len(lines) == 1
|
||||
assert json.loads(lines[0])['chat'] == 'Alice'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wechat-decrypt"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1,<9" },
|
||||
{ name = "pycryptodome", specifier = ">=3.19,<4" },
|
||||
{ name = "zstandard", specifier = ">=0.22,<1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
||||
]
|
||||
|
|
@ -0,0 +1,554 @@
|
|||
"""
|
||||
wx - 微信本地数据 CLI
|
||||
|
||||
自动管理 daemon 生命周期,无需用户手动启动。
|
||||
|
||||
用法:
|
||||
wx sessions 最近会话
|
||||
wx history "张三" 聊天记录
|
||||
wx search "关键词" 搜索消息
|
||||
wx contacts 联系人列表
|
||||
wx watch 实时监听新消息
|
||||
wx daemon status/stop/logs daemon 管理
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
CLI_DIR = os.path.join(os.path.expanduser("~"), ".wechat-cli")
|
||||
SOCK_PATH = os.path.join(CLI_DIR, "daemon.sock")
|
||||
PID_PATH = os.path.join(CLI_DIR, "daemon.pid")
|
||||
LOG_PATH = os.path.join(CLI_DIR, "daemon.log")
|
||||
DAEMON_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wx_daemon.py")
|
||||
STARTUP_TIMEOUT = 15 # 等待 daemon 启动的最长秒数
|
||||
|
||||
# ─── daemon 管理 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _is_alive() -> bool:
|
||||
if not os.path.exists(SOCK_PATH):
|
||||
return False
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
s.connect(SOCK_PATH)
|
||||
s.sendall(b'{"cmd":"ping"}\n')
|
||||
resp = json.loads(s.makefile().readline())
|
||||
s.close()
|
||||
return resp.get("pong") is True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _start_daemon() -> None:
|
||||
subprocess.Popen(
|
||||
[sys.executable, DAEMON_SCRIPT],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
deadline = time.time() + STARTUP_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.3)
|
||||
if _is_alive():
|
||||
return
|
||||
raise click.ClickException(
|
||||
f"wx-daemon 启动超时(>{STARTUP_TIMEOUT}s)\n"
|
||||
f"请查看日志: {LOG_PATH}"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_daemon() -> None:
|
||||
if not _is_alive():
|
||||
click.echo("⏳ 启动 wx-daemon...", err=True)
|
||||
_start_daemon()
|
||||
|
||||
# ─── 通信 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _send(req: dict, timeout: int = 30) -> dict:
|
||||
_ensure_daemon()
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
s.connect(SOCK_PATH)
|
||||
s.sendall((json.dumps(req, ensure_ascii=False) + '\n').encode())
|
||||
resp = json.loads(s.makefile().readline())
|
||||
s.close()
|
||||
if not resp.get("ok"):
|
||||
raise click.ClickException(resp.get("error", "未知错误"))
|
||||
return resp
|
||||
|
||||
# ─── 时间解析 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_time(value: str, is_end: bool = False) -> int:
|
||||
from datetime import datetime
|
||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'):
|
||||
try:
|
||||
dt = datetime.strptime(value, fmt)
|
||||
if fmt == '%Y-%m-%d' and is_end:
|
||||
dt = dt.replace(hour=23, minute=59, second=59)
|
||||
return int(dt.timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
raise click.BadParameter(
|
||||
f"无法解析时间 '{value}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS"
|
||||
)
|
||||
|
||||
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
||||
@click.version_option("0.1.0", prog_name="wx")
|
||||
def cli():
|
||||
"""wx — 微信本地数据 CLI"""
|
||||
|
||||
|
||||
# ─── init ────────────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json")
|
||||
|
||||
|
||||
def _detect_db_dir() -> str | None:
|
||||
"""自动检测微信数据库目录(支持 macOS/Linux)。"""
|
||||
if platform.system() == "Darwin":
|
||||
pattern = os.path.expanduser(
|
||||
"~/Library/Containers/com.tencent.xinWeChat/Data/Documents"
|
||||
"/xwechat_files/*/db_storage"
|
||||
)
|
||||
candidates = sorted(
|
||||
(p for p in glob.glob(pattern) if os.path.isdir(p)),
|
||||
key=os.path.getmtime,
|
||||
reverse=True,
|
||||
)
|
||||
return candidates[0] if candidates else None
|
||||
if platform.system() == "Linux":
|
||||
patterns = [
|
||||
os.path.expanduser("~/Documents/xwechat_files/*/db_storage"),
|
||||
os.path.expanduser("~/.local/share/weixin/data/db_storage"),
|
||||
]
|
||||
candidates = []
|
||||
for pat in patterns:
|
||||
candidates.extend(p for p in glob.glob(pat) if os.path.isdir(p))
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0] if candidates else None
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_scanner() -> str:
|
||||
"""确保 macOS C 扫描器已编译,返回二进制路径。"""
|
||||
binary = os.path.join(SCRIPT_DIR, "find_all_keys_macos")
|
||||
if os.path.exists(binary):
|
||||
return binary
|
||||
src = os.path.join(SCRIPT_DIR, "find_all_keys_macos.c")
|
||||
if not os.path.exists(src):
|
||||
raise click.ClickException(f"找不到扫描器源文件: {src}")
|
||||
click.echo("编译密钥扫描器...", err=True)
|
||||
# Try with Xcode SDK first, then fallback to plain clang
|
||||
sdk_path = (
|
||||
"/Applications/Xcode.app/Contents/Developer/Platforms"
|
||||
"/MacOSX.platform/Developer/SDKs/MacOSX.sdk"
|
||||
)
|
||||
cmds = []
|
||||
if os.path.isdir(sdk_path):
|
||||
cmds.append(["clang", "-O2", "-isysroot", sdk_path, "-o", binary, src])
|
||||
cmds.append(["clang", "-O2", "-o", binary, src])
|
||||
for cmd in cmds:
|
||||
ret = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if ret.returncode == 0:
|
||||
click.echo("编译完成", err=True)
|
||||
return binary
|
||||
raise click.ClickException(f"编译失败: {ret.stderr.strip()}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--force', is_flag=True, help='强制重新扫描(覆盖已有配置)')
|
||||
def init(force):
|
||||
"""初始化:检测数据目录并扫描加密密钥
|
||||
|
||||
\b
|
||||
首次使用前运行(WeChat 需正在运行):
|
||||
wx init
|
||||
重新扫描密钥(例如微信更新后):
|
||||
wx init --force
|
||||
"""
|
||||
# Check if already initialized
|
||||
if not force and os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cfg = json.load(open(CONFIG_FILE, encoding='utf-8'))
|
||||
db_dir = cfg.get("db_dir", "")
|
||||
keys_file = cfg.get("keys_file", "all_keys.json")
|
||||
if not os.path.isabs(keys_file):
|
||||
keys_file = os.path.join(SCRIPT_DIR, keys_file)
|
||||
if (db_dir and "your_wxid" not in db_dir
|
||||
and os.path.isdir(db_dir)
|
||||
and os.path.exists(keys_file)):
|
||||
click.echo(f"已初始化,数据目录: {db_dir}")
|
||||
click.echo("如需重新扫描密钥,使用 --force")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 1: Detect db_dir
|
||||
click.echo("检测微信数据目录...")
|
||||
db_dir = _detect_db_dir()
|
||||
if not db_dir:
|
||||
raise click.ClickException(
|
||||
"未能自动检测到微信数据目录\n"
|
||||
"请手动编辑 config.json 中的 db_dir 字段\n"
|
||||
"路径格式(macOS): ~/Library/Containers/com.tencent.xinWeChat/..."
|
||||
"/xwechat_files/<wxid>/db_storage"
|
||||
)
|
||||
click.echo(f"找到数据目录: {db_dir}")
|
||||
|
||||
# Step 2: Compile scanner (macOS only)
|
||||
if platform.system() == "Darwin":
|
||||
scanner = _ensure_scanner()
|
||||
|
||||
# Step 3: Run key extraction
|
||||
keys_file = os.path.join(SCRIPT_DIR, "all_keys.json")
|
||||
click.echo("扫描加密密钥(需要 sudo 权限)...")
|
||||
ret = subprocess.run(
|
||||
["sudo", scanner],
|
||||
capture_output=False, # let stdout/stderr pass through
|
||||
cwd=SCRIPT_DIR,
|
||||
)
|
||||
if ret.returncode != 0:
|
||||
raise click.ClickException("密钥扫描失败,请确认微信正在运行")
|
||||
if not os.path.exists(keys_file):
|
||||
raise click.ClickException(f"扫描完成但未找到输出文件: {keys_file}")
|
||||
with open(keys_file, encoding='utf-8') as f:
|
||||
keys = json.load(f)
|
||||
real_keys = {k: v for k, v in keys.items() if not k.startswith('_')}
|
||||
click.echo(f"成功提取 {len(real_keys)} 个数据库密钥")
|
||||
else:
|
||||
click.echo("非 macOS 系统,请手动运行密钥提取脚本")
|
||||
|
||||
# Step 4: Update config.json
|
||||
cfg = {}
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cfg = json.load(open(CONFIG_FILE, encoding='utf-8'))
|
||||
except Exception:
|
||||
pass
|
||||
cfg["db_dir"] = db_dir
|
||||
if "keys_file" not in cfg:
|
||||
cfg["keys_file"] = "all_keys.json"
|
||||
if "decrypted_dir" not in cfg:
|
||||
cfg["decrypted_dir"] = "decrypted"
|
||||
with open(CONFIG_FILE, "w", encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
click.echo(f"配置已保存: {CONFIG_FILE}")
|
||||
click.echo("初始化完成,可以使用 wx sessions / wx history 等命令了")
|
||||
|
||||
|
||||
# ─── sessions ────────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@click.option('-n', '--limit', default=20, show_default=True, help='会话数量')
|
||||
@click.option('--json', 'as_json', is_flag=True, help='输出原始 JSON')
|
||||
def sessions(limit, as_json):
|
||||
"""列出最近会话"""
|
||||
resp = _send({"cmd": "sessions", "limit": limit})
|
||||
data = resp.get("sessions", [])
|
||||
|
||||
if as_json:
|
||||
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
for s in data:
|
||||
unread = f" \033[31m({s['unread']}未读)\033[0m" if s.get('unread', 0) > 0 else ''
|
||||
group = ' [群]' if s['is_group'] else ''
|
||||
sender = f"{s['last_sender']}: " if s.get('last_sender') else ''
|
||||
click.echo(f"\033[90m[{s['time']}]\033[0m \033[1m{s['chat']}\033[0m{group}{unread}")
|
||||
click.echo(f" {s['last_msg_type']}: {sender}{s['summary']}")
|
||||
click.echo()
|
||||
|
||||
|
||||
# ─── history ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@click.argument('chat')
|
||||
@click.option('-n', '--limit', default=50, show_default=True, help='消息数量')
|
||||
@click.option('--offset', default=0, help='分页偏移')
|
||||
@click.option('--since', default=None, metavar='DATE', help='起始时间 YYYY-MM-DD')
|
||||
@click.option('--until', default=None, metavar='DATE', help='结束时间 YYYY-MM-DD')
|
||||
@click.option('--json', 'as_json', is_flag=True, help='输出原始 JSON')
|
||||
def history(chat, limit, offset, since, until, as_json):
|
||||
"""查看聊天记录
|
||||
|
||||
\b
|
||||
示例:
|
||||
wx history "张三"
|
||||
wx history "AI群" --since 2026-04-01 --until 2026-04-15
|
||||
wx history "张三" -n 100 --offset 50
|
||||
"""
|
||||
req = {"cmd": "history", "chat": chat, "limit": limit, "offset": offset}
|
||||
if since:
|
||||
req["since"] = _parse_time(since)
|
||||
if until:
|
||||
req["until"] = _parse_time(until, is_end=True)
|
||||
|
||||
resp = _send(req)
|
||||
|
||||
if as_json:
|
||||
click.echo(json.dumps(resp.get("messages", []), ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
group = ' [群]' if resp.get('is_group') else ''
|
||||
click.echo(f"=== {resp['chat']}{group} ({resp['count']} 条) ===\n")
|
||||
for m in resp.get("messages", []):
|
||||
sender = f"\033[33m{m['sender']}\033[0m: " if m.get('sender') else ''
|
||||
click.echo(f"\033[90m[{m['time']}]\033[0m {sender}{m['content']}")
|
||||
|
||||
|
||||
# ─── search ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@click.argument('keyword')
|
||||
@click.option('--in', 'chats', multiple=True, metavar='CHAT', help='限定聊天(可多次指定)')
|
||||
@click.option('-n', '--limit', default=20, show_default=True)
|
||||
@click.option('--since', default=None, metavar='DATE')
|
||||
@click.option('--until', default=None, metavar='DATE')
|
||||
@click.option('--json', 'as_json', is_flag=True)
|
||||
def search(keyword, chats, limit, since, until, as_json):
|
||||
"""搜索消息
|
||||
|
||||
\b
|
||||
示例:
|
||||
wx search "Claude"
|
||||
wx search "deadline" --in "TeamA" --in "TeamB"
|
||||
wx search "会议" --since 2026-04-01
|
||||
"""
|
||||
req = {"cmd": "search", "keyword": keyword, "limit": limit}
|
||||
if chats:
|
||||
req["chats"] = list(chats)
|
||||
if since:
|
||||
req["since"] = _parse_time(since)
|
||||
if until:
|
||||
req["until"] = _parse_time(until, is_end=True)
|
||||
|
||||
resp = _send(req)
|
||||
results = resp.get("results", [])
|
||||
|
||||
if as_json:
|
||||
click.echo(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
click.echo(f'搜索 "{keyword}",找到 {resp["count"]} 条:\n')
|
||||
for r in results:
|
||||
sender = f"\033[33m{r['sender']}\033[0m: " if r.get('sender') else ''
|
||||
chat = f"\033[36m[{r['chat']}]\033[0m " if r.get('chat') else ''
|
||||
click.echo(f"\033[90m[{r['time']}]\033[0m {chat}{sender}{r['content']}")
|
||||
|
||||
|
||||
# ─── contacts ────────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@click.option('-q', '--query', default=None, help='按名字过滤')
|
||||
@click.option('-n', '--limit', default=50, show_default=True)
|
||||
@click.option('--json', 'as_json', is_flag=True)
|
||||
def contacts(query, limit, as_json):
|
||||
"""查看联系人
|
||||
|
||||
\b
|
||||
示例:
|
||||
wx contacts
|
||||
wx contacts -q "李"
|
||||
"""
|
||||
resp = _send({"cmd": "contacts", "query": query, "limit": limit})
|
||||
data = resp.get("contacts", [])
|
||||
|
||||
if as_json:
|
||||
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
click.echo(f"共 {resp.get('total', len(data))} 个联系人(显示 {len(data)} 个):\n")
|
||||
for c in data:
|
||||
click.echo(f" {c['display']:<20} {c['username']}")
|
||||
|
||||
|
||||
# ─── export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@click.argument('chat')
|
||||
@click.option('--since', default=None, metavar='DATE', help='起始时间 YYYY-MM-DD')
|
||||
@click.option('--until', default=None, metavar='DATE', help='结束时间 YYYY-MM-DD')
|
||||
@click.option('-n', '--limit', default=500, show_default=True, help='最多导出条数')
|
||||
@click.option('-f', '--format', 'fmt', type=click.Choice(['markdown', 'txt', 'json']),
|
||||
default='markdown', show_default=True, help='输出格式')
|
||||
@click.option('-o', '--output', default=None, metavar='FILE', help='输出文件(默认 stdout)')
|
||||
def export(chat, since, until, limit, fmt, output):
|
||||
"""导出聊天记录到文件
|
||||
|
||||
\b
|
||||
示例:
|
||||
wx export "张三"
|
||||
wx export "AI群" --since 2026-01-01 --format markdown -o chat.md
|
||||
wx export "张三" --format json -o chat.json
|
||||
"""
|
||||
req = {"cmd": "history", "chat": chat, "limit": limit, "offset": 0}
|
||||
if since:
|
||||
req["since"] = _parse_time(since)
|
||||
if until:
|
||||
req["until"] = _parse_time(until, is_end=True)
|
||||
|
||||
resp = _send(req, timeout=60)
|
||||
messages = resp.get("messages", [])
|
||||
chat_name = resp.get("chat", chat)
|
||||
is_group = resp.get("is_group", False)
|
||||
count = len(messages)
|
||||
|
||||
if fmt == 'json':
|
||||
text = json.dumps(resp, ensure_ascii=False, indent=2)
|
||||
elif fmt == 'txt':
|
||||
lines = [f"=== {chat_name}{'[群]' if is_group else ''} ({count} 条) ===\n"]
|
||||
for m in messages:
|
||||
sender = f"{m['sender']}: " if m.get('sender') else ''
|
||||
lines.append(f"[{m['time']}] {sender}{m['content']}")
|
||||
text = '\n'.join(lines)
|
||||
else: # markdown
|
||||
lines = [
|
||||
f"# {chat_name}{'(群聊)' if is_group else ''}",
|
||||
f"\n> 导出 {count} 条消息\n",
|
||||
]
|
||||
for m in messages:
|
||||
sender_md = f"**{m['sender']}**: " if m.get('sender') else ''
|
||||
content = m['content'].replace('\n', '\n> ')
|
||||
lines.append(f"### {m['time']}\n\n{sender_md}{content}\n")
|
||||
text = '\n'.join(lines)
|
||||
|
||||
if output:
|
||||
with open(output, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
click.echo(f"已导出 {count} 条消息到 {output}")
|
||||
else:
|
||||
click.echo(text)
|
||||
|
||||
|
||||
# ─── watch ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command()
|
||||
@click.option('--chat', default=None, help='只显示指定聊天的消息')
|
||||
@click.option('--json', 'as_json', is_flag=True, help='输出 JSON lines(方便 jq 处理)')
|
||||
def watch(chat, as_json):
|
||||
"""实时监听新消息(Ctrl+C 退出)
|
||||
|
||||
\b
|
||||
示例:
|
||||
wx watch
|
||||
wx watch --chat "AI交流群"
|
||||
wx watch --json | jq .content
|
||||
"""
|
||||
_ensure_daemon()
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(SOCK_PATH)
|
||||
s.sendall((json.dumps({"cmd": "watch"}) + '\n').encode())
|
||||
|
||||
if not as_json:
|
||||
click.echo("监听中(Ctrl+C 退出)...\n", err=True)
|
||||
|
||||
try:
|
||||
for line in s.makefile():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
evt = event.get("event", "")
|
||||
if evt in ("connected", "heartbeat"):
|
||||
continue
|
||||
|
||||
# 过滤指定聊天
|
||||
if chat and event.get("chat") != chat and event.get("username") != chat:
|
||||
continue
|
||||
|
||||
if as_json:
|
||||
click.echo(line)
|
||||
continue
|
||||
|
||||
time_s = event.get('time', '')
|
||||
chat_s = event.get('chat', '')
|
||||
is_group = event.get('is_group', False)
|
||||
sender = event.get('sender', '')
|
||||
content = event.get('content', '')
|
||||
|
||||
chat_part = f"\033[36m[{chat_s}]\033[0m " if is_group else f"\033[1m{chat_s}\033[0m "
|
||||
sender_part = f"\033[33m{sender}\033[0m: " if sender else ''
|
||||
click.echo(f"\033[90m[{time_s}]\033[0m {chat_part}{sender_part}{content}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── daemon 子命令组 ──────────────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def daemon():
|
||||
"""管理 wx-daemon"""
|
||||
|
||||
|
||||
@daemon.command()
|
||||
def status():
|
||||
"""查看 daemon 运行状态"""
|
||||
if _is_alive():
|
||||
pid = open(PID_PATH).read().strip() if os.path.exists(PID_PATH) else '?'
|
||||
click.echo(f"✓ wx-daemon 运行中 (PID {pid})")
|
||||
else:
|
||||
click.echo("✗ wx-daemon 未运行")
|
||||
|
||||
|
||||
@daemon.command()
|
||||
def stop():
|
||||
"""停止 daemon"""
|
||||
if not os.path.exists(PID_PATH):
|
||||
click.echo("daemon 未运行")
|
||||
return
|
||||
try:
|
||||
pid = int(open(PID_PATH).read().strip())
|
||||
import signal
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
click.echo(f"✓ 已停止 wx-daemon (PID {pid})")
|
||||
except (ValueError, ProcessLookupError):
|
||||
click.echo("daemon 进程不存在,清理残留文件")
|
||||
for p in (SOCK_PATH, PID_PATH):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@daemon.command()
|
||||
@click.option('-f', '--follow', is_flag=True, help='持续输出(tail -f)')
|
||||
@click.option('-n', '--lines', default=50, show_default=True, help='显示最近 N 行')
|
||||
def logs(follow, lines):
|
||||
"""查看 daemon 日志"""
|
||||
if not os.path.exists(LOG_PATH):
|
||||
click.echo("暂无日志")
|
||||
return
|
||||
if follow:
|
||||
import subprocess as sp
|
||||
sp.run(['tail', f'-{lines}', '-f', LOG_PATH])
|
||||
else:
|
||||
with open(LOG_PATH) as f:
|
||||
all_lines = f.readlines()
|
||||
click.echo(''.join(all_lines[-lines:]), nl=False)
|
||||
|
||||
|
||||
# ─── 入口 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
|
@ -0,0 +1,832 @@
|
|||
"""
|
||||
wx-daemon: 微信数据访问守护进程
|
||||
|
||||
启动后常驻后台,通过 Unix socket 响应 CLI 查询,持续监听 WAL 变化推送实时消息。
|
||||
|
||||
Socket : ~/.wechat-cli/daemon.sock
|
||||
PID : ~/.wechat-cli/daemon.pid
|
||||
Log : ~/.wechat-cli/daemon.log
|
||||
Cache : ~/.wechat-cli/cache/
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
from datetime import datetime
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
import zstandard as zstd
|
||||
|
||||
# ─── 路径常量 ─────────────────────────────────────────────────────────────────
|
||||
CLI_DIR = os.path.join(os.path.expanduser("~"), ".wechat-cli")
|
||||
SOCK_PATH = os.path.join(CLI_DIR, "daemon.sock")
|
||||
PID_PATH = os.path.join(CLI_DIR, "daemon.pid")
|
||||
LOG_PATH = os.path.join(CLI_DIR, "daemon.log")
|
||||
CACHE_DIR = os.path.join(CLI_DIR, "cache")
|
||||
MTIME_FILE = os.path.join(CACHE_DIR, "_mtimes.json")
|
||||
|
||||
os.makedirs(CLI_DIR, exist_ok=True)
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
|
||||
# ─── 加密常量 ─────────────────────────────────────────────────────────────────
|
||||
PAGE_SZ = 4096
|
||||
SALT_SZ = 16
|
||||
RESERVE_SZ = 80
|
||||
SQLITE_HDR = b'SQLite format 3\x00'
|
||||
WAL_HDR_SZ = 32
|
||||
WAL_FRAME_HDR = 24
|
||||
|
||||
# ─── 配置加载 ─────────────────────────────────────────────────────────────────
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _SCRIPT_DIR)
|
||||
|
||||
from config import load_config
|
||||
from key_utils import get_key_info, strip_key_metadata
|
||||
|
||||
_cfg = load_config()
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
|
||||
with open(KEYS_FILE, encoding="utf-8") as _f:
|
||||
ALL_KEYS = strip_key_metadata(json.load(_f))
|
||||
|
||||
_zstd = zstd.ZstdDecompressor()
|
||||
|
||||
# ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
# ─── 解密 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _decrypt_page(enc_key: bytes, page_data: bytes, pgno: int) -> bytes:
|
||||
iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16]
|
||||
if pgno == 1:
|
||||
enc = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ]
|
||||
dec = AES.new(enc_key, AES.MODE_CBC, iv).decrypt(enc)
|
||||
return bytes(SQLITE_HDR + dec + b'\x00' * RESERVE_SZ)
|
||||
enc = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||
dec = AES.new(enc_key, AES.MODE_CBC, iv).decrypt(enc)
|
||||
return dec + b'\x00' * RESERVE_SZ
|
||||
|
||||
|
||||
def _full_decrypt(db_path: str, out_path: str, enc_key: bytes) -> None:
|
||||
size = os.path.getsize(db_path)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(db_path, 'rb') as fin, open(out_path, 'wb') as fout:
|
||||
for pgno in range(1, size // PAGE_SZ + 1):
|
||||
page = fin.read(PAGE_SZ)
|
||||
if not page:
|
||||
break
|
||||
if len(page) < PAGE_SZ:
|
||||
page = page + b'\x00' * (PAGE_SZ - len(page))
|
||||
fout.write(_decrypt_page(enc_key, page, pgno))
|
||||
|
||||
|
||||
def _apply_wal(wal_path: str, out_path: str, enc_key: bytes) -> None:
|
||||
if not os.path.exists(wal_path):
|
||||
return
|
||||
wal_size = os.path.getsize(wal_path)
|
||||
if wal_size <= WAL_HDR_SZ:
|
||||
return
|
||||
frame_size = WAL_FRAME_HDR + PAGE_SZ
|
||||
with open(wal_path, 'rb') as wf, open(out_path, 'r+b') as df:
|
||||
hdr = wf.read(WAL_HDR_SZ)
|
||||
s1 = struct.unpack('>I', hdr[16:20])[0]
|
||||
s2 = struct.unpack('>I', hdr[20:24])[0]
|
||||
while wf.tell() + frame_size <= wal_size:
|
||||
fh = wf.read(WAL_FRAME_HDR)
|
||||
if len(fh) < WAL_FRAME_HDR:
|
||||
break
|
||||
pgno = struct.unpack('>I', fh[0:4])[0]
|
||||
fs1 = struct.unpack('>I', fh[8:12])[0]
|
||||
fs2 = struct.unpack('>I', fh[12:16])[0]
|
||||
ep = wf.read(PAGE_SZ)
|
||||
if len(ep) < PAGE_SZ:
|
||||
break
|
||||
if pgno == 0 or pgno > 1_000_000:
|
||||
continue
|
||||
if fs1 != s1 or fs2 != s2:
|
||||
continue
|
||||
dec = _decrypt_page(enc_key, ep, pgno)
|
||||
df.seek((pgno - 1) * PAGE_SZ)
|
||||
df.write(dec)
|
||||
|
||||
# ─── DB 缓存(mtime 感知,跨进程重启可复用)────────────────────────────────────
|
||||
|
||||
class DBCache:
|
||||
def __init__(self):
|
||||
self._cache: dict[str, tuple[float, float, str]] = {} # rel -> (db_mt, wal_mt, path)
|
||||
self._lock = threading.Lock()
|
||||
self._load_persistent()
|
||||
|
||||
def _cache_path(self, rel_key: str) -> str:
|
||||
h = hashlib.md5(rel_key.encode()).hexdigest()[:12]
|
||||
return os.path.join(CACHE_DIR, f"{h}.db")
|
||||
|
||||
def _load_persistent(self) -> None:
|
||||
if not os.path.exists(MTIME_FILE):
|
||||
return
|
||||
try:
|
||||
saved = json.loads(open(MTIME_FILE, encoding='utf-8').read())
|
||||
except Exception:
|
||||
return
|
||||
reused = 0
|
||||
for rel_key, info in saved.items():
|
||||
path = info.get("path", "")
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
db_path = os.path.join(DB_DIR, rel_key.replace('\\', os.sep).replace('/', os.sep))
|
||||
wal_path = db_path + "-wal"
|
||||
try:
|
||||
db_mt = os.path.getmtime(db_path)
|
||||
wal_mt = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
|
||||
except OSError:
|
||||
continue
|
||||
if db_mt == info.get("db_mt") and wal_mt == info.get("wal_mt"):
|
||||
self._cache[rel_key] = (db_mt, wal_mt, path)
|
||||
reused += 1
|
||||
if reused:
|
||||
_log(f"DBCache: 复用 {reused} 个已解密 DB")
|
||||
|
||||
def _save_persistent(self) -> None:
|
||||
data = {k: {"db_mt": v[0], "wal_mt": v[1], "path": v[2]} for k, v in self._cache.items()}
|
||||
try:
|
||||
with open(MTIME_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def get(self, rel_key: str) -> str | None:
|
||||
key_info = get_key_info(ALL_KEYS, rel_key)
|
||||
if not key_info:
|
||||
return None
|
||||
db_path = os.path.join(DB_DIR, rel_key.replace('\\', os.sep).replace('/', os.sep))
|
||||
wal_path = db_path + "-wal"
|
||||
if not os.path.exists(db_path):
|
||||
return None
|
||||
try:
|
||||
db_mt = os.path.getmtime(db_path)
|
||||
wal_mt = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
cached = self._cache.get(rel_key)
|
||||
if cached and cached[0] == db_mt and cached[1] == wal_mt and os.path.exists(cached[2]):
|
||||
return cached[2]
|
||||
out = self._cache_path(rel_key)
|
||||
enc_key = bytes.fromhex(key_info["enc_key"])
|
||||
t0 = time.perf_counter()
|
||||
_full_decrypt(db_path, out, enc_key)
|
||||
_apply_wal(wal_path, out, enc_key)
|
||||
ms = (time.perf_counter() - t0) * 1000
|
||||
_log(f"解密 {rel_key} ({ms:.0f}ms)")
|
||||
self._cache[rel_key] = (db_mt, wal_mt, out)
|
||||
self._save_persistent()
|
||||
return out
|
||||
|
||||
|
||||
_db = DBCache()
|
||||
|
||||
# ─── 消息 DB 列表 ─────────────────────────────────────────────────────────────
|
||||
|
||||
MSG_DB_KEYS = sorted([
|
||||
k for k in ALL_KEYS
|
||||
if re.search(r'message[/\\]message_\d+\.db$', k)
|
||||
])
|
||||
|
||||
# ─── 联系人缓存 ───────────────────────────────────────────────────────────────
|
||||
|
||||
_names: dict[str, str] | None = None
|
||||
_names_lock = threading.Lock()
|
||||
_md5_to_uname: dict[str, str] | None = None
|
||||
_md5_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_names() -> dict[str, str]:
|
||||
global _names
|
||||
with _names_lock:
|
||||
if _names is not None:
|
||||
return _names
|
||||
path = _db.get(os.path.join("contact", "contact.db"))
|
||||
if not path:
|
||||
_names = {}
|
||||
return _names
|
||||
try:
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT username, nick_name, remark FROM contact"
|
||||
).fetchall()
|
||||
_names = {u: (r if r else (n if n else u)) for u, n, r in rows}
|
||||
except Exception:
|
||||
_names = {}
|
||||
return _names
|
||||
|
||||
|
||||
def _get_md5_lookup() -> dict[str, str]:
|
||||
"""返回 {md5(username): username},用于全局搜索时从表名反推联系人。"""
|
||||
global _md5_to_uname
|
||||
with _md5_lock:
|
||||
if _md5_to_uname is not None:
|
||||
return _md5_to_uname
|
||||
names = _load_names()
|
||||
_md5_to_uname = {hashlib.md5(u.encode()).hexdigest(): u for u in names}
|
||||
return _md5_to_uname
|
||||
|
||||
|
||||
def _refresh_names() -> None:
|
||||
"""强制刷新联系人缓存(新联系人/新群加入时调用)"""
|
||||
global _names, _md5_to_uname
|
||||
with _names_lock:
|
||||
_names = None
|
||||
with _md5_lock:
|
||||
_md5_to_uname = None
|
||||
_load_names()
|
||||
_get_md5_lookup()
|
||||
|
||||
# ─── 辅助 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
_XML_BAD = re.compile(r'<!DOCTYPE|<!ENTITY', re.IGNORECASE)
|
||||
|
||||
|
||||
def _fmt_type(t) -> str:
|
||||
try:
|
||||
base = int(t) & 0xFFFFFFFF if int(t) > 0xFFFFFFFF else int(t)
|
||||
except (TypeError, ValueError):
|
||||
return f'type={t}'
|
||||
return {
|
||||
1: '文本', 3: '图片', 34: '语音', 42: '名片', 43: '视频',
|
||||
47: '表情', 48: '位置', 49: '链接/文件', 50: '通话',
|
||||
10000: '系统', 10002: '撤回',
|
||||
}.get(base, f'type={base}')
|
||||
|
||||
|
||||
def _decompress(content, ct) -> str | None:
|
||||
if ct == 4 and isinstance(content, bytes):
|
||||
try:
|
||||
return _zstd.decompress(content).decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(content, bytes):
|
||||
return content.decode('utf-8', errors='replace')
|
||||
return content
|
||||
|
||||
|
||||
def _fmt_content(local_id: int, local_type, content: str | None, is_group: bool) -> str:
|
||||
try:
|
||||
base = int(local_type) & 0xFFFFFFFF if int(local_type) > 0xFFFFFFFF else int(local_type)
|
||||
except (TypeError, ValueError):
|
||||
base = 0
|
||||
if base == 3:
|
||||
return f"[图片] local_id={local_id}"
|
||||
if base == 47:
|
||||
return "[表情]"
|
||||
if base == 50:
|
||||
return "[通话]"
|
||||
# 群聊消息内容带 "sender:\n" 前缀,解析 XML 前先剥离
|
||||
text = content or ''
|
||||
if is_group and ':\n' in text:
|
||||
text = text.split(':\n', 1)[1]
|
||||
if base == 49 and text and '<appmsg' in text and not _XML_BAD.search(text):
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.fromstring(text)
|
||||
appmsg = root.find('.//appmsg')
|
||||
if appmsg is not None:
|
||||
title = (appmsg.findtext('title') or '').strip()
|
||||
atype = (appmsg.findtext('type') or '').strip()
|
||||
if atype == '6':
|
||||
return f"[文件] {title}" if title else "[文件]"
|
||||
if atype == '57':
|
||||
ref = appmsg.find('.//refermsg')
|
||||
ref_content = ''
|
||||
if ref is not None:
|
||||
ref_content = re.sub(r'\s+', ' ', (ref.findtext('content') or '')).strip()
|
||||
if len(ref_content) > 80:
|
||||
ref_content = ref_content[:80] + '...'
|
||||
quote = f"[引用] {title}" if title else "[引用]"
|
||||
return f"{quote}\n ↳ {ref_content}" if ref_content else quote
|
||||
if atype in ('33', '36', '44'):
|
||||
return f"[小程序] {title}" if title else "[小程序]"
|
||||
return f"[链接] {title}" if title else "[链接/文件]"
|
||||
except Exception:
|
||||
pass
|
||||
return text
|
||||
|
||||
|
||||
def _resolve_username(chat_name: str) -> str | None:
|
||||
names = _load_names()
|
||||
if chat_name in names or '@chatroom' in chat_name or chat_name.startswith('wxid_'):
|
||||
return chat_name
|
||||
low = chat_name.lower()
|
||||
for uname, display in names.items():
|
||||
if low == display.lower():
|
||||
return uname
|
||||
for uname, display in names.items():
|
||||
if low in display.lower():
|
||||
return uname
|
||||
return None
|
||||
|
||||
|
||||
def _load_id2u(conn: sqlite3.Connection) -> dict[int, str]:
|
||||
try:
|
||||
return {r: u for r, u in conn.execute("SELECT rowid, user_name FROM Name2Id").fetchall() if u}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _sender_label(real_sender_id, content, is_group, chat_username, id2u, names) -> str:
|
||||
sender_uname = id2u.get(real_sender_id, '')
|
||||
if is_group:
|
||||
if sender_uname and sender_uname != chat_username:
|
||||
return names.get(sender_uname, sender_uname)
|
||||
if content and ':\n' in content:
|
||||
raw = content.split(':\n', 1)[0]
|
||||
return names.get(raw, raw)
|
||||
return ''
|
||||
return names.get(sender_uname, '') if sender_uname and sender_uname != chat_username else ''
|
||||
|
||||
|
||||
def _find_msg_tables(username: str) -> list[dict]:
|
||||
table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
|
||||
if not re.fullmatch(r'Msg_[0-9a-f]{32}', table_name):
|
||||
return []
|
||||
results = []
|
||||
for rel_key in MSG_DB_KEYS:
|
||||
path = _db.get(rel_key)
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
continue
|
||||
max_ts = conn.execute(f"SELECT MAX(create_time) FROM [{table_name}]").fetchone()[0] or 0
|
||||
results.append({'path': path, 'table': table_name, 'max_ts': max_ts})
|
||||
except Exception:
|
||||
continue
|
||||
results.sort(key=lambda x: x['max_ts'], reverse=True)
|
||||
return results
|
||||
|
||||
# ─── 查询函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def q_sessions(limit: int = 20) -> dict:
|
||||
path = _db.get(os.path.join("session", "session.db"))
|
||||
if not path:
|
||||
return {"error": "无法解密 session.db"}
|
||||
names = _load_names()
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT username, unread_count, summary, last_timestamp,
|
||||
last_msg_type, last_msg_sender, last_sender_display_name
|
||||
FROM SessionTable
|
||||
WHERE last_timestamp > 0
|
||||
ORDER BY last_timestamp DESC LIMIT ?
|
||||
""", (limit,)).fetchall()
|
||||
|
||||
results = []
|
||||
for username, unread, summary, ts, msg_type, sender, sender_name in rows:
|
||||
display = names.get(username, username)
|
||||
is_group = '@chatroom' in username
|
||||
if isinstance(summary, bytes):
|
||||
try:
|
||||
summary = _zstd.decompress(summary).decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
summary = '(压缩内容)'
|
||||
if isinstance(summary, str) and ':\n' in summary:
|
||||
summary = summary.split(':\n', 1)[1]
|
||||
sender_display = ''
|
||||
if is_group and sender:
|
||||
sender_display = names.get(sender, sender_name or sender)
|
||||
results.append({
|
||||
"chat": display,
|
||||
"username": username,
|
||||
"is_group": is_group,
|
||||
"unread": unread or 0,
|
||||
"last_msg_type": _fmt_type(msg_type),
|
||||
"last_sender": sender_display,
|
||||
"summary": str(summary or ''),
|
||||
"timestamp": ts,
|
||||
"time": datetime.fromtimestamp(ts).strftime('%m-%d %H:%M'),
|
||||
})
|
||||
return {"sessions": results}
|
||||
|
||||
|
||||
def q_history(chat_name: str, limit: int = 50, offset: int = 0,
|
||||
since: int | None = None, until: int | None = None) -> dict:
|
||||
username = _resolve_username(chat_name)
|
||||
if not username:
|
||||
return {"error": f"找不到联系人: {chat_name}"}
|
||||
names = _load_names()
|
||||
display = names.get(username, username)
|
||||
is_group = '@chatroom' in username
|
||||
tables = _find_msg_tables(username)
|
||||
if not tables:
|
||||
return {"error": f"找不到 {display} 的消息记录"}
|
||||
|
||||
all_msgs: list[dict] = []
|
||||
for tbl in tables:
|
||||
try:
|
||||
with closing(sqlite3.connect(tbl['path'])) as conn:
|
||||
id2u = _load_id2u(conn)
|
||||
clauses, params = [], []
|
||||
if since:
|
||||
clauses.append('create_time >= ?'); params.append(since)
|
||||
if until:
|
||||
clauses.append('create_time <= ?'); params.append(until)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ''
|
||||
rows = conn.execute(
|
||||
f"SELECT local_id, local_type, create_time, real_sender_id,"
|
||||
f" message_content, WCDB_CT_message_content"
|
||||
f" FROM [{tbl['table']}] {where}"
|
||||
f" ORDER BY create_time DESC LIMIT ? OFFSET ?",
|
||||
(*params, limit + offset, 0)
|
||||
).fetchall()
|
||||
for local_id, local_type, ts, real_sender_id, content, ct in rows:
|
||||
content = _decompress(content, ct)
|
||||
if content is None:
|
||||
content = '(无法解压)'
|
||||
sender = _sender_label(real_sender_id, content, is_group, username, id2u, names)
|
||||
text = _fmt_content(local_id, local_type, content, is_group)
|
||||
all_msgs.append({
|
||||
"timestamp": ts,
|
||||
"time": datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M'),
|
||||
"sender": sender,
|
||||
"content": text,
|
||||
"type": _fmt_type(local_type),
|
||||
"local_id": local_id,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
all_msgs.sort(key=lambda m: m['timestamp'], reverse=True)
|
||||
paged = all_msgs[offset: offset + limit]
|
||||
paged.sort(key=lambda m: m['timestamp'])
|
||||
return {
|
||||
"chat": display,
|
||||
"username": username,
|
||||
"is_group": is_group,
|
||||
"count": len(paged),
|
||||
"messages": paged,
|
||||
}
|
||||
|
||||
|
||||
def q_search(keyword: str, chats: list[str] | None = None,
|
||||
limit: int = 20, since: int | None = None, until: int | None = None) -> dict:
|
||||
names = _load_names()
|
||||
results: list[dict] = []
|
||||
|
||||
# 构建搜索目标 (db_path, table_name, chat_display, username)
|
||||
targets: list[tuple[str, str, str, str]] = []
|
||||
|
||||
if chats:
|
||||
for chat_name in chats:
|
||||
uname = _resolve_username(chat_name)
|
||||
if not uname:
|
||||
continue
|
||||
for tbl in _find_msg_tables(uname):
|
||||
targets.append((tbl['path'], tbl['table'], names.get(uname, uname), uname))
|
||||
else:
|
||||
md5_lookup = _get_md5_lookup()
|
||||
for rel_key in MSG_DB_KEYS:
|
||||
path = _db.get(rel_key)
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
table_rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||
).fetchall()
|
||||
for (tname,) in table_rows:
|
||||
if not re.fullmatch(r'Msg_[0-9a-f]{32}', tname):
|
||||
continue
|
||||
uname = md5_lookup.get(tname[4:], '')
|
||||
display = names.get(uname, uname) if uname else ''
|
||||
targets.append((path, tname, display, uname))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 按 db_path 分组,减少重复打开
|
||||
by_path: dict[str, list[tuple[str, str, str]]] = {}
|
||||
for db_path, table, display, uname in targets:
|
||||
by_path.setdefault(db_path, []).append((table, display, uname))
|
||||
|
||||
for db_path, table_list in by_path.items():
|
||||
try:
|
||||
with closing(sqlite3.connect(db_path)) as conn:
|
||||
id2u = _load_id2u(conn)
|
||||
for table, display, uname in table_list:
|
||||
clauses = ['message_content LIKE ?']
|
||||
params = [f'%{keyword}%']
|
||||
if since:
|
||||
clauses.append('create_time >= ?'); params.append(since)
|
||||
if until:
|
||||
clauses.append('create_time <= ?'); params.append(until)
|
||||
where = f"WHERE {' AND '.join(clauses)}"
|
||||
rows = conn.execute(
|
||||
f"SELECT local_id, local_type, create_time, real_sender_id,"
|
||||
f" message_content, WCDB_CT_message_content"
|
||||
f" FROM [{table}] {where}"
|
||||
f" ORDER BY create_time DESC LIMIT ?",
|
||||
(*params, limit * 3)
|
||||
).fetchall()
|
||||
is_group = uname and '@chatroom' in uname
|
||||
for local_id, local_type, ts, real_sender_id, content, ct in rows:
|
||||
content = _decompress(content, ct)
|
||||
if content is None:
|
||||
continue
|
||||
sender = _sender_label(real_sender_id, content, is_group or False,
|
||||
uname or '', id2u, names)
|
||||
text = _fmt_content(local_id, local_type, content, is_group or False)
|
||||
chat_display = display or uname or table
|
||||
results.append({
|
||||
"timestamp": ts,
|
||||
"time": datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M'),
|
||||
"chat": chat_display,
|
||||
"sender": sender,
|
||||
"content": text,
|
||||
"type": _fmt_type(local_type),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
results.sort(key=lambda r: r['timestamp'], reverse=True)
|
||||
paged = results[:limit]
|
||||
return {"keyword": keyword, "count": len(paged), "results": paged}
|
||||
|
||||
|
||||
def q_contacts(query: str | None = None, limit: int = 50) -> dict:
|
||||
names = _load_names()
|
||||
contacts = [
|
||||
{"username": u, "display": d}
|
||||
for u, d in names.items()
|
||||
if not u.startswith('gh_') # 排除公众号
|
||||
and not u.startswith('biz_') # 排除服务号
|
||||
]
|
||||
if query:
|
||||
low = query.lower()
|
||||
contacts = [c for c in contacts
|
||||
if low in c['display'].lower() or low in c['username'].lower()]
|
||||
contacts.sort(key=lambda c: c['display'])
|
||||
return {"contacts": contacts[:limit], "total": len(contacts)}
|
||||
|
||||
# ─── 实时推送(watch)────────────────────────────────────────────────────────
|
||||
|
||||
_watch_clients: list[queue.Queue] = []
|
||||
_watch_lock = threading.Lock()
|
||||
|
||||
|
||||
def _broadcast(event: dict) -> None:
|
||||
line = json.dumps(event, ensure_ascii=False)
|
||||
with _watch_lock:
|
||||
dead = []
|
||||
for q in _watch_clients:
|
||||
try:
|
||||
q.put_nowait(line)
|
||||
except queue.Full:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
_watch_clients.remove(q)
|
||||
|
||||
|
||||
def _wal_watcher() -> None:
|
||||
"""后台线程:每 500ms 检测 session.db-wal 的 mtime,有变化时推送新消息"""
|
||||
last_mtime: dict[str, float] = {}
|
||||
last_ts: dict[str, int] = {} # username -> last pushed timestamp
|
||||
initialized = False
|
||||
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
with _watch_lock:
|
||||
if not _watch_clients:
|
||||
continue
|
||||
|
||||
session_wal = os.path.join(DB_DIR, "session", "session.db-wal")
|
||||
try:
|
||||
mtime = os.path.getmtime(session_wal)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
prev = last_mtime.get(session_wal, 0.0)
|
||||
if mtime == prev:
|
||||
continue
|
||||
last_mtime[session_wal] = mtime
|
||||
|
||||
# 解密 session.db(缓存会处理 mtime,只有真的变了才重新解密)
|
||||
path = _db.get(os.path.join("session", "session.db"))
|
||||
if not path:
|
||||
continue
|
||||
names = _load_names()
|
||||
try:
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender
|
||||
FROM SessionTable WHERE last_timestamp > 0
|
||||
ORDER BY last_timestamp DESC LIMIT 50
|
||||
""").fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for username, summary, ts, msg_type, sender in rows:
|
||||
if not initialized:
|
||||
# 第一轮只建立基线,不推送
|
||||
last_ts[username] = ts
|
||||
continue
|
||||
prev_ts = last_ts.get(username, 0)
|
||||
if ts <= prev_ts:
|
||||
continue
|
||||
last_ts[username] = ts
|
||||
|
||||
display = names.get(username, username)
|
||||
is_group = '@chatroom' in username
|
||||
if isinstance(summary, bytes):
|
||||
try:
|
||||
summary = _zstd.decompress(summary).decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
summary = '(压缩内容)'
|
||||
if isinstance(summary, str) and ':\n' in summary:
|
||||
summary = summary.split(':\n', 1)[1]
|
||||
sender_display = names.get(sender, sender) if sender else ''
|
||||
|
||||
_broadcast({
|
||||
"event": "message",
|
||||
"time": datetime.fromtimestamp(ts).strftime('%H:%M'),
|
||||
"chat": display,
|
||||
"username": username,
|
||||
"is_group": is_group,
|
||||
"sender": sender_display,
|
||||
"content": str(summary or ''),
|
||||
"type": _fmt_type(msg_type),
|
||||
"timestamp": ts,
|
||||
})
|
||||
|
||||
if not initialized:
|
||||
initialized = True
|
||||
|
||||
# ─── 命令路由 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _dispatch(req: dict) -> dict:
|
||||
cmd = req.get("cmd", "")
|
||||
try:
|
||||
if cmd == "ping":
|
||||
return {"ok": True, "pong": True}
|
||||
if cmd == "sessions":
|
||||
return {"ok": True, **q_sessions(int(req.get("limit", 20)))}
|
||||
if cmd == "history":
|
||||
return {"ok": True, **q_history(
|
||||
req["chat"],
|
||||
limit=int(req.get("limit", 50)),
|
||||
offset=int(req.get("offset", 0)),
|
||||
since=req.get("since"),
|
||||
until=req.get("until"),
|
||||
)}
|
||||
if cmd == "search":
|
||||
return {"ok": True, **q_search(
|
||||
req["keyword"],
|
||||
chats=req.get("chats"),
|
||||
limit=int(req.get("limit", 20)),
|
||||
since=req.get("since"),
|
||||
until=req.get("until"),
|
||||
)}
|
||||
if cmd == "contacts":
|
||||
return {"ok": True, **q_contacts(req.get("query"), int(req.get("limit", 50)))}
|
||||
return {"ok": False, "error": f"未知命令: {cmd}"}
|
||||
except KeyError as e:
|
||||
return {"ok": False, "error": f"缺少参数: {e}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# ─── Unix Socket Server ───────────────────────────────────────────────────────
|
||||
|
||||
def _handle_client(conn: socket.socket) -> None:
|
||||
try:
|
||||
f = conn.makefile('rwb', buffering=0)
|
||||
line = f.readline()
|
||||
if not line:
|
||||
return
|
||||
req = json.loads(line.decode('utf-8'))
|
||||
|
||||
if req.get("cmd") == "watch":
|
||||
# 流式模式:daemon 持续推事件,直到客户端断开
|
||||
q: queue.Queue = queue.Queue(maxsize=500)
|
||||
with _watch_lock:
|
||||
_watch_clients.append(q)
|
||||
_write_line(f, {"event": "connected"})
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event_line = q.get(timeout=30)
|
||||
f.write((event_line + '\n').encode())
|
||||
f.flush()
|
||||
except queue.Empty:
|
||||
_write_line(f, {"event": "heartbeat"})
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass
|
||||
finally:
|
||||
with _watch_lock:
|
||||
try:
|
||||
_watch_clients.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
resp = _dispatch(req)
|
||||
_write_line(f, resp)
|
||||
except Exception as e:
|
||||
try:
|
||||
_write_line(conn.makefile('rwb', buffering=0), {"ok": False, "error": str(e)})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_line(f, obj: dict) -> None:
|
||||
f.write((json.dumps(obj, ensure_ascii=False) + '\n').encode())
|
||||
f.flush()
|
||||
|
||||
|
||||
def _serve() -> None:
|
||||
if os.path.exists(SOCK_PATH):
|
||||
os.unlink(SOCK_PATH)
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(SOCK_PATH)
|
||||
os.chmod(SOCK_PATH, 0o600)
|
||||
server.listen(64)
|
||||
_log(f"监听 {SOCK_PATH}")
|
||||
while True:
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
threading.Thread(target=_handle_client, args=(conn,), daemon=True).start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ─── 守护进程化 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _daemonize() -> None:
|
||||
if os.fork() > 0:
|
||||
sys.exit(0)
|
||||
os.setsid()
|
||||
if os.fork() > 0:
|
||||
sys.exit(0)
|
||||
sys.stdin = open(os.devnull, 'r')
|
||||
log_file = open(LOG_PATH, 'a', buffering=1)
|
||||
sys.stdout = log_file
|
||||
sys.stderr = log_file
|
||||
|
||||
# ─── 入口 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main(foreground: bool = False) -> None:
|
||||
if not foreground:
|
||||
_daemonize()
|
||||
|
||||
with open(PID_PATH, 'w') as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
def _cleanup(sig=None, frame=None):
|
||||
for p in (SOCK_PATH, PID_PATH):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, _cleanup)
|
||||
signal.signal(signal.SIGINT, _cleanup)
|
||||
|
||||
_log("wx-daemon 启动")
|
||||
_log(f"DB_DIR: {DB_DIR}")
|
||||
_log(f"密钥数量: {len(ALL_KEYS)}")
|
||||
|
||||
# 预热:加载联系人 + 解密 session.db(最常用的两个)
|
||||
_load_names()
|
||||
_db.get(os.path.join("session", "session.db"))
|
||||
_log(f"预热完成,联系人 {len(_names or {})} 个")
|
||||
|
||||
# WAL 监听线程
|
||||
threading.Thread(target=_wal_watcher, daemon=True, name='wal-watcher').start()
|
||||
|
||||
# Socket server(阻塞)
|
||||
_serve()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(foreground='--fg' in sys.argv)
|
||||
Loading…
Reference in New Issue