diff --git a/.github/workflows/windows-wechat-vault.yml b/.github/workflows/windows-wechat-vault.yml new file mode 100644 index 0000000..ff2d4bb --- /dev/null +++ b/.github/workflows/windows-wechat-vault.yml @@ -0,0 +1,40 @@ +name: Windows WeChat Vault + +on: + pull_request: + paths: + - "yichen-wechat-windows-vault/**" + - ".github/workflows/windows-wechat-vault.yml" + push: + branches: [main] + paths: + - "yichen-wechat-windows-vault/**" + - ".github/workflows/windows-wechat-vault.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: yichen-wechat-windows-vault/requirements.txt + - name: Install pinned dependencies + run: python -m pip install -r yichen-wechat-windows-vault/requirements.txt + - name: Compile scripts + run: >- + python -m py_compile + yichen-wechat-windows-vault/scripts/sqlcipher_codec.py + yichen-wechat-windows-vault/scripts/wal_snapshot.py + yichen-wechat-windows-vault/scripts/secret_store.py + yichen-wechat-windows-vault/scripts/windows_memory.py + yichen-wechat-windows-vault/scripts/windows_vault.py + yichen-wechat-windows-vault/scripts/vault_cli.py + - name: Run tests + run: python -m unittest discover -s yichen-wechat-windows-vault/tests -v diff --git a/.gitignore b/.gitignore index 10fa5f9..fe39ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ **/wechat-keys.json **/wechat-*.json **/wechat-*-state.json +**/YichenWeChatVault/ +**/keys/account.json **/.env **/.env.* **/*secret* diff --git a/README.md b/README.md index 4f83240..6e83791 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ To request commercial authorization, contact me on WeChat at `yichen365ai` and i 20. Choose between Step and Doubao/Volcengine ASR without duplicate submissions (`yichen-asr`) 21. Create authorized WeCom documents and manage todos, meetings, and schedules through the official CLI without controlling the desktop app (`yichen-wecom-operations`) 22. Turn one public X Post or Thread URL into verified 3:4 image slices and a finished video that embeds complete native video visuals and preserves their original audio when present (`yichen-x-slicer`) +23. Build read-only, integrity-checked local Weixin 4.x snapshots on Windows without `wx-cli`, injection, hooks, or process control (`yichen-wechat-windows-vault`) ## Included Skills @@ -237,6 +238,19 @@ Turn one public X status URL into finished social assets: Install this Skill directly with `npx skills add mcncarl/yichen-skills --skill yichen-x-slicer`. +### 23) `yichen-wechat-windows-vault` + +Independent Windows counterpart to the Mac WeChat local vault: + +- Uses read-only Windows process inspection only after explicit current-task consent +- Accepts a database key only after SQLCipher HMAC and SQLite-header validation +- Protects stored keys with current-user DPAPI and keeps snapshots under `%LOCALAPPDATA%` +- Copies DB/WAL/SHM as a stable set, validates WAL checksums and commits, and runs SQLite integrity checks +- Supports full or incremental immutable snapshots plus the Mac skill's contacts, sessions, history, search, export, Favorites, and Moments query surface +- Does not depend on `wx-cli`, Frida, injection, hooks, drivers, Weixin UI automation, or process-control APIs + +See [yichen-wechat-windows-vault/README.md](./yichen-wechat-windows-vault/README.md) for the consent boundary, installation, architecture, and verification evidence. + ## Project Structure ```text @@ -261,6 +275,12 @@ yichen-skills/ │ ├─ list_contacts.py │ ├─ search_sns.py │ └─ wechat_digest.py +├─ yichen-wechat-windows-vault/ +│ ├─ SKILL.md +│ ├─ README.md +│ ├─ requirements.txt +│ ├─ scripts/ +│ └─ tests/ ├─ yichen-mac-wechat-dual-open/ │ ├─ SKILL.md │ ├─ scripts/ @@ -368,6 +388,7 @@ yichen-skills/ - Dependencies: - X article drafts: `pip install playwright pycryptodome && python3 -m playwright install chromium` - WeChat local vault: `pip install pycryptodome zstandard` + - Weixin Windows vault (Python 3.10+): `pip install -r yichen-wechat-windows-vault/requirements.txt` - WeChat dual open: `pip install Pillow` - Content archive (Douyin): `pip install playwright requests && python3 -m playwright install chromium` - Content archive (Xiaohongshu): `pip install requests` diff --git a/README.zh.md b/README.zh.md index 1202953..0f1cc4f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -41,6 +41,7 @@ 20. 在 Step 与豆包/火山 ASR 之间安全路由并避免重复提交(`yichen-asr`) 21. 通过企业微信官方 CLI 创建授权文档并管理待办、会议和日程,不操控客户端(`yichen-wecom-operations`) 22. 把一条公开 X Post 或 Thread 链接转成经过验收的 3:4 图片切片与成片,完整嵌入原生视频并在有源音轨时保留原声(`yichen-x-slicer`) +23. 在 Windows 上生成只读、经过完整性校验的微信 4.x 本地快照,不依赖 `wx-cli`,不注入、不 Hook、不控制进程(`yichen-wechat-windows-vault`) ## 包含的技能 @@ -236,6 +237,19 @@ Mac 微信双开——无需第三方工具,一条命令搞定: 可直接运行 `npx skills add mcncarl/yichen-skills --skill yichen-x-slicer` 安装。 +### 23) `yichen-wechat-windows-vault` + +Mac 微信本地 Vault 的独立 Windows 对应版本: + +- 仅在当前任务得到明确同意后使用 Windows 只读进程检查 +- 只有同时通过 SQLCipher HMAC 与 SQLite 文件头校验的数据库 key 才会被接受 +- key 使用当前 Windows 用户的 DPAPI 加密,快照保存在 `%LOCALAPPDATA%` 私有目录 +- 把 DB/WAL/SHM 作为稳定文件集复制,校验 WAL 帧与提交,并运行 SQLite 完整性检查 +- 支持全量/增量不可变快照,以及与 Mac 版一致的联系人、会话、历史、搜索、导出、收藏夹和朋友圈查询 +- 不依赖 `wx-cli`、Frida、注入、Hook、驱动、微信 UI 自动化或进程控制 API + +同意边界、安装、架构和验证证据见 [yichen-wechat-windows-vault/README.md](./yichen-wechat-windows-vault/README.md)。 + ## 目录结构 ```text @@ -260,6 +274,12 @@ yichen-skills/ │ ├─ list_contacts.py │ ├─ search_sns.py │ └─ wechat_digest.py +├─ yichen-wechat-windows-vault/ +│ ├─ SKILL.md +│ ├─ README.md +│ ├─ requirements.txt +│ ├─ scripts/ +│ └─ tests/ ├─ yichen-mac-wechat-dual-open/ │ ├─ SKILL.md │ ├─ scripts/ diff --git a/yichen-wechat-windows-vault/PROVENANCE.md b/yichen-wechat-windows-vault/PROVENANCE.md new file mode 100644 index 0000000..1360f20 --- /dev/null +++ b/yichen-wechat-windows-vault/PROVENANCE.md @@ -0,0 +1,30 @@ +# Provenance and clean implementation statement + +This directory is a new Windows implementation created after the earlier Windows proposal was rejected. It does not contain or invoke `wx-cli`, and no code from that rejected implementation was reused. + +## File lineage + +- `scripts/windows_memory.py`, `windows_vault.py`, `secret_store.py`, `sqlcipher_codec.py`, and `wal_snapshot.py` are new implementations written for this contribution. +- SQLCipher geometry and HMAC behavior were implemented from the public Tencent SQLCipher source, Tencent WCDB documentation, Zetetic SQLCipher documentation, and SQLite's official WAL format documentation. SHM `maxFrame`/`nBackfill` handling follows the public Tencent WCDB WAL repair parser and SQLite wal-index layout. +- Windows process-memory and DPAPI calls use Microsoft-documented APIs through Python `ctypes`. +- `scripts/vault_cli.py` is adapted from this repository's own `yichen-wechat-local-vault/scripts/vault_cli.py` so that Windows and Mac expose the same read-only query commands. Its configuration and data-root handling were changed for Windows. +- No Tencent, Zetetic, SQLite, Microsoft, `cryptography`, or `zstandard` source code is copied or vendored here. + +## Deliberately excluded behavior + +- no spawning or relaunching Weixin; +- no process termination, suspension, resume, debugging, injection, hooking, remote allocation, or memory writes; +- no UI automation or message sending; +- no third-party Weixin CLI or binary; +- no hard-coded key, salt, account identifier, process address, or private test data; +- no silent key capture or plaintext export. + +## Validation evidence expected for a release + +1. Windows CI passes all synthetic codec, WAL, DPAPI, query, export, and safety tests. +2. `skill-creator` metadata validation passes. +3. A current official Windows Weixin build is diagnosed without exposing account data. +4. With explicit consent, at least one real active database key is captured and validates. +5. After the user manually exits Weixin, every discovered DB/WAL/SHM set is snapshotted, all required capability databases refresh into a promoted generation, optional gaps are explicitly disclosed, and query smoke tests pass. + +Items 4 and 5 are local-only evidence and must never publish keys, database files, account paths, or chat content. diff --git a/yichen-wechat-windows-vault/README.md b/yichen-wechat-windows-vault/README.md new file mode 100644 index 0000000..f511f14 --- /dev/null +++ b/yichen-wechat-windows-vault/README.md @@ -0,0 +1,122 @@ +# Yichen WeChat Windows Vault + +An independent Windows counterpart to `yichen-wechat-local-vault`. It creates local, immutable plaintext snapshots of the current Windows user's Weixin 4.x databases and provides the same read-only query/export surface as the Mac skill. + +## What it does + +- Discovers official Windows Weixin account databases under the current user's `Documents\xwechat_files`. +- With explicit current-task consent, reads only `Weixin.exe` process memory and waits for an ordinary SQLCipher key-use window. +- Aggregates independently validated matches across every readable Weixin subprocess instead of assuming all database contexts live in one process. +- Validates every captured key against both the SQLCipher page HMAC and SQLite header before accepting it. +- Protects accepted keys with current-user Windows DPAPI; raw keys are never printed or stored in the repository. +- Copies DB/WAL/SHM only after the user manually exits Weixin. +- Validates the SQLite WAL header plus the native-endian SHM wal-index/checkpoint state, then validates and applies only the active committed frame range. +- Runs `quick_check` and `integrity_check` before atomically promoting a snapshot. +- Supports contacts, sessions, unread/new messages, history, global search, statistics, Markdown export, group digest sources, Favorites, and Moments. +- Opens plaintext snapshot databases with SQLite `mode=ro` plus `PRAGMA query_only`; exports are atomic and refuse to replace an existing file unless `--overwrite` is explicitly supplied. + +It does not use `wx-cli`, Frida, injection, hooks, drivers, Weixin UI automation, or process-control APIs. + +## Supported environment + +- Windows 10/11 x64 +- Official Weixin 4.x +- Python 3.10+ + +Local validation was performed on Windows 11 with Weixin `4.1.13.7`. The memory scanner recognizes documented Tencent SQLCipher context geometry and fails closed if the ABI or page validation changes. + +## Install + +```powershell +py -3 -m venv "$env:LOCALAPPDATA\YichenWeChatVault\runtime" +& "$env:LOCALAPPDATA\YichenWeChatVault\runtime\Scripts\python.exe" -m pip install --upgrade pip +& "$env:LOCALAPPDATA\YichenWeChatVault\runtime\Scripts\python.exe" -m pip install -r ".\requirements.txt" +``` + +Runtime dependencies are exactly pinned. See `THIRD_PARTY_NOTICES.md` and `sbom.spdx.json`. + +## Quick start + +```powershell +python .\scripts\windows_vault.py diagnose +``` + +After the user explicitly approves reading Weixin process memory and storing the resulting database keys locally: + +```powershell +python .\scripts\windows_vault.py capture --targets all --duration 240 --consent-read-process-memory +``` + +The user should manually visit the relevant Weixin areas while capture runs. Key buffers are protected while idle and are visible only during normal database operations. The tool does not create that activity itself. + +Then the user manually exits Weixin and runs: + +```powershell +python .\scripts\windows_vault.py refresh --mode full +python .\scripts\windows_vault.py status +python .\scripts\vault_cli.py sessions --limit 20 --format text +``` + +Run `python .\scripts\vault_cli.py --help` for all query commands. + +An explicit export path is collision-safe by default. If the target already exists, choose a new path or deliberately add `--overwrite`; group digest-source filenames automatically receive a `-run-N` suffix on same-second collisions. + +For later runs, `refresh --mode incremental` is the default. It still creates a new immutable generation and re-copies and hashes every encrypted DB/WAL/SHM set, but it reuses an integrity-checked plaintext database when the encrypted set and DPAPI key fingerprint are unchanged. Use `--mode full` to force decryption of every database. + +## Private data layout + +```text +%LOCALAPPDATA%\YichenWeChatVault\ +├── keys\account.json # DPAPI ciphertext and non-secret metadata +└── vault\ + ├── current.json # updated only after required coverage succeeds + ├── state\ # read-only query cursors + └── generations\\ + ├── encrypted\db_storage\ # stable DB/WAL/SHM copies + ├── decrypted\db_storage\ # plaintext local snapshot + └── manifest.json # hashes, WAL report, integrity result +``` + +Old generations are never deleted automatically. Exports default to `%USERPROFILE%\Documents\YichenWeChatVault\exports` and may contain plaintext personal data. + +## Snapshot coverage + +A Windows account can contain low-frequency auxiliary databases whose key is never used unless that feature has data. Promotion therefore follows the Mac skill's keyed-database behavior while keeping a stricter capability gate: + +- Required when present: `contact/contact.db`, `session/session.db`, `sns/sns.db`, `favorite/favorite.db`, `message/message_resource.db`, every `message/message_N.db`, and every `message/biz_message_N.db`. +- Optional and explicitly disclosed when unavailable: search indexes and feature-specific stores such as chatbot, emoticon, media-cache, WeClaw, solitaire, and other auxiliary databases. + +Every discovered DB/WAL/SHM set is still copied and listed in the manifest. A missing or invalid required database prevents promotion; an optional failure remains visible through `optional_missing_databases` and can be captured later without blocking the already verified Mac-equivalent query surface. `all_databases_decrypted` is true only when no optional database is missing. + +## Why process-memory reading is necessary + +Current Weixin protects SQLCipher key buffers while they are idle. During normal database page encryption/decryption, the existing buffer is briefly made usable and then protected again. This project samples the already-running process using the read-only Windows access rights `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`; a candidate is retained only when it verifies against the target database. It does not modify the process or invoke Weixin code. + +This is sensitive behavior and therefore requires an explicit command-line consent flag in addition to the Skill's instruction to obtain current-task user approval. + +## WAL correctness + +Copying or decrypting only the `.db` file can lose committed updates that exist only in `-wal`. The snapshot pipeline validates the WAL header and both native-endian SHM wal-index header copies, including their checksums, salt, page size, committed `maxFrame`, and `nBackfill`. Frames at or below `nBackfill` are already present in the database, and frames beyond `maxFrame` are inactive storage that SQLite/WCDB may leave in the WAL file after a reset. Only the active range is checked for rolling checksum continuity, decrypted with per-page SQLCipher HMAC verification, and applied through its final commit. The result is truncated to the validated committed database size and must pass SQLite integrity checks. + +The test suite constructs a real reserved-byte SQLite fixture, encrypts it page-by-page, writes a valid WAL-only update, and proves the merged plaintext database contains the update. It separately proves that a validated SHM reset ignores stale WAL capacity while a corrupted active frame without that boundary still fails closed. It also covers tampered page HMACs, DPAPI round trips, consent gating, safety API bans, and query/export behavior. + +## Verification + +```powershell +python -m unittest discover -s .\tests -v +python -m py_compile .\scripts\sqlcipher_codec.py .\scripts\wal_snapshot.py .\scripts\secret_store.py .\scripts\windows_memory.py .\scripts\windows_vault.py .\scripts\vault_cli.py +``` + +Privacy-safe local acceptance on Windows 11 / Weixin `4.1.13.7` discovered 19 DB/WAL sets, DPAPI-protected 11 strictly validated keys, promoted a full generation with every required capability database and 9 integrity-checked plaintext databases, and disclosed 10 optional gaps. Two of those optional gaps were FTS databases whose codec extension is unavailable in the standard Python SQLite runtime; the other eight had no active key. Contacts, sessions, history, Favorites, and Moments returned non-empty results through `mode=ro` / `query_only` connections. A following incremental generation integrity-checked and reused all 9 unchanged plaintext databases. No private values or account paths were published. + +## Technical references + +- [Tencent SQLCipher fork](https://github.com/Tencent/sqlcipher) — codec and cipher-context layout. +- [Tencent WCDB encryption documentation](https://github.com/Tencent/wcdb/wiki/C%2B%2B-%E5%8A%A0%E5%AF%86%E4%B8%8E%E9%85%8D%E7%BD%AE) — WCDB cipher-key behavior and defaults. +- [Tencent WCDB WAL repair parser](https://github.com/Tencent/wcdb/blob/master/src/common/repair/parse/Wal.cpp) — SHM `maxFrame`/`nBackfill` boundaries and rolling-checksum recovery semantics. +- [SQLCipher design](https://www.zetetic.net/sqlcipher/design/) — salt, page encryption, IV, and HMAC design. +- [SQLite WAL file format](https://www.sqlite.org/fileformat2.html#walformat) — header, frame, commit, and checksum semantics. +- [Microsoft `VirtualQueryEx`](https://learn.microsoft.com/windows/win32/api/memoryapi/nf-memoryapi-virtualqueryex) and [`ReadProcessMemory`](https://learn.microsoft.com/windows/win32/api/memoryapi/nf-memoryapi-readprocessmemory) — read-only process inspection APIs. +- [Microsoft DPAPI `CryptProtectData`](https://learn.microsoft.com/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata) — current-user key protection. + +See `PROVENANCE.md` for the implementation lineage and clean-version statement. diff --git a/yichen-wechat-windows-vault/SECURITY.md b/yichen-wechat-windows-vault/SECURITY.md new file mode 100644 index 0000000..74184e3 --- /dev/null +++ b/yichen-wechat-windows-vault/SECURITY.md @@ -0,0 +1,48 @@ +# Security and privacy model + +## Protected assets + +- Weixin SQLCipher keys +- encrypted and plaintext Weixin databases +- account paths and identifiers +- chat, contact, Moments, Favorites, and media metadata + +## Trust boundary + +The tool runs as the same Windows user who owns the local Weixin profile. It is for personal, authorized local use only. It makes no claim to isolate a plaintext snapshot from malware or another process already running as that user. + +## Consent gates + +Process-memory capture requires both: + +1. explicit approval in the current user task after explaining the data and destination; and +2. the literal `--consent-read-process-memory` flag. + +Exports may contain plaintext personal data. Confirm the destination and scope before writing a report. + +## Secret handling + +- A candidate key is accepted only after SQLCipher HMAC and SQLite-header validation. +- Accepted keys are protected with current-user DPAPI and account-binding entropy. +- Raw keys and salts are never printed. +- Key stores, database snapshots, and normal exports are ignored by repository `.gitignore` patterns. +- Manifests contain fingerprints and hashes, not raw keys. + +## Source integrity and rollback + +- Source DB/WAL/SHM files are opened only for reading. +- SHM wal-index header copies, native-endian checksums, salt, `maxFrame`, and `nBackfill` are validated before deciding which WAL frames are active. Stale file capacity beyond the validated boundary is never applied. +- Plaintext query connections use SQLite URI `mode=ro` and `PRAGMA query_only`. +- Export writes are atomic and refuse existing destinations unless the caller explicitly supplies `--overwrite`. +- Snapshot promotion is atomic and occurs only after every required capability database passes integrity checks. +- Optional feature stores remain listed as failures in the manifest and never masquerade as decrypted; `all_databases_decrypted` distinguishes zero-gap coverage. +- A generation missing any required capability database never replaces `current.json`. +- Old generations are retained, allowing rollback without destructive commands. + +## Version changes + +An unknown codec layout, unreadable pointer, HMAC mismatch, stale DPAPI entry, invalid active WAL checksum, invalid SHM state, or required-database SQLite integrity failure is a hard error. A feature-specific optional database that the standard Python SQLite runtime cannot integrity-check is isolated and disclosed instead of aborting the generation. Do not bypass validation or add version-specific absolute addresses. Re-audit the public structures and add tests first. + +## Reporting a vulnerability + +Follow the repository's maintainer contact policy. Never attach keys, databases, account paths, screenshots of private chats, or full memory dumps to a public issue. diff --git a/yichen-wechat-windows-vault/SKILL.md b/yichen-wechat-windows-vault/SKILL.md new file mode 100644 index 0000000..53bae58 --- /dev/null +++ b/yichen-wechat-windows-vault/SKILL.md @@ -0,0 +1,141 @@ +--- +name: yichen-wechat-windows-vault +description: | + 微信 Windows 4.x 本地数据库全量/增量解析与数字资产库。用于本机 Windows 微信聊天记录、联系人、群聊、朋友圈、收藏夹、附件索引的只读密钥捕获、完整快照、WAL 正确解密、查询、搜索、导出、关系复盘、客户跟进和群聊素材沉淀。触发词:Windows 微信解析、微信 Windows 全量、微信 Windows 增量、导出 Windows 微信聊天、朋友圈解析、收藏夹解析、yichen-wechat-windows-vault。 +--- + +# 微信 Windows 本地解析 Vault + +这是 `yichen-wechat-local-vault` 的独立 Windows 实现。它把本机微信数据库复制到用户私有目录,验证 SQLCipher HMAC,合并最后一个有效 WAL 提交,再对明文快照做只读查询。不要把它用于不属于当前 Windows 用户的账号或设备。 + +## 不可突破的边界 + +- 默认不读取微信进程内存。只有用户在当前任务中明确同意“读取微信进程内存并把数据库密钥保存到本机私有目录”后,才能使用 `capture --consent-read-process-memory`。 +- 不启动、关闭、暂停、恢复、注入、Hook、调试或操控微信进程;不调用微信 UI,不发送消息。需要数据库活动或退出微信时,只能让用户手动操作。 +- 不依赖 `wx-cli`、Frida、DLL 注入、驱动或微信专用第三方二进制。 +- 不在回复、日志或导出中显示原始 key、完整 salt、账号目录名、wxid 或无关聊天内容。 +- 密钥只保存为当前 Windows 用户可解开的 DPAPI 密文;明文数据库只进入 `%LOCALAPPDATA%\YichenWeChatVault\vault`。 +- 不自动删除旧快照。用户明确要求清理时,先列出准确 generation、大小和路径,获得确认后再删除指定 generation;不得递归删除 vault 根目录。 +- 查询与导出只读已完成的明文快照。禁止写回微信数据库。 +- 查询连接必须保持 SQLite `mode=ro` 与 `PRAGMA query_only`;导出文件已存在时默认拒绝替换,只有用户明确要求覆盖后才能传 `--overwrite`。 + +## 环境要求 + +- Windows 10/11 x64。 +- 官方 Windows 微信 4.x,和数据库属于同一个 Windows 用户。 +- Python 3.10+。 +- 安装固定依赖: + +```powershell +py -3 -m pip install -r "{{SKILL_DIR}}\requirements.txt" +``` + +已在 Windows 微信 `4.1.13.7` 验证内存结构发现和活动消息库的只读 key 捕获。微信升级后如果结构变化,捕获会失败关闭,不要猜地址或降级校验;先运行测试并重新审计公开结构。 + +## 首次工作流 + +### 1. 只读诊断 + +```powershell +py -3 "{{SKILL_DIR}}\scripts\windows_vault.py" diagnose +``` + +诊断只返回账号根目录指纹、数据库数量、WAL 数量和微信进程数量。 + +### 2. 明确获得内存读取与本地密钥存储授权 + +先向用户说明:工具将以 `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` 读取当前用户的 `Weixin.exe`,捕获的数据库 AES key 会经 DPAPI 加密后保存到 `%LOCALAPPDATA%\YichenWeChatVault\keys\account.json`,不会显示、上传或写入 Git。 + +只有用户明确同意后运行: + +```powershell +py -3 "{{SKILL_DIR}}\scripts\windows_vault.py" capture --targets all --duration 240 --consent-read-process-memory +``` + +捕获期间让用户手动打开需要的数据区域,例如聊天、通讯录、朋友圈和收藏夹。工具只等待数据库正常读写时极短的解保护窗口。若仍有缺失,使用返回的相对数据库路径做定向捕获: + +Windows 微信可能把数据库上下文分散到多个 `Weixin.exe` 子进程;捕获器必须聚合所有可读子进程中分别通过页校验的结果,不得只选择候选最多的单个进程。 + +```powershell +py -3 "{{SKILL_DIR}}\scripts\windows_vault.py" capture --targets "message/message_0.db" --duration 120 --consent-read-process-memory +``` + +不要替用户点击微信。不要要求用户发送包含隐私的内容;如确需触发消息库写入,让用户自行决定是否在测试会话发送无敏感内容。 + +### 3. 用户手动完全退出微信 + +生成一致快照前,要求用户从微信菜单手动退出,并确认托盘中不再运行。Skill 不得代为结束进程。 + +### 4. 首次全量刷新 + +```powershell +py -3 "{{SKILL_DIR}}\scripts\windows_vault.py" refresh --mode full +``` + +刷新会: + +1. 再次确认没有 `Weixin.exe`。 +2. 把每个 DB、WAL、SHM 作为稳定文件集复制到新的不可变 generation。 +3. 验证每个 SQLCipher 页 HMAC。 +4. 验证 WAL 头以及 SHM 双份 wal-index 头的 checksum、盐、页大小、`maxFrame` 与 `nBackfill`;跳过已回填或重置后失效的帧,只校验并合并活动范围内最后一个有效 commit 之前的帧。 +5. 运行 SQLite `quick_check` 和 `integrity_check`。 +6. 只有必需能力库全部成功时才原子更新 `current.json`。必需库包括联系人、会话、朋友圈、收藏夹、消息资源,以及所有现有的 `message_N.db` / `biz_message_N.db`;低频功能辅助库缺失必须写入 `optional_missing_databases`,不得伪装成已解密。 + +必需库缺 key、旧 key、损坏页、活动 WAL 错误或 SQLite 完整性错误必须作为失败返回,不得用旧明文库伪装成功。可选库缺少运行时扩展或校验失败不得中断整批,也不得阻止已通过完整性检查的必需能力快照,但必须在 manifest 和命令结果中逐库披露;后续可定向补抓。 + +日常使用 `refresh --mode incremental`(默认)。增量模式仍会创建新的不可变 generation,并重新稳定复制和哈希每组 DB/WAL/SHM;只有加密文件组和 DPAPI key 指纹均未变化时,才复用上一代通过完整性检查的明文库。需要强制逐库重新解密时使用 `--mode full`。 + +## 日常状态与查询 + +查看 key 和快照覆盖率: + +```powershell +py -3 "{{SKILL_DIR}}\scripts\windows_vault.py" status +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" status --format text +``` + +查询入口和 Mac 版本保持一致: + +```powershell +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" sessions --limit 20 --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" unread --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" new-messages --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" contacts --query "关键词" --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" members "群名" --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" history "联系人或群名" --start-time "2026-05-01" --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" search "关键词" --chat "群名" --type link --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" stats "群名" --start-time "2026-05-01" --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" export "群名" --format markdown --output ".\chat.md" +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" favorites --type article --query "关键词" --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" moments --name "联系人" --start "2026-05-01" --format text +``` + +支持的消息类型过滤:`text`、`image`、`voice`、`video`、`sticker`、`location`、`link`、`file`、`call`、`system`。 + +## 群聊摘要素材包 + +用户明确要群聊精华、日报、复盘或“从上次继续”时: + +```powershell +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" digest-source "群名" --start "2026-05-01" --end "2026-05-14" --format text +py -3 "{{SKILL_DIR}}\scripts\vault_cli.py" digest-source "群名" --since-last --data-root ".\wechat-digests" --format text +``` + +大批量消息先生成素材文件,再分析。图片只有在对应说明文件存在时才描述;否则明确写“图片内容不可见”,不得编造。 + +## 输出原则 + +- 回复优先给状态、数量、失败项和报告路径;不要粘贴整段私聊。 +- 明确区分 DPAPI 密钥库、加密快照、明文快照和用户导出报告。 +- 导出报告可能包含明文隐私,写入前确认用户指定路径;默认导出目录是 `%USERPROFILE%\Documents\YichenWeChatVault\exports`。 +- 朋友圈和收藏夹缺 key 时只补抓相关库,不扩大到不必要范围。 +- 所有异常都保持源数据库不变;任何缺少必需能力库的 generation 都不能成为 current。可选库缺失不等于成功,必须保留明确清单。 + +## 维护与验证 + +```powershell +py -3 -m unittest discover -s "{{SKILL_DIR}}\tests" -v +py -3 -m py_compile "{{SKILL_DIR}}\scripts\*.py" +``` + +依赖、许可证、来源和威胁边界见 `README.md`、`PROVENANCE.md`、`SECURITY.md`、`THIRD_PARTY_NOTICES.md` 和 `sbom.spdx.json`。 diff --git a/yichen-wechat-windows-vault/THIRD_PARTY_NOTICES.md b/yichen-wechat-windows-vault/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..4e1fb06 --- /dev/null +++ b/yichen-wechat-windows-vault/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +No third-party source code or binary is vendored in this Skill. Python packages are installed from PyPI at setup time. + +| Package | Pinned version | Purpose | License | Upstream | +|---|---:|---|---|---| +| `cryptography` | 50.0.0 | AES-256-CBC page encryption primitives used by the decoder | Apache-2.0 OR BSD-3-Clause | https://github.com/pyca/cryptography | +| `zstandard` | 0.25.0 | Optional decoding of Zstandard-compressed Weixin message fields | BSD-3-Clause | https://github.com/indygreg/python-zstandard | +| `cffi` | 2.1.1 | Transitive dependency of `cryptography` | MIT-0 | https://github.com/python-cffi/cffi | +| `pycparser` | 3.0 | Transitive dependency of `cffi` | BSD-3-Clause | https://github.com/eliben/pycparser | + +The package metadata and full license texts are available from each upstream distribution. The repository's own license continues to govern this Skill's source code and usage. + +Public technical documentation and source repositories listed in `README.md` were used as references. Referencing those materials does not bundle their code or change this repository's license. diff --git a/yichen-wechat-windows-vault/requirements.txt b/yichen-wechat-windows-vault/requirements.txt new file mode 100644 index 0000000..05f2f9d --- /dev/null +++ b/yichen-wechat-windows-vault/requirements.txt @@ -0,0 +1,4 @@ +cryptography==50.0.0 +zstandard==0.25.0 +cffi==2.1.1 +pycparser==3.0 diff --git a/yichen-wechat-windows-vault/sbom.spdx.json b/yichen-wechat-windows-vault/sbom.spdx.json new file mode 100644 index 0000000..ca6f516 --- /dev/null +++ b/yichen-wechat-windows-vault/sbom.spdx.json @@ -0,0 +1,92 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "name": "yichen-wechat-windows-vault-sbom", + "documentNamespace": "https://github.com/mcncarl/yichen-skills/sbom/yichen-wechat-windows-vault/1.0.0", + "creationInfo": { + "created": "2026-08-24T00:00:00Z", + "creators": [ + "Tool: Codex" + ] + }, + "packages": [ + { + "SPDXID": "SPDXRef-Package-Skill", + "name": "yichen-wechat-windows-vault", + "versionInfo": "1.0.0", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION" + }, + { + "SPDXID": "SPDXRef-Package-Cryptography", + "name": "cryptography", + "versionInfo": "50.0.0", + "downloadLocation": "https://pypi.org/project/cryptography/50.0.0/", + "filesAnalyzed": false, + "licenseConcluded": "Apache-2.0 OR BSD-3-Clause", + "licenseDeclared": "Apache-2.0 OR BSD-3-Clause", + "copyrightText": "Copyright The Python Cryptographic Authority and contributors" + }, + { + "SPDXID": "SPDXRef-Package-Zstandard", + "name": "zstandard", + "versionInfo": "0.25.0", + "downloadLocation": "https://pypi.org/project/zstandard/0.25.0/", + "filesAnalyzed": false, + "licenseConcluded": "BSD-3-Clause", + "licenseDeclared": "BSD-3-Clause", + "copyrightText": "Copyright Gregory Szorc and contributors" + }, + { + "SPDXID": "SPDXRef-Package-CFFI", + "name": "cffi", + "versionInfo": "2.1.1", + "downloadLocation": "https://pypi.org/project/cffi/2.1.1/", + "filesAnalyzed": false, + "licenseConcluded": "MIT-0", + "licenseDeclared": "MIT-0", + "copyrightText": "Copyright CFFI contributors" + }, + { + "SPDXID": "SPDXRef-Package-Pycparser", + "name": "pycparser", + "versionInfo": "3.0", + "downloadLocation": "https://pypi.org/project/pycparser/3.0/", + "filesAnalyzed": false, + "licenseConcluded": "BSD-3-Clause", + "licenseDeclared": "BSD-3-Clause", + "copyrightText": "Copyright Eli Bendersky and contributors" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-Skill" + }, + { + "spdxElementId": "SPDXRef-Package-Skill", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-Cryptography" + }, + { + "spdxElementId": "SPDXRef-Package-Skill", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-Zstandard" + }, + { + "spdxElementId": "SPDXRef-Package-Cryptography", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-CFFI" + }, + { + "spdxElementId": "SPDXRef-Package-CFFI", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-Pycparser" + } + ] +} diff --git a/yichen-wechat-windows-vault/scripts/sqlcipher_codec.py b/yichen-wechat-windows-vault/scripts/sqlcipher_codec.py new file mode 100644 index 0000000..fad5ccc --- /dev/null +++ b/yichen-wechat-windows-vault/scripts/sqlcipher_codec.py @@ -0,0 +1,260 @@ +"""Small, auditable SQLCipher page codec for local Weixin snapshots. + +Only documented SQLCipher page formats are implemented. The module never +loads Weixin binaries and never writes to a source database. +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import sqlite3 +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +SQLITE_HEADER = b"SQLite format 3\x00" +HMAC_SALT_MASK = 0x3A + + +@dataclass(frozen=True) +class CipherProfile: + """SQLCipher page geometry and digest algorithms.""" + + page_size: int = 4096 + reserve_size: int = 80 + iv_size: int = 16 + hmac_size: int = 64 + hmac_algorithm: str = "sha512" + kdf_algorithm: str = "sha512" + kdf_iterations: int = 256_000 + hmac_kdf_iterations: int = 2 + + @property + def reserve_offset(self) -> int: + return self.page_size - self.reserve_size + + @property + def hmac_offset(self) -> int: + return self.reserve_offset + self.iv_size + + def encrypted_offset(self, page_number: int) -> int: + return 16 if page_number == 1 else 0 + + +DEFAULT_PROFILE = CipherProfile() +COMPATIBLE_PROFILES = ( + DEFAULT_PROFILE, + CipherProfile( + page_size=4096, + reserve_size=48, + hmac_size=20, + hmac_algorithm="sha1", + kdf_algorithm="sha1", + kdf_iterations=64_000, + ), + CipherProfile( + page_size=1024, + reserve_size=48, + hmac_size=20, + hmac_algorithm="sha1", + kdf_algorithm="sha1", + kdf_iterations=64_000, + ), +) + + +def _aes_cbc_decrypt(key: bytes, iv: bytes, payload: bytes) -> bytes: + if len(key) != 32: + raise ValueError("AES-256 key must contain exactly 32 bytes") + if len(iv) != 16 or not payload or len(payload) % 16: + raise ValueError("invalid AES-CBC page geometry") + decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor() + return decryptor.update(payload) + decryptor.finalize() + + +def derive_hmac_key(key: bytes, salt: bytes, profile: CipherProfile = DEFAULT_PROFILE) -> bytes: + if len(key) != 32 or len(salt) != 16: + raise ValueError("SQLCipher key/salt length is invalid") + hmac_salt = bytes(value ^ HMAC_SALT_MASK for value in salt) + return hashlib.pbkdf2_hmac( + profile.kdf_algorithm, + key, + hmac_salt, + profile.hmac_kdf_iterations, + 32, + ) + + +def page_hmac( + page: bytes, + page_number: int, + key: bytes, + salt: bytes, + profile: CipherProfile = DEFAULT_PROFILE, +) -> bytes: + """Calculate SQLCipher's HMAC over ciphertext, IV, and LE page number.""" + if len(page) != profile.page_size or page_number < 1: + raise ValueError("invalid encrypted page") + start = profile.encrypted_offset(page_number) + hmac_key = derive_hmac_key(key, salt, profile) + authenticated = page[start : profile.hmac_offset] + page_number.to_bytes(4, "little") + return hmac.new(hmac_key, authenticated, profile.hmac_algorithm).digest() + + +def verify_page_hmac( + page: bytes, + page_number: int, + key: bytes, + salt: bytes, + profile: CipherProfile = DEFAULT_PROFILE, +) -> bool: + expected = page[profile.hmac_offset : profile.hmac_offset + profile.hmac_size] + actual = page_hmac(page, page_number, key, salt, profile)[: profile.hmac_size] + return len(expected) == profile.hmac_size and hmac.compare_digest(actual, expected) + + +def decrypt_first_block( + page: bytes, + key: bytes, + profile: CipherProfile = DEFAULT_PROFILE, +) -> bytes: + """Decrypt SQLite header bytes 16..31 from encrypted page one.""" + if len(page) < profile.page_size: + raise ValueError("database does not contain a complete first page") + iv = page[profile.reserve_offset : profile.reserve_offset + profile.iv_size] + return _aes_cbc_decrypt(key, iv, page[16:32]) + + +def looks_like_sqlite_header_tail( + block: bytes, + profile: CipherProfile = DEFAULT_PROFILE, +) -> bool: + """Strictly validate SQLite header bytes 16..31.""" + if len(block) < 16: + return False + encoded_page_size = int.from_bytes(block[0:2], "big") + if encoded_page_size == 1: + encoded_page_size = 65_536 + return ( + encoded_page_size == profile.page_size + and block[2] in (1, 2) + and block[3] in (1, 2) + and block[4] == profile.reserve_size + and block[5:8] == b"\x40\x20\x20" + ) + + +def key_matches_page( + page: bytes, + key: bytes, + profile: CipherProfile = DEFAULT_PROFILE, +) -> bool: + try: + page = page[: profile.page_size] + salt = page[:16] + return ( + len(page) == profile.page_size + and verify_page_hmac(page, 1, key, salt, profile) + and looks_like_sqlite_header_tail(decrypt_first_block(page, key, profile), profile) + ) + except (ValueError, TypeError): + return False + + +def match_key_material(page: bytes, material: bytes) -> tuple[bytes, CipherProfile, str] | None: + """Try raw AES material and documented SQLCipher KDF profiles.""" + salt = page[:16] + for profile in COMPATIBLE_PROFILES: + candidate_page = page[: profile.page_size] + if len(candidate_page) == profile.page_size and key_matches_page(candidate_page, material, profile): + return material, profile, "raw" + derived = hashlib.pbkdf2_hmac( + profile.kdf_algorithm, + material, + salt, + profile.kdf_iterations, + 32, + ) + if len(candidate_page) == profile.page_size and key_matches_page(candidate_page, derived, profile): + return derived, profile, f"sqlcipher-kdf/{profile.kdf_algorithm}" + return None + + +def decrypt_page( + page: bytes, + page_number: int, + key: bytes, + salt: bytes, + profile: CipherProfile = DEFAULT_PROFILE, + *, + verify_hmac: bool = True, +) -> bytes: + if len(page) != profile.page_size or page_number < 1: + raise ValueError(f"page {page_number} has invalid size") + if verify_hmac and not verify_page_hmac(page, page_number, key, salt, profile): + raise ValueError(f"page {page_number} SQLCipher HMAC mismatch") + prefix = profile.encrypted_offset(page_number) + iv = page[profile.reserve_offset : profile.reserve_offset + profile.iv_size] + clear = bytearray(_aes_cbc_decrypt(key, iv, page[prefix : profile.reserve_offset])) + if page_number == 1: + if not looks_like_sqlite_header_tail(clear[:16], profile): + raise ValueError("key/profile did not produce a valid SQLite header") + clear = bytearray(SQLITE_HEADER) + clear + clear.extend(b"\x00" * profile.reserve_size) + if len(clear) != profile.page_size: + raise AssertionError("decrypted page geometry is inconsistent") + return bytes(clear) + + +def sqlite_integrity_check(path: Path) -> None: + uri = Path(path).resolve().as_uri() + "?mode=ro" + connection = sqlite3.connect(uri, uri=True) + try: + quick = connection.execute("PRAGMA quick_check").fetchone() + full = connection.execute("PRAGMA integrity_check").fetchone() + finally: + connection.close() + if not quick or quick[0] != "ok" or not full or full[0] != "ok": + raise ValueError(f"SQLite integrity checks failed: quick={quick!r}, full={full!r}") + + +def decrypt_database( + source: Path, + destination: Path, + key: bytes, + profile: CipherProfile = DEFAULT_PROFILE, + *, + run_integrity_check: bool = True, +) -> None: + """Decrypt a base database atomically, verifying every encrypted page.""" + source = Path(source) + destination = Path(destination) + size = source.stat().st_size + if size == 0 or size % profile.page_size: + raise ValueError(f"encrypted database size is not a page multiple: {source.name}") + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent + ) + temporary = Path(temporary_name) + try: + with source.open("rb") as encrypted, os.fdopen(descriptor, "wb") as clear: + first_page = encrypted.read(profile.page_size) + salt = first_page[:16] + clear.write(decrypt_page(first_page, 1, key, salt, profile)) + for page_number in range(2, size // profile.page_size + 1): + page = encrypted.read(profile.page_size) + clear.write(decrypt_page(page, page_number, key, salt, profile)) + clear.flush() + os.fsync(clear.fileno()) + if run_integrity_check: + sqlite_integrity_check(temporary) + os.replace(temporary, destination) + finally: + if temporary.exists(): + temporary.unlink() diff --git a/yichen-wechat-windows-vault/scripts/vault_cli.py b/yichen-wechat-windows-vault/scripts/vault_cli.py new file mode 100644 index 0000000..ab8cfd2 --- /dev/null +++ b/yichen-wechat-windows-vault/scripts/vault_cli.py @@ -0,0 +1,1421 @@ +#!/usr/bin/env python3 +""" +Unified read-only query CLI for the decrypted Windows Weixin vault. + +This script intentionally reads only a complete local snapshot produced by +windows_vault.py refresh. It does not touch the Weixin UI, does not send messages, +and does not modify WeChat databases. +""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import sys +import tempfile +from typing import Iterator +from xml.etree import ElementTree as ET + +try: + import zstandard as zstd + ZSTD_DECODER = zstd.ZstdDecompressor() +except Exception: # pragma: no cover - optional runtime dependency + zstd = None + ZSTD_DECODER = None + + +PRIVATE_ROOT = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "YichenWeChatVault" +CONFIG_FILE = PRIVATE_ROOT / "settings.json" +DEFAULT_VAULT_DIR = PRIVATE_ROOT / "vault" +CURRENT_FILE = DEFAULT_VAULT_DIR / "current.json" +DEFAULT_EXPORTS_DIR = Path.home() / "Documents/YichenWeChatVault/exports" +STATE_FILE = DEFAULT_VAULT_DIR / "state/vault_cli_last_check.json" + +ZSTD_MAGIC = b"\x28\xb5\x2f\xfd" + +MESSAGE_TYPE_FILTERS = { + "text": (1,), + "image": (3,), + "voice": (34,), + "video": (43,), + "sticker": (47,), + "location": (48,), + "link": (49,), + "file": (49, 6), + "call": (50,), + "system": (10000,), +} + +TYPE_LABELS = { + 1: "文本", + 3: "图片", + 34: "语音", + 42: "名片", + 43: "视频", + 47: "表情", + 48: "位置", + 49: "链接/文件", + 50: "通话", + 10000: "系统", + 10002: "撤回", +} + +FAVORITE_TYPE_MAP = { + 1: "文本", + 2: "图片", + 5: "文章", + 19: "名片", + 20: "视频号", +} + +FAVORITE_TYPE_FILTERS = { + "text": 1, + "image": 2, + "article": 5, + "card": 19, + "video": 20, +} + + +def load_json(path: Path) -> dict: + if not path.exists(): + return {} + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def save_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(data, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + try: + os.chmod(temporary, 0o600) + except OSError: + pass + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def load_config() -> dict: + config = load_json(CONFIG_FILE) + current = load_json(CURRENT_FILE) + if current.get("decrypted_dir"): + config["decrypted_dir"] = current["decrypted_dir"] + return config + + +def resolve_decrypted_dir(value: str | None = None) -> Path: + config = load_config() + selected = value or config.get("decrypted_dir") + if not selected: + raise SystemExit("No complete vault snapshot exists. Run windows_vault.py refresh first.") + return Path(selected).expanduser() + + +def resolve_exports_dir(value: str | None = None) -> Path: + config = load_config() + return Path(value or config.get("exports_dir") or DEFAULT_EXPORTS_DIR).expanduser() + + +def resolve_db_dir() -> Path | None: + config = load_config() + if config.get("db_base_path"): + return Path(config["db_base_path"]).expanduser() + base = Path.home() / "Documents/xwechat_files" + if base.is_dir(): + roots = [path for path in base.glob("**/db_storage") if (path / "contact/contact.db").is_file()] + if len(roots) == 1: + return roots[0] + return None + + +@contextmanager +def connect(path: Path) -> Iterator[sqlite3.Connection]: + """Open a snapshot database in enforced read-only/query-only mode.""" + uri = Path(path).resolve().as_uri() + "?mode=ro" + connection = sqlite3.connect(uri, uri=True) + try: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA query_only=ON") + yield connection + finally: + connection.close() + + +def write_text_atomic(path: Path, text: str, *, overwrite: bool = False) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and not overwrite: + raise SystemExit(f"输出文件已存在;如需替换请显式传 --overwrite: {path}") + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + if path.exists() and not overwrite: + raise SystemExit(f"输出文件已存在;如需替换请显式传 --overwrite: {path}") + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def table_columns(con: sqlite3.Connection, table: str) -> set[str]: + try: + return {row["name"] for row in con.execute(f"PRAGMA table_info([{table}])")} + except sqlite3.Error: + return set() + + +def table_exists(con: sqlite3.Connection, table: str) -> bool: + row = con.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ).fetchone() + return bool(row) + + +def safe_name(value: str) -> str: + value = re.sub(r"[\\/:*?\"<>|]+", "_", value).strip() + value = re.sub(r"\s+", "_", value) + return value[:100] or "wechat_export" + + +def parse_time(value: str | None, *, end_of_day: bool = False) -> int | None: + if not value: + return None + value = value.strip() + 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 end_of_day: + dt = dt.replace(hour=23, minute=59, second=59) + return int(dt.timestamp()) + except ValueError: + pass + raise SystemExit(f"时间格式不支持: {value}") + + +def split_msg_type(local_type: int | None) -> tuple[int, int]: + try: + value = int(local_type or 0) + except (TypeError, ValueError): + return 0, 0 + if value > 0xFFFFFFFF: + return value & 0xFFFFFFFF, value >> 32 + return value, 0 + + +def type_label(local_type: int | None) -> str: + base_type, _ = split_msg_type(local_type) + return TYPE_LABELS.get(base_type, f"type={local_type}") + + +def matches_type(local_type: int | None, filter_name: str | None) -> bool: + if not filter_name: + return True + base_type, sub_type = split_msg_type(local_type) + expected = MESSAGE_TYPE_FILTERS[filter_name] + if base_type != expected[0]: + return False + return len(expected) == 1 or sub_type == expected[1] + + +def decode_value(value, compression_flag=None) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + data = bytes(value) + should_try_zstd = data.startswith(ZSTD_MAGIC) or compression_flag == 4 + if should_try_zstd and ZSTD_DECODER: + try: + data = ZSTD_DECODER.decompress(data, max_output_size=1_000_000) + except Exception: + return "[压缩消息解码失败]" + try: + return data.decode("utf-8", errors="replace") + except Exception: + return "[二进制内容]" + + +def output(data, fmt: str) -> None: + if fmt == "json": + print(json.dumps(data, ensure_ascii=False, indent=2)) + else: + print(data) + + +def load_contacts(decrypted_dir: Path) -> tuple[dict[str, dict], dict[int, str]]: + contact_db = decrypted_dir / "contact/contact.db" + if not contact_db.exists(): + return {}, {} + contacts: dict[str, dict] = {} + id_to_username: dict[int, str] = {} + with connect(contact_db) as con: + if not table_exists(con, "contact"): + return {}, {} + for row in con.execute("SELECT * FROM contact"): + item = dict(row) + username = item.get("username") or item.get("userName") or "" + if not username: + continue + username = str(username) + contact_id = item.get("id") + if contact_id is not None: + try: + id_to_username[int(contact_id)] = username + except (TypeError, ValueError): + pass + nick = str(item.get("nick_name") or item.get("nickname") or "") + remark = str(item.get("remark") or "") + alias = str(item.get("alias") or "") + display = remark or nick or alias or username + contacts[username] = { + "username": username, + "display_name": display, + "remark": remark, + "nick_name": nick, + "alias": alias, + "description": str(item.get("description") or ""), + "avatar": str(item.get("small_head_url") or item.get("big_head_url") or ""), + "is_group": "@chatroom" in username, + "is_subscription": username.startswith("gh_"), + "raw": item, + } + return contacts, id_to_username + + +def display_name(username: str, contacts: dict[str, dict]) -> str: + return contacts.get(username, {}).get("display_name") or username + + +def resolve_chat(query: str, contacts: dict[str, dict]) -> dict | None: + if query in contacts: + return contacts[query] + if query.startswith("wxid_") or "@chatroom" in query or query.startswith("gh_"): + return { + "username": query, + "display_name": contacts.get(query, {}).get("display_name", query), + "is_group": "@chatroom" in query, + } + q = query.lower() + exact = [c for c in contacts.values() if q == c["display_name"].lower()] + if exact: + return exact[0] + fuzzy = [ + c + for c in contacts.values() + if any(q in str(c.get(field) or "").lower() for field in ("display_name", "remark", "nick_name", "alias", "username")) + ] + return fuzzy[0] if fuzzy else None + + +def message_dbs(decrypted_dir: Path) -> list[Path]: + return sorted((decrypted_dir / "message").glob("message_*.db")) + + +def message_table(username: str) -> str: + return "Msg_" + hashlib.md5(username.encode()).hexdigest() + + +def load_name2id(con: sqlite3.Connection) -> dict[int, str]: + mapping: dict[int, str] = {} + if not table_exists(con, "Name2Id"): + return mapping + try: + for row in con.execute("SELECT rowid, user_name FROM Name2Id"): + if row["user_name"]: + mapping[int(row["rowid"])] = str(row["user_name"]) + except sqlite3.Error: + return {} + return mapping + + +def username_for_table(table: str, con: sqlite3.Connection) -> str: + if not table.startswith("Msg_"): + return "" + target = table[4:] + if not table_exists(con, "Name2Id"): + return "" + try: + for row in con.execute("SELECT user_name FROM Name2Id"): + username = str(row["user_name"] or "") + if hashlib.md5(username.encode()).hexdigest() == target: + return username + except sqlite3.Error: + return "" + return "" + + +def username_for_table_from_contacts(table: str, contacts: dict[str, dict]) -> str: + if not table.startswith("Msg_"): + return "" + target = table[4:] + for username in contacts: + if hashlib.md5(username.encode()).hexdigest() == target: + return username + return "" + + +def message_columns(con: sqlite3.Connection, table: str) -> dict[str, str]: + columns = table_columns(con, table) + result = {} + for key, choices in { + "local_id": ("local_id", "id", "rowid"), + "server_id": ("server_id",), + "local_type": ("local_type", "type"), + "create_time": ("create_time", "timestamp"), + "real_sender_id": ("real_sender_id", "sender_id"), + "message_content": ("message_content", "content"), + "compress_content": ("compress_content", "WCDB_CT_message_content"), + "compression_flag": ("WCDB_CT_message_content",), + }.items(): + for choice in choices: + if choice == "rowid" or choice in columns: + result[key] = choice + break + return result + + +def build_select_sql(table: str, cols: dict[str, str], start_ts: int | None, end_ts: int | None, keyword: str | None, type_name: str | None, limit: int | None, offset: int = 0) -> tuple[str, list]: + select_parts = [] + aliases = { + "local_id": "local_id", + "server_id": "server_id", + "local_type": "local_type", + "create_time": "create_time", + "real_sender_id": "real_sender_id", + "message_content": "message_content", + "compress_content": "compress_content", + "compression_flag": "compression_flag", + } + for key, alias in aliases.items(): + col = cols.get(key) + if col: + select_parts.append(f"{col} AS {alias}") + else: + select_parts.append(f"NULL AS {alias}") + clauses = [] + params: list = [] + if start_ts is not None and cols.get("create_time"): + clauses.append(f"{cols['create_time']} >= ?") + params.append(start_ts) + if end_ts is not None and cols.get("create_time"): + clauses.append(f"{cols['create_time']} <= ?") + params.append(end_ts) + if keyword and cols.get("message_content"): + clauses.append(f"{cols['message_content']} LIKE ?") + params.append(f"%{keyword}%") + if type_name and cols.get("local_type"): + expected = MESSAGE_TYPE_FILTERS[type_name] + base = expected[0] + clauses.append(f"({cols['local_type']} & 4294967295) = ?") + params.append(base) + if len(expected) > 1: + sub = expected[1] + clauses.append(f"(({cols['local_type']} >> 32) & 4294967295) = ?") + params.append(sub) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + sql = f"SELECT {', '.join(select_parts)} FROM [{table}] {where} ORDER BY create_time DESC" + if limit is not None: + sql += " LIMIT ? OFFSET ?" + params.extend([limit, offset]) + return sql, params + + +def parse_sender_prefix(content: str) -> tuple[str, str]: + if ":\n" not in content: + return "", content + sender, text = content.split(":\n", 1) + if sender.startswith("wxid_") or "@chatroom" not in sender: + return sender, text + return "", content + + +def media_hint(local_type: int | None, content: str, resolve_media: bool, db_dir: Path | None, chat_username: str, ts: int) -> str | None: + base_type, _ = split_msg_type(local_type) + if base_type == 3: + return "[图片]" + if base_type == 34: + match = re.search(r'voicelength="(\d+)"', content or "") + if match: + return f"[语音,约 {int(match.group(1)) / 1000:.1f} 秒]" + return "[语音]" + if base_type == 43: + return "[视频]" + if base_type == 47: + return "[表情]" + if base_type == 48: + return "[位置]" + if base_type == 42: + return "[名片]" + if base_type == 50: + return "[通话]" + if base_type != 49 or not resolve_media or not db_dir: + return None + title = "" + try: + root = ET.fromstring(content) + title = (root.findtext(".//title") or root.findtext(".//filename") or "").strip() + except Exception: + pass + if not title: + return None + file_dir = db_dir.parent / "msg/file" + if not file_dir.exists(): + return f"[文件] {title}" + month = datetime.fromtimestamp(ts).strftime("%Y-%m") + month_dir = file_dir / month + if month_dir.exists(): + candidate = month_dir / title + if candidate.exists(): + return f"[文件] {title}\n{candidate}" + return f"[文件] {title}" + + +def format_content(local_type: int | None, content: str, resolve_media: bool, db_dir: Path | None, chat_username: str, ts: int) -> str: + hint = media_hint(local_type, content, resolve_media, db_dir, chat_username, ts) + if hint: + return hint + base_type, sub_type = split_msg_type(local_type) + if base_type == 49: + try: + root = ET.fromstring(content) + app_type = int(root.findtext(".//type") or 0) + title = (root.findtext(".//title") or "").strip() + desc = (root.findtext(".//des") or "").strip() + if app_type == 5: + return f"[链接] {title or desc}".strip() + if app_type in (33, 36, 44): + return f"[小程序] {title or desc}".strip() + if sub_type == 6 or app_type == 6: + return f"[文件] {title or desc}".strip() + return f"[链接/文件] {title or desc}".strip() + except Exception: + return content.strip() or "[链接/文件]" + if base_type not in (1, 10000): + return f"[{type_label(local_type)}] {content}".strip() + return content.strip() + + +def row_to_message(row: sqlite3.Row, db_name: str, table: str, chat: dict, contacts: dict[str, dict], name2id: dict[int, str], resolve_media: bool = False, db_dir: Path | None = None) -> dict: + local_type = row["local_type"] + ts = int(row["create_time"] or 0) + content = decode_value(row["message_content"], row["compression_flag"]) or decode_value(row["compress_content"], row["compression_flag"]) + prefix_sender, content = parse_sender_prefix(content) + sender_username = "" + sender_id = row["real_sender_id"] + try: + sender_username = name2id.get(int(sender_id), "") + except (TypeError, ValueError): + sender_username = "" + if not sender_username: + sender_username = prefix_sender + if chat.get("is_group"): + sender = display_name(sender_username, contacts) if sender_username else "" + elif sender_username and sender_username != chat["username"]: + sender = display_name(sender_username, contacts) + elif str(sender_id) == "2": + sender = "我" + else: + sender = chat["display_name"] + text = format_content(local_type, content, resolve_media, db_dir, chat["username"], ts) + return { + "db": db_name, + "table": table, + "local_id": row["local_id"], + "server_id": row["server_id"], + "type": type_label(local_type), + "local_type": local_type, + "sender": sender, + "sender_username": sender_username, + "timestamp": ts, + "time": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "", + "content": text, + } + + +def find_chat_tables(decrypted_dir: Path, chat: dict) -> list[tuple[Path, str]]: + target = message_table(chat["username"]) + matches = [] + for db_path in message_dbs(decrypted_dir): + with connect(db_path) as con: + if table_exists(con, target): + matches.append((db_path, target)) + return matches + + +def collect_history(decrypted_dir: Path, chat: dict, start_ts: int | None, end_ts: int | None, limit: int, offset: int, type_name: str | None, resolve_media: bool = False) -> list[dict]: + contacts, _ = load_contacts(decrypted_dir) + db_dir = resolve_db_dir() + rows = [] + candidate_limit = limit + offset + for db_path, table in find_chat_tables(decrypted_dir, chat): + with connect(db_path) as con: + cols = message_columns(con, table) + sql, params = build_select_sql(table, cols, start_ts, end_ts, None, type_name, candidate_limit, 0) + name2id = load_name2id(con) + for row in con.execute(sql, params): + msg = row_to_message(row, db_path.name, table, chat, contacts, name2id, resolve_media, db_dir) + if matches_type(msg["local_type"], type_name): + rows.append(msg) + rows.sort(key=lambda item: (item["timestamp"], str(item["local_id"])), reverse=True) + page = rows[offset : offset + limit] + page.sort(key=lambda item: (item["timestamp"], str(item["local_id"]))) + return page + + +def command_status(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + names = [ + "contact/contact.db", + "session/session.db", + "sns/sns.db", + "favorite/favorite.db", + "message/message_resource.db", + ] + names.extend(str(p.relative_to(decrypted_dir)) for p in message_dbs(decrypted_dir)) + data = { + "decrypted_dir": str(decrypted_dir), + "exists": decrypted_dir.exists(), + "databases": [{"path": rel, "available": (decrypted_dir / rel).exists()} for rel in sorted(set(names))], + } + output(data if args.format == "json" else render_status_text(data), args.format) + + +def render_status_text(data: dict) -> str: + lines = [f"明文 vault: {data['decrypted_dir']}", f"状态: {'可用' if data['exists'] else '不存在'}", "", "数据库:"] + for item in data["databases"]: + lines.append(f" {'OK' if item['available'] else '--'} {item['path']}") + return "\n".join(lines) + + +def session_rows(decrypted_dir: Path, only_unread: bool, limit: int | None = None) -> list[dict]: + session_db = decrypted_dir / "session/session.db" + if not session_db.exists(): + raise SystemExit(f"找不到 session.db,请先增量或全量解密: {session_db}") + contacts, _ = load_contacts(decrypted_dir) + rows = [] + with connect(session_db) as con: + if not table_exists(con, "SessionTable"): + raise SystemExit("session.db 中没有 SessionTable") + where = "WHERE unread_count > 0" if only_unread else "WHERE last_timestamp > 0" + sql = ( + "SELECT username, unread_count, summary, last_timestamp, last_msg_type, " + f"last_msg_sender, last_sender_display_name FROM SessionTable {where} " + "ORDER BY last_timestamp DESC" + ) + if limit is not None: + sql += " LIMIT ?" + query = con.execute(sql, (limit,)) + else: + query = con.execute(sql) + for row in query: + username = str(row["username"] or "") + summary = decode_value(row["summary"], 4) + if ":\n" in summary: + summary = summary.split(":\n", 1)[1] + sender_username = str(row["last_msg_sender"] or "") + sender = display_name(sender_username, contacts) if sender_username else str(row["last_sender_display_name"] or "") + ts = int(row["last_timestamp"] or 0) + rows.append({ + "chat": display_name(username, contacts), + "username": username, + "is_group": "@chatroom" in username, + "unread": int(row["unread_count"] or 0), + "last_message": summary, + "msg_type": type_label(row["last_msg_type"]), + "sender": sender, + "timestamp": ts, + "time": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "", + }) + return rows + + +def render_sessions_text(rows: list[dict], title: str) -> str: + if not rows: + return "没有找到会话" + lines = [f"{title}({len(rows)} 个)", ""] + for row in rows: + head = f"[{row['time']}] {row['chat']}" + if row["is_group"]: + head += " [群]" + if row["unread"]: + head += f" ({row['unread']}条未读)" + body = f" {row['msg_type']}: " + if row["sender"] and row["is_group"]: + body += f"{row['sender']}: " + body += row["last_message"] + lines.extend([head, body, ""]) + return "\n".join(lines).rstrip() + + +def command_sessions(args: argparse.Namespace) -> None: + rows = session_rows(resolve_decrypted_dir(args.decrypted_dir), False, args.limit) + output(rows if args.format == "json" else render_sessions_text(rows, "最近会话"), args.format) + + +def command_unread(args: argparse.Namespace) -> None: + rows = session_rows(resolve_decrypted_dir(args.decrypted_dir), True, args.limit) + output(rows if args.format == "json" else render_sessions_text(rows, "未读会话"), args.format) + + +def command_new_messages(args: argparse.Namespace) -> None: + rows = session_rows(resolve_decrypted_dir(args.decrypted_dir), False, None) + current = {row["username"]: row["timestamp"] for row in rows} + previous = load_json(STATE_FILE) + if not previous: + save_json(STATE_FILE, current) + unread = [row for row in rows if row["unread"] > 0] + data = {"first_call": True, "unread_count": len(unread), "messages": unread} + text = render_sessions_text(unread, "当前未读会话") if unread else "当前无未读消息;已记录状态,下次只返回新增。" + output(data if args.format == "json" else text, args.format) + return + changed = [row for row in rows if row["timestamp"] > int(previous.get(row["username"], 0) or 0)] + changed.sort(key=lambda item: item["timestamp"]) + save_json(STATE_FILE, current) + data = {"first_call": False, "new_count": len(changed), "messages": changed} + text = render_sessions_text(changed, "新增消息") if changed else "无新消息" + output(data if args.format == "json" else text, args.format) + + +def command_contacts(args: argparse.Namespace) -> None: + contacts, _ = load_contacts(resolve_decrypted_dir(args.decrypted_dir)) + if args.detail: + item = resolve_chat(args.detail, contacts) + if not item: + raise SystemExit(f"找不到联系人: {args.detail}") + data = {k: v for k, v in item.items() if k != "raw"} + output(data if args.format == "json" else render_contact_detail(data), args.format) + return + items = list(contacts.values()) + if args.query: + q = args.query.lower() + items = [ + item for item in items + if any(q in str(item.get(field) or "").lower() for field in ("username", "display_name", "remark", "nick_name", "alias")) + ] + items.sort(key=lambda item: (not item["is_group"], item["display_name"])) + items = [{k: v for k, v in item.items() if k != "raw"} for item in items[: args.limit]] + output({"count": len(items), "contacts": items} if args.format == "json" else render_contacts_text(items), args.format) + + +def render_contacts_text(items: list[dict]) -> str: + if not items: + return "没有找到联系人" + lines = [] + for index, item in enumerate(items, 1): + tag = "群" if item["is_group"] else "联系人" + lines.append(f"{index}. [{tag}] {item['display_name']}") + if item.get("remark"): + lines.append(f" 备注: {item['remark']}") + if item.get("nick_name") and item["nick_name"] != item.get("remark"): + lines.append(f" 昵称: {item['nick_name']}") + lines.append(f" username: {item['username']}") + return "\n".join(lines) + + +def render_contact_detail(item: dict) -> str: + lines = [item["display_name"], f"username: {item['username']}"] + for key, label in [("remark", "备注"), ("nick_name", "昵称"), ("alias", "微信号"), ("description", "描述"), ("avatar", "头像")]: + if item.get(key): + lines.append(f"{label}: {item[key]}") + lines.append(f"类型: {'群聊' if item.get('is_group') else '联系人'}") + return "\n".join(lines) + + +def command_members(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + contacts, _ = load_contacts(decrypted_dir) + group = resolve_chat(args.group, contacts) + if not group or not group.get("is_group"): + raise SystemExit(f"找不到群聊: {args.group}") + contact_db = decrypted_dir / "contact/contact.db" + owner = "" + members = [] + with connect(contact_db) as con: + if table_exists(con, "contact") and table_exists(con, "chat_room") and table_exists(con, "chatroom_member"): + room = con.execute("SELECT id FROM contact WHERE username=? OR userName=?", (group["username"], group["username"])).fetchone() + if room: + room_id = room["id"] + owner_row = con.execute("SELECT owner FROM chat_room WHERE id=?", (room_id,)).fetchone() + owner_username = str(owner_row["owner"] or "") if owner_row else "" + owner = display_name(owner_username, contacts) if owner_username else "" + ids = [row["member_id"] for row in con.execute("SELECT member_id FROM chatroom_member WHERE room_id=?", (room_id,))] + if ids: + placeholders = ",".join("?" for _ in ids) + for row in con.execute(f"SELECT id, username, nick_name, remark FROM contact WHERE id IN ({placeholders})", ids): + username = str(row["username"] or "") + members.append({ + "username": username, + "display_name": row["remark"] or row["nick_name"] or username, + "remark": row["remark"] or "", + "nick_name": row["nick_name"] or "", + "is_owner": username == owner_username, + }) + if not members: + stats = collect_stats(decrypted_dir, group, None, None) + members = [{"display_name": item["name"], "message_count": item["count"]} for item in stats["top_senders"]] + members.sort(key=lambda item: (not item.get("is_owner", False), item.get("display_name", ""))) + data = {"group": group["display_name"], "username": group["username"], "owner": owner, "member_count": len(members), "members": members} + output(data if args.format == "json" else render_members_text(data), args.format) + + +def render_members_text(data: dict) -> str: + lines = [f"{data['group']} 群成员({data['member_count']} 人)"] + if data.get("owner"): + lines.append(f"群主: {data['owner']}") + for index, item in enumerate(data["members"], 1): + suffix = " [群主]" if item.get("is_owner") else "" + count = f" - {item['message_count']}条" if "message_count" in item else "" + lines.append(f"{index}. {item['display_name']}{suffix}{count}") + return "\n".join(lines) + + +def command_history(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + contacts, _ = load_contacts(decrypted_dir) + chat = resolve_chat(args.chat, contacts) + if not chat: + raise SystemExit(f"找不到聊天对象: {args.chat}") + rows = collect_history( + decrypted_dir, + chat, + parse_time(args.start_time), + parse_time(args.end_time, end_of_day=True), + args.limit, + args.offset, + args.type, + args.media, + ) + data = {"chat": chat["display_name"], "username": chat["username"], "count": len(rows), "messages": rows} + output(data if args.format == "json" else render_messages_text(rows), args.format) + + +def render_messages_text(rows: list[dict]) -> str: + if not rows: + return "没有找到消息" + lines = [] + for row in rows: + sender = f"{row['sender']}: " if row.get("sender") else "" + lines.append(f"[{row['time']}] {sender}{row['content']}") + return "\n".join(lines) + + +def command_search(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + contacts, _ = load_contacts(decrypted_dir) + start_ts = parse_time(args.start_time) + end_ts = parse_time(args.end_time, end_of_day=True) + candidate_limit = args.limit + args.offset + results = [] + chats = [] + if args.chat: + for chat_query in args.chat: + chat = resolve_chat(chat_query, contacts) + if chat: + chats.append(chat) + for chat in chats: + for row in collect_history(decrypted_dir, chat, start_ts, end_ts, candidate_limit, 0, args.type, False): + if args.keyword.lower() in row["content"].lower(): + row["chat"] = chat["display_name"] + row["chat_username"] = chat["username"] + results.append(row) + else: + for db_path in message_dbs(decrypted_dir): + with connect(db_path) as con: + tables = [row["name"] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")] + name2id = load_name2id(con) + for table in tables: + username = username_for_table(table, con) or username_for_table_from_contacts(table, contacts) + chat = { + "username": username, + "display_name": display_name(username, contacts) if username else table, + "is_group": "@chatroom" in username, + } + cols = message_columns(con, table) + sql, params = build_select_sql(table, cols, start_ts, end_ts, args.keyword, args.type, candidate_limit, 0) + try: + for row in con.execute(sql, params): + msg = row_to_message(row, db_path.name, table, chat, contacts, name2id) + if args.keyword.lower() in msg["content"].lower() and matches_type(msg["local_type"], args.type): + msg["chat"] = chat["display_name"] + msg["chat_username"] = chat["username"] + results.append(msg) + except sqlite3.Error: + continue + results.sort(key=lambda item: item["timestamp"], reverse=True) + page = results[args.offset : args.offset + args.limit] + page.sort(key=lambda item: item["timestamp"]) + data = {"keyword": args.keyword, "count": len(page), "messages": page} + output(data if args.format == "json" else render_search_text(page), args.format) + + +def render_search_text(rows: list[dict]) -> str: + if not rows: + return "没有找到匹配消息" + return "\n".join(f"[{row['time']}] [{row.get('chat', '')}] {row.get('sender', '')}: {row['content']}".strip() for row in rows) + + +def collect_stats(decrypted_dir: Path, chat: dict, start_ts: int | None, end_ts: int | None) -> dict: + contacts, _ = load_contacts(decrypted_dir) + total = 0 + type_counts: dict[str, int] = {} + sender_counts: dict[str, int] = {} + hourly = {hour: 0 for hour in range(24)} + for db_path, table in find_chat_tables(decrypted_dir, chat): + with connect(db_path) as con: + name2id = load_name2id(con) + cols = message_columns(con, table) + clauses = [] + params = [] + if start_ts is not None: + clauses.append(f"{cols['create_time']} >= ?") + params.append(start_ts) + if end_ts is not None: + clauses.append(f"{cols['create_time']} <= ?") + params.append(end_ts) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + for row in con.execute(f"SELECT {cols['local_type']} AS local_type, COUNT(*) AS count FROM [{table}] {where} GROUP BY {cols['local_type']}", params): + label = type_label(row["local_type"]) + count = int(row["count"] or 0) + type_counts[label] = type_counts.get(label, 0) + count + total += count + for row in con.execute(f"SELECT {cols['real_sender_id']} AS sender_id, COUNT(*) AS count FROM [{table}] {where} GROUP BY {cols['real_sender_id']}", params): + sender_username = name2id.get(int(row["sender_id"] or 0), "") + sender = display_name(sender_username, contacts) if sender_username else str(row["sender_id"]) + sender_counts[sender] = sender_counts.get(sender, 0) + int(row["count"] or 0) + for row in con.execute(f"SELECT cast(strftime('%H', {cols['create_time']}, 'unixepoch', 'localtime') as integer) AS hour, COUNT(*) AS count FROM [{table}] {where} GROUP BY hour", params): + if row["hour"] is not None: + hourly[int(row["hour"])] += int(row["count"] or 0) + return { + "total": total, + "type_breakdown": dict(sorted(type_counts.items(), key=lambda item: item[1], reverse=True)), + "top_senders": [{"name": name, "count": count} for name, count in sorted(sender_counts.items(), key=lambda item: item[1], reverse=True)[:10]], + "hourly": hourly, + } + + +def command_stats(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + contacts, _ = load_contacts(decrypted_dir) + chat = resolve_chat(args.chat, contacts) + if not chat: + raise SystemExit(f"找不到聊天对象: {args.chat}") + stats = collect_stats(decrypted_dir, chat, parse_time(args.start_time), parse_time(args.end_time, end_of_day=True)) + data = {"chat": chat["display_name"], "username": chat["username"], "is_group": chat.get("is_group", False), **stats} + output(data if args.format == "json" else render_stats_text(data), args.format) + + +def render_stats_text(data: dict) -> str: + lines = [f"{data['chat']} 统计", f"消息总数: {data['total']}", "", "消息类型:"] + for label, count in data["type_breakdown"].items(): + pct = count / data["total"] * 100 if data["total"] else 0 + lines.append(f" {label}: {count} ({pct:.1f}%)") + lines.append("") + lines.append("发言排行:") + for item in data["top_senders"]: + lines.append(f" {item['name']}: {item['count']}") + lines.append("") + lines.append("24小时分布:") + max_count = max(data["hourly"].values()) if data["hourly"] else 0 + for hour in range(24): + count = data["hourly"].get(hour, 0) + bar = "#" * (int(count / max_count * 24) if max_count else 0) + lines.append(f" {hour:02d}:00 | {bar} {count}") + return "\n".join(lines) + + +def command_export(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + exports_dir = resolve_exports_dir(args.exports_dir) + contacts, _ = load_contacts(decrypted_dir) + chat = resolve_chat(args.chat, contacts) + if not chat: + raise SystemExit(f"找不到聊天对象: {args.chat}") + rows = collect_history( + decrypted_dir, + chat, + parse_time(args.start_time), + parse_time(args.end_time, end_of_day=True), + args.limit, + 0, + args.type, + args.media, + ) + if args.format == "markdown": + body = render_export_markdown(chat, rows, args.start_time, args.end_time) + suffix = "md" + else: + body = render_messages_text(rows) + suffix = "txt" + out_path = Path(args.output).expanduser() if args.output else exports_dir / "cli_exports" / f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{safe_name(chat['display_name'])}.{suffix}" + write_text_atomic(out_path, body.rstrip() + "\n", overwrite=args.overwrite) + print(out_path) + print(f"Exported {len(rows)} messages.") + + +def digest_range_label(start_time: str | None, end_time: str | None) -> str: + if start_time and end_time and start_time[:10] == end_time[:10]: + return start_time[:10] + if start_time or end_time: + return f"{(start_time or 'earliest')[:10]}_{(end_time or 'latest')[:10]}" + return datetime.now().strftime("%Y-%m-%d") + + +def digest_folder(data_root: Path, group: dict) -> Path: + return data_root / f"{safe_name(group['username'])}-{safe_name(group['display_name'])}" + + +def last_digest_timestamp(folder: Path) -> int | None: + history = load_json(folder / "history.json") + value = (history.get("last_digest") or {}).get("last_message_timestamp") + if value: + try: + return int(value) + except (TypeError, ValueError): + return None + return None + + +def digest_stats_from_rows(rows: list[dict]) -> dict: + counts: dict[str, int] = {} + usable = [] + for row in rows: + content = row.get("content") or "" + if row.get("type") == "系统" or "revokemsg" in content: + continue + usable.append(row) + sender = row.get("sender") or "未知" + counts[sender] = counts.get(sender, 0) + 1 + leaderboard = [{"name": name, "count": count} for name, count in sorted(counts.items(), key=lambda item: item[1], reverse=True)[:10]] + active_senders = [{"name": name, "count": count} for name, count in sorted(counts.items(), key=lambda item: item[1], reverse=True) if count >= 3] + return { + "message_count": len(usable), + "leaderboard": leaderboard, + "active_senders": active_senders, + "last_message_timestamp": max((row["timestamp"] for row in rows), default=0), + } + + +def render_digest_source_markdown(group: dict, rows: list[dict], stats: dict, range_text: str) -> str: + lines = [ + f"{group['display_name']} 群聊精华素材 · {range_text}", + "", + f"消息统计: 共 {stats['message_count']} 条消息", + ] + for index, item in enumerate(stats["leaderboard"], 1): + lines.append(f"{index}. {item['name']}: {item['count']} 条") + lines.extend([ + "", + "群友画像候选(3条以上)", + ]) + if stats["active_senders"]: + for item in stats["active_senders"]: + lines.append(f"- {item['name']}: {item['count']} 条") + else: + lines.append("- 无") + lines.extend([ + "", + "消息素材", + "", + ]) + for row in rows: + sender = row.get("sender") or "未知" + lines.append(f"- id={row.get('local_id')} time={row['time']} sender={sender} type={row['type']} content={row['content']}") + return "\n".join(lines) + + +def command_digest_source(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + contacts, _ = load_contacts(decrypted_dir) + group = resolve_chat(args.group, contacts) + if not group or not group.get("is_group"): + raise SystemExit(f"找不到群聊: {args.group}") + + data_root = Path(args.data_root).expanduser() if args.data_root else Path.cwd() / "wechat" + folder = digest_folder(data_root, group) + folder.mkdir(parents=True, exist_ok=True) + for child in ("profiles", "profiles-roast", "imgs", "sources"): + (folder / child).mkdir(parents=True, exist_ok=True) + + start_ts = parse_time(args.start) + if args.since_last: + start_ts = last_digest_timestamp(folder) or start_ts + end_ts = parse_time(args.end, end_of_day=True) + rows = collect_history(decrypted_dir, group, start_ts, end_ts, args.limit, 0, None, args.media) + stats = digest_stats_from_rows(rows) + range_text = digest_range_label(args.start, args.end) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + source_json = folder / "sources" / f"{stamp}-{range_text}.json" + source_md = folder / "sources" / f"{stamp}-{range_text}.md" + collision = 1 + while source_json.exists() or source_md.exists(): + collision += 1 + source_json = folder / "sources" / f"{stamp}-{range_text}-run-{collision}.json" + source_md = folder / "sources" / f"{stamp}-{range_text}-run-{collision}.md" + payload = { + "group": {"name": group["display_name"], "username": group["username"]}, + "range": {"start": args.start or "", "end": args.end or "", "since_last": args.since_last}, + "stats": stats, + "messages": rows, + "notes": { + "image_content_is_opaque": True, + "image_description_extension": str(folder / "imgs/{message_id}.txt"), + "profiles_dir": str(folder / "profiles"), + "history_file": str(folder / "history.json"), + }, + } + write_text_atomic(source_json, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + write_text_atomic(source_md, render_digest_source_markdown(group, rows, stats, range_text) + "\n") + result = { + "folder": str(folder), + "source_json": str(source_json), + "source_markdown": str(source_md), + "message_count": len(rows), + "digest_stats_count": stats["message_count"], + "last_message_timestamp": stats["last_message_timestamp"], + "next_step": "Use the source files to draft a normal or roast digest, then update history.json after the final digest is accepted.", + } + output(result if args.format == "json" else "\n".join(f"{k}: {v}" for k, v in result.items()), args.format) + + +def render_export_markdown(chat: dict, rows: list[dict], start_time: str | None, end_time: str | None) -> str: + lines = [ + f"# 聊天记录: {chat['display_name']}", + "", + f"- 会话 ID: {chat['username']}", + f"- 类型: {'群聊' if chat.get('is_group') else '私聊'}", + f"- 时间范围: {start_time or '最早'} ~ {end_time or '最新'}", + f"- 导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + f"- 消息数量: {len(rows)}", + "", + "## 时间线", + "", + ] + for row in rows: + sender = f"{row['sender']}: " if row.get("sender") else "" + lines.append(f"- {row['time']} [{row['type']}] {sender}{row['content']}") + return "\n".join(lines) + + +def parse_favorite(content: str, fav_type: int) -> str: + if not content: + return "" + try: + root = ET.fromstring(content) + except ET.ParseError: + return "" + item = root if root.tag == "favitem" else root.find(".//favitem") + if item is None: + return "" + if fav_type == 1: + return (item.findtext("desc") or "").strip() + if fav_type == 2: + return "[图片收藏]" + if fav_type == 5: + title = (item.findtext(".//pagetitle") or "").strip() + desc = (item.findtext(".//pagedesc") or "").strip() + return f"{title} - {desc}" if desc else title + if fav_type == 19: + return (item.findtext("desc") or "").strip() + if fav_type == 20: + nickname = (item.findtext(".//nickname") or "").strip() + desc = (item.findtext(".//desc") or "").strip() + return " ".join(part for part in (nickname, desc) if part) or "[视频号]" + return (item.findtext("desc") or "").strip() or "[收藏]" + + +def command_favorites(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + fav_db = decrypted_dir / "favorite/favorite.db" + if not fav_db.exists(): + raise SystemExit(f"找不到 favorite.db: {fav_db}") + contacts, _ = load_contacts(decrypted_dir) + where = [] + params = [] + if args.type: + where.append("type = ?") + params.append(FAVORITE_TYPE_FILTERS[args.type]) + if args.query: + where.append("content LIKE ?") + params.append(f"%{args.query}%") + where_sql = f"WHERE {' AND '.join(where)}" if where else "" + rows = [] + with connect(fav_db) as con: + if not table_exists(con, "fav_db_item"): + raise SystemExit("favorite.db 中没有 fav_db_item") + for row in con.execute( + f"SELECT local_id, type, update_time, content, fromusr, realchatname FROM fav_db_item {where_sql} ORDER BY update_time DESC LIMIT ?", + (*params, args.limit), + ): + ts = int(row["update_time"] or 0) + fav_type = int(row["type"] or 0) + rows.append({ + "id": row["local_id"], + "type": FAVORITE_TYPE_MAP.get(fav_type, f"type={fav_type}"), + "time": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "", + "summary": parse_favorite(row["content"] or "", fav_type), + "from": display_name(str(row["fromusr"] or ""), contacts) if row["fromusr"] else "", + "source_chat": display_name(str(row["realchatname"] or ""), contacts) if row["realchatname"] else "", + }) + data = {"count": len(rows), "favorites": rows} + output(data if args.format == "json" else render_favorites_text(rows), args.format) + + +def render_favorites_text(rows: list[dict]) -> str: + if not rows: + return "没有找到收藏" + lines = [] + for row in rows: + entry = f"[{row['time']}] [{row['type']}] {row['summary']}" + if row["from"]: + entry += f"\n 来自: {row['from']}" + if row["source_chat"]: + entry += f"\n 聊天: {row['source_chat']}" + lines.append(entry) + return "\n\n".join(lines) + + +def xml_text(root: ET.Element, path: str) -> str: + node = root.find(path) + return (node.text or "").strip() if node is not None else "" + + +def parse_moment(content: str, tid: str, db_user: str) -> dict: + try: + root = ET.fromstring(content) + except ET.ParseError: + root = ET.fromstring(re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", content)) + timeline = root.find("TimelineObject") if root.tag != "TimelineObject" else root + if timeline is None: + timeline = root + media = [] + for item in timeline.findall(".//media"): + media.append({ + "type": xml_text(item, "type"), + "url": xml_text(item, "url"), + "thumb": xml_text(item, "thumb"), + }) + links = [] + for path in ("ContentObject/contentUrl", ".//url"): + for node in timeline.findall(path): + if node.text and node.text.strip(): + links.append(node.text.strip()) + create_time = xml_text(timeline, "createTime") + ts = int(create_time) if create_time.isdigit() else 0 + return { + "tid": tid, + "username": xml_text(timeline, "username") or db_user, + "nickname": xml_text(timeline, "nickname"), + "timestamp": ts, + "time": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "", + "content": xml_text(timeline, "contentDesc"), + "type": xml_text(timeline, "ContentObject/contentStyle") or xml_text(timeline, "ContentObject/contentSubStyle"), + "media": media, + "links": sorted(set(links)), + } + + +def command_moments(args: argparse.Namespace) -> None: + decrypted_dir = resolve_decrypted_dir(args.decrypted_dir) + sns_db = decrypted_dir / "sns/sns.db" + if not sns_db.exists(): + raise SystemExit(f"找不到 sns.db: {sns_db}") + contacts, _ = load_contacts(decrypted_dir) + usernames = set(args.username or []) + if args.name: + q = args.name.lower() + for item in contacts.values(): + if any(q in str(item.get(field) or "").lower() for field in ("username", "display_name", "remark", "nick_name", "alias")): + usernames.add(item["username"]) + if not usernames: + raise SystemExit("请传 --name 或 --username") + start_ts = parse_time(args.start) + end_ts = parse_time(args.end, end_of_day=True) + posts = [] + with connect(sns_db) as con: + placeholders = ",".join("?" for _ in usernames) + for row in con.execute(f"SELECT tid, user_name, content FROM SnsTimeLine WHERE user_name IN ({placeholders})", tuple(usernames)): + if not row["content"]: + continue + try: + post = parse_moment(row["content"], str(row["tid"]), str(row["user_name"])) + except Exception as exc: + post = {"tid": str(row["tid"]), "username": str(row["user_name"]), "time": "", "timestamp": 0, "content": f"[无法解析 XML: {exc}]", "media": [], "links": []} + if start_ts and (not post["timestamp"] or post["timestamp"] < start_ts): + continue + if end_ts and (not post["timestamp"] or post["timestamp"] > end_ts): + continue + if args.keyword: + haystack = json.dumps(post, ensure_ascii=False) + if args.keyword.lower() not in haystack.lower(): + continue + post["display_name"] = display_name(post["username"], contacts) + posts.append(post) + posts.sort(key=lambda item: item["timestamp"], reverse=True) + posts = posts[: args.limit] + data = {"count": len(posts), "moments": posts} + output(data if args.format == "json" else render_moments_text(posts), args.format) + + +def render_moments_text(posts: list[dict]) -> str: + if not posts: + return "没有找到匹配朋友圈" + lines = [] + for post in posts: + suffix = [] + if post.get("media"): + suffix.append(f"{len(post['media'])}个媒体") + if post.get("links"): + suffix.append(f"{len(post['links'])}个链接") + header = f"[{post['time']}] {post.get('display_name') or post['username']}" + if suffix: + header += f" ({','.join(suffix)})" + lines.append(header) + lines.append(post.get("content") or "[无文字内容]") + for link in post.get("links", [])[:5]: + lines.append(f" link: {link}") + lines.append("") + return "\n".join(lines).rstrip() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Query the decrypted Windows Weixin vault.") + parser.add_argument("--decrypted-dir", help="覆盖明文 vault 目录") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("status", help="查看 vault 可用数据库") + p.add_argument("--format", choices=["json", "text"], default="text") + p.set_defaults(func=command_status) + + p = sub.add_parser("sessions", help="最近会话") + p.add_argument("--limit", type=int, default=20) + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_sessions) + + p = sub.add_parser("unread", help="未读会话") + p.add_argument("--limit", type=int, default=50) + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_unread) + + p = sub.add_parser("new-messages", help="自上次调用以来的新消息") + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_new_messages) + + p = sub.add_parser("contacts", help="联系人/群聊搜索") + p.add_argument("--query") + p.add_argument("--detail") + p.add_argument("--limit", type=int, default=50) + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_contacts) + + p = sub.add_parser("members", help="群成员") + p.add_argument("group") + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_members) + + p = sub.add_parser("history", help="聊天记录") + p.add_argument("chat") + p.add_argument("--limit", type=int, default=50) + p.add_argument("--offset", type=int, default=0) + p.add_argument("--start-time", default="") + p.add_argument("--end-time", default="") + p.add_argument("--type", choices=sorted(MESSAGE_TYPE_FILTERS)) + p.add_argument("--media", action="store_true", help="尝试附带本地文件路径提示") + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_history) + + p = sub.add_parser("search", help="全局或指定会话搜索") + p.add_argument("keyword") + p.add_argument("--chat", action="append") + p.add_argument("--limit", type=int, default=50) + p.add_argument("--offset", type=int, default=0) + p.add_argument("--start-time", default="") + p.add_argument("--end-time", default="") + p.add_argument("--type", choices=sorted(MESSAGE_TYPE_FILTERS)) + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_search) + + p = sub.add_parser("stats", help="聊天统计") + p.add_argument("chat") + p.add_argument("--start-time", default="") + p.add_argument("--end-time", default="") + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_stats) + + p = sub.add_parser("export", help="导出聊天记录") + p.add_argument("chat") + p.add_argument("--format", choices=["markdown", "txt"], default="markdown") + p.add_argument("--output") + p.add_argument("--exports-dir") + p.add_argument("--start-time", default="") + p.add_argument("--end-time", default="") + p.add_argument("--limit", type=int, default=500) + p.add_argument("--type", choices=sorted(MESSAGE_TYPE_FILTERS)) + p.add_argument("--media", action="store_true") + p.add_argument("--overwrite", action="store_true", help="显式允许替换已存在的目标文件") + p.set_defaults(func=command_export) + + p = sub.add_parser("digest-source", help="生成群聊摘要素材包") + p.add_argument("group") + p.add_argument("--start", help="开始时间 YYYY-MM-DD [HH:MM[:SS]]") + p.add_argument("--end", help="结束时间 YYYY-MM-DD [HH:MM[:SS]]") + p.add_argument("--since-last", action="store_true", help="优先从该群 history.json 的上次摘要时间继续") + p.add_argument("--data-root", help="摘要归档根目录,默认当前项目的 wechat/") + p.add_argument("--limit", type=int, default=5000) + p.add_argument("--media", action="store_true", help="尝试附带本地文件路径提示") + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_digest_source) + + p = sub.add_parser("favorites", help="收藏夹") + p.add_argument("--limit", type=int, default=20) + p.add_argument("--type", choices=sorted(FAVORITE_TYPE_FILTERS)) + p.add_argument("--query") + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_favorites) + + p = sub.add_parser("moments", help="朋友圈") + p.add_argument("--name") + p.add_argument("--username", action="append") + p.add_argument("--start") + p.add_argument("--end") + p.add_argument("--keyword") + p.add_argument("--limit", type=int, default=50) + p.add_argument("--format", choices=["json", "text"], default="json") + p.set_defaults(func=command_moments) + return parser + + +def main(argv: list[str] | None = None) -> None: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + parser = build_parser() + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/yichen-wechat-windows-vault/scripts/wal_snapshot.py b/yichen-wechat-windows-vault/scripts/wal_snapshot.py new file mode 100644 index 0000000..7b06285 --- /dev/null +++ b/yichen-wechat-windows-vault/scripts/wal_snapshot.py @@ -0,0 +1,289 @@ +"""Validate and merge committed encrypted SQLite WAL frames.""" + +from __future__ import annotations + +import os +import struct +import sys +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path + +from sqlcipher_codec import ( + CipherProfile, + DEFAULT_PROFILE, + decrypt_database, + decrypt_page, + sqlite_integrity_check, +) + + +WAL_MAGIC_LITTLE_ENDIAN_CHECKSUM = 0x377F0682 +WAL_MAGIC_BIG_ENDIAN_CHECKSUM = 0x377F0683 +WAL_HEADER_SIZE = 32 +WAL_FRAME_HEADER_SIZE = 24 + + +@dataclass(frozen=True) +class WalFrame: + index: int + page_number: int + database_pages_after_commit: int + encrypted_page: bytes + + +@dataclass(frozen=True) +class ShmState: + max_frame: int + backfill: int + database_pages: int + + +@dataclass(frozen=True) +class WalReport: + present: bool + valid_frames: int = 0 + committed_frames: int = 0 + applied_frames: int = 0 + database_pages: int | None = None + ignored_trailing_bytes: int = 0 + ignored_invalid_frames: int = 0 + shm_validated: bool = False + checkpointed_frames: int = 0 + + def as_dict(self) -> dict: + return asdict(self) + + +def wal_checksum( + data: bytes, + checksum: tuple[int, int] = (0, 0), + *, + byteorder: str, +) -> tuple[int, int]: + """Implement SQLite's rolling WAL checksum.""" + if len(data) % 8 or byteorder not in ("little", "big"): + raise ValueError("WAL checksum input must contain 8-byte pairs") + prefix = "<" if byteorder == "little" else ">" + words = struct.unpack(f"{prefix}{len(data) // 4}I", data) + first, second = checksum + for index in range(0, len(words), 2): + first = (first + words[index] + second) & 0xFFFFFFFF + second = (second + words[index + 1] + first) & 0xFFFFFFFF + return first, second + + +def parse_shm( + path: Path, + expected_page_size: int, + wal_salt: bytes, + complete_wal_frames: int, +) -> ShmState: + """Validate the native-endian SQLite wal-index header and checkpoint state.""" + data = Path(path).read_bytes() + if len(data) < 136: + raise ValueError("SHM is shorter than the SQLite wal-index header") + byteorder = sys.byteorder + prefix = "<" if byteorder == "little" else ">" + candidates: list[tuple[bytes, ShmState]] = [] + for offset in (0, 48): + header = data[offset : offset + 48] + version = struct.unpack_from(f"{prefix}I", header, 0)[0] + initialized = header[12] + checksum_order = header[13] + page_size = struct.unpack_from(f"{prefix}H", header, 14)[0] + if page_size == 1: + page_size = 65_536 + stored_checksum = struct.unpack(f"{prefix}II", header[40:48]) + if ( + version != 3_007_000 + or initialized != 1 + or checksum_order not in (0, 1) + or page_size != expected_page_size + or header[32:40] != wal_salt + or wal_checksum(header[:40], byteorder=byteorder) != stored_checksum + ): + continue + max_frame = struct.unpack_from(f"{prefix}I", header, 16)[0] + database_pages = struct.unpack_from(f"{prefix}I", header, 20)[0] + if max_frame > complete_wal_frames: + continue + candidates.append( + ( + header, + ShmState( + max_frame=max_frame, + backfill=struct.unpack_from(f"{prefix}I", data, 96)[0], + database_pages=database_pages, + ), + ) + ) + if not candidates: + raise ValueError("SHM does not contain a valid wal-index header") + if len(candidates) == 2 and candidates[0][0] != candidates[1][0]: + raise ValueError("SHM wal-index header copies disagree") + state = candidates[0][1] + if state.backfill > state.max_frame: + raise ValueError("SHM backfill exceeds its committed frame boundary") + return state + + +def parse_wal( + path: Path, + expected_page_size: int, + *, + start_frame: int = 1, + max_frames: int | None = None, +) -> tuple[list[WalFrame], int, int]: + """Return valid frames, ignored trailing byte count, and invalid frame count.""" + data = Path(path).read_bytes() + if not data: + return [], 0, 0 + if len(data) < WAL_HEADER_SIZE: + raise ValueError("WAL is shorter than its header") + header = data[:WAL_HEADER_SIZE] + magic, version, page_size = struct.unpack(">III", header[:12]) + if magic == WAL_MAGIC_LITTLE_ENDIAN_CHECKSUM: + checksum_order = "little" + elif magic == WAL_MAGIC_BIG_ENDIAN_CHECKSUM: + checksum_order = "big" + else: + raise ValueError(f"unsupported WAL magic: 0x{magic:08x}") + if version != 3_007_000: + raise ValueError(f"unsupported WAL format version: {version}") + if page_size == 1: + page_size = 65_536 + if page_size != expected_page_size: + raise ValueError(f"WAL page size {page_size} does not match database {expected_page_size}") + checksum = wal_checksum(header[:24], byteorder=checksum_order) + if checksum != struct.unpack(">II", header[24:32]): + raise ValueError("WAL header checksum mismatch") + + salt = header[16:24] + frame_size = WAL_FRAME_HEADER_SIZE + page_size + payload = data[WAL_HEADER_SIZE:] + available_frames = len(payload) // frame_size + trailing = len(payload) % frame_size + complete_frames = available_frames if max_frames is None else min(available_frames, max_frames) + if start_frame < 1 or start_frame > complete_frames + 1: + raise ValueError("WAL start frame is outside the available frame range") + if start_frame > 1: + prior_offset = WAL_HEADER_SIZE + (start_frame - 2) * frame_size + checksum = struct.unpack(">II", data[prior_offset + 16 : prior_offset + 24]) + frames: list[WalFrame] = [] + invalid_frames = 0 + for index in range(start_frame - 1, complete_frames): + offset = WAL_HEADER_SIZE + index * frame_size + frame_header = data[offset : offset + WAL_FRAME_HEADER_SIZE] + encrypted_page = data[offset + WAL_FRAME_HEADER_SIZE : offset + frame_size] + page_number, commit_pages = struct.unpack(">II", frame_header[:8]) + if page_number < 1 or frame_header[8:16] != salt: + invalid_frames = complete_frames - index + break + candidate = wal_checksum(frame_header[:8] + encrypted_page, checksum, byteorder=checksum_order) + if candidate != struct.unpack(">II", frame_header[16:24]): + invalid_frames = complete_frames - index + break + checksum = candidate + frames.append(WalFrame(index + 1, page_number, commit_pages, encrypted_page)) + return frames, trailing, invalid_frames + + +def committed_prefix(frames: list[WalFrame]) -> tuple[list[WalFrame], int | None]: + last_commit_index = -1 + database_pages: int | None = None + for index, frame in enumerate(frames): + if frame.database_pages_after_commit: + last_commit_index = index + database_pages = frame.database_pages_after_commit + return frames[: last_commit_index + 1], database_pages + + +def decrypt_database_with_wal( + database: Path, + wal: Path | None, + destination: Path, + key: bytes, + profile: CipherProfile = DEFAULT_PROFILE, +) -> WalReport: + """Atomically decrypt a DB and merge only the last valid committed WAL prefix.""" + database = Path(database) + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, staging_name = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".staging", dir=destination.parent + ) + os.close(descriptor) + staging = Path(staging_name) + staging.unlink() + try: + decrypt_database(database, staging, key, profile, run_integrity_check=False) + report = WalReport(present=False) + if wal is not None and Path(wal).is_file() and Path(wal).stat().st_size: + wal_path = Path(wal) + wal_size = wal_path.stat().st_size + frame_size = WAL_FRAME_HEADER_SIZE + profile.page_size + complete_wal_frames = max(0, (wal_size - WAL_HEADER_SIZE) // frame_size) + with wal_path.open("rb") as wal_source: + wal_header = wal_source.read(WAL_HEADER_SIZE) + shm_path = Path(str(database) + "-shm") + shm_state = ( + parse_shm( + shm_path, + profile.page_size, + wal_header[16:24], + complete_wal_frames, + ) + if shm_path.is_file() + else None + ) + start_frame = shm_state.backfill + 1 if shm_state is not None else 1 + max_frames = shm_state.max_frame if shm_state is not None else None + frames, trailing, invalid = parse_wal( + wal_path, + profile.page_size, + start_frame=start_frame, + max_frames=max_frames, + ) + if invalid: + raise ValueError(f"WAL contains {invalid} invalid complete frame(s)") + committed, database_pages = committed_prefix(frames) + if shm_state is not None: + if committed and database_pages != shm_state.database_pages: + raise ValueError("WAL commit size disagrees with validated SHM state") + if not committed: + database_pages = shm_state.database_pages + with database.open("rb") as source: + salt = source.read(16) + with staging.open("r+b") as clear: + for frame in committed: + page = decrypt_page( + frame.encrypted_page, + frame.page_number, + key, + salt, + profile, + ) + clear.seek((frame.page_number - 1) * profile.page_size) + clear.write(page) + if database_pages is not None: + clear.truncate(database_pages * profile.page_size) + clear.flush() + os.fsync(clear.fileno()) + report = WalReport( + present=True, + valid_frames=len(frames), + committed_frames=len(committed), + applied_frames=len(committed), + database_pages=database_pages, + ignored_trailing_bytes=trailing, + ignored_invalid_frames=invalid, + shm_validated=shm_state is not None, + checkpointed_frames=shm_state.backfill if shm_state is not None else 0, + ) + sqlite_integrity_check(staging) + os.replace(staging, destination) + return report + finally: + if staging.exists(): + staging.unlink() diff --git a/yichen-wechat-windows-vault/scripts/windows_memory.py b/yichen-wechat-windows-vault/scripts/windows_memory.py new file mode 100644 index 0000000..4d0a0cf --- /dev/null +++ b/yichen-wechat-windows-vault/scripts/windows_memory.py @@ -0,0 +1,420 @@ +"""Read-only Windows process-memory capture for SQLCipher keys. + +The implementation uses only PROCESS_QUERY_INFORMATION and PROCESS_VM_READ. +It never injects code, hooks functions, starts, suspends, or terminates Weixin. +""" + +from __future__ import annotations + +import ctypes +import ctypes.wintypes as wt +import hashlib +import os +import struct +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path + +from sqlcipher_codec import CipherProfile, DEFAULT_PROFILE, key_matches_page + + +PROCESS_QUERY_INFORMATION = 0x0400 +PROCESS_VM_READ = 0x0010 +MEM_COMMIT = 0x1000 +PAGE_GUARD = 0x100 +PAGE_NOACCESS = 0x01 +READABLE_MASK = 0x02 | 0x04 | 0x08 | 0x20 | 0x40 | 0x80 +TH32CS_SNAPPROCESS = 0x00000002 +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value +SCAN_CHUNK_SIZE = 16 * 1024 * 1024 +SCAN_OVERLAP = 192 +MAX_REGION_SIZE = 2 * 1024 * 1024 * 1024 + + +class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wt.DWORD), + ("cntUsage", wt.DWORD), + ("th32ProcessID", wt.DWORD), + ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wt.DWORD), + ("cntThreads", wt.DWORD), + ("th32ParentProcessID", wt.DWORD), + ("pcPriClassBase", wt.LONG), + ("dwFlags", wt.DWORD), + ("szExeFile", wt.WCHAR * 260), + ] + + +class MEMORY_BASIC_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BaseAddress", ctypes.c_void_p), + ("AllocationBase", ctypes.c_void_p), + ("AllocationProtect", wt.DWORD), + ("PartitionId", wt.WORD), + ("RegionSize", ctypes.c_size_t), + ("State", wt.DWORD), + ("Protect", wt.DWORD), + ("Type", wt.DWORD), + ] + + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) if os.name == "nt" else None +if kernel32 is not None: + kernel32.CreateToolhelp32Snapshot.argtypes = (wt.DWORD, wt.DWORD) + kernel32.CreateToolhelp32Snapshot.restype = wt.HANDLE + kernel32.Process32FirstW.argtypes = (wt.HANDLE, ctypes.POINTER(PROCESSENTRY32W)) + kernel32.Process32FirstW.restype = wt.BOOL + kernel32.Process32NextW.argtypes = (wt.HANDLE, ctypes.POINTER(PROCESSENTRY32W)) + kernel32.Process32NextW.restype = wt.BOOL + kernel32.OpenProcess.argtypes = (wt.DWORD, wt.BOOL, wt.DWORD) + kernel32.OpenProcess.restype = wt.HANDLE + kernel32.VirtualQueryEx.argtypes = ( + wt.HANDLE, + ctypes.c_void_p, + ctypes.POINTER(MEMORY_BASIC_INFORMATION), + ctypes.c_size_t, + ) + kernel32.VirtualQueryEx.restype = ctypes.c_size_t + kernel32.ReadProcessMemory.argtypes = ( + wt.HANDLE, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_size_t), + ) + kernel32.ReadProcessMemory.restype = wt.BOOL + kernel32.CloseHandle.argtypes = (wt.HANDLE,) + kernel32.CloseHandle.restype = wt.BOOL + + +@dataclass(frozen=True) +class DatabaseTarget: + database: str + path: Path + page: bytes + profile: CipherProfile = DEFAULT_PROFILE + + @property + def salt(self) -> bytes: + return self.page[:16] + + @classmethod + def from_path(cls, database: str, path: Path) -> "DatabaseTarget": + path = Path(path) + with path.open("rb") as stream: + page = stream.read(DEFAULT_PROFILE.page_size) + if len(page) != DEFAULT_PROFILE.page_size: + raise ValueError(f"database has no complete first page: {database}") + return cls(database=database, path=path, page=page) + + +@dataclass(frozen=True) +class Candidate: + target: DatabaseTarget + key_address: int + + +@dataclass(frozen=True) +class Match: + database: str + pid: int + key: bytes + profile: CipherProfile + key_kind: str = "sqlcipher-unshield-window" + + def safe_dict(self) -> dict: + return { + "database": self.database, + "pid": self.pid, + "key_fingerprint": hashlib.sha256(self.key).hexdigest()[:16], + "page_size": self.profile.page_size, + "reserve_size": self.profile.reserve_size, + "key_kind": self.key_kind, + } + + +@dataclass(frozen=True) +class CaptureReport: + pids_checked: int + codec_contexts: int + monitored_buffers: int + matches: tuple[Match, ...] + + def safe_dict(self) -> dict: + return { + "pids_checked": self.pids_checked, + "codec_contexts": self.codec_contexts, + "monitored_buffers": self.monitored_buffers, + "matched_databases": sorted(match.database for match in self.matches), + "matched_count": len(self.matches), + } + + +def _require_windows() -> None: + if kernel32 is None: + raise RuntimeError("Weixin process capture is available only on Windows") + + +def find_process_ids(executable: str = "Weixin.exe") -> list[int]: + _require_windows() + snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == INVALID_HANDLE_VALUE: + raise ctypes.WinError(ctypes.get_last_error()) + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(entry) + result: list[int] = [] + try: + more = kernel32.Process32FirstW(snapshot, ctypes.byref(entry)) + while more: + if entry.szExeFile.casefold() == executable.casefold(): + result.append(int(entry.th32ProcessID)) + more = kernel32.Process32NextW(snapshot, ctypes.byref(entry)) + finally: + kernel32.CloseHandle(snapshot) + return result + + +def _read_region(handle: int, address: int, size: int) -> bytes: + if not address or size <= 0: + return b"" + buffer = ctypes.create_string_buffer(size) + read = ctypes.c_size_t() + if not kernel32.ReadProcessMemory( + handle, + ctypes.c_void_p(address), + buffer, + size, + ctypes.byref(read), + ): + return b"" + return buffer.raw[: read.value] + + +def _readable_regions(handle: int) -> list[tuple[int, int]]: + result: list[tuple[int, int]] = [] + address = 0 + info = MEMORY_BASIC_INFORMATION() + while kernel32.VirtualQueryEx( + handle, + ctypes.c_void_p(address), + ctypes.byref(info), + ctypes.sizeof(info), + ): + base = int(info.BaseAddress or 0) + size = int(info.RegionSize) + if ( + info.State == MEM_COMMIT + and info.Protect & READABLE_MASK + and not info.Protect & (PAGE_GUARD | PAGE_NOACCESS) + and 0 < size <= MAX_REGION_SIZE + ): + result.append((base, size)) + next_address = base + max(size, 0x1000) + if next_address <= address: + break + address = next_address + return result + + +def _address_is_readable(address: int, regions: list[tuple[int, int]], size: int) -> bool: + return any(base <= address and address + size <= base + length for base, length in regions) + + +def _iter_region_data(handle: int, base: int, size: int): + offset = 0 + carry = b"" + while offset < size: + requested = min(SCAN_CHUNK_SIZE, size - offset) + current = _read_region(handle, base + offset, requested) + if not current: + offset += requested + carry = b"" + continue + combined = carry + current + yield base + offset - len(carry), combined + carry = combined[-SCAN_OVERLAP:] + offset += len(current) + + +def _codec_patterns(targets: list[DatabaseTarget]) -> tuple[bytes, ...]: + profiles = {(target.profile.page_size, target.profile.reserve_size) for target in targets} + patterns: list[bytes] = [] + for page_size, reserve_size in sorted(profiles): + hmac_size = 64 if reserve_size == 80 else 20 + for keyspec_size in (99, 163): + patterns.append( + struct.pack( + "<9I", + 16, + 32, + 16, + 16, + page_size, + keyspec_size, + reserve_size, + hmac_size, + 0, + ) + ) + return tuple(patterns) + + +def _find_candidates( + handle: int, + regions: list[tuple[int, int]], + targets: list[DatabaseTarget], +) -> tuple[list[Candidate], int]: + by_salt = {target.salt: target for target in targets} + context_bases: set[int] = set() + for base, size in regions: + for chunk_base, data in _iter_region_data(handle, base, size): + for pattern in _codec_patterns(targets): + cursor = data.find(pattern) + while cursor >= 0: + context_base = chunk_base + cursor - 12 + if _address_is_readable(context_base, regions, 136): + context_bases.add(context_base) + cursor = data.find(pattern, cursor + 1) + + candidates: dict[tuple[str, int], Candidate] = {} + matched_contexts = 0 + for context_base in context_bases: + codec = _read_region(handle, context_base, 136) + if len(codec) != 136: + continue + salt_address = struct.unpack_from(" tuple[Match, ...]: + if duration < 0: + raise ValueError("capture duration cannot be negative") + matches: dict[str, Match] = {} + lock = threading.Lock() + deadline = time.monotonic() + duration + stop = threading.Event() + + def validate(candidate: Candidate, key: bytes) -> None: + if len(key) != 32 or not key_matches_page(candidate.target.page, key, candidate.target.profile): + return + with lock: + matches.setdefault( + candidate.target.database, + Match( + database=candidate.target.database, + pid=pid, + key=key, + profile=candidate.target.profile, + ), + ) + if len(matches) == len({item.target.database for item in candidates}): + stop.set() + + for candidate in candidates: + validate(candidate, _read_region(handle, candidate.key_address, 32)) + if stop.is_set() or duration == 0: + return tuple(matches.values()) + + def worker(worker_index: int) -> None: + ordered = candidates[worker_index % len(candidates) :] + candidates[: worker_index % len(candidates)] + previous: dict[int, bytes] = {} + while not stop.is_set() and time.monotonic() < deadline: + for candidate in ordered: + if candidate.target.database in matches: + continue + key = _read_region(handle, candidate.key_address, 32) + if len(key) != 32 or previous.get(candidate.key_address) == key: + continue + previous[candidate.key_address] = key + validate(candidate, key) + + worker_count = min(8, max(2, len(candidates) * 2)) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [executor.submit(worker, index) for index in range(worker_count)] + for future in futures: + future.result() + return tuple(matches.values()) + + +def capture_keys( + targets: list[DatabaseTarget], + duration: float, + pids: list[int] | None = None, +) -> CaptureReport: + """Aggregate validated keys across every readable Weixin process.""" + _require_windows() + if not targets: + raise ValueError("at least one database target is required") + checked = 0 + readable: list[tuple[int, int, list[Candidate], int]] = [] + for pid in pids or find_process_ids(): + handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid) + if not handle: + continue + checked += 1 + try: + regions = _readable_regions(handle) + candidates, contexts = _find_candidates(handle, regions, targets) + except Exception: + kernel32.CloseHandle(handle) + for _pid, prior_handle, _candidates, _contexts in readable: + kernel32.CloseHandle(prior_handle) + raise + if candidates: + readable.append((pid, handle, candidates, contexts)) + else: + kernel32.CloseHandle(handle) + if not checked: + raise RuntimeError("no readable Weixin.exe process was found") + if not readable: + raise RuntimeError("no matching SQLCipher contexts were found in the running Weixin process") + + matches: dict[str, Match] = {} + try: + with ThreadPoolExecutor(max_workers=min(8, len(readable))) as executor: + futures = [ + executor.submit(_monitor_candidates, handle, pid, candidates, duration) + for pid, handle, candidates, _contexts in readable + ] + for future in futures: + for match in future.result(): + matches.setdefault(match.database, match) + return CaptureReport( + pids_checked=checked, + codec_contexts=sum(item[3] for item in readable), + monitored_buffers=sum(len(item[2]) for item in readable), + matches=tuple(matches.values()), + ) + finally: + for _pid, handle, _candidates, _contexts in readable: + kernel32.CloseHandle(handle) diff --git a/yichen-wechat-windows-vault/scripts/windows_vault.py b/yichen-wechat-windows-vault/scripts/windows_vault.py new file mode 100644 index 0000000..7eee320 --- /dev/null +++ b/yichen-wechat-windows-vault/scripts/windows_vault.py @@ -0,0 +1,542 @@ +"""Independent Windows Weixin local-vault capture and snapshot CLI.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import sys +import tempfile +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from secret_store import KeyStore, account_binding, fingerprint +from sqlcipher_codec import key_matches_page, sqlite_integrity_check +from wal_snapshot import decrypt_database_with_wal +from windows_memory import DatabaseTarget, capture_keys, find_process_ids + + +PRIVATE_ROOT = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "YichenWeChatVault" +DEFAULT_KEY_STORE = PRIVATE_ROOT / "keys/account.json" +DEFAULT_VAULT = PRIVATE_ROOT / "vault" +REQUIRED_DATABASES = { + "contact/contact.db", + "session/session.db", + "sns/sns.db", + "favorite/favorite.db", + "message/message_resource.db", +} +REQUIRED_MESSAGE_PATTERN = re.compile(r"^message/(?:message|biz_message)_\d+\.db$") + + +def discover_roots() -> list[Path]: + base = Path.home() / "Documents" / "xwechat_files" + roots: list[Path] = [] + if base.is_dir(): + for storage in base.glob("**/db_storage"): + if (storage / "contact/contact.db").is_file() and (storage / "message").is_dir(): + roots.append(storage.parent.resolve()) + return sorted(set(roots), key=lambda path: path.stat().st_mtime, reverse=True) + + +def choose_root(value: str | None) -> Path: + if value: + root = Path(value).expanduser().resolve() + if not (root / "db_storage").is_dir(): + raise ValueError("selected root does not contain db_storage") + return root + roots = discover_roots() + if len(roots) != 1: + raise RuntimeError(f"expected exactly one account root, found {len(roots)}; pass --root") + return roots[0] + + +def redact_root(root: Path) -> str: + return f"" + + +def ensure_paths_separate(output: Path, account_root: Path, label: str) -> None: + """Refuse output locations that contain or sit inside the source account tree.""" + output = Path(output).expanduser().resolve() + account_root = Path(account_root).expanduser().resolve() + if output.is_relative_to(account_root) or account_root.is_relative_to(output): + raise ValueError(f"{label} must be separate from the Weixin account tree") + + +def database_paths(root: Path) -> dict[str, Path]: + storage = root / "db_storage" + result: dict[str, Path] = {} + for path in sorted(storage.glob("**/*.db")): + if path.stat().st_size >= 4096: + result[path.relative_to(storage).as_posix()] = path + return result + + +def database_is_required(relative: str) -> bool: + """Required databases provide the Mac-equivalent documented query surface.""" + return relative in REQUIRED_DATABASES or bool(REQUIRED_MESSAGE_PATTERN.fullmatch(relative)) + + +def select_databases(root: Path, expression: str) -> list[DatabaseTarget]: + available = database_paths(root) + requested = list(available) if expression.strip().casefold() == "all" else [ + item.strip().replace("\\", "/") for item in expression.split(",") if item.strip() + ] + unknown = sorted(set(requested) - set(available)) + if unknown: + raise ValueError(f"unknown database path(s): {', '.join(unknown)}") + return [DatabaseTarget.from_path(relative, available[relative]) for relative in requested] + + +def diagnostics(root: Path | None = None) -> dict: + roots = [root] if root else discover_roots() + return { + "platform": sys.platform, + "weixin_processes": len(find_process_ids()) if os.name == "nt" else 0, + "accounts": [ + { + "root": redact_root(item), + "database_count": len(database_paths(item)), + "wal_count": len(list((item / "db_storage").glob("**/*.db-wal"))), + } + for item in roots + ], + "private_vault": r"%LOCALAPPDATA%\YichenWeChatVault", + } + + +def capture( + root: Path, + targets_expression: str, + duration: float, + key_store_path: Path, + consent: bool, +) -> dict: + if not consent: + raise PermissionError( + "capture requires --consent-read-process-memory after the user explicitly approves this scope" + ) + ensure_paths_separate(key_store_path, root, "key store") + targets = select_databases(root, targets_expression) + store = KeyStore(key_store_path, root) + pending: list[DatabaseTarget] = [] + reused: list[str] = [] + for target in targets: + try: + key, profile = store.get(target.database) + except (KeyError, ValueError): + pending.append(target) + continue + if key_matches_page(target.page, key, profile): + reused.append(target.database) + else: + pending.append(target) + + if pending: + report = capture_keys(pending, duration) + by_database = {target.database: target for target in pending} + for match in report.matches: + target = by_database[match.database] + store.put( + match.database, + match.key, + target.salt, + match.profile, + match.key_kind, + ) + safe = report.safe_dict() + else: + safe = { + "pids_checked": 0, + "codec_contexts": 0, + "monitored_buffers": 0, + "matched_databases": [], + "matched_count": 0, + } + stored = sorted(store.metadata()) + return { + "account": redact_root(root), + "requested": len(targets), + "reused": sorted(reused), + **safe, + "stored_databases": stored, + "missing_databases": sorted(target.database for target in targets if target.database not in stored), + "key_store": str(key_store_path), + "keys_redacted": True, + } + + +def _source_set(database: Path) -> list[Path]: + result = [database] + for suffix in ("-wal", "-shm"): + companion = Path(str(database) + suffix) + if companion.exists(): + result.append(companion) + return result + + +def _metadata(paths: list[Path]) -> dict[str, tuple[int, int]]: + return {path.name: (path.stat().st_size, path.stat().st_mtime_ns) for path in paths} + + +def stable_copy_set(database: Path, destination: Path, retries: int = 5) -> list[Path]: + """Copy DB/WAL/SHM as one stable set without replacing source files.""" + destination.parent.mkdir(parents=True, exist_ok=True) + for attempt in range(retries): + sources = _source_set(database) + before = _metadata(sources) + copied: list[Path] = [] + try: + for source in sources: + suffix = source.name[len(database.name) :] + target = Path(str(destination) + suffix) + temporary = Path(str(target) + ".tmp") + copied.append(temporary) + shutil.copyfile(source, temporary) + except OSError: + for temporary in copied: + temporary.unlink(missing_ok=True) + raise + after_sources = _source_set(database) + after = _metadata(after_sources) + if before == after and [path.name for path in sources] == [path.name for path in after_sources]: + finals: list[Path] = [] + for temporary in copied: + final = Path(str(temporary)[:-4]) + os.replace(temporary, final) + finals.append(final) + return finals + for temporary in copied: + temporary.unlink(missing_ok=True) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"source set changed while copying: {database.name}") + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def encrypted_file_records(database: Path, copied: list[Path]) -> list[dict]: + """Describe a copied DB/WAL/SHM set without exposing account paths.""" + records: list[dict] = [] + for path in copied: + suffix = path.name[len(database.name) :] + records.append( + { + "kind": "database" if not suffix else suffix.removeprefix("-"), + "bytes": path.stat().st_size, + "sha256": file_sha256(path), + } + ) + return records + + +def _load_previous_snapshot( + vault: Path, expected_account_binding: str +) -> tuple[dict[str, dict], Path | None]: + """Load only the vault-owned current snapshot metadata for incremental reuse.""" + current_path = Path(vault) / "current.json" + if not current_path.is_file(): + return {}, None + try: + current = json.loads(current_path.read_text(encoding="utf-8")) + vault_root = Path(vault).resolve() + manifest_path = Path(current["manifest"]).resolve() + decrypted_root = Path(current["decrypted_dir"]).resolve() + manifest_path.relative_to(vault_root) + decrypted_root.relative_to(vault_root) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("account_binding") != expected_account_binding: + return {}, None + records = { + record["database"]: record + for record in manifest.get("records", []) + if record.get("status") == "ok" + } + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + return {}, None + return records, decrypted_root + + +def _copy_plaintext_atomic(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + shutil.copyfile(source, temporary) + sqlite_integrity_check(temporary) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + + +def _write_json_atomic(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def refresh(root: Path, key_store_path: Path, vault: Path, mode: str = "incremental") -> dict: + if mode not in {"full", "incremental"}: + raise ValueError("refresh mode must be 'full' or 'incremental'") + ensure_paths_separate(key_store_path, root, "key store") + ensure_paths_separate(vault, root, "vault") + if find_process_ids(): + raise RuntimeError( + "Weixin is running; exit it manually before refresh so DB/WAL/SHM form a stable snapshot" + ) + store = KeyStore(key_store_path, root) + databases = database_paths(root) + if not databases: + raise RuntimeError("no databases found under selected root") + + binding = account_binding(root) + previous_records, previous_decrypted_root = ( + _load_previous_snapshot(vault, binding) if mode == "incremental" else ({}, None) + ) + generation = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8] + generation_root = Path(vault) / "generations" / generation + encrypted_root = generation_root / "encrypted/db_storage" + decrypted_root = generation_root / "decrypted/db_storage" + manifest_path = generation_root / "manifest.json" + records: list[dict] = [] + manifest = { + "format": 1, + "generation": generation, + "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "account_binding": binding, + "mode": mode, + "complete": False, + "privacy": { + "contains_plaintext_weixin_data": True, + "local_only": True, + "do_not_sync_or_commit": True, + }, + "records": records, + } + _write_json_atomic(manifest_path, manifest) + for relative, source in databases.items(): + encrypted = encrypted_root / relative + try: + copied = stable_copy_set(source, encrypted) + except (OSError, RuntimeError) as error: + records.append( + { + "database": relative, + "required": database_is_required(relative), + "status": "snapshot-copy-failed", + "reason": type(error).__name__, + } + ) + continue + record = { + "database": relative, + "required": database_is_required(relative), + "encrypted_sha256": file_sha256(encrypted), + "companions": [path.name[len(encrypted.name) :] for path in copied[1:]], + "encrypted_files": encrypted_file_records(encrypted, copied), + } + try: + key, profile = store.get(relative) + except (KeyError, ValueError) as error: + record.update({"status": "missing-key", "reason": type(error).__name__}) + records.append(record) + continue + with encrypted.open("rb") as stream: + first_page = stream.read(profile.page_size) + if not key_matches_page(first_page, key, profile): + record.update({"status": "stale-key"}) + records.append(record) + continue + destination = decrypted_root / relative + key_fingerprint = fingerprint(key) + previous = previous_records.get(relative) + previous_plaintext = ( + previous_decrypted_root / relative if previous_decrypted_root is not None else None + ) + if ( + mode == "incremental" + and previous is not None + and previous.get("encrypted_files") == record["encrypted_files"] + and previous.get("key_fingerprint") == key_fingerprint + and previous_plaintext is not None + and previous_plaintext.is_file() + ): + try: + _copy_plaintext_atomic(previous_plaintext, destination) + except (OSError, ValueError): + pass + else: + record.update( + { + "status": "ok", + "key_fingerprint": key_fingerprint, + "decrypted_bytes": destination.stat().st_size, + "decrypted_sha256": file_sha256(destination), + "integrity": "ok", + "incremental_reuse": True, + "wal": previous.get("wal", {"present": False, "applied_frames": 0}), + } + ) + records.append(record) + continue + wal = Path(str(encrypted) + "-wal") + try: + wal_report = decrypt_database_with_wal( + encrypted, + wal if wal.exists() else None, + destination, + key, + profile, + ) + except (OSError, RuntimeError, ValueError, sqlite3.DatabaseError) as error: + record.update({"status": "decode-failed", "reason": type(error).__name__}) + records.append(record) + continue + record.update( + { + "status": "ok", + "key_fingerprint": key_fingerprint, + "decrypted_bytes": destination.stat().st_size, + "decrypted_sha256": file_sha256(destination), + "integrity": "ok", + "incremental_reuse": False, + "wal": wal_report.as_dict(), + } + ) + records.append(record) + required_failures = [ + record for record in records if record.get("required") and record["status"] != "ok" + ] + optional_failures = [ + record for record in records if not record.get("required") and record["status"] != "ok" + ] + manifest["complete"] = not required_failures + manifest["all_databases_decrypted"] = all(record["status"] == "ok" for record in records) + manifest["database_count"] = len(records) + manifest["decrypted_count"] = sum(record["status"] == "ok" for record in records) + manifest["missing_count"] = sum(record["status"] != "ok" for record in records) + manifest["required_missing_count"] = len(required_failures) + manifest["optional_missing_count"] = len(optional_failures) + manifest["required_missing_databases"] = [record["database"] for record in required_failures] + manifest["optional_missing_databases"] = [record["database"] for record in optional_failures] + _write_json_atomic(manifest_path, manifest) + if manifest["complete"]: + _write_json_atomic( + Path(vault) / "current.json", + { + "format": 1, + "generation": generation, + "manifest": str(manifest_path), + "decrypted_dir": str(decrypted_root), + }, + ) + return { + "generation": generation, + "complete": manifest["complete"], + "database_count": manifest["database_count"], + "decrypted_count": manifest["decrypted_count"], + "missing_count": manifest["missing_count"], + "required_missing_count": manifest["required_missing_count"], + "optional_missing_count": manifest["optional_missing_count"], + "optional_missing_databases": manifest["optional_missing_databases"], + "manifest": str(manifest_path), + "decrypted_dir": str(decrypted_root), + } + + +def status(root: Path, key_store_path: Path, vault: Path) -> dict: + available = database_paths(root) + try: + stored = KeyStore(key_store_path, root).metadata() + except (FileNotFoundError, ValueError): + stored = {} + current_path = Path(vault) / "current.json" + current = json.loads(current_path.read_text(encoding="utf-8")) if current_path.exists() else None + return { + "account": redact_root(root), + "databases": len(available), + "stored_keys": len(stored), + "missing_keys": sorted(set(available) - set(stored)), + "current_snapshot": current, + "weixin_running": bool(find_process_ids()), + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Independent Windows Weixin local vault") + sub = parser.add_subparsers(dest="command", required=True) + diagnose = sub.add_parser("diagnose") + diagnose.add_argument("--root") + capture_parser = sub.add_parser("capture") + capture_parser.add_argument("--root") + capture_parser.add_argument("--targets", default="all") + capture_parser.add_argument("--duration", type=float, default=120.0) + capture_parser.add_argument("--key-store", default=str(DEFAULT_KEY_STORE)) + capture_parser.add_argument("--consent-read-process-memory", action="store_true") + refresh_parser = sub.add_parser("refresh") + refresh_parser.add_argument("--root") + refresh_parser.add_argument("--key-store", default=str(DEFAULT_KEY_STORE)) + refresh_parser.add_argument("--vault", default=str(DEFAULT_VAULT)) + refresh_parser.add_argument("--mode", choices=("full", "incremental"), default="incremental") + status_parser = sub.add_parser("status") + status_parser.add_argument("--root") + status_parser.add_argument("--key-store", default=str(DEFAULT_KEY_STORE)) + status_parser.add_argument("--vault", default=str(DEFAULT_VAULT)) + return parser + + +def main(argv: list[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + args = build_parser().parse_args(argv) + if args.command == "diagnose": + selected = choose_root(args.root) if args.root else None + print(json.dumps(diagnostics(selected), ensure_ascii=False, indent=2)) + return 0 + root = choose_root(args.root) + if args.command == "capture": + result = capture( + root, + args.targets, + args.duration, + Path(args.key_store), + args.consent_read_process_memory, + ) + elif args.command == "refresh": + result = refresh(root, Path(args.key_store), Path(args.vault), args.mode) + elif args.command == "status": + result = status(root, Path(args.key_store), Path(args.vault)) + else: + return 2 + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yichen-wechat-windows-vault/tests/test_codec_wal.py b/yichen-wechat-windows-vault/tests/test_codec_wal.py new file mode 100644 index 0000000..d6aac08 --- /dev/null +++ b/yichen-wechat-windows-vault/tests/test_codec_wal.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import hashlib +import hmac +import ctypes +import json +import os +import sqlite3 +import struct +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch +from pathlib import Path + + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from sqlcipher_codec import ( # noqa: E402 + DEFAULT_PROFILE, + derive_hmac_key, + key_matches_page, + sqlite_integrity_check, +) +from wal_snapshot import ( # noqa: E402 + WAL_MAGIC_LITTLE_ENDIAN_CHECKSUM, + decrypt_database_with_wal, + parse_wal, + wal_checksum, +) +import windows_vault # noqa: E402 +import windows_memory # noqa: E402 + + +def encrypt_page(clear_page: bytes, page_number: int, key: bytes, salt: bytes) -> bytes: + profile = DEFAULT_PROFILE + clear = bytearray(clear_page) + clear[profile.reserve_offset :] = b"\x00" * profile.reserve_size + if page_number == 1: + clear[20] = profile.reserve_size + start = profile.encrypted_offset(page_number) + iv = hashlib.sha256(b"iv" + page_number.to_bytes(4, "little")).digest()[:16] + encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor() + ciphertext = encryptor.update(bytes(clear[start : profile.reserve_offset])) + encryptor.finalize() + encrypted = bytearray(profile.page_size) + if page_number == 1: + encrypted[:16] = salt + encrypted[start : profile.reserve_offset] = ciphertext + encrypted[profile.reserve_offset : profile.hmac_offset] = iv + message = bytes(encrypted[start : profile.hmac_offset]) + page_number.to_bytes(4, "little") + digest = hmac.new(derive_hmac_key(key, salt), message, hashlib.sha512).digest() + encrypted[profile.hmac_offset : profile.hmac_offset + profile.hmac_size] = digest + return bytes(encrypted) + + +def encrypt_database(clear: Path, encrypted: Path, key: bytes, salt: bytes) -> None: + data = clear.read_bytes() + if len(data) % DEFAULT_PROFILE.page_size: + raise AssertionError("fixture database is not page aligned") + output = bytearray() + for index in range(len(data) // DEFAULT_PROFILE.page_size): + page = data[index * DEFAULT_PROFILE.page_size : (index + 1) * DEFAULT_PROFILE.page_size] + output.extend(encrypt_page(page, index + 1, key, salt)) + encrypted.write_bytes(output) + + +def build_wal(frames: list[tuple[int, int, bytes]]) -> bytes: + page_size = DEFAULT_PROFILE.page_size + header = bytearray( + struct.pack( + ">IIIIII", + WAL_MAGIC_LITTLE_ENDIAN_CHECKSUM, + 3_007_000, + page_size, + 0, + 0x12345678, + 0x90ABCDEF, + ) + ) + checksum = wal_checksum(bytes(header), byteorder="little") + header.extend(struct.pack(">II", *checksum)) + output = bytearray(header) + salt = header[16:24] + for page_number, commit_pages, encrypted_page in frames: + frame_header = bytearray(struct.pack(">II", page_number, commit_pages) + salt) + checksum = wal_checksum(bytes(frame_header[:8]) + encrypted_page, checksum, byteorder="little") + frame_header.extend(struct.pack(">II", *checksum)) + output.extend(frame_header) + output.extend(encrypted_page) + return bytes(output) + + +def build_shm(wal: bytes, max_frame: int, database_pages: int, backfill: int) -> bytes: + prefix = "<" if sys.byteorder == "little" else ">" + header = bytearray(48) + struct.pack_into(f"{prefix}I", header, 0, 3_007_000) + header[12] = 1 + header[13] = int(sys.byteorder == "big") + struct.pack_into(f"{prefix}H", header, 14, DEFAULT_PROFILE.page_size) + struct.pack_into(f"{prefix}I", header, 16, max_frame) + struct.pack_into(f"{prefix}I", header, 20, database_pages) + header[32:40] = wal[16:24] + struct.pack_into( + f"{prefix}II", + header, + 40, + *wal_checksum(bytes(header[:40]), byteorder=sys.byteorder), + ) + checkpoint = bytearray(40) + struct.pack_into(f"{prefix}I", checkpoint, 0, backfill) + return bytes(header + header + checkpoint) + + +class CodecWalTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.key = bytes(range(32)) + self.salt = bytes(range(16, 32)) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def make_database(self, path: Path, rows: list[str]) -> None: + if os.name != "nt": + self.skipTest("fixture uses Windows sqlite3_file_control") + sqlite = ctypes.WinDLL(str(Path(sys.base_prefix) / "DLLs/sqlite3.dll")) + sqlite.sqlite3_open.argtypes = (ctypes.c_char_p, ctypes.POINTER(ctypes.c_void_p)) + sqlite.sqlite3_open.restype = ctypes.c_int + sqlite.sqlite3_exec.argtypes = ( + ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_char_p), + ) + sqlite.sqlite3_exec.restype = ctypes.c_int + sqlite.sqlite3_file_control.argtypes = ( + ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_void_p, + ) + sqlite.sqlite3_file_control.restype = ctypes.c_int + sqlite.sqlite3_close.argtypes = (ctypes.c_void_p,) + handle = ctypes.c_void_p() + self.assertEqual(sqlite.sqlite3_open(os.fsencode(path), ctypes.byref(handle)), 0) + try: + error = ctypes.c_char_p() + self.assertEqual( + sqlite.sqlite3_exec(handle, b"PRAGMA page_size=4096;", None, None, ctypes.byref(error)), + 0, + ) + reserve = ctypes.c_int(80) + self.assertEqual(sqlite.sqlite3_file_control(handle, b"main", 38, ctypes.byref(reserve)), 0) + values = ",".join("('" + row.replace("'", "''") + "')" for row in rows) + sql = ( + "CREATE TABLE items(id INTEGER PRIMARY KEY, payload TEXT NOT NULL);" + f"INSERT INTO items(payload) VALUES {values};" + ).encode("utf-8") + self.assertEqual(sqlite.sqlite3_exec(handle, sql, None, None, ctypes.byref(error)), 0) + finally: + sqlite.sqlite3_close(handle) + self.assertEqual(path.read_bytes()[20], 80) + sqlite_integrity_check(path) + + def test_database_key_and_hmac_validation(self) -> None: + clear = self.root / "clear.db" + encrypted = self.root / "encrypted.db" + output = self.root / "output.db" + self.make_database(clear, ["base"]) + encrypt_database(clear, encrypted, self.key, self.salt) + first_page = encrypted.read_bytes()[:4096] + self.assertTrue(key_matches_page(first_page, self.key)) + tampered = bytearray(first_page) + tampered[100] ^= 1 + self.assertFalse(key_matches_page(bytes(tampered), self.key)) + report = decrypt_database_with_wal(encrypted, None, output, self.key) + self.assertFalse(report.present) + connection = sqlite3.connect(output) + try: + self.assertEqual(connection.execute("SELECT payload FROM items").fetchall(), [("base",)]) + finally: + connection.close() + + def test_wal_only_commit_is_applied_and_trailing_bytes_are_ignored(self) -> None: + base_clear = self.root / "base-clear.db" + updated_clear = self.root / "updated-clear.db" + encrypted = self.root / "encrypted.db" + wal = self.root / "encrypted.db-wal" + output = self.root / "output.db" + self.make_database(base_clear, ["base"]) + shutil_data = base_clear.read_bytes() + updated_clear.write_bytes(shutil_data) + connection = sqlite3.connect(updated_clear) + try: + connection.execute("INSERT INTO items(payload) VALUES ('wal-only')") + connection.commit() + finally: + connection.close() + encrypt_database(base_clear, encrypted, self.key, self.salt) + + base_data = base_clear.read_bytes() + updated_data = updated_clear.read_bytes() + page_count = len(updated_data) // DEFAULT_PROFILE.page_size + changed: list[tuple[int, int, bytes]] = [] + differing_pages = [ + index + 1 + for index in range(page_count) + if base_data[index * 4096 : (index + 1) * 4096] + != updated_data[index * 4096 : (index + 1) * 4096] + ] + self.assertTrue(differing_pages) + for position, page_number in enumerate(differing_pages): + clear_page = updated_data[(page_number - 1) * 4096 : page_number * 4096] + commit_pages = page_count if position == len(differing_pages) - 1 else 0 + changed.append((page_number, commit_pages, encrypt_page(clear_page, page_number, self.key, self.salt))) + wal.write_bytes(build_wal(changed) + b"partial-tail") + + parsed, trailing, invalid = parse_wal(wal, 4096) + self.assertEqual(len(parsed), len(changed)) + self.assertEqual(trailing, len(b"partial-tail")) + self.assertEqual(invalid, 0) + report = decrypt_database_with_wal(encrypted, wal, output, self.key) + self.assertEqual(report.applied_frames, len(changed)) + self.assertEqual(report.ignored_trailing_bytes, len(b"partial-tail")) + connection = sqlite3.connect(output) + try: + rows = connection.execute("SELECT payload FROM items ORDER BY id").fetchall() + finally: + connection.close() + self.assertEqual(rows, [("base",), ("wal-only",)]) + + def test_wal_checksum_rejects_corrupted_frame(self) -> None: + clear = self.root / "clear.db" + encrypted = self.root / "encrypted.db" + wal = self.root / "encrypted.db-wal" + self.make_database(clear, ["base"]) + encrypt_database(clear, encrypted, self.key, self.salt) + page = encrypted.read_bytes()[:4096] + data = bytearray(build_wal([(1, len(encrypted.read_bytes()) // 4096, page)])) + data[-1] ^= 1 + wal.write_bytes(data) + frames, trailing, invalid = parse_wal(wal, 4096) + self.assertEqual(frames, []) + self.assertEqual(trailing, 0) + self.assertEqual(invalid, 1) + with self.assertRaisesRegex(ValueError, "invalid complete frame"): + decrypt_database_with_wal(encrypted, wal, self.root / "rejected.db", self.key) + + def test_valid_shm_ignores_stale_frames_after_wal_index_reset(self) -> None: + clear = self.root / "clear.db" + encrypted = self.root / "encrypted.db" + wal = self.root / "encrypted.db-wal" + shm = self.root / "encrypted.db-shm" + output = self.root / "output.db" + self.make_database(clear, ["base"]) + encrypt_database(clear, encrypted, self.key, self.salt) + database_pages = encrypted.stat().st_size // DEFAULT_PROFILE.page_size + stale = bytearray(build_wal([(1, database_pages, encrypted.read_bytes()[:4096])])) + stale[-1] ^= 1 + wal.write_bytes(stale) + shm.write_bytes(build_shm(stale, max_frame=0, database_pages=database_pages, backfill=0)) + + report = decrypt_database_with_wal(encrypted, wal, output, self.key) + + self.assertTrue(report.shm_validated) + self.assertEqual(report.applied_frames, 0) + connection = sqlite3.connect(output) + try: + rows = connection.execute("SELECT payload FROM items ORDER BY id").fetchall() + finally: + connection.close() + self.assertEqual(rows, [("base",)]) + + def test_incremental_refresh_reuses_only_unchanged_verified_plaintext(self) -> None: + clear = self.root / "clear.db" + encrypted = self.root / "contact.db" + account_root = self.root / "account" + vault = self.root / "vault" + account_root.mkdir() + self.make_database(clear, ["base"]) + encrypt_database(clear, encrypted, self.key, self.salt) + key_store = MagicMock() + + def get_key(relative: str): + if relative in {"contact/contact.db", "general/general.db"}: + return self.key, DEFAULT_PROFILE + raise KeyError(relative) + + key_store.get.side_effect = get_key + databases = { + "contact/contact.db": encrypted, + "general/general.db": encrypted, + } + + real_decrypt = windows_vault.decrypt_database_with_wal + + def decrypt_or_raise(database, wal, destination, key, profile): + if destination.as_posix().endswith("general/general.db"): + raise sqlite3.OperationalError("fixture codec extension is unavailable") + return real_decrypt(database, wal, destination, key, profile) + + with ( + patch.object(windows_vault, "find_process_ids", return_value=[]), + patch.object(windows_vault, "database_paths", return_value=databases), + patch.object(windows_vault, "KeyStore", return_value=key_store), + patch.object( + windows_vault, + "decrypt_database_with_wal", + side_effect=decrypt_or_raise, + ), + ): + first = windows_vault.refresh(account_root, self.root / "keys.json", vault, "full") + second = windows_vault.refresh( + account_root, self.root / "keys.json", vault, "incremental" + ) + second_manifest = json.loads(Path(second["manifest"]).read_text(encoding="utf-8")) + self.assertTrue(first["complete"]) + self.assertEqual(first["optional_missing_count"], 1) + contact_record = next( + record + for record in second_manifest["records"] + if record["database"] == "contact/contact.db" + ) + self.assertTrue(contact_record["incremental_reuse"]) + + updated = self.root / "updated.db" + self.make_database(updated, ["changed"]) + encrypt_database(updated, encrypted, self.key, self.salt) + third = windows_vault.refresh( + account_root, self.root / "keys.json", vault, "incremental" + ) + third_manifest = json.loads(Path(third["manifest"]).read_text(encoding="utf-8")) + contact_record = next( + record + for record in third_manifest["records"] + if record["database"] == "contact/contact.db" + ) + self.assertFalse(contact_record["incremental_reuse"]) + + def test_missing_required_database_key_prevents_snapshot_promotion(self) -> None: + clear = self.root / "clear.db" + encrypted = self.root / "encrypted.db" + account_root = self.root / "account" + vault = self.root / "vault" + account_root.mkdir() + self.make_database(clear, ["required"]) + encrypt_database(clear, encrypted, self.key, self.salt) + key_store = MagicMock() + + def get_key(relative: str): + if relative == "contact/contact.db": + return self.key, DEFAULT_PROFILE + raise KeyError(relative) + + key_store.get.side_effect = get_key + databases = { + "contact/contact.db": encrypted, + "session/session.db": encrypted, + } + with ( + patch.object(windows_vault, "find_process_ids", return_value=[]), + patch.object(windows_vault, "database_paths", return_value=databases), + patch.object(windows_vault, "KeyStore", return_value=key_store), + ): + result = windows_vault.refresh(account_root, self.root / "keys.json", vault, "full") + + self.assertFalse(result["complete"]) + self.assertEqual(result["required_missing_count"], 1) + self.assertFalse((vault / "current.json").exists()) + + def test_public_codec_geometry_finds_and_validates_a_read_only_key_buffer(self) -> None: + clear = self.root / "clear.db" + encrypted = self.root / "message_0.db" + self.make_database(clear, ["scanner"]) + encrypt_database(clear, encrypted, self.key, self.salt) + target = windows_memory.DatabaseTarget.from_path("message/message_0.db", encrypted) + + context_address = 0x2000 + salt_address = 0x2800 + cipher_address = 0x3000 + key_address = 0x3800 + codec = bytearray(136) + pattern = windows_memory._codec_patterns([target])[0] + codec[12 : 12 + len(pattern)] = pattern + struct.pack_into(" bytes: + return memory.get((address, size), b"") + + regions = [(0x1000, 0x4000)] + with ( + patch.object( + windows_memory, + "_iter_region_data", + return_value=[(context_address, bytes(codec))], + ), + patch.object(windows_memory, "_read_region", side_effect=read_memory), + ): + candidates, contexts = windows_memory._find_candidates(1, regions, [target]) + matches = windows_memory._monitor_candidates(1, 1234, candidates, 0) + + self.assertEqual(contexts, 1) + self.assertEqual(len(candidates), 1) + self.assertEqual(candidates[0].key_address, key_address) + self.assertEqual([match.database for match in matches], ["message/message_0.db"]) + + def test_capture_aggregates_validated_matches_from_multiple_weixin_processes(self) -> None: + page = b"x" * DEFAULT_PROFILE.page_size + first = windows_memory.DatabaseTarget("first.db", self.root / "first.db", page) + second = windows_memory.DatabaseTarget("second.db", self.root / "second.db", page) + candidate_by_pid = { + 101: [windows_memory.Candidate(first, 0x1000)], + 202: [windows_memory.Candidate(second, 0x2000)], + } + fake_kernel32 = MagicMock() + fake_kernel32.OpenProcess.side_effect = lambda _rights, _inherit, pid: pid + + def find_candidates(handle, _regions, _targets): + return candidate_by_pid[handle], 1 + + def monitor(_handle, pid, candidates, _duration): + target = candidates[0].target + return ( + windows_memory.Match( + database=target.database, + pid=pid, + key=self.key, + profile=DEFAULT_PROFILE, + ), + ) + + with ( + patch.object(windows_memory, "kernel32", fake_kernel32), + patch.object(windows_memory, "find_process_ids", return_value=[101, 202]), + patch.object(windows_memory, "_readable_regions", return_value=[(0x1000, 0x4000)]), + patch.object(windows_memory, "_find_candidates", side_effect=find_candidates), + patch.object(windows_memory, "_monitor_candidates", side_effect=monitor), + ): + report = windows_memory.capture_keys([first, second], 0) + + self.assertEqual(report.pids_checked, 2) + self.assertEqual(report.codec_contexts, 2) + self.assertEqual(report.monitored_buffers, 2) + self.assertEqual( + sorted(match.database for match in report.matches), + ["first.db", "second.db"], + ) + self.assertEqual(fake_kernel32.CloseHandle.call_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/yichen-wechat-windows-vault/tests/test_query_cli.py b/yichen-wechat-windows-vault/tests/test_query_cli.py new file mode 100644 index 0000000..deb92c3 --- /dev/null +++ b/yichen-wechat-windows-vault/tests/test_query_cli.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import ast +import hashlib +import json +import os +import sqlite3 +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CLI = ROOT / "scripts/vault_cli.py" +sys.path.insert(0, str(ROOT / "scripts")) + +import vault_cli # noqa: E402 + + +class QueryCliTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.decrypted = Path(self.temporary.name) / "decrypted" + (self.decrypted / "contact").mkdir(parents=True) + (self.decrypted / "session").mkdir(parents=True) + (self.decrypted / "message").mkdir(parents=True) + (self.decrypted / "favorite").mkdir(parents=True) + (self.decrypted / "sns").mkdir(parents=True) + self.local_app_data = Path(self.temporary.name) / "local-app-data" + self.username = "fixture-user" + self.group_username = "fixture-group@chatroom" + self.member_username = "fixture-member" + self.timestamp = int(time.time()) + + connection = sqlite3.connect(self.decrypted / "contact/contact.db") + try: + connection.execute( + "CREATE TABLE contact(id INTEGER, username TEXT, nick_name TEXT, remark TEXT, alias TEXT)" + ) + connection.execute( + "INSERT INTO contact VALUES (1, ?, 'Fixture Nick', 'Fixture Friend', 'fixture')", + (self.username,), + ) + connection.execute( + "INSERT INTO contact VALUES (2, ?, 'Fixture Group', '', '')", + (self.group_username,), + ) + connection.execute( + "INSERT INTO contact VALUES (3, ?, 'Fixture Member', '', '')", + (self.member_username,), + ) + connection.execute("CREATE TABLE chat_room(id INTEGER, owner TEXT)") + connection.execute( + "INSERT INTO chat_room VALUES (2, ?)", + (self.username,), + ) + connection.execute("CREATE TABLE chatroom_member(room_id INTEGER, member_id INTEGER)") + connection.executemany( + "INSERT INTO chatroom_member VALUES (2, ?)", + [(1,), (3,)], + ) + connection.commit() + finally: + connection.close() + + connection = sqlite3.connect(self.decrypted / "session/session.db") + try: + connection.execute( + "CREATE TABLE SessionTable(username TEXT, unread_count INTEGER, summary TEXT, " + "last_timestamp INTEGER, last_msg_type INTEGER, last_msg_sender TEXT, " + "last_sender_display_name TEXT)" + ) + connection.execute( + "INSERT INTO SessionTable VALUES (?, 1, 'fixture hello', ?, 1, ?, 'Fixture Friend')", + (self.username, self.timestamp, self.username), + ) + connection.execute( + "INSERT INTO SessionTable VALUES (?, 2, 'group fixture hello', ?, 1, ?, 'Fixture Friend')", + (self.group_username, self.timestamp, self.username), + ) + connection.commit() + finally: + connection.close() + + table = "Msg_" + hashlib.md5(self.username.encode()).hexdigest() + group_table = "Msg_" + hashlib.md5(self.group_username.encode()).hexdigest() + connection = sqlite3.connect(self.decrypted / "message/message_0.db") + try: + connection.execute("CREATE TABLE Name2Id(user_name TEXT)") + connection.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (1, ?)", (self.username,)) + connection.execute( + "INSERT INTO Name2Id(rowid, user_name) VALUES (2, ?)", (self.member_username,) + ) + connection.execute( + f"CREATE TABLE [{table}](local_id INTEGER, server_id INTEGER, local_type INTEGER, " + "create_time INTEGER, real_sender_id INTEGER, message_content TEXT)" + ) + connection.execute( + f"INSERT INTO [{table}] VALUES (1, 10, 1, ?, 2, 'fixture hello')", + (self.timestamp,), + ) + connection.execute( + f"CREATE TABLE [{group_table}](local_id INTEGER, server_id INTEGER, local_type INTEGER, " + "create_time INTEGER, real_sender_id INTEGER, message_content TEXT)" + ) + connection.execute( + f"INSERT INTO [{group_table}] VALUES (2, 20, 1, ?, 1, 'group fixture hello')", + (self.timestamp,), + ) + connection.commit() + finally: + connection.close() + + connection = sqlite3.connect(self.decrypted / "favorite/favorite.db") + try: + connection.execute( + "CREATE TABLE fav_db_item(local_id INTEGER, type INTEGER, update_time INTEGER, " + "content TEXT, fromusr TEXT, realchatname TEXT)" + ) + favorite_xml = ( + "Fixture Article" + "fixture favorite" + ) + connection.execute( + "INSERT INTO fav_db_item VALUES (1, 5, ?, ?, ?, ?)", + (self.timestamp, favorite_xml, self.username, self.group_username), + ) + connection.commit() + finally: + connection.close() + + connection = sqlite3.connect(self.decrypted / "sns/sns.db") + try: + connection.execute("CREATE TABLE SnsTimeLine(tid TEXT, user_name TEXT, content TEXT)") + moment_xml = ( + "fixture-userFixture Nick" + f"{self.timestamp}fixture moment" + "1" + ) + connection.execute( + "INSERT INTO SnsTimeLine VALUES ('moment-1', ?, ?)", + (self.username, moment_xml), + ) + connection.commit() + finally: + connection.close() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def run_cli(self, *args: str) -> subprocess.CompletedProcess: + environment = os.environ.copy() + environment["LOCALAPPDATA"] = str(self.local_app_data) + return subprocess.run( + [sys.executable, str(CLI), "--decrypted-dir", str(self.decrypted), *args], + capture_output=True, + text=True, + timeout=30, + env=environment, + ) + + def test_contacts_sessions_history_and_search(self) -> None: + contacts = self.run_cli("contacts", "--query", "Friend", "--format", "json") + self.assertEqual(contacts.returncode, 0, contacts.stderr) + self.assertEqual(json.loads(contacts.stdout)["count"], 1) + + sessions = self.run_cli("sessions", "--format", "json") + self.assertEqual(sessions.returncode, 0, sessions.stderr) + self.assertEqual(json.loads(sessions.stdout)[0]["last_message"], "fixture hello") + + history = self.run_cli("history", "Fixture Friend", "--format", "json") + self.assertEqual(history.returncode, 0, history.stderr) + self.assertEqual(json.loads(history.stdout)["messages"][0]["content"], "fixture hello") + + search = self.run_cli( + "search", "fixture", "--chat", "Fixture Friend", "--format", "json" + ) + self.assertEqual(search.returncode, 0, search.stderr) + self.assertEqual(json.loads(search.stdout)["count"], 1) + + def test_markdown_export(self) -> None: + output = Path(self.temporary.name) / "fixture-export.md" + result = self.run_cli( + "export", + "Fixture Friend", + "--output", + str(output), + "--format", + "markdown", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(output.is_file()) + self.assertIn("fixture hello", output.read_text(encoding="utf-8")) + + refused = self.run_cli( + "export", "Fixture Friend", "--output", str(output), "--format", "markdown" + ) + self.assertNotEqual(refused.returncode, 0) + self.assertIn("--overwrite", refused.stderr) + replaced = self.run_cli( + "export", + "Fixture Friend", + "--output", + str(output), + "--format", + "markdown", + "--overwrite", + ) + self.assertEqual(replaced.returncode, 0, replaced.stderr) + + def test_remaining_mac_equivalent_commands(self) -> None: + status = self.run_cli("status", "--format", "json") + self.assertEqual(status.returncode, 0, status.stderr) + self.assertTrue(json.loads(status.stdout)["exists"]) + + unread = self.run_cli("unread", "--format", "json") + self.assertEqual(unread.returncode, 0, unread.stderr) + self.assertEqual(len(json.loads(unread.stdout)), 2) + + first_new = self.run_cli("new-messages", "--format", "json") + self.assertEqual(first_new.returncode, 0, first_new.stderr) + self.assertTrue(json.loads(first_new.stdout)["first_call"]) + second_new = self.run_cli("new-messages", "--format", "json") + self.assertEqual(second_new.returncode, 0, second_new.stderr) + self.assertEqual(json.loads(second_new.stdout)["new_count"], 0) + + members = self.run_cli("members", "Fixture Group", "--format", "json") + self.assertEqual(members.returncode, 0, members.stderr) + self.assertEqual(json.loads(members.stdout)["member_count"], 2) + + stats = self.run_cli("stats", "Fixture Group", "--format", "json") + self.assertEqual(stats.returncode, 0, stats.stderr) + self.assertEqual(json.loads(stats.stdout)["total"], 1) + + digest_root = Path(self.temporary.name) / "digests" + digest = self.run_cli( + "digest-source", + "Fixture Group", + "--data-root", + str(digest_root), + "--format", + "json", + ) + self.assertEqual(digest.returncode, 0, digest.stderr) + digest_payload = json.loads(digest.stdout) + self.assertEqual(digest_payload["message_count"], 1) + self.assertTrue(Path(digest_payload["source_json"]).is_file()) + + favorite = self.run_cli( + "favorites", "--type", "article", "--query", "Fixture", "--format", "json" + ) + self.assertEqual(favorite.returncode, 0, favorite.stderr) + self.assertEqual(json.loads(favorite.stdout)["count"], 1) + + moment = self.run_cli( + "moments", "--name", "Fixture Friend", "--keyword", "fixture", "--format", "json" + ) + self.assertEqual(moment.returncode, 0, moment.stderr) + self.assertEqual(json.loads(moment.stdout)["moments"][0]["content"], "fixture moment") + + def test_command_surface_matches_repository_mac_skill(self) -> None: + def commands(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + return { + node.args[0].value + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "add_parser" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + } + + mac_cli = ROOT.parent / "yichen-wechat-local-vault/scripts/vault_cli.py" + self.assertEqual(commands(CLI), commands(mac_cli)) + + def test_snapshot_connection_enforces_read_only_mode(self) -> None: + with vault_cli.connect(self.decrypted / "contact/contact.db") as connection: + self.assertEqual(connection.execute("PRAGMA query_only").fetchone()[0], 1) + with self.assertRaises(sqlite3.OperationalError): + connection.execute("CREATE TABLE forbidden_write(value TEXT)") + + +if __name__ == "__main__": + unittest.main() diff --git a/yichen-wechat-windows-vault/tests/test_safety.py b/yichen-wechat-windows-vault/tests/test_safety.py new file mode 100644 index 0000000..e4c4f42 --- /dev/null +++ b/yichen-wechat-windows-vault/tests/test_safety.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import subprocess +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import windows_vault # noqa: E402 + + +class SafetyTests(unittest.TestCase): + def test_capture_requires_explicit_consent_before_process_access(self) -> None: + with self.assertRaises(PermissionError): + windows_vault.capture( + Path("unused"), + "all", + 0, + Path("unused.json"), + consent=False, + ) + + def test_scripts_contain_no_process_control_or_injection_apis(self) -> None: + forbidden = ( + "createremotethread", + "writeprocessmemory", + "virtualallocex", + "debugactiveprocess", + "terminateprocess", + "suspendthread", + "resumethread", + "frida", + "wx-cli", + "wx_cli", + ) + combined = "\n".join(path.read_text(encoding="utf-8").casefold() for path in SCRIPTS.glob("*.py")) + for token in forbidden: + self.assertNotIn(token, combined) + + def test_cli_help_smoke(self) -> None: + for script in ("windows_vault.py", "vault_cli.py"): + result = subprocess.run( + [sys.executable, str(SCRIPTS / script), "--help"], + capture_output=True, + text=True, + timeout=20, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_private_outputs_cannot_overlap_source_account_tree(self) -> None: + account = Path("account").resolve() + with self.assertRaises(ValueError): + windows_vault.ensure_paths_separate(account / "vault", account, "vault") + with self.assertRaises(ValueError): + windows_vault.ensure_paths_separate(account.parent, account, "vault") + + +if __name__ == "__main__": + unittest.main() diff --git a/yichen-wechat-windows-vault/tests/test_secret_store.py b/yichen-wechat-windows-vault/tests/test_secret_store.py new file mode 100644 index 0000000..cf7a82c --- /dev/null +++ b/yichen-wechat-windows-vault/tests/test_secret_store.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from secret_store import KeyStore, WindowsDPAPI # noqa: E402 +from sqlcipher_codec import DEFAULT_PROFILE # noqa: E402 + + +class FakeProtector: + def protect(self, cleartext: bytes, entropy: bytes) -> bytes: + return bytes(value ^ entropy[index % len(entropy)] for index, value in enumerate(cleartext)) + + def unprotect(self, ciphertext: bytes, entropy: bytes) -> bytes: + return self.protect(ciphertext, entropy) + + +class SecretStoreTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) / "account" + self.root.mkdir() + self.path = Path(self.temporary.name) / "keys.json" + self.key = bytes(range(32)) + self.salt = bytes(range(16)) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_key_is_not_stored_as_plain_hex(self) -> None: + store = KeyStore(self.path, self.root, FakeProtector()) + store.put("message/message_0.db", self.key, self.salt, DEFAULT_PROFILE, "test") + serialized = self.path.read_text(encoding="utf-8") + self.assertNotIn(self.key.hex(), serialized) + restored, profile = store.get("message/message_0.db") + self.assertEqual(restored, self.key) + self.assertEqual(profile, DEFAULT_PROFILE) + metadata = store.metadata()["message/message_0.db"] + self.assertNotIn("protected_key", metadata) + + def test_store_is_bound_to_account_root(self) -> None: + store = KeyStore(self.path, self.root, FakeProtector()) + store.put("contact/contact.db", self.key, self.salt, DEFAULT_PROFILE, "test") + other = Path(self.temporary.name) / "other-account" + other.mkdir() + with self.assertRaises(ValueError): + KeyStore(self.path, other, FakeProtector()).metadata() + + @unittest.skipUnless(os.name == "nt", "DPAPI test requires Windows") + def test_real_dpapi_round_trip(self) -> None: + protector = WindowsDPAPI() + entropy = bytes(range(32)) + protected = protector.protect(self.key, entropy) + self.assertNotEqual(protected, self.key) + self.assertEqual(protector.unprotect(protected, entropy), self.key) + + +if __name__ == "__main__": + unittest.main()