Merge pull request #1 from jackwener/feat/rust-rewrite

feat: Rust 完整重写 + 全部 bug 修复
pull/2/head
jakevin 2026-04-16 15:17:38 +08:00 committed by GitHub
commit 6b7285c730
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 7192 additions and 8384 deletions

View File

@ -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:*)"
]
}
}

70
.github/workflows/release.yml vendored 100644
View File

@ -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 }}

2
.gitignore vendored
View File

@ -23,3 +23,5 @@ __pycache__/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
find_all_keys_macos
.claude/worktrees/

1332
Cargo.lock generated 100644

File diff suppressed because it is too large Load Diff

68
Cargo.toml 100644
View File

@ -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
View File

@ -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和商店表情StoreCDN 下载后本地缓存 ## 快速开始
- **富媒体内容解析**: 链接卡片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) - macOS (Apple Silicon / Intel)
- WeChat 4.x (macOS 版) - WeChat 4.x (macOS 版,需 ad-hoc 签名,见下文)
- Xcode Command Line Tools: `xcode-select --install` - Python 3.12+
- 微信需要 ad-hoc 签名(或安装了防撤回补丁): - [uv](https://docs.astral.sh/uv/)Python 包管理)
`sudo codesign --force --deep --sign - /Applications/WeChat.app` - Xcode Command Line Tools`xcode-select --install`
### 编译和使用 ### 安装
```bash ```bash
# 编译 git clone git@github.com:jackwener/wx-cli.git
cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation cd wx-cli
uv sync
# 运行(自动查找微信进程、扫描内存、匹配 DB salt
sudo ./find_all_keys_macos
# 或指定 PID
sudo ./find_all_keys_macos <pid>
``` ```
输出 `all_keys.json`,格式兼容 `decrypt_db.py`,可直接用于解密: ### 初始化(首次使用)
微信需要 ad-hoc 签名才能被扫描内存:
```bash ```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-SHA512256,000 次迭代
- **页结构**4096 bytes/pagereserve = 80IV 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`
### DBCachemtime 感知缓存)
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
View File

@ -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)。

View File

@ -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) NoneV2 格式文件返回 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)

View File

@ -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()

View File

@ -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;
}

View File

@ -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)

View File

@ -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 名称wechatWeChatAppEx
再用 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)

View File

@ -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)

View File

@ -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;
}

View File

@ -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()

View File

@ -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()

View File

@ -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}")

View File

@ -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当前有效framepatch到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
View File

@ -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()

File diff suppressed because it is too large Load Diff

View File

@ -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()

File diff suppressed because it is too large Load Diff

9
pyproject.toml 100644
View File

@ -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",
]

View File

@ -1,3 +0,0 @@
pycryptodome>=3.19,<4
zstandard>=0.22,<1
mcp>=1.0,<2

View File

@ -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(())
}

View File

@ -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(())
}

72
src/cli/export.rs 100644
View File

@ -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(())
}

80
src/cli/history.rs 100644
View File

@ -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)
}

96
src/cli/init.rs 100644
View File

@ -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")
}

169
src/cli/mod.rs 100644
View File

@ -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),
}
}

61
src/cli/search.rs 100644
View File

@ -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(())
}

View File

@ -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(())
}

View File

@ -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)
}

94
src/cli/watch.rs 100644
View File

@ -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(())
}

274
src/config.rs 100644
View File

@ -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
}

106
src/crypto/mod.rs 100644
View File

@ -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 解密(不去除 paddingSQLCipher 不使用 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(())
}

73
src/crypto/wal.rs 100644
View File

@ -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(())
}

215
src/daemon/cache.rs 100644
View File

@ -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)
}

280
src/daemon/mod.rs 100644
View File

@ -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);
}

677
src/daemon/query.rs 100644
View File

@ -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())
}

View File

@ -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 serverUnix 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(),
})
}

122
src/ipc.rs 100644
View File

@ -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")
}
}

14
src/main.rs 100644
View File

@ -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();
}
}

View File

@ -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 &regions {
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;
}
}

View File

@ -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;
}
}

84
src/scanner/mod.rs 100644
View File

@ -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字节 salthex来自数据库文件头
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 字节作为 salthex如果是明文 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()
}
}

View File

@ -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;
}
}

View File

@ -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)

128
uv.lock 100644
View File

@ -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" },
]

554
wx.py 100644
View File

@ -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()

832
wx_daemon.py 100644
View File

@ -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)