Author SHA1 Message Date
dependabot[bot] eb0c8db964 chore(deps): bump sha2 from 0.10.9 to 0.11.0
Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 10:52:15 +00:00
pandorafuture 26bae79fc4 perf: eliminate repeated SQLCipher key derivation 2026-07-10 18:49:15 +08:00
pandorafuture b31a416f20 docs: 添加 Agent Skill、Release 下载安装方式,移除 key scan 相关描述
- 新增 SKILL.md,支持通过 npx skills add 安装到 AI 编程助手
- README 安装章节增加从 Release 下载预编译二进制的方式
- README 安装章节增加 AI 编程助手集成说明
- 移除 key scan / Mach VM 内存扫描相关描述,统一为 key extract
- 修正 Contact Hiding 描述、导出示例补 --all、命令一览补 key set-image
- 移除远程 scp 部署段落,简化单列前置条件表格为文本
2026-06-05 17:42:28 +08:00
pandorafuture 9fcf3a033b docs(readme): 安装章节增加从 Release 下载预编译二进制的方式 2026-06-05 17:09:21 +08:00
pandorafuture 125c762727 chore(deps): 升级核心依赖至最新版本
- rusqlite 0.32 → 0.40,涵盖全部 5 个 crate
- aes 0.8 → 0.9、cbc 0.1 → 0.2,涵盖加解密相关 4 个 crate
- toml 0.8 → 1.1 配置解析库升级
- 迁移 cipher 0.5 新接口,替换已废弃的加解密方法
- 修复 aes::cipher::Array 弃用警告,改用 TryFrom 转换
2026-06-05 00:14:25 +08:00
pandorafuture 0f22f335e5 Merge remote-tracking branch 'origin/dependabot/github_actions/softprops/action-gh-release-3' 2026-06-04 23:31:06 +08:00
pandorafuture 76cf16ffef Merge remote-tracking branch 'origin/dependabot/github_actions/actions/checkout-6' 2026-06-04 23:31:06 +08:00
dependabot[bot] 4d8272029c chore(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 15:23:31 +00:00
dependabot[bot] eb91993ca5 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 15:20:25 +00:00
37 changed files with 1366 additions and 488 deletions
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
fmt:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
@@ -22,7 +22,7 @@ jobs:
clippy:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -31,6 +31,6 @@ jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- run: cargo test
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -41,7 +41,7 @@ jobs:
mv wx-cli-${{ github.ref_name }}-macos-arm64.tar.gz ${{ github.workspace }}/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: wx-cli-${{ github.ref_name }}-macos-arm64.tar.gz
body: ${{ steps.changelog.outputs.body }}
Generated
+376 -291
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -8,3 +8,18 @@ edition = "2021"
license = "MIT"
repository = "https://github.com/pandorafuture/wx-cli"
description = "WeChat macOS database decryption and query tool"
# PBKDF2 intentionally runs 256k rounds. Keep the crypto crate optimized in
# dev/test builds so parallel integration tests and local debug binaries do not
# spend seconds per database deriving SQLCipher keys.
[profile.dev.package.wx-decrypt]
opt-level = 3
[profile.dev.package.pbkdf2]
opt-level = 3
[profile.dev.package.sha2]
opt-level = 3
[profile.dev.package.hmac]
opt-level = 3
+32 -20
View File
@@ -1,6 +1,6 @@
# wx-cli
WeChat macOS 数据库解密与查询工具。支持通过 `key scan`Mach VM 内存扫描)或 `key extract`(LLDB hook)提取密钥,解密并查询 WeChat 4.1.7.x / 4.1.8.x 的 Apple SEE 加密 SQLite 数据库。
WeChat macOS 数据库解密与查询工具。支持通过 `key extract`(LLDB hook)提取密钥,解密并查询 WeChat 4.1.7.x / 4.1.8.x 的 Apple SEE 加密 SQLite 数据库。
## 支持范围
@@ -9,13 +9,7 @@ WeChat macOS 数据库解密与查询工具。支持通过 `key scan`Mach VM
## 前置条件
密钥提取**需要 SIP 关闭**SIP enabled 时 `task_for_pid` 被内核拒绝,即使 root 也不行)。如果你已有密钥,可以跳过 SIP 要求,直接用 `key set` 手动录入。
| 条件 | key scan | key extract |
|------|----------|-------------|
| SIP disabled + sudo | **可用** | **可用,但通常不需要 sudo** |
| SIP disabled + 无 sudo | 不可用 | **可用(推荐)** |
| SIP enabled | 不可用 | 不可用 |
密钥提取**需要 SIP 关闭**SIP enabled 时 `task_for_pid` 被内核拒绝,即使 root 也不行),通常不需要 sudo。如果你已有密钥,可以跳过 SIP 要求,直接用 `key set` 手动录入。
`key extract`LLDB 方式)还需要:
@@ -25,6 +19,24 @@ WeChat macOS 数据库解密与查询工具。支持通过 `key scan`Mach VM
## 安装
### 从 Release 下载(推荐)
前往 [Releases](https://github.com/pandorafuture/wx-cli/releases/latest) 下载预编译二进制(macOS arm64),或使用命令行:
```bash
# 下载最新 release
curl -fSL "$(curl -fsSL https://api.github.com/repos/pandorafuture/wx-cli/releases/latest \
| grep -o '"browser_download_url": "[^"]*macos-arm64[^"]*"' \
| cut -d'"' -f4)" -o wx-cli.tar.gz
tar xzf wx-cli.tar.gz
# 安装到 PATH
mkdir -p ~/.local/bin
mv wx-cli ~/.local/bin/
chmod +x ~/.local/bin/wx-cli
wx-cli --version
```
### 从源码构建
```bash
@@ -48,10 +60,13 @@ source ~/.zshrc
wx-cli --version
```
如果目标机器在远程(例如 VM),本机构建后传输即可,远程不需要 Rust 工具链:
### AI 编程助手集成
本项目提供 [Agent Skill](https://skills.sh),安装后 Claude Code、Codex、Cursor 等 AI 编程助手可直接协助查询微信数据:
```bash
scp target/release/wx-cli user@remote:~/.local/bin/wx-cli
npx skills add pandorafuture/wx-cli
```
## 使用
@@ -66,17 +81,14 @@ wx-cli status # 查看 WeChat 运行状态和所有账号密钥/缓存状
### 2. 提取密钥
```bash
# 方式 A(推荐):内存扫描重启 WeChat,需要 sudo
sudo wx-cli key scan
# 方式 BLLDB hook — 会重启 WeChat,通常不需要 sudo
# LLDB hook 提取密钥重启 WeChat通常不需要 sudo
wx-cli key extract --timeout 120
# 查看已保存的密钥
wx-cli key list
```
`key scan` 从 WeChat 进程内存中提取已缓存的数据库密钥;`key extract` 通过 LLDB hook 捕获 PBKDF2 调用获取原始密钥,覆盖范围更广
`key extract` 通过 LLDB hook 捕获 PBKDF2 调用获取原始密钥,覆盖所有数据库
手动设置密钥:
@@ -104,7 +116,7 @@ wx-cli query 张三 --limit 20 # 查某人的消息
wx-cli search 周末 --limit 20 # 全局关键词搜索
wx-cli query 张三 --type text # 按消息类型过滤
wx-cli query 周末爬山群 # 群聊消息
wx-cli export 张三 -o /tmp/export --format json # 导出会话
wx-cli export 张三 -o /tmp/export --all --format json # 导出会话
wx-cli watch --poll --poll-ms 3000 # 实时监听新消息
```
@@ -139,10 +151,10 @@ REST 端点:`/api/v1/health`、`/api/v1/sessions`、`/api/v1/contacts`、`/api
|------|------|
| `wx-cli status` | 查看 WeChat 运行状态 |
| `wx-cli doctor` | 检查环境(SIP 等) |
| `sudo wx-cli key scan` | 内存扫描提取密钥(推荐) |
| `wx-cli key extract` | LLDB hook 提取密钥 |
| `wx-cli key list` | 查看已保存密钥 |
| `wx-cli key set <account> <key>` | 手动设置密钥 |
| `wx-cli key set-image <account> <image-key>` | 手动设置图片密钥 |
| `wx-cli decrypt` | 解密数据库 |
| `wx-cli sessions` | 最近会话列表 |
| `wx-cli contacts --search <名字>` | 搜索联系人 |
@@ -160,7 +172,7 @@ REST 端点:`/api/v1/health`、`/api/v1/sessions`、`/api/v1/contacts`、`/api
## Contact Hiding
按账号隐藏指定联系人、群聊或带特定标签的联系人。启用后,查询、导出、监控和服务接口默认应用隐藏规则。
按账号隐藏指定联系人、群聊或带特定标签的联系人。启用后,查询、导出、监控等命令默认应用隐藏规则(全文搜索除外)
配置文件:`~/Library/Application Support/wx-cli/config/settings.toml`
@@ -190,7 +202,7 @@ ignore_tags = ["同事", "客户"]
wx-cli/
├── crates/
│ ├── wx-decrypt/ # 核心解密库(KDF、逐页解密、整库解密)
│ ├── wx-keychain/ # 密钥提取(LLDB / Mach VM)与本地存储
│ ├── wx-keychain/ # 密钥提取(LLDB hook)与本地存储
│ ├── wx-cli/ # CLI 入口
│ ├── wx-db/ # 数据库查询(联系人、消息、会话、群聊)
│ ├── wx-media/ # 媒体解密(图片、语音、视频)
@@ -205,7 +217,7 @@ wx-cli/
- 确认 WeChat 已弹出登录界面并完成登录
- 增加超时:`--timeout 300`
- 检查日志:`$TMPDIR/wx-cli/lldb/wechat_lldb_output.txt`
- 检查日志:`$TMPDIR/wx-cli/lldb/wx_cli_lldb_output.txt`
### SIP / DevToolsSecurity 报错
+392
View File
@@ -0,0 +1,392 @@
---
name: wx-cli
description: Use when the user asks about their WeChat messages, contacts, conversations, chat history, or needs to decrypt/search WeChat data. Provides structured CLI commands for querying encrypted WeChat macOS databases.
---
# wx-cli — WeChat 数据查询工具
## Overview
`wx-cli` 解密并查询 WeChat macOS 加密数据库。零参数即可运行——自动检测账号、密钥和数据目录。
**核心原则:**`--format json` 获取结构化数据供分析;用默认 text 格式展示给用户。
## When to Use
- 用户问"最近跟谁聊了什么"、"某人发过什么消息"
- 用户要搜索聊天记录中的关键词
- 用户要查看联系人信息
- 用户要解密数据库或图片
- 用户提到 WeChat / 微信相关数据需求
## Quick Reference
| 需求 | 命令 |
|------|------|
| 当前状态 | `wx-cli status` |
| **密钥提取(推荐)** | `wx-cli key extract` |
| 手动设置数据库密钥 | `wx-cli key set <account_id> <hex_key>` |
| 手动设置图片密钥 | `wx-cli key set-image <account_id> <image_key>` |
| 已存密钥列表 | `wx-cli key list` |
| 解密数据库 | `wx-cli decrypt` |
| 增量解密 | `wx-cli decrypt --incremental` |
| 解密图片(自动推导密钥)| `wx-cli decode-image <路径> -d <data_dir>` |
| 解密图片(手动 V2 key| `wx-cli media decrypt-dat <路径> --v2-key <key>` |
| 解密图片(KeyStore| `wx-cli decode-image <路径> --account <account_id>` |
| 提取语音 | `wx-cli media extract-voice --media-dir <dir> <svr_id>` |
| 提取语音(原始 SILK| `wx-cli media extract-voice --media-dir <dir> <svr_id> --raw` |
| 解密视频号视频 | `wx-cli media decrypt-video <文件> --seed <seed>` |
| 查询 hardlink 路径 | `wx-cli media resolve-path --db <hardlink.db> <key>` |
| 检查 DB 文件 | `wx-cli info <db_file>` |
| 版本信息 | `wx-cli --version` |
| 最近会话列表 | `wx-cli sessions` |
| 查看系统路径 | `wx-cli paths` |
| 查看系统路径(JSON | `wx-cli paths --json` |
| 忽略隐藏配置查看完整会话列表 | `wx-cli sessions --show-hidden` |
| 搜索联系人 | `wx-cli contacts --search <关键词>` |
| 忽略隐藏配置搜索联系人 | `wx-cli contacts --search <关键词> --show-hidden` |
| 查某人的消息 | `wx-cli query <联系人名/账号ID>` |
| 忽略隐藏配置查看消息 | `wx-cli query <联系人> --show-hidden` |
| 定位某条消息上下文 | `wx-cli query <联系人> --around-sort-seq <seq> --context 10` |
| 按 server_id 定位上下文 | `wx-cli query <联系人> --around-server-id <id> --context 10` |
| 增量拉取新消息 | `wx-cli query <联系人> --after-sort-seq <seq> --limit 20` |
| 全局搜索关键词 | `wx-cli search <关键词>` |
| **导出会话(TXT,默认并行)** | `wx-cli export <联系人> -o <目录> --all` |
| **导出会话(JSON,默认并行)** | `wx-cli export <联系人> -o <目录> --all --format json` |
| 忽略隐藏配置导出会话 | `wx-cli export <联系人> -o <目录> --all --show-hidden` |
| 导出(无媒体) | `wx-cli export <联系人> -o <目录> --all --no-media` |
| **启动 HTTP API** | `wx-cli server run` |
| 查看 HTTP 服务状态 | `wx-cli server status` |
| 停止 HTTP 服务 | `wx-cli server stop` |
| 重启 HTTP 服务 | `wx-cli server restart` |
| HTTP API(远程) | `wx-cli server run --host 0.0.0.0 --token <secret>` |
| 实时监听会话变化 | `wx-cli watch` |
| 实时监听(忽略隐藏配置) | `wx-cli watch --show-hidden` |
| 实时监听(轮询) | `wx-cli watch --poll --poll-ms 3000` |
| 实时监听(文件事件模式) | `wx-cli watch --fsnotify` |
| 前置条件检查 | `wx-cli doctor` |
| 前置条件检查 + 修复建议 | `wx-cli doctor --fix` |
## 前置条件
密钥提取**需要 SIP 禁用**,通常不需要 sudo。SIP enabled 时 `task_for_pid` 被内核拒绝(kern_return=5),即使 root 也不行。检查 SIP 状态:`csrutil status`。禁用需在 Recovery Mode 执行 `csrutil disable`
## 常用工作流
### 0. 首次使用:提取密钥并解密
WeChat 数据库是加密的,查询前必须先有密钥。**需要 SIP 禁用**SIP enabled 时 `task_for_pid` 被内核拒绝):
```bash
# LLDB hook 提取密钥 — 会重启 WeChat,需要 LLDB + python3,通常不需要 sudo
wx-cli key extract --timeout 120
```
`key extract` 拿到的是完整数据库密钥,**覆盖所有数据库**。后续多数查询可直接读取加密数据库,无需先执行 `decrypt`
提取后验证:
```bash
# 查看已存密钥(`raw=yes` 表示已保存完整数据库密钥)
wx-cli key list
# 有完整数据库密钥时:query/sessions/search/contacts/server run/watch 可直接读取加密数据库,无需先 decrypt
# 如需明文导出
wx-cli decrypt
wx-cli decrypt --incremental
```
手动设置密钥:
```bash
wx-cli key set <account_id> 0123456789abcdef... # 数据库密钥(32 字节 hex
wx-cli key set-image <account_id> abcdefghijklmnop # 图片 AES 密钥(V2 格式)
```
**密钥类型说明:**
| 密钥 | 用途 | 提取方式 | 覆盖范围 |
|------|------|---------|---------|
| 完整数据库密钥(`raw_key` | 解密 SQLite 数据库 | `key extract`LLDB hook | 所有 DB |
| Image key16 bytes | 解密 V2 格式 .dat 图片 | `decode-image -d <data_dir>` 自动推导 | — |
### 1. 查看最近聊天
```bash
wx-cli sessions --limit 10
# 返回按时间倒序的会话列表,含联系人显示名和最后一条消息摘要
```
### 2. 查找某人并读消息
```bash
# 先搜联系人(支持昵称、备注、wxid、微信号、手机号等模糊匹配)
wx-cli contacts --search 张三
# 用搜到的名字或 wxid 查消息
wx-cli query 张三 --limit 20
# 用 JSON 获取结构化数据
wx-cli query 张三 --format json --limit 50
```
### 2a. 联系人隐藏规则
配置文件:`~/Library/Application Support/wx-cli/config/settings.toml`
```toml
[accounts."<account_id>"]
ignore_contacts = ["wxid_hidden_contact"]
ignore_tags = ["同事"]
```
- `query` / `sessions` / `contacts` / `export` / `watch` 支持 `--show-hidden` 忽略隐藏配置
- `search` 当前**不会自动应用隐藏配置**
- **隐私优先:** 当输出里出现 `[消息已隐藏]`,除非用户明确要求,agent 不应主动追加 `--show-hidden`
### 3. 全局搜索关键词
```bash
wx-cli search 周末 --limit 20
# 优先查询 WeChat 自带的全文索引库,搜索通常在亚秒级完成
```
**搜索语义:**
- 中文按字拆分;英文支持 Porter stemming
- 多词搜索为 AND 逻辑
- `stats.scanned=0` 表示走微信内置全文索引;`stats.scanned>0` 表示退回到全量扫描
### 4. 按条件过滤消息
```bash
wx-cli query 张三 --type text # 按类型:text/image/voice/video/emoji/app/system/revoke
wx-cli query 张三 --since 1772600000 --until 1772700000 # 时间范围(Unix 秒)
```
### 4b. 锚点上下文查询
```bash
wx-cli query 张三 --around-sort-seq 1773421188000 --context 10 # 按 sort_seq 定位前后 10 条
wx-cli query 张三 --around-server-id 5455993825313690274 --context 10 # 按 server_id 定位
wx-cli query 张三 --after-sort-seq 1773421188000 --limit 20 # 增量拉取新消息
```
**行为规则:**
- 锚点查询始终按升序返回,`--order desc` 会被忽略
- `--context` 默认 50,仅对 `around-*` 有效
- `around-*` 参数与 `--since`/`--until`/`--all` 互斥
- `--around-sort-seq``--around-server-id``--after-sort-seq` 三者互斥
### 5. 群聊查询
```bash
wx-cli query 18819405230@chatroom --limit 10 # 群聊 ID
wx-cli query 周末爬山群 # 或群名模糊匹配
```
### 6. 导出会话
```bash
wx-cli export 张三 -o /tmp/export/ --all # TXT 格式,全部消息
wx-cli export 张三 -o /tmp/export/ --all --format json # JSON 格式
wx-cli export 张三 -o /tmp/export/ --all --no-media # 跳过媒体文件
wx-cli export 张三 -o /tmp/export/ --all --show-emoji # 显示表情细节
```
**排序默认 `asc`**(时间正序),与 `query` 默认 `desc` 相反。
### 7. 图片解密与转码
```bash
# 推荐:-d 自动推导 V2 密钥
wx-cli decode-image input.dat -d <account_data_dir> -o output.png
# 批量目录
wx-cli decode-image /path/to/dat_dir/ -d <account_data_dir> -o /tmp/output/
# 直接传入 V2 AES key
wx-cli media decrypt-dat input.dat --v2-key abcdefghijklmnop -o output.png
```
### 8. 语音提取
```bash
wx-cli media extract-voice --media-dir <dir> <svr_id> -o voice.mp3 # 默认 MP3(需 ffmpeg
wx-cli media extract-voice --media-dir <dir> <svr_id> --raw -o voice.silk # 原始 SILK
```
### 8b. Hardlink 路径查询
```bash
wx-cli media resolve-path --db /path/to/hardlink.db <md5_key> # 图片
wx-cli media resolve-path --db /path/to/hardlink.db --media-type video <key> # 视频
wx-cli media resolve-path --db /path/to/hardlink.db --media-type file <key> # 文件
```
### 9. 视频号视频解密
```bash
wx-cli media decrypt-video encrypted.bin --seed 2105122989 -o video.mp4 # 十进制 seed
wx-cli media decrypt-video encrypted.bin --seed 0x7d844e8d -o video.mp4 # 十六进制 seed
```
### 10. HTTP API 服务
```bash
wx-cli server run # 本地启动(默认 127.0.0.1:9100
wx-cli server run --host 0.0.0.0 --token mysecret # 远程访问(必须设 token
wx-cli server status / stop / restart # 管理服务
```
### 10a. CLI 自动复用已运行的 server
`sessions``contacts``query``search` 默认会先尝试连接本机 `http://127.0.0.1:9100`,如果 server 可用就走 HTTP,否则回退到本地直查。
| 参数 | 说明 |
|------|------|
| `--server-url <url>` | 覆盖默认地址 |
| `--server-token <token>` | Bearer token |
| `--server-only` | 只走远程,不回退 |
| `--no-server` | 强制本地查询 |
### 10b. REST 端点(只读)
| 端点 | 说明 | 关键参数 |
|------|------|---------|
| `GET /api/v1/health` | 健康探测 | 无 |
| `GET /api/v1/sessions` | 会话列表 | `limit`, `offset`, `order`, `show_hidden` |
| `GET /api/v1/contacts` | 联系人列表 | `limit`, `offset`, `search`, `show_hidden` |
| `GET /api/v1/messages` | 消息查询 | `contact`(必填), `limit`, `offset`, `since`, `until`, `type`, `order`, `around_sort_seq`, `around_server_id`, `after_sort_seq`, `context`, `show_hidden` |
| `GET /api/v1/media` | 媒体内容直出 | `server_id`(必填), `talker`(必填), `format=ogg\|mp3`(仅语音) |
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
| `GET /api/v1/events` | SSE 事件流 | 无 |
**认证:**`--token` 启动时须携带 `Authorization: Bearer <token>``--host` 不是本机地址时 `--token` 必填。
当前 HTTP API 为**只读**,没有 send/reply/webhook 等写接口。
### 11. 实时监听
```bash
wx-cli watch # 默认启动
wx-cli watch --poll --poll-ms 5000 # 自定义轮询间隔
wx-cli watch --format json # JSON 格式(每行一个 JSON 对象)
wx-cli watch --show-hidden # 忽略隐藏配置
```
### 12. 前置条件检查
```bash
wx-cli doctor # 列出各项检查结果(SIP、DevToolsSecurity、_developer、lldb、python3
wx-cli doctor --fix # 对 FAIL 项输出修复命令
```
## 错误自动恢复
**`error: no key for account <account_id>`**
```bash
wx-cli status # 1. 确认 WeChat 状态
wx-cli key extract --timeout 120 # 2. 提取密钥
wx-cli key list # 3. 验证密钥
wx-cli sessions # 4. 重试查询
```
**`task_for_pid failed (kern_return=5)`** — SIP 启用,需在 Recovery Mode 执行 `csrutil disable`
**`warning: ffmpeg not found`** — 安装 ffmpeg`brew install ffmpeg`,或 `FFMPEG_PATH=/path/to/ffmpeg wx-cli ...`
## JSON 输出结构
所有查询命令加 `--format json` 返回统一信封:
```json
{
"items": [...],
"paging": { "offset": 0, "limit": 20, "returned": 20, "total": 103, "has_more": true },
"stats": { "scanned": 103, "skipped": 0, "elapsed_ms": 3 }
}
```
| 命令 | item 关键字段 |
|------|-------------|
| sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` |
| query | `sort_seq`, `server_id`, `msg_type`, `sender`, `content`, `direction` |
| contacts | `user_name`, `alias`, `remark`, `nick_name`, `phone`, `labels` |
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
**易混淆字段:**
- contacts 用 `user_name`(下划线),sessions 用 `username`(无下划线)
- message 的 `sender` 才是消息级 self/other 判断依据
- `/api/v1/health``current_account.wxid` 是判断"我发的"的主事实源
**JSON 编程使用:** JSON 走 stdout,诊断信息走 stderr。分离采集才能正确解析:
```bash
OUTPUT=$(wx-cli query 张三 --format json --limit 5)
echo "$OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['items']))"
```
## 分页与排序
```bash
wx-cli query 张三 --format json --limit 10 --offset 0 # 第一页
wx-cli query 张三 --format json --limit 10 --offset 10 # 第二页
wx-cli query 张三 --order asc # 时间正序
wx-cli query 张三 --all # 获取全部消息(上限 20,000
```
## 消息类型标签(type=49 结构化解析)
| 变体 | sub_type | 输出 |
|------|----------|------|
| Link | 4, 5, 7, 92 | `[链接] <标题>` |
| File | 6 | `[文件] <文件名>` |
| MiniProgram | 33, 36 | `[小程序] <名称>` |
| MergedMessages | 19 | `[聊天记录] <标题>` |
| Quote | 57 | `[引用 @发送者: 原文] 回复文本` |
| Transfer | 2000 | `[转账] ¥金额` |
| RedEnvelope | 2001, 2003 | `[红包] <标题>` |
| ChannelVideo | 51, 63 | `[视频号] <标题>` |
| Pat | 62 | `[拍一拍]` |
JSON `content` 字段为 tagged union:外层 key 是变体名,值是结构化字段。
## 文件路径
| 类别 | 路径(macOS | 用途 |
|------|---------------|------|
| Config | `~/Library/Application Support/wx-cli/config/` | 密钥、设置 |
| Cache | `~/Library/Caches/wx-cli/` | 解密后数据库 |
| State | `~/Library/Application Support/wx-cli/state/` | 服务运行时 |
| Logs | `~/Library/Logs/wx-cli/` | 服务日志 |
| Temp | `$TMPDIR/wx-cli/` | 密钥提取临时文件 |
使用 `wx-cli paths` 查看所有路径。
## 注意事项
- 所有命令自动检测账号和密钥,通常无需 `--account``--key`
- `query` 支持 wxid、chatroom ID、filehelper,也支持中文名模糊匹配
- `search` 直接查询 WeChat 自带 `message_fts.db`,无需单独建索引
- `search` 当前不会自动应用隐藏配置,也没有 `--show-hidden`
- `--all` 覆盖 `--limit`(当前上限 20,000);`export --all` 会按批次拉完全部结果
- `--limit 0` 会退回到该命令的默认值
- `server run` 启动后会自动增量刷新解密缓存
## 诊断策略
当查询返回空结果时,**不要反复换参数重试**:
```bash
# 1. 检查 stats.skipped
wx-cli query <contact> --all --format json 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(f'items={len(d[\"items\"])}, skipped={d[\"stats\"][\"skipped\"]}')"
# 2. skipped > 0 → 重新解密
wx-cli decrypt
# 3. items=0 且 skipped=0 → 检查联系人
wx-cli contacts --search <name> --format json
```
+2 -2
View File
@@ -19,13 +19,13 @@ wx-media = { path = "../wx-media" }
wx-monitor = { path = "../wx-monitor" }
wx-context = { path = "../wx-context" }
wx-paths = { path = "../wx-paths" }
rusqlite = { version = "0.32", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
toml = "1.1"
ureq = { version = "2", features = ["json"] }
url = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
+17 -18
View File
@@ -77,24 +77,23 @@ pub fn cmd_query(
if options.is_enabled() && !preserve_local_warning {
let client = ThinClient::new(options.clone());
match client.probe_health().and_then(|_| {
fetch_remote_query(
&client,
contact,
since,
until,
msg_type.clone(),
effective_limit,
offset,
order.clone(),
around_sort_seq,
around_server_id,
context,
after_sort_seq,
show_hidden,
)
}) {
Ok(envelope) => {
match client.probe_health() {
Ok(()) => {
let envelope = fetch_remote_query(
&client,
contact,
since,
until,
msg_type.clone(),
effective_limit,
offset,
order.clone(),
around_sort_seq,
around_server_id,
context,
after_sort_seq,
show_hidden,
)?;
let is_group = envelope
.items
.first()
+5 -4
View File
@@ -1,8 +1,6 @@
use std::path::PathBuf;
use wx_context::{
open_fts_connection_with_key, AccountContext, ContactResolver, ResolveParams,
};
use wx_context::{register_mm_fts_tokenizer, AccountContext, ContactResolver, ResolveParams};
use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions};
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
@@ -64,7 +62,10 @@ fn load_local_search(
// --- Native FTS search → fallback to scan ---
let use_fallback = match db.message_fts_path.as_deref() {
Some(fts_path) => match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) {
Some(fts_path) => match db.open_related_readonly(fts_path).and_then(|conn| {
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
Ok(conn)
}) {
Ok(conn) => {
match wx_db::native_fts::search_message_fts(
&conn,
+13 -7
View File
@@ -19,8 +19,8 @@ use tokio::signal::unix::SignalKind;
use tokio::sync::{broadcast, mpsc, watch};
use tokio_util::sync::CancellationToken;
use wx_context::{
open_fts_connection_with_key, register_mm_fts_tokenizer, write_shard_metadata_sidecar,
AccountContext, ContactResolver, DecryptRequest, PersistentCache, ResolveParams,
register_mm_fts_tokenizer, write_shard_metadata_sidecar, AccountContext, ContactResolver,
DecryptRequest, PersistentCache, ResolveParams,
};
use crate::util::{print_cache_stats, print_detection_note};
@@ -122,7 +122,10 @@ pub async fn cmd_serve(
// 3b. Open independent FTS connection (outside WechatDb Mutex)
let fts_conn = db.message_fts_path.as_deref().and_then(|fts_path| {
match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) {
match db.open_related_readonly(fts_path).and_then(|conn| {
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
Ok(conn)
}) {
Ok(conn) => {
if let Ok(mode) =
conn.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
@@ -196,7 +199,7 @@ pub async fn cmd_serve(
// 3c. Open hardlink.db connection (pooled, outside WechatDb Mutex)
let hardlink_db_conn = if hardlink_db_path.exists() {
match wx_db::open_readonly_connection(&hardlink_db_path, acct.raw_key.as_ref()) {
match db.open_related_readonly(&hardlink_db_path) {
Ok(conn) => {
eprintln!("server/hardlink: opened pooled connection");
Some(conn)
@@ -222,9 +225,14 @@ pub async fn cmd_serve(
}
let watch_mode = resolve_watch_mode(poll, fsnotify);
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
let config = wx_monitor::MonitorConfig {
encrypted_session_dir,
key_material: acct.key_material.clone(),
key_material: if monitor_derived_keys.is_empty() {
acct.key_material.clone()
} else {
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
},
params,
watch_mode: watch_mode.clone(),
poll_interval: Duration::from_millis(poll_ms),
@@ -239,7 +247,6 @@ pub async fn cmd_serve(
// Capture values before moving db into Mutex
let fts_path_for_refresh = db.message_fts_path.clone();
let raw_key_for_refresh = acct.raw_key;
// 5. Create refresh task channels
let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshTrigger>(64);
@@ -398,7 +405,6 @@ pub async fn cmd_serve(
shutdown_bg.clone(),
)
.with_fts(bg_state.fts_conn.clone(), fts_path_for_refresh)
.with_raw_key(raw_key_for_refresh)
.with_caches(
Some(Arc::clone(&bg_state.name2id_cache)),
Some(Arc::clone(&bg_state.media_db_paths)),
+5 -11
View File
@@ -6,7 +6,7 @@ use rusqlite::Connection;
use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken;
use wx_context::{
open_fts_connection, open_fts_connection_with_key, DecryptProgress, DecryptRequest,
open_fts_connection, register_mm_fts_tokenizer, DecryptProgress, DecryptRequest,
PersistentCache,
};
use wx_db::WechatDb;
@@ -35,8 +35,6 @@ pub struct RefreshTask {
fts_conn: Option<Arc<std::sync::Mutex<Connection>>>,
/// Path to FTS DB for reopening.
fts_path: Option<PathBuf>,
/// Raw key for encrypted FTS reopen.
raw_key: Option<[u8; 32]>,
/// Cache of name2id mapping — cleared when FTS is reopened.
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>,
/// Cache of media DB paths — cleared on every refresh.
@@ -61,18 +59,12 @@ impl RefreshTask {
shutdown,
fts_conn: None,
fts_path: None,
raw_key: None,
name2id_cache: None,
media_db_paths: None,
hardlink_db_conn: None,
}
}
pub fn with_raw_key(mut self, raw_key: Option<[u8; 32]>) -> Self {
self.raw_key = raw_key;
self
}
/// Set the independent FTS connection and path for refresh reopening.
pub fn with_fts(
mut self,
@@ -128,7 +120,6 @@ impl RefreshTask {
let cache = self.cache.clone();
let fts_conn = self.fts_conn.clone();
let fts_path = self.fts_path.clone();
let raw_key = self.raw_key;
let success = tokio::task::spawn_blocking(move || {
if let Some(cache) = cache {
// Decrypt-cache mode: decrypt then selective reopen
@@ -264,7 +255,10 @@ impl RefreshTask {
// Reopen independent FTS connection
if let (Some(fts_mutex), Some(path)) = (&fts_conn, &fts_path) {
match open_fts_connection_with_key(path, raw_key.as_ref()) {
match guard.open_related_readonly(path).and_then(|conn| {
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
Ok(conn)
}) {
Ok(new_conn) => {
if let Ok(mut fts_guard) = fts_mutex.lock()
as Result<std::sync::MutexGuard<'_, Connection>, _>
+6 -1
View File
@@ -318,9 +318,14 @@ pub async fn cmd_watch(
}
let watch_mode = resolve_watch_mode(poll, fsnotify);
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
let config = wx_monitor::MonitorConfig {
encrypted_session_dir,
key_material: acct.key_material.clone(),
key_material: if monitor_derived_keys.is_empty() {
acct.key_material.clone()
} else {
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
},
params,
watch_mode: watch_mode.clone(),
poll_interval: Duration::from_millis(poll_ms),
+8 -7
View File
@@ -10,7 +10,7 @@ pub fn open_db_core(
) -> Result<(wx_db::WechatDb, Option<DecryptStats>), Box<dyn std::error::Error>> {
if acct.raw_key.is_some() {
eprintln!("Direct encrypted open (SQLCipher)");
let db = wx_context::open_encrypted_db(acct)?;
let db = wx_context::open_encrypted_db_core(acct)?;
Ok((db, None))
} else {
let params = &wx_decrypt::MACOS_4_1_7_31;
@@ -18,7 +18,7 @@ pub fn open_db_core(
let stats = DecryptRequest::new()
.core()
.execute_with_progress(&cache, progress)?;
let db = wx_db::WechatDb::open(cache.decrypted_root())?;
let db = wx_db::WechatDb::open_core(cache.decrypted_root())?;
Ok((db, Some(stats)))
}
}
@@ -139,9 +139,10 @@ pub fn effective_limit_all(all: bool, limit: usize) -> usize {
}
}
/// Attempt a remote API call via ThinClient; on connection/auth failure fall back to the
/// local path. This encapsulates the `probe_health → remote_fn → should_fallback → local_fn`
/// pattern shared by `search`, `contacts`, and `sessions`.
/// Attempt a remote API call via ThinClient. In auto mode, fall back locally only when
/// the initial health probe cannot reach/authenticate with a usable server. Once health
/// succeeds, a failed business request is returned to the caller instead of launching an
/// expensive local SQLCipher query after waiting for the remote timeout.
pub fn try_remote_or_local<T>(
options: &ThinClientOptions,
remote_fn: impl FnOnce(&ThinClient) -> Result<T, ThinClientError>,
@@ -150,8 +151,8 @@ pub fn try_remote_or_local<T>(
) -> Result<T, Box<dyn std::error::Error>> {
if options.is_enabled() {
let client = ThinClient::new(options.clone());
match client.probe_health().and_then(|_| remote_fn(&client)) {
Ok(result) => return Ok(result),
match client.probe_health() {
Ok(()) => return remote_fn(&client).map_err(Into::into),
Err(err) if err.should_fallback(options.mode) => {
eprintln!(
"note: remote server unavailable, falling back to local {label} ({})",
+39 -2
View File
@@ -4,6 +4,8 @@ use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_wx-cli")
}
@@ -278,7 +280,8 @@ fn server_only_fails_when_remote_unavailable() {
#[test]
fn unavailable_remote_falls_back_to_local() {
let output = Command::new(bin())
let (mut command, _home) = command_without_local_account();
let output = command
.args(["sessions", "--server-url", "http://127.0.0.1:9"])
.output()
.expect("run sessions fallback");
@@ -314,7 +317,8 @@ fn no_server_bypasses_remote_probe() {
}
});
let output = Command::new(bin())
let (mut command, _home) = command_without_local_account();
let output = command
.args([
"sessions",
"--no-server",
@@ -333,6 +337,39 @@ fn no_server_bypasses_remote_probe() {
);
}
#[test]
fn healthy_server_business_transport_failure_does_not_fall_back() {
let (base_url, handle) = spawn_sequence_server(2, |_request, index| match index {
0 => http_response("200 OK", "{\"ready\":true}"),
// Close the second connection without a response. This is classified as an
// unavailable transport error, but health already proved the server was selected.
1 => String::new(),
_ => unreachable!(),
});
let output = Command::new(bin())
.args(["sessions", "--server-url", &base_url])
.output()
.expect("run sessions with failed business request");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("error:"));
assert!(!stderr.contains("falling back to local"));
handle.join().unwrap();
}
fn command_without_local_account() -> (Command, TempDir) {
let home = TempDir::new().expect("create isolated home");
let mut command = Command::new(bin());
command
.env("HOME", home.path())
.env_remove("WECHAT_CLI_DATA_DIR")
.env_remove("WECHAT_CLI_ACCOUNT")
.env_remove("WECHAT_CLI_KEY");
(command, home)
}
fn spawn_sequence_server(
expected_requests: usize,
responder: impl Fn(String, usize) -> String + Send + 'static,
+4 -4
View File
@@ -11,7 +11,7 @@ wx-paths = { path = "../wx-paths" }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
hex = "0.4"
rusqlite = { version = "0.32", features = ["bundled-sqlcipher"] }
rusqlite = { version = "0.40", features = ["bundled-sqlcipher"] }
dashmap = "6"
rayon = "1"
rust-stemmers = "1"
@@ -19,9 +19,9 @@ rust-stemmers = "1"
[dev-dependencies]
tempfile = "3"
filetime = "0.2"
aes = "0.8"
cbc = "0.1"
aes = "0.9"
cbc = "0.2"
hmac = "0.12"
sha2 = "0.10"
sha2 = "0.11"
pbkdf2 = { version = "0.12", features = ["hmac"] }
hex = "0.4"
+2 -2
View File
@@ -944,7 +944,7 @@ mod tests {
salt: &[u8; 16],
params: &wx_decrypt::CryptoParams,
) {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use aes::cipher::{BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -963,7 +963,7 @@ mod tests {
let mut ciphertext = plaintext;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
Aes256CbcEnc::new((&enc_key).into(), (&iv).into())
.encrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.encrypt_padded::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.unwrap();
let mut page = Vec::with_capacity(params.page_size);
+41 -2
View File
@@ -36,6 +36,25 @@ pub use progress::{DecryptProgress, DecryptStats};
pub use shard_routing::{route_shards_for_query, write_shard_metadata_sidecar};
pub use visibility::VisibilityIndex;
/// Read persisted per-database derived keys for this account.
/// Ephemeral `--key` contexts deliberately ignore the store because the supplied
/// raw key may not match its cached entries.
pub fn persisted_derived_keys(
account: &AccountContext,
) -> Result<Vec<wx_decrypt::EncKeyPair>, ContextError> {
if !account.writeback_enabled {
return Ok(Vec::new());
}
let store = wx_keychain::KeyStore::load_default()?;
Ok(match store.resolve_key_material(&account.account_id) {
Some(wx_decrypt::KeyMaterial::EncKeys(pairs)) => pairs,
Some(wx_decrypt::KeyMaterial::EncKey { key, salt }) => {
vec![wx_decrypt::EncKeyPair { key, salt }]
}
_ => Vec::new(),
})
}
/// Open encrypted WeChat DB directory directly (no pool, no FTS).
/// For one-shot commands: contacts, sessions, query, search, export.
pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
@@ -43,7 +62,25 @@ pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, Co
.raw_key
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
let encrypted_root = account.data_dir.join("db_storage");
let db = wx_db::WechatDb::open_encrypted(&encrypted_root, raw_key)?;
let derived_keys = persisted_derived_keys(account)?;
let db =
wx_db::WechatDb::open_encrypted_with_key_cache(&encrypted_root, raw_key, &derived_keys)?;
Ok(db)
}
/// Open only encrypted contact.db and session.db directly.
/// This avoids deriving keys for and scanning message shards for core-only commands.
pub fn open_encrypted_db_core(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
let raw_key = account
.raw_key
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
let encrypted_root = account.data_dir.join("db_storage");
let derived_keys = persisted_derived_keys(account)?;
let db = wx_db::WechatDb::open_encrypted_core_with_key_cache(
&encrypted_root,
raw_key,
&derived_keys,
)?;
Ok(db)
}
@@ -56,9 +93,11 @@ pub fn open_encrypted_db_with_pool(
.raw_key
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
let encrypted_root = account.data_dir.join("db_storage");
let db = wx_db::WechatDb::open_encrypted_with_pool(
let derived_keys = persisted_derived_keys(account)?;
let db = wx_db::WechatDb::open_encrypted_with_pool_and_key_cache(
&encrypted_root,
raw_key,
&derived_keys,
register_mm_fts_tokenizer,
)?;
Ok(db)
+2 -2
View File
@@ -8,7 +8,7 @@ fn build_encrypted_db(
salt: &[u8; 16],
params: &wx_decrypt::CryptoParams,
) {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use aes::cipher::{BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -22,7 +22,7 @@ fn build_encrypted_db(
let mut ciphertext = plaintext;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
Aes256CbcEnc::new((&enc_key).into(), (&iv).into())
.encrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.encrypt_padded::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.unwrap();
let mut page = Vec::with_capacity(params.page_size);
+2 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
[dependencies]
rusqlite = { version = "0.32", features = ["bundled-sqlcipher"] }
rusqlite = { version = "0.40", features = ["bundled-sqlcipher"] }
prost = "0.13"
zstd = "0.13"
md5 = "0.7"
@@ -12,6 +12,7 @@ thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
wx-decrypt = { path = "../wx-decrypt" }
[dev-dependencies]
insta = { version = "1", features = ["yaml"] }
+2 -1
View File
@@ -414,7 +414,8 @@ impl WechatDb {
)?;
for shard in &self.shards {
let shard_conn = WechatDb::open_shard_with_key(shard, self.raw_key.as_ref())?;
let shard_conn =
WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref())?;
// List Msg_* tables in this shard
let mut table_stmt = shard_conn.prepare(
+23 -18
View File
@@ -12,7 +12,7 @@ use crate::model::{
effective_limit, split_local_type, AnchorMode, Message, MessageQuery, MessageQueryResult,
QueryStats, SortOrder,
};
use crate::open::{MessageShard, WechatDb};
use crate::open::{MessageShard, SqlcipherKey, WechatDb};
/// Dispatch mode for regular (non-anchor) queries.
enum RegularQueryMode {
@@ -56,13 +56,13 @@ fn prepare_shard_query<'a>(
table_name: &str,
warnings: &mut Vec<ShardWarning>,
pooled_conn: Option<&'a Connection>,
raw_key: Option<&[u8; 32]>,
sqlcipher_key: Option<&SqlcipherKey>,
) -> Option<PreparedShard<'a>> {
let shard_path = shard.path.display().to_string();
let conn = match pooled_conn {
Some(conn) => ShardConnection::Borrowed(conn),
None => match WechatDb::open_shard_with_key(shard, raw_key) {
None => match WechatDb::open_shard_with_key(shard, sqlcipher_key) {
Ok(c) => ShardConnection::Owned(c),
Err(e) => {
warnings.push(ShardWarning {
@@ -176,7 +176,7 @@ impl WechatDb {
&table_name,
&mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(),
self.sqlcipher_key.as_ref(),
) {
Some(p) => p,
None => continue,
@@ -296,12 +296,12 @@ impl WechatDb {
for shard in &shards {
let count = if let Some(pool) = self.pool() {
if let Some(conn) = pool.get(&shard.path) {
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
Self::count_shard(conn, &sql, start_time, end_time, msg_type_filter)
} else {
continue;
}
} else {
match crate::open::open_connection(&shard.path, self.raw_key.as_ref()) {
match crate::open::open_connection(&shard.path, self.sqlcipher_key.as_ref()) {
Ok(conn) => {
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
}
@@ -385,7 +385,7 @@ impl WechatDb {
table_name,
&mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(),
self.sqlcipher_key.as_ref(),
) {
Some(p) => p,
None => continue,
@@ -476,7 +476,7 @@ impl WechatDb {
table_name,
&mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(),
self.sqlcipher_key.as_ref(),
) {
Some(p) => p,
None => continue,
@@ -607,7 +607,7 @@ impl WechatDb {
table_name,
&mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(),
self.sqlcipher_key.as_ref(),
) {
Some(p) => p,
None => continue,
@@ -683,7 +683,7 @@ impl WechatDb {
table_name,
&mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(),
self.sqlcipher_key.as_ref(),
) {
Some(p) => p,
None => continue,
@@ -812,16 +812,21 @@ impl WechatDb {
known_usernames.iter().map(|u| (u.clone(), 0)).collect();
for shard in self.all_shards() {
let conn = match WechatDb::open_shard_with_key(shard, self.raw_key.as_ref()) {
Ok(c) => c,
Err(e) => {
eprintln!(
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
shard.path.display()
);
continue;
let conn = if let Some(conn) = self.pool().and_then(|pool| pool.get(&shard.path)) {
ShardConnection::Borrowed(conn)
} else {
match WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref()) {
Ok(conn) => ShardConnection::Owned(conn),
Err(e) => {
eprintln!(
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
shard.path.display()
);
continue;
}
}
};
let conn = conn.as_conn();
// Discover Msg_* tables in this shard
let mut stmt = match conn
+314 -41
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::fmt;
use std::os::raw::c_void;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::sync::{Arc, Mutex, RwLock};
use rusqlite::Connection;
@@ -18,6 +18,111 @@ pub(crate) struct MessageShard {
pub end_unix: i64,
}
/// A raw WeChat key plus an in-process cache of SQLCipher's derived keys.
///
/// SQLCipher normally runs its 256k-round PBKDF2 every time a connection is
/// opened. WeChat uses a different salt per database, but the same database is
/// often opened several times during one command (metadata scan, query, count,
/// refresh). Passing SQLCipher's raw keyspec lets us derive once per salt and
/// reuse the result for every subsequent connection.
#[derive(Clone)]
pub(crate) struct SqlcipherKey {
raw_key: [u8; 32],
derived_keys: Arc<Mutex<HashMap<[u8; 16], CachedKey>>>,
}
#[derive(Clone, Copy)]
struct CachedKey {
key: [u8; 32],
/// Preloaded keys come from the persisted key store and get one raw-key
/// fallback if validation fails. Keys derived in this process are trusted.
preloaded: bool,
}
impl SqlcipherKey {
fn new(raw_key: [u8; 32]) -> Self {
Self::with_preloaded(raw_key, &[])
}
fn with_preloaded(raw_key: [u8; 32], pairs: &[wx_decrypt::EncKeyPair]) -> Self {
let derived_keys = pairs
.iter()
.map(|pair| {
(
pair.salt,
CachedKey {
key: pair.key,
preloaded: true,
},
)
})
.collect();
Self {
raw_key,
derived_keys: Arc::new(Mutex::new(derived_keys)),
}
}
fn keyspec_for_path(&self, path: &Path) -> Result<(Vec<u8>, [u8; 16], bool), DbError> {
let salt = wx_decrypt::read_db_salt(path)
.map_err(|e| DbError::EncryptionKey(format!("failed to read database salt: {e}")))?;
let cached = {
let mut cache = self.derived_keys.lock().map_err(|_| {
DbError::EncryptionKey("derived-key cache lock was poisoned".into())
})?;
*cache.entry(salt).or_insert_with(|| {
let key = wx_decrypt::kdf::derive_enc_key(
&self.raw_key,
&salt,
&wx_decrypt::MACOS_4_1_7_31,
);
CachedKey {
key,
preloaded: false,
}
})
};
// SQLCipher raw-key syntax includes the original 16-byte database salt.
// Supplying this ASCII keyspec to sqlite3_key() skips SQLCipher's PBKDF2.
let keyspec = format!("x'{}{}'", hex::encode(cached.key), hex::encode(salt)).into_bytes();
Ok((keyspec, salt, cached.preloaded))
}
fn mark_verified(&self, salt: [u8; 16]) -> Result<(), DbError> {
let mut cache = self
.derived_keys
.lock()
.map_err(|_| DbError::EncryptionKey("derived-key cache lock was poisoned".into()))?;
if let Some(entry) = cache.get_mut(&salt) {
entry.preloaded = false;
}
Ok(())
}
fn rederive_keyspec(&self, salt: [u8; 16]) -> Result<Vec<u8>, DbError> {
let key =
wx_decrypt::kdf::derive_enc_key(&self.raw_key, &salt, &wx_decrypt::MACOS_4_1_7_31);
self.derived_keys
.lock()
.map_err(|_| DbError::EncryptionKey("derived-key cache lock was poisoned".into()))?
.insert(
salt,
CachedKey {
key,
preloaded: false,
},
);
Ok(format!("x'{}{}'", hex::encode(key), hex::encode(salt)).into_bytes())
}
#[cfg(test)]
fn cached_salt_count(&self) -> usize {
self.derived_keys.lock().unwrap().len()
}
}
/// Handle to an opened (decrypted) WeChat database directory.
///
/// Holds connections to contact/session databases and metadata about
@@ -34,8 +139,8 @@ pub struct WechatDb {
pub contact_fts_path: Option<PathBuf>,
/// Optional pre-opened connection pool for serve mode.
pub(crate) pool: Option<ShardPool>,
/// Raw key for encrypted direct open. Stored for reopen operations.
pub(crate) raw_key: Option<[u8; 32]>,
/// Shared raw/derived key state for encrypted direct open and reopen operations.
pub(crate) sqlcipher_key: Option<SqlcipherKey>,
/// Lazily initialized cache of label_id -> label_name from contact_label table.
/// Cleared on `reopen_contacts()` so label changes are visible.
pub(crate) label_cache: RwLock<Option<HashMap<String, String>>>,
@@ -54,30 +159,59 @@ pub fn open_readonly_connection(
path: &Path,
raw_key: Option<&[u8; 32]>,
) -> Result<Connection, DbError> {
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
if let Some(key) = raw_key {
unsafe {
let rc = rusqlite::ffi::sqlite3_key(conn.handle(), key.as_ptr() as *const c_void, 32);
if rc != 0 {
return Err(DbError::EncryptionKey(format!(
"sqlite3_key failed: rc={rc}"
)));
}
}
conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.map_err(|_| DbError::EncryptionKey("incorrect key or not an encrypted database".into()))?;
conn.execute_batch("PRAGMA query_only = ON")?;
}
Ok(conn)
let key = raw_key.copied().map(SqlcipherKey::new);
open_connection(path, key.as_ref())
}
pub(crate) fn open_connection(
path: &Path,
raw_key: Option<&[u8; 32]>,
sqlcipher_key: Option<&SqlcipherKey>,
) -> Result<Connection, DbError> {
open_readonly_connection(path, raw_key)
if let Some(key) = sqlcipher_key {
let (keyspec, salt, preloaded) = key.keyspec_for_path(path)?;
match open_connection_with_keyspec(path, &keyspec) {
Ok(conn) => {
if preloaded {
key.mark_verified(salt)?;
}
Ok(conn)
}
Err(DbError::EncryptionKey(_)) if preloaded => {
// Persisted entries are an optimization, never a single point of
// failure. Re-derive once from the raw key if an entry is stale.
let keyspec = key.rederive_keyspec(salt)?;
open_connection_with_keyspec(path, &keyspec)
}
Err(err) => Err(err),
}
} else {
Ok(Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)?)
}
}
fn open_connection_with_keyspec(path: &Path, keyspec: &[u8]) -> Result<Connection, DbError> {
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
unsafe {
let rc = rusqlite::ffi::sqlite3_key(
conn.handle(),
keyspec.as_ptr() as *const c_void,
keyspec.len() as i32,
);
if rc != 0 {
return Err(DbError::EncryptionKey(format!(
"sqlite3_key failed: rc={rc}"
)));
}
}
conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.map_err(|_| DbError::EncryptionKey("incorrect key or not an encrypted database".into()))?;
conn.execute_batch("PRAGMA query_only = ON")?;
Ok(conn)
}
impl WechatDb {
@@ -87,7 +221,13 @@ impl WechatDb {
/// does not exist. Message shards are optional here; message queries will
/// return `DbError::NoShards` if no numbered shard is available.
pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), None)
Self::open_internal(path.as_ref(), None, true)
}
/// Open only contact.db and session.db, without scanning message shards.
/// Useful for contacts, sessions, and monitoring commands that never read messages.
pub fn open_core(path: impl AsRef<Path>) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), None, false)
}
/// Open a decrypted WeChat database directory with a pre-opened
@@ -104,7 +244,39 @@ impl WechatDb {
/// Open an encrypted WeChat database directory directly using `sqlite3_key()`.
pub fn open_encrypted(path: impl AsRef<Path>, raw_key: [u8; 32]) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), Some(raw_key))
Self::open_internal(path.as_ref(), Some(SqlcipherKey::new(raw_key)), true)
}
/// Open an encrypted directory and seed the per-salt cache with persisted
/// derived keys, falling back to the raw key for missing or stale entries.
pub fn open_encrypted_with_key_cache(
path: impl AsRef<Path>,
raw_key: [u8; 32],
pairs: &[wx_decrypt::EncKeyPair],
) -> Result<Self, DbError> {
Self::open_internal(
path.as_ref(),
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
true,
)
}
/// Open only encrypted contact.db and session.db, without scanning message shards.
pub fn open_encrypted_core(path: impl AsRef<Path>, raw_key: [u8; 32]) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), Some(SqlcipherKey::new(raw_key)), false)
}
/// Core-only variant of [`WechatDb::open_encrypted_with_key_cache`].
pub fn open_encrypted_core_with_key_cache(
path: impl AsRef<Path>,
raw_key: [u8; 32],
pairs: &[wx_decrypt::EncKeyPair],
) -> Result<Self, DbError> {
Self::open_internal(
path.as_ref(),
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
false,
)
}
/// Open an encrypted WeChat database directory with a pre-opened
@@ -114,35 +286,53 @@ impl WechatDb {
raw_key: [u8; 32],
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
) -> Result<Self, DbError> {
Self::open_with_pool_internal(path, Some(raw_key), fts_init)
Self::open_with_pool_internal(path, Some(SqlcipherKey::new(raw_key)), fts_init)
}
fn open_internal(path: &Path, raw_key: Option<[u8; 32]>) -> Result<Self, DbError> {
/// Pool variant seeded with persisted per-salt derived keys.
pub fn open_encrypted_with_pool_and_key_cache(
path: impl AsRef<Path>,
raw_key: [u8; 32],
pairs: &[wx_decrypt::EncKeyPair],
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
) -> Result<Self, DbError> {
Self::open_with_pool_internal(
path,
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
fts_init,
)
}
fn open_internal(
path: &Path,
sqlcipher_key: Option<SqlcipherKey>,
scan_message_shards: bool,
) -> Result<Self, DbError> {
if !path.exists() {
return Err(DbError::NotFound(path.display().to_string()));
}
let key_ref = raw_key.as_ref();
let key_ref = sqlcipher_key.as_ref();
// Open contact.db
let contact_path = path.join("contact").join("contact.db");
if !contact_path.exists() {
return Err(DbError::NotFound(contact_path.display().to_string()));
}
let contact_conn = open_readonly_connection(&contact_path, key_ref)?;
let contact_conn = open_connection(&contact_path, key_ref)?;
// Open session.db
let session_path = path.join("session").join("session.db");
if !session_path.exists() {
return Err(DbError::NotFound(session_path.display().to_string()));
}
let session_conn = open_readonly_connection(&session_path, key_ref)?;
let session_conn = open_connection(&session_path, key_ref)?;
// Scan message shards
let msg_dir = path.join("message");
let mut shards = Vec::new();
if msg_dir.is_dir() {
if scan_message_shards && msg_dir.is_dir() {
let mut entries: Vec<PathBuf> = std::fs::read_dir(&msg_dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
@@ -196,23 +386,23 @@ impl WechatDb {
}
},
pool: None,
raw_key,
sqlcipher_key,
label_cache: RwLock::new(None),
})
}
fn open_with_pool_internal(
path: impl AsRef<Path>,
raw_key: Option<[u8; 32]>,
sqlcipher_key: Option<SqlcipherKey>,
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
) -> Result<Self, DbError> {
let mut db = Self::open_internal(path.as_ref(), raw_key)?;
let mut db = Self::open_internal(path.as_ref(), sqlcipher_key.clone(), true)?;
let fts_init_arc: Arc<crate::pool::FtsInitFn> = Arc::new(fts_init);
let pool = ShardPool::open(
&db.shards,
db.message_fts_path.as_deref(),
Some(fts_init_arc),
raw_key,
sqlcipher_key,
)?;
db.pool = Some(pool);
Ok(db)
@@ -220,14 +410,14 @@ impl WechatDb {
/// Re-open the session.db connection to pick up external changes.
pub fn reopen_sessions(&mut self) -> Result<(), DbError> {
self.session_conn = open_connection(&self.session_path, self.raw_key.as_ref())?;
self.session_conn = open_connection(&self.session_path, self.sqlcipher_key.as_ref())?;
Ok(())
}
/// Re-open the contact.db connection to pick up external changes.
/// Also invalidates the label cache so it is reloaded on next query.
pub fn reopen_contacts(&mut self) -> Result<(), DbError> {
self.contact_conn = open_connection(&self.contact_path, self.raw_key.as_ref())?;
self.contact_conn = open_connection(&self.contact_path, self.sqlcipher_key.as_ref())?;
*self.label_cache.write().unwrap() = None;
Ok(())
}
@@ -270,6 +460,13 @@ impl WechatDb {
self.pool.as_ref()
}
/// Open another database from the same encrypted account while reusing this
/// handle's derived-key cache. This is used by serve-mode auxiliary FTS and
/// media connections so refreshes do not re-run PBKDF2.
pub fn open_related_readonly(&self, path: &Path) -> Result<Connection, DbError> {
open_connection(path, self.sqlcipher_key.as_ref())
}
/// Return shards whose time range overlaps `[start, end]`.
pub(crate) fn shards_for_range(&self, start: i64, end: i64) -> Vec<&MessageShard> {
self.shards
@@ -307,9 +504,9 @@ impl WechatDb {
/// Open a SQLite connection to a specific shard, optionally encrypted.
pub(crate) fn open_shard_with_key(
shard: &MessageShard,
raw_key: Option<&[u8; 32]>,
sqlcipher_key: Option<&SqlcipherKey>,
) -> Result<Connection, DbError> {
open_readonly_connection(&shard.path, raw_key)
open_connection(&shard.path, sqlcipher_key)
}
}
@@ -341,8 +538,8 @@ fn is_numbered_message_shard(path: &Path) -> bool {
/// Try to read the timestamp from a message shard's Timestamp table.
/// Returns 0 if the table does not exist or is empty.
fn read_shard_timestamp(path: &Path, raw_key: Option<&[u8; 32]>) -> i64 {
let conn = match open_connection(path, raw_key) {
fn read_shard_timestamp(path: &Path, sqlcipher_key: Option<&SqlcipherKey>) -> i64 {
let conn = match open_connection(path, sqlcipher_key) {
Ok(c) => c,
Err(_) => return 0,
};
@@ -425,9 +622,35 @@ mod tests {
build_encrypted_db_storage(&root, &raw_key);
let mut db = WechatDb::open_encrypted(&root, raw_key).unwrap();
let key = db.sqlcipher_key.clone().unwrap();
let cached_before = key.cached_salt_count();
assert_eq!(
cached_before, 3,
"contact, session, and message salts cached"
);
// Reopen should succeed (re-applies sqlite3_key)
db.reopen_sessions().unwrap();
db.reopen_contacts().unwrap();
assert_eq!(
key.cached_salt_count(),
cached_before,
"reopen must reuse derived keys instead of deriving again"
);
}
#[test]
fn open_core_does_not_scan_message_shards() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("db_storage");
std::fs::create_dir_all(root.join("contact")).unwrap();
std::fs::create_dir_all(root.join("session")).unwrap();
std::fs::create_dir_all(root.join("message")).unwrap();
Connection::open(root.join("contact/contact.db")).unwrap();
Connection::open(root.join("session/session.db")).unwrap();
std::fs::write(root.join("message/message_0.db"), b"not a sqlite database").unwrap();
let db = WechatDb::open_core(&root).unwrap();
assert!(db.shards.is_empty());
}
#[test]
@@ -457,7 +680,57 @@ mod tests {
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
);
let conn = open_connection(&path, Some(&raw_key)).unwrap();
let key = SqlcipherKey::new(raw_key);
let conn = open_connection(&path, Some(&key)).unwrap();
let val: i64 = conn
.query_row("SELECT id FROM t", [], |r| r.get(0))
.unwrap();
assert_eq!(val, 42);
}
#[test]
fn preloaded_derived_key_opens_encrypted_database() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("enc.db");
let raw_key = [0xAB_u8; 32];
create_encrypted_db(
&path,
&raw_key,
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
);
let salt = wx_decrypt::read_db_salt(&path).unwrap();
let enc_key = wx_decrypt::kdf::derive_enc_key(&raw_key, &salt, &wx_decrypt::MACOS_4_1_7_31);
let key =
SqlcipherKey::with_preloaded(raw_key, &[wx_decrypt::EncKeyPair { key: enc_key, salt }]);
let conn = open_connection(&path, Some(&key)).unwrap();
let val: i64 = conn
.query_row("SELECT id FROM t", [], |r| r.get(0))
.unwrap();
assert_eq!(val, 42);
assert_eq!(key.cached_salt_count(), 1);
}
#[test]
fn stale_preloaded_key_falls_back_to_raw_key() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("enc.db");
let raw_key = [0xAB_u8; 32];
create_encrypted_db(
&path,
&raw_key,
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
);
let salt = wx_decrypt::read_db_salt(&path).unwrap();
let key = SqlcipherKey::with_preloaded(
raw_key,
&[wx_decrypt::EncKeyPair {
key: [0xCD; 32],
salt,
}],
);
let conn = open_connection(&path, Some(&key)).unwrap();
let val: i64 = conn
.query_row("SELECT id FROM t", [], |r| r.get(0))
.unwrap();
+10 -10
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use rusqlite::Connection;
use crate::error::DbError;
use crate::open::MessageShard;
use crate::open::{MessageShard, SqlcipherKey};
pub(crate) type FtsInitFn = dyn Fn(&Connection) -> Result<(), String> + Send + Sync;
@@ -19,7 +19,7 @@ pub struct ShardPool {
fts_conn: Option<Connection>,
fts_path: Option<PathBuf>,
fts_init: Option<Arc<FtsInitFn>>,
raw_key: Option<[u8; 32]>,
sqlcipher_key: Option<SqlcipherKey>,
}
impl std::fmt::Debug for ShardPool {
@@ -41,22 +41,22 @@ impl ShardPool {
shards: &[MessageShard],
fts_path: Option<&Path>,
fts_init: Option<Arc<FtsInitFn>>,
raw_key: Option<[u8; 32]>,
sqlcipher_key: Option<SqlcipherKey>,
) -> Result<Self, DbError> {
let mut conns = HashMap::with_capacity(shards.len());
for shard in shards {
let conn = crate::open::open_connection(&shard.path, raw_key.as_ref())?;
let conn = crate::open::open_connection(&shard.path, sqlcipher_key.as_ref())?;
conns.insert(shard.path.clone(), conn);
}
let fts_conn = match (fts_path, &fts_init) {
(Some(path), Some(init)) => {
let conn = crate::open::open_connection(path, raw_key.as_ref())?;
let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
init(&conn).map_err(DbError::FtsInit)?;
Some(conn)
}
(Some(path), None) => {
let conn = crate::open::open_connection(path, raw_key.as_ref())?;
let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
Some(conn)
}
_ => None,
@@ -67,7 +67,7 @@ impl ShardPool {
fts_conn,
fts_path: fts_path.map(|p| p.to_path_buf()),
fts_init,
raw_key,
sqlcipher_key,
})
}
@@ -79,7 +79,7 @@ impl ShardPool {
/// Close and reopen one shard connection.
pub fn reopen_shard(&mut self, path: &Path) -> Result<(), DbError> {
if self.conns.contains_key(path) {
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?;
let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
self.conns.insert(path.to_path_buf(), conn);
}
Ok(())
@@ -93,7 +93,7 @@ impl ShardPool {
/// Close and reopen the FTS connection, re-registering the tokenizer.
pub fn reopen_fts(&mut self) -> Result<(), DbError> {
if let Some(path) = &self.fts_path {
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?;
let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
if let Some(init) = &self.fts_init {
init(&conn).map_err(DbError::FtsInit)?;
}
@@ -106,7 +106,7 @@ impl ShardPool {
pub fn reopen_all(&mut self) -> Result<(), DbError> {
let paths: Vec<PathBuf> = self.conns.keys().cloned().collect();
for path in paths {
let conn = crate::open::open_connection(&path, self.raw_key.as_ref())?;
let conn = crate::open::open_connection(&path, self.sqlcipher_key.as_ref())?;
self.conns.insert(path, conn);
}
self.reopen_fts()?;
+3 -3
View File
@@ -4,10 +4,10 @@ version.workspace = true
edition.workspace = true
[dependencies]
aes = "0.8"
cbc = "0.1"
aes = "0.9"
cbc = "0.2"
pbkdf2 = { version = "0.12", features = ["sha2"] }
sha2 = "0.10"
sha2 = "0.11"
hmac = "0.12"
thiserror = "2"
+2 -2
View File
@@ -289,7 +289,7 @@ mod tests {
params: &CryptoParams,
salt: Option<&[u8; 16]>,
) -> Vec<u8> {
use aes::cipher::{block_padding::NoPadding, BlockEncryptMut, KeyIvInit};
use aes::cipher::{block_padding::NoPadding, BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -306,7 +306,7 @@ mod tests {
let mut ciphertext = plaintext.clone();
Aes256CbcEnc::new(enc_key.into(), (&iv).into())
.encrypt_padded_mut::<NoPadding>(&mut ciphertext, data_len)
.encrypt_padded::<NoPadding>(&mut ciphertext, data_len)
.expect("encryption should not fail");
let mut page = vec![0u8; params.page_size];
+2 -2
View File
@@ -74,7 +74,7 @@ mod tests {
/// Build a single-page encrypted DB file that `decrypt_db` can process.
fn build_encrypted_db(path: &Path, raw_key: &[u8; 32], salt: &[u8; 16], params: &CryptoParams) {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use aes::cipher::{BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -93,7 +93,7 @@ mod tests {
let mut ciphertext = plaintext;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
Aes256CbcEnc::new((&enc_key).into(), (&iv).into())
.encrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.encrypt_padded::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.unwrap();
let mut page = Vec::with_capacity(params.page_size);
+3 -3
View File
@@ -1,4 +1,4 @@
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
use aes::cipher::{block_padding::NoPadding, BlockModeDecrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -45,8 +45,8 @@ pub fn decrypt_page(
let encrypted = &page_buf[offset..params.page_size - params.reserve];
let mut buf = encrypted.to_vec();
Aes256CbcDec::new(enc_key.into(), iv.into())
.decrypt_padded_mut::<NoPadding>(&mut buf)
Aes256CbcDec::new(enc_key.into(), iv.try_into().expect("IV length mismatch"))
.decrypt_padded::<NoPadding>(&mut buf)
.map_err(|e| DecryptError::AesDecryptFailed {
page_num,
reason: e.to_string(),
+2 -2
View File
@@ -380,7 +380,7 @@ mod tests {
params: &CryptoParams,
salt: Option<&[u8; 16]>,
) -> Vec<u8> {
use aes::cipher::{block_padding::NoPadding, BlockEncryptMut, KeyIvInit};
use aes::cipher::{block_padding::NoPadding, BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -397,7 +397,7 @@ mod tests {
let mut ciphertext = plaintext.clone();
Aes256CbcEnc::new(enc_key.into(), (&iv).into())
.encrypt_padded_mut::<NoPadding>(&mut ciphertext, data_len)
.encrypt_padded::<NoPadding>(&mut ciphertext, data_len)
.expect("encryption should not fail");
let mut page = vec![0u8; params.page_size];
+4 -4
View File
@@ -9,7 +9,7 @@ wx-paths = { path = "../wx-paths" }
tokio = { version = "1", features = ["process", "time", "io-util", "rt-multi-thread", "macros"] }
regex = "1"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
toml = "1.1"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
hex = "0.4"
@@ -21,8 +21,8 @@ mach2 = "0.6"
libc = "0.2"
[dev-dependencies]
aes = "0.8"
cbc = "0.1"
aes = "0.9"
cbc = "0.2"
hmac = "0.12"
sha2 = "0.10"
sha2 = "0.11"
tempfile = "3"
+2 -2
View File
@@ -247,7 +247,7 @@ mod tests {
}
fn build_first_page(enc_key: &[u8; 32], salt: &[u8; 16]) -> Vec<u8> {
use aes::cipher::{block_padding::NoPadding, BlockEncryptMut, KeyIvInit};
use aes::cipher::{block_padding::NoPadding, BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -259,7 +259,7 @@ mod tests {
let mut ciphertext = plaintext;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
Aes256CbcEnc::new(enc_key.into(), (&iv).into())
.encrypt_padded_mut::<NoPadding>(&mut ciphertext, data_len)
.encrypt_padded::<NoPadding>(&mut ciphertext, data_len)
.unwrap();
let mut page = vec![0u8; params.page_size];
+2 -2
View File
@@ -8,10 +8,10 @@ default = []
audio = ["dep:silk-rs"]
[dependencies]
aes = "0.8"
aes = "0.9"
ecb = "0.1"
cipher = { version = "0.4", features = ["block-padding"] }
rusqlite = { version = "0.32", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
md5 = "0.7"
base64 = "0.22"
thiserror = "2"
+2 -2
View File
@@ -181,7 +181,7 @@ fn decrypt_v1_v2(
/// AES-128-ECB decrypt with PKCS7 unpadding.
fn aes_ecb_decrypt(ciphertext: &[u8], key: &[u8; 16]) -> Result<Vec<u8>, MediaError> {
use aes::cipher::{BlockDecrypt, KeyInit};
use aes::cipher::{BlockCipherDecrypt, KeyInit};
use aes::Aes128;
if ciphertext.is_empty() {
@@ -200,7 +200,7 @@ fn aes_ecb_decrypt(ciphertext: &[u8], key: &[u8; 16]) -> Result<Vec<u8>, MediaEr
let mut decrypted = ciphertext.to_vec();
for chunk in decrypted.chunks_exact_mut(16) {
cipher.decrypt_block(chunk.into());
cipher.decrypt_block(chunk.try_into().expect("chunk length mismatch"));
}
// PKCS7 unpadding
+6 -6
View File
@@ -15,7 +15,7 @@ fn make_xor_dat(key: u8) -> Vec<u8> {
/// Header: 07 08 V1 08 07 (6B) + aes_size LE (4B) + xor_size LE (4B) + 0x01 (1B) = 15B
/// Then AES-ECB encrypted payload with fixed key, then raw, then XOR tail.
fn make_v1_dat() -> Vec<u8> {
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use aes::Aes128;
let key = b"cfcd208495d565ef"; // md5("0")[:16]
@@ -28,8 +28,8 @@ fn make_v1_dat() -> Vec<u8> {
];
let mut block2 = [16u8; 16]; // full PKCS7 padding block
cipher.encrypt_block((&mut block1).into());
cipher.encrypt_block((&mut block2).into());
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
let aes_size: u32 = 16; // original plaintext size
let xor_size: u32 = 0;
@@ -46,7 +46,7 @@ fn make_v1_dat() -> Vec<u8> {
/// Build a V2-encrypted `.dat` file with known AES key and XOR tail.
fn make_v2_dat(aes_key: &[u8; 16], xor_key: u8) -> Vec<u8> {
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use aes::Aes128;
let cipher = Aes128::new(aes_key.into());
@@ -58,8 +58,8 @@ fn make_v2_dat(aes_key: &[u8; 16], xor_key: u8) -> Vec<u8> {
];
let mut block2 = [16u8; 16]; // PKCS7 padding block
cipher.encrypt_block((&mut block1).into());
cipher.encrypt_block((&mut block2).into());
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
let aes_size: u32 = 16;
// Tail: 4 bytes XOR-encrypted
+4 -4
View File
@@ -15,10 +15,10 @@ thiserror = "2"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
aes = "0.8"
cbc = "0.1"
aes = "0.9"
cbc = "0.2"
hmac = "0.12"
sha2 = "0.10"
sha2 = "0.11"
pbkdf2 = { version = "0.12", features = ["sha2"] }
rusqlite = { version = "0.32", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
+2 -2
View File
@@ -275,7 +275,7 @@ mod tests {
/// Build a minimal encrypted session.db that `decrypt_db` can process.
fn build_encrypted_session_db(path: &Path, raw_key: &[u8; 32]) -> PathBuf {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use aes::cipher::{BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -317,7 +317,7 @@ mod tests {
let mut ciphertext = plaintext.clone();
let encryptor = Aes256CbcEnc::new((&enc_key).into(), (&iv).into());
encryptor
.encrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.encrypt_padded::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.unwrap();
// Assemble page: salt + ciphertext + IV + HMAC
+15 -3
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use futures_core::Stream;
use wx_db::{SessionQuery, WechatDb};
use wx_decrypt::{CryptoParams, KeyMaterial};
use wx_decrypt::{CryptoParams, EncKeyPair, KeyMaterial};
use crate::cache::{DecryptCache, UpdateKind};
use crate::error::MonitorError;
@@ -100,7 +100,19 @@ impl WechatMonitor {
let (db, cache) = if let (Some(raw_key), Some(ref encrypted_root)) =
(config.raw_key, &config.encrypted_root)
{
let db = WechatDb::open_encrypted(encrypted_root, raw_key)?;
let derived_keys: Vec<EncKeyPair> = match &config.key_material {
KeyMaterial::EncKeys(pairs) => pairs.clone(),
KeyMaterial::EncKey { key, salt } => vec![EncKeyPair {
key: *key,
salt: *salt,
}],
KeyMaterial::RawKey(_) => Vec::new(),
};
let db = WechatDb::open_encrypted_core_with_key_cache(
encrypted_root,
raw_key,
&derived_keys,
)?;
(db, None)
} else {
let mut cache = DecryptCache::new(
@@ -109,7 +121,7 @@ impl WechatMonitor {
config.params,
)?;
cache.initial_decrypt()?;
let db = WechatDb::open(cache.decrypted_root())?;
let db = WechatDb::open_core(cache.decrypted_root())?;
(db, Some(cache))
};
+2 -2
View File
@@ -35,7 +35,7 @@ fn encrypt_page(
page_num: u32,
salt: &[u8; 16],
) -> Vec<u8> {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use aes::cipher::{BlockModeEncrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
@@ -52,7 +52,7 @@ fn encrypt_page(
let mut ciphertext = plaintext.to_vec();
let encryptor = Aes256CbcEnc::new(enc_key.into(), (&iv).into());
encryptor
.encrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.encrypt_padded::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.unwrap();
// Assemble encrypted page