From 0d8d9f111ece96d3457deae592c01ccd9113d067 Mon Sep 17 00:00:00 2001 From: Magicapple <1540796514@qq.com> Date: Tue, 22 Sep 2026 12:25:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E6=96=B0=E5=A2=9E=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20WCDB=20=E5=8A=A0=E5=AF=86=E7=9A=84=E6=BC=94?= =?UTF-8?q?=E7=A4=BA=E6=95=B0=E6=8D=AE=E5=BA=93=E7=A7=8D=E5=AD=90=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 贡献者本地通常没有微信环境,也不应拿真实聊天记录做调试;已有的 tools/seed_ai_acceptance.py 生成的是明文 SQLite,只覆盖 AI 验收, 无法验证「密钥 -> 逐页 HMAC 校验 -> 解密 -> 解析」这条主链路。 新增 tools/seed_demo_database.py,生成完全虚构但真实加密的演示账号: - 逐页 AES-CBC + 逐页 HMAC-SHA512,密钥派生复用上游 wechat_decrypt 的常量与函数(PAGE_SIZE / RESERVE_SIZE / _derive_sqlcipher_enc_key / _derive_mac_key),上游调整页格式时脚本随之变化; - 5 个会话 29 条消息,覆盖文字、链接卡片 / 转账卡片(含 refermsg 引用)、 系统消息、表情与语音占位; - Name2Id 不含账号本人,与真实微信结构一致,避免会话列表多出一条自己; - 昵称统一带「示例」标记、域名 example.com,任何导出物都能一眼识别为虚构数据。 同时提供 --check 自校验:调用 scan_account_databases_from_path 与 decrypt_wechat_databases 走完整解密链路,逐页校验 HMAC,并检查解密后 PRAGMA integrity_check 与关键表是否存在。 回归测试 tests/test_seed_demo_database.py 守护「演示数据与真实解密链路一致」 这一契约,逐页比对 HMAC 与 _compute_page_hmac 的结果。 实测:--check 输出「成功解密 2/2 个」,上游解密器日志打印 Page 1 HMAC verification passed: mode=sqlcipher_passphrase, successful_pages=7 failed_pages=0,reserved_space=80。 默认输出到 output/demo,已被 /output/ 规则忽略,不进入版本库; 脚本只写 --output 指定目录,不读取也不修改任何真实微信目录。 --- docs/demo-database-seed.md | 91 ++++++ tests/test_seed_demo_database.py | 142 ++++++++++ tools/seed_demo_database.py | 465 +++++++++++++++++++++++++++++++ 3 files changed, 698 insertions(+) create mode 100644 docs/demo-database-seed.md create mode 100644 tests/test_seed_demo_database.py create mode 100644 tools/seed_demo_database.py diff --git a/docs/demo-database-seed.md b/docs/demo-database-seed.md new file mode 100644 index 00000000..668c1fcf --- /dev/null +++ b/docs/demo-database-seed.md @@ -0,0 +1,91 @@ +# 演示数据库种子脚本 + +`tools/seed_demo_database.py` 生成一个**完全虚构、但真实经过微信 4.x WCDB 加密**的演示账号, +用于开发、回归测试、文档截图与演示视频。 + +## 为什么需要它 + +项目里已有的 `tools/seed_ai_acceptance.py` 生成的是**明文 SQLite**,只覆盖 AI 验收这一条链路。 +但在以下场景里,明文库不够用: + +- 验证「密钥 -> 逐页 HMAC 校验 -> 解密 -> 解析 -> 展示」的完整链路; +- 排查解密失败时,需要一个**格式正确、内容已知**的对照样本; +- 贡献者本地没有微信环境,或不愿意用真实聊天记录做调试; +- 文档、官网、演示视频需要可以公开的素材。 + +真实聊天记录包含隐私,不适合进入任何公开产物;明文库又无法覆盖解密链路。 +本脚本填补的正是这个空档:**格式与真实库一致,内容 100% 虚构**。 + +## 生成的内容 + +``` +output/demo/ +├── demo_keys.json # 演示密钥(account -> key/display_name/alias) +└── databases/ + └── wxid_demo_2026/ + └── db_storage/ + ├── message/message_0.db # 加密,5 个会话共 29 条消息 + └── contact/contact.db # 加密,6 个联系人 / 1 个群 +``` + +覆盖的消息类型:文字(`1`)、链接卡片与转账卡片(`49`,含 `refermsg` 引用结构)、 +系统消息(`10000`)、表情(`47`)、语音占位(`34`)。 + +结构上的两点刻意设计: + +1. **`Name2Id` 不包含账号本人** —— 真实微信如此。账号本人由「出现在全部单聊表中的 + `real_sender_id`」判定;如果把自己的行写进 `Name2Id`,会话列表会多出一条自己和自己聊天的记录。 +2. **昵称全部带「示例」标记,域名统一 `example.com`,群号使用 `10000000001@chatroom`** —— + 确保任何截图或导出产物都能被一眼识别为虚构数据。 + +## 用法 + +```bash +# 生成到 output/demo(已被 /output/ 规则忽略,不会进入版本库) +python tools/seed_demo_database.py + +# 生成后用项目自身的扫描与解密器做端到端自校验 +python tools/seed_demo_database.py --check + +# 指定输出目录 +python tools/seed_demo_database.py --output /tmp/demo-account +``` + +`--check` 会调用 `scan_account_databases_from_path` 与 `decrypt_wechat_databases`, +逐页验证 HMAC、检查解密后 `PRAGMA integrity_check` 是否为 `ok`、以及 `Name2Id` / `Contact` +表是否存在。自校验通过时输出类似: + +``` +自校验通过:扫描到 5 个数据库,成功解密 2/2 个 +``` + +## 加密格式 + +脚本实现的是 `wechat_decrypt._decrypt_page` 的**逆过程**,参数直接从上游模块导入而非硬编码, +因此上游调整页格式时脚本会跟着一起变: + +| 项 | 值 | +| --- | --- | +| 页大小 | 4096 字节 | +| 密钥派生 | PBKDF2-HMAC-SHA512,256000 轮,32 字节 | +| mac 密钥 | PBKDF2-HMAC-SHA512(enc_key, salt ^ 0x3A, 2 轮) | +| 每页布局 | `ciphertext + iv(16) + hmac(64)`,第 1 页前加 16 字节 salt | +| HMAC 覆盖 | 密文 + IV + 小端页码(SHA-512) | +| 每页预留 | 80 字节(= IV 16 + HMAC 64) | + +明文库在加密前需要「每页预留 80 字节」的页面布局,这一步容易出错:仅靠 +`PRAGMA user_version` 让 SQLite 落盘文件头是不够的,还必须把 page1 btree 头里 +「cell 内容区起始」(文件偏移 `100 + 5`)从 4096 改成 4016,否则 SQLite 会按原始 +页头计算空闲空间并报 `database integrity check: database disk image is malformed`。 + +## 回归保护 + +`tests/test_seed_demo_database.py` 守护「演示数据与真实解密链路一致」这一契约: +逐页比对 HMAC 与 `_compute_page_hmac` 的结果、验证解密后是合法 SQLite、 +校验 `Name2Id` 不含本人、并确认昵称都带虚构标记。 + +## 安全边界 + +- 脚本只写 `--output` 指定的目录,**不读取、不修改**任何真实微信目录。 +- 演示密钥是固定常量(`sha256("wechat-data-analysis-demo-key")`),公开无风险; + 它只用于打开本脚本自己生成的样本库。 diff --git a/tests/test_seed_demo_database.py b/tests/test_seed_demo_database.py new file mode 100644 index 00000000..137af4a5 --- /dev/null +++ b/tests/test_seed_demo_database.py @@ -0,0 +1,142 @@ +"""回归:``tools/seed_demo_database.py`` 生成的演示库必须能被项目解密器读取。 + +这个测试守护的是「演示数据与真实解密链路一致」这一契约:如果 +``wechat_decrypt`` 的页面格式、HMAC 覆盖范围或预留字节数发生变化,而种子脚本 +没有同步,测试会立刻失败——避免演示环境悄悄偏离真实格式后又被人当成基准。 +""" + +from __future__ import annotations + +import sqlite3 +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tools")) + +from seed_demo_database import ( # noqa: E402 + DEMO_ACCOUNT, + DEMO_GROUP, + DEMO_KEY, + encrypt_wcdb, + seed, +) + +from wechat_decrypt_tool.wechat_decrypt import ( # noqa: E402 + PAGE_SIZE, + RESERVE_SIZE, + SQLITE_HEADER, + _compute_page_hmac, + _derive_mac_key, + _derive_sqlcipher_enc_key, +) + + +def _decrypt_to_plain(encrypted: bytes, key_hex: str) -> bytes: + """按 ``wechat_decrypt._decrypt_page`` 的规则还原整库明文。""" + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + if len(encrypted) % PAGE_SIZE != 0: + raise AssertionError("加密库长度不是 4096 的整数倍") + + salt = encrypted[:16] + enc_key = _derive_sqlcipher_enc_key(bytes.fromhex(key_hex), salt) + plain = bytearray() + for page_num in range(1, len(encrypted) // PAGE_SIZE + 1): + start = (page_num - 1) * PAGE_SIZE + page = encrypted[start : start + PAGE_SIZE] + iv = page[PAGE_SIZE - RESERVE_SIZE : PAGE_SIZE - RESERVE_SIZE + 16] + offset = 16 if page_num == 1 else 0 + body = page[offset : PAGE_SIZE - RESERVE_SIZE] + cipher = Cipher( + algorithms.AES(enc_key), modes.CBC(iv), backend=default_backend() + ) + decryptor = cipher.decryptor() + decrypted = decryptor.update(body) + decryptor.finalize() + if page_num == 1: + plain += SQLITE_HEADER + decrypted + b"\x00" * RESERVE_SIZE + else: + plain += decrypted + b"\x00" * RESERVE_SIZE + return bytes(plain) + + +class TestSeedDemoDatabase(unittest.TestCase): + def setUp(self) -> None: + self._tmp = TemporaryDirectory() + self.output = Path(self._tmp.name) + self.paths = seed(self.output) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_generated_page_hmac_matches_decryptor(self): + """逐页 HMAC 必须与解密器的校验算法一致,否则真实解密会拒绝该库。""" + raw = self.paths["message_db"].read_bytes() + self.assertEqual(len(raw) % PAGE_SIZE, 0) + + salt = raw[:16] + enc_key = _derive_sqlcipher_enc_key(bytes.fromhex(DEMO_KEY), salt) + mac_key = _derive_mac_key(enc_key, salt) + + for page_num in range(1, len(raw) // PAGE_SIZE + 1): + start = (page_num - 1) * PAGE_SIZE + page = raw[start : start + PAGE_SIZE] + expected = _compute_page_hmac(mac_key, page, page_num) + stored = page[PAGE_SIZE - 64 :] + self.assertEqual( + stored, expected, f"第 {page_num} 页 HMAC 与解密器算法不一致" + ) + + def test_generated_databases_decrypt_to_valid_sqlite(self): + for key_name in ("message_db", "contact_db"): + path = self.paths[key_name] + plain = _decrypt_to_plain(path.read_bytes(), DEMO_KEY) + self.assertTrue(plain.startswith(SQLITE_HEADER)) + + decrypted_path = self.output / f"{key_name}.decrypted.db" + decrypted_path.write_bytes(plain) + conn = sqlite3.connect(decrypted_path) + try: + status = conn.execute("PRAGMA integrity_check").fetchone()[0] + finally: + conn.close() + self.assertEqual(status, "ok", f"{key_name} 解密后完整性检查失败") + + def test_demo_account_excludes_self_from_name2id(self): + """真实微信的 Name2Id 不含账号本人,演示库必须保持同样结构。""" + plain = _decrypt_to_plain(self.paths["message_db"].read_bytes(), DEMO_KEY) + decrypted_path = self.output / "name2id.decrypted.db" + decrypted_path.write_bytes(plain) + conn = sqlite3.connect(decrypted_path) + try: + names = [row[0] for row in conn.execute("SELECT user_name FROM Name2Id")] + finally: + conn.close() + self.assertNotIn(DEMO_ACCOUNT, names) + self.assertIn(DEMO_GROUP, names) + + def test_demo_content_is_marked_fictional(self): + """所有昵称都带「示例」标记,避免演示数据被误认为真实数据。""" + plain = _decrypt_to_plain(self.paths["contact_db"].read_bytes(), DEMO_KEY) + decrypted_path = self.output / "contact_content.decrypted.db" + decrypted_path.write_bytes(plain) + conn = sqlite3.connect(decrypted_path) + try: + rows = conn.execute("SELECT nick_name FROM Contact").fetchall() + finally: + conn.close() + self.assertTrue(rows) + for (nick_name,) in rows: + self.assertIn("示例", str(nick_name)) + + def test_encrypt_wcdb_rejects_unaligned_input(self): + with self.assertRaises(ValueError): + encrypt_wcdb(b"not-a-full-page", DEMO_KEY) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/seed_demo_database.py b/tools/seed_demo_database.py new file mode 100644 index 00000000..50f887f0 --- /dev/null +++ b/tools/seed_demo_database.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +"""生成完全虚构、且真实经过 WCDB 加密的演示账号,用于开发、测试与截图。 + +与 ``tools/seed_ai_acceptance.py`` 的区别:后者生成的是**明文** SQLite,只覆盖 +AI 验收场景;本脚本生成的是**逐页 AES-CBC + 逐页 HMAC 的微信 4.x 加密库**, +因此可以驱动完整的「密钥 -> 解密 -> 解析 -> 展示」链路,也不需要任何真实微信数据。 + +用途 +---- +- 贡献者在没有真实微信环境时跑通解密与读取流程; +- 截图、文档、演示视频使用可公开的虚构数据; +- 回归测试需要「真实加密格式 + 真实表结构」的样本。 + +所有姓名、群名、正文都带「示例」标记;域名统一 example.com;电话 13800000000。 + +用法:: + + python tools/seed_demo_database.py # 输出到 output/demo + python tools/seed_demo_database.py --check # 解密器自校验 + python tools/seed_demo_database.py --output /tmp/demo-account # 指定输出目录 + +默认输出到 ``output/demo``,该目录已被 ``/output/`` 规则忽略;脚本只写 +``--output`` 指定的目录,不读取也不修改任何真实微信目录。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import os +import sqlite3 +import struct +import sys +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from wechat_decrypt_tool.wechat_decrypt import ( # noqa: E402 + HMAC_SIZE, + IV_SIZE, + KEY_SIZE, + PAGE_SIZE, + RESERVE_SIZE, + SALT_SIZE, + _derive_mac_key, + _derive_sqlcipher_enc_key, +) + +DEMO_ACCOUNT = "wxid_demo_2026" +DEMO_DISPLAY_NAME = "示例账号" +# 固定密钥:公开样本库不需要保密,固定值让同一提交产出可复现的库结构。 +DEMO_KEY = hashlib.sha256(b"wechat-data-analysis-demo-key").hexdigest() +DEMO_ALIAS = "demo_user" + +EXAMPLE_URL = "https://example.com" + +# 群聊房间号固定为示例值,避免与任何真实群聊撞号。 +DEMO_GROUP = "10000000001@chatroom" + + +def _derive_keys(key_hex: str, salt: bytes) -> tuple[bytes, bytes]: + key_material = bytes.fromhex(key_hex) + enc_key = _derive_sqlcipher_enc_key(key_material, salt) + mac_key = _derive_mac_key(enc_key, salt) + return enc_key, mac_key + + +def encrypt_wcdb(plain: bytes, key_hex: str) -> bytes: + """把明文 SQLite 整库加密为微信 4.x 页面格式(``wechat_decrypt`` 的逆过程)。 + + 每页布局:``[salt(仅第 1 页)] + ciphertext + iv + hmac``,其中密文长度为 + ``PAGE_SIZE - RESERVE_SIZE - salt_offset``;HMAC 覆盖密文与 IV,再拼小端页码。 + """ + if len(plain) % PAGE_SIZE != 0: + raise ValueError("待加密数据库必须严格按 4096 字节分页") + + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + salt = os.urandom(SALT_SIZE) + enc_key, mac_key = _derive_keys(key_hex, salt) + + out = bytearray() + for page_num in range(1, len(plain) // PAGE_SIZE + 1): + start = (page_num - 1) * PAGE_SIZE + chunk = plain[start : start + PAGE_SIZE] + offset = SALT_SIZE if page_num == 1 else 0 + payload = chunk[offset : PAGE_SIZE - RESERVE_SIZE] + iv = os.urandom(IV_SIZE) + cipher = Cipher( + algorithms.AES(enc_key), modes.CBC(iv), backend=default_backend() + ) + encryptor = cipher.encryptor() + ciphertext = encryptor.update(payload) + encryptor.finalize() + digest = hmac.new(mac_key, digestmod=hashlib.sha512) + digest.update(ciphertext) + digest.update(iv) + digest.update(page_num.to_bytes(4, "little")) + page = (salt if page_num == 1 else b"") + ciphertext + iv + digest.digest() + if len(page) != PAGE_SIZE: + raise AssertionError(f"第 {page_num} 页长度异常: {len(page)}") + out += page + return bytes(out) + + +def _patch_reserved_space(path: Path) -> None: + """把明文库改成每页预留 80 字节(与解密还原后的页面布局一致)。 + + ``PRAGMA user_version`` 只写文件头、不产生任何 cell,因此页尾预留区保持空闲; + 随后必须把 page1 btree 头的「cell 内容区起始」从 4096 改成 4096-80,否则 + SQLite 会按原页头判定空闲空间,报 ``database disk image is malformed``。 + """ + with path.open("r+b") as handle: + handle.seek(20) + handle.write(bytes([RESERVE_SIZE])) + handle.seek(100 + 5) + handle.write(struct.pack(">H", PAGE_SIZE - RESERVE_SIZE)) + + +def create_plain_database(path: Path) -> sqlite3.Connection: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + path.unlink() + bootstrap = sqlite3.connect(path) + bootstrap.execute("PRAGMA user_version=1") + bootstrap.commit() + bootstrap.close() + _patch_reserved_space(path) + conn = sqlite3.connect(path) + conn.execute("PRAGMA journal_mode=DELETE") + return conn + + +def _timestamp(days_ago: float, hour: int, minute: int) -> int: + base = time.time() - days_ago * 86400 + local = time.localtime(base) + return int( + time.mktime( + (local.tm_year, local.tm_mon, local.tm_mday, hour, minute, 0, 0, 0, -1) + ) + ) + + +def _message( + local_id: int, + sender_rowid: int, + local_type: int, + created_at: int, + content: str, + compress_flag: int = 0, +) -> tuple: + server_id = 880000000 + local_id * 13 + return ( + local_id, + server_id, + local_type, + sender_rowid, + created_at, + content, + b"", + b"", + compress_flag, + ) + + +def build_message_database(path: Path) -> None: + conn = create_plain_database(path) + cur = conn.cursor() + + # 真实微信的 Name2Id 不包含账号本人;本人由「出现在全部单聊表中的 + # real_sender_id」判定,因此这里保持同样的结构,聊天列表才不会多出一条。 + name2id = [ + (2, "wxid_demo_zhangwei"), + (3, "wxid_demo_liting"), + (4, "wxid_demo_wangfang"), + (5, "wxid_demo_liuyang"), + (6, DEMO_GROUP), + ] + cur.execute("CREATE TABLE Name2Id (user_name TEXT)") + cur.executemany("INSERT INTO Name2Id (rowid, user_name) VALUES (?, ?)", name2id) + + chats: dict[str, list[tuple]] = {} + + # --- 群聊:覆盖文字、链接卡片、系统消息、表情、引用 --- + rows: list[tuple] = [] + ts = _timestamp(2, 9, 15) + rows.append(_message(1, 4, 1, ts, "各位早上好,今天十点对齐一下示例项目的排期")) + rows.append(_message(2, 2, 1, ts + 180, "收到,上周的示例数据我已经整理好了")) + rows.append(_message(3, 3, 1, ts + 240, "示例模块的联调环境已经部署到测试服")) + rows.append(_message(4, 1, 1, ts + 400, "辛苦大家,会议链接稍后发到群里")) + rows.append( + _message( + 5, + 5, + 49, + ts + 420, + "示例项目排期表(9 月)" + "包含里程碑与分工的示例文档5" + f"{EXAMPLE_URL}/docs/demo-plan", + ) + ) + rows.append( + _message(6, 1, 10000, ts + 500, "示例-李婷 邀请 示例-王芳 加入了群聊") + ) + rows.append(_message(7, 4, 1, ts + 700, "欢迎新同事,示例团队欢迎你")) + rows.append( + _message( + 8, + 2, + 49, + ts + 900, + "示例接口文档 v2" + "更新了示例接口的返回结构57" + f"{EXAMPLE_URL}/docs/demo-api" + "18800000014" + f"{DEMO_GROUP}wxid_demo_liuyang" + "示例-刘洋" + "示例接口的返回结构谁能同步一下?", + ) + ) + rows.append(_message(9, 3, 47, ts + 960, "[示例表情]")) + rows.append(_message(10, 1, 1, _timestamp(0, 9, 41), "上午的示例评审结论我整理好了,下午发出来")) + rows.append(_message(11, 5, 1, _timestamp(0, 9, 45), "收到,示例版本的发布时间定在周五")) + chats[DEMO_GROUP] = rows + + # --- 单聊:日常闲聊 --- + rows = [] + ts = _timestamp(1, 20, 10) + rows.append(_message(1, 2, 1, ts, "周末的示例聚会你来吗?")) + rows.append(_message(2, 1, 1, ts + 120, "来,示例场地我已经订好了")) + rows.append(_message(3, 2, 1, ts + 200, "那我把示例名单统计一下")) + rows.append( + _message( + 4, + 2, + 49, + ts + 300, + "示例餐厅订座确认" + "周六 18:00,8 人桌5" + f"{EXAMPLE_URL}/booking/demo123", + ) + ) + rows.append(_message(5, 1, 1, _timestamp(0, 8, 30), "早,通勤路上听了你推荐的示例播客,不错")) + rows.append(_message(6, 2, 1, _timestamp(0, 8, 42), "示例播客是我最近的下饭神器")) + chats["wxid_demo_zhangwei"] = rows + + # --- 单聊:出行计划(含转账卡片)--- + rows = [] + ts = _timestamp(3, 15, 20) + rows.append(_message(1, 3, 1, ts, "示例城市这周末天气不错,适合去拍外景")) + rows.append(_message(2, 1, 1, ts + 200, "好,示例相机的电池我充上")) + rows.append( + _message( + 3, + 3, + 49, + ts + 600, + "示例民宿预订" + "两晚套房,含示例早餐5" + f"{EXAMPLE_URL}/stay/demo-room", + ) + ) + rows.append( + _message( + 4, + 1, + 49, + ts + 800, + "微信转账2000" + "¥288.00" + "1", + ) + ) + rows.append(_message(5, 3, 1, ts + 900, "示例民宿的定金我收到了,周五出发")) + chats["wxid_demo_liting"] = rows + + # --- 单聊:工作对接(含语音消息占位)--- + rows = [] + ts = _timestamp(0, 9, 2) + rows.append(_message(1, 4, 1, ts, "示例报告初稿在附件里,麻烦帮忙看看第二部分")) + rows.append(_message(2, 1, 1, ts + 300, "收到,我十一点前把批注发你")) + rows.append(_message(3, 4, 34, ts + 600, "")) + rows.append(_message(4, 4, 1, ts + 700, "语音里说的示例口径以文档为准")) + chats["wxid_demo_wangfang"] = rows + + # --- 单聊:发布检查 --- + rows = [] + ts = _timestamp(1, 16, 40) + rows.append(_message(1, 5, 1, ts, "示例环境的冒烟测试我跑完了,全部通过")) + rows.append(_message(2, 1, 1, ts + 400, "效率很高,示例包我来打")) + rows.append(_message(3, 5, 1, ts + 800, "好,打完包我在群里同步")) + chats["wxid_demo_liuyang"] = rows + + for talker, chat_rows in chats.items(): + table = "Msg_" + hashlib.md5(talker.encode("utf-8")).hexdigest() + cur.execute( + f"""CREATE TABLE [{table}] ( + local_id INTEGER, server_id INTEGER, local_type INTEGER, + real_sender_id INTEGER, create_time INTEGER, + message_content BLOB, source BLOB, packed_info_data BLOB, + WCDB_CT_message_content INTEGER)""" + ) + cur.executemany( + f"INSERT INTO [{table}] VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", chat_rows + ) + + conn.commit() + status = conn.execute("PRAGMA integrity_check").fetchone()[0] + if status != "ok": + raise AssertionError(f"message 库完整性检查失败: {status}") + conn.close() + + +def build_contact_database(path: Path) -> None: + conn = create_plain_database(path) + cur = conn.cursor() + cur.execute( + """CREATE TABLE Contact ( + user_name TEXT, nick_name TEXT, remark TEXT, alias TEXT, + description TEXT, local_type INTEGER)""" + ) + rows = [ + (DEMO_ACCOUNT, DEMO_DISPLAY_NAME, "", DEMO_ALIAS, "示例账号自我介绍", 1), + ("wxid_demo_zhangwei", "示例-张伟", "示例-张伟", "demo_zw", "示例好友:产品同事", 1), + ("wxid_demo_liting", "示例-李婷", "示例-李婷", "demo_lt", "示例好友:摄影搭子", 1), + ("wxid_demo_wangfang", "示例-王芳", "示例-王芳", "demo_wf", "示例好友:报告对接", 1), + ("wxid_demo_liuyang", "示例-刘洋", "示例-刘洋", "demo_ly", "示例好友:测试同事", 1), + (DEMO_GROUP, "示例-产品讨论群", "", "", "示例群聊", 2), + ] + cur.executemany("INSERT INTO Contact VALUES (?, ?, ?, ?, ?, ?)", rows) + conn.commit() + status = conn.execute("PRAGMA integrity_check").fetchone()[0] + if status != "ok": + raise AssertionError(f"contact 库完整性检查失败: {status}") + conn.close() + + +def _write_encrypted(plain_db: Path, target: Path) -> None: + plain = plain_db.read_bytes() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(encrypt_wcdb(plain, DEMO_KEY)) + + +def seed(output: Path) -> dict[str, Path]: + account_root = output / "databases" / DEMO_ACCOUNT + message_dir = account_root / "db_storage" / "message" + contact_dir = account_root / "db_storage" / "contact" + + work_dir = output / "_plain" + work_dir.mkdir(parents=True, exist_ok=True) + plain_message = work_dir / "message_0.plain.db" + plain_contact = work_dir / "contact.plain.db" + + build_message_database(plain_message) + build_contact_database(plain_contact) + _write_encrypted(plain_message, message_dir / "message_0.db") + _write_encrypted(plain_contact, contact_dir / "contact.db") + + keys_path = output / "demo_keys.json" + keys_path.write_text( + json.dumps( + { + DEMO_ACCOUNT: { + "key": DEMO_KEY, + "display_name": DEMO_DISPLAY_NAME, + "alias": DEMO_ALIAS, + } + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + return { + "account_root": account_root, + "message_db": message_dir / "message_0.db", + "contact_db": contact_dir / "contact.db", + "keys": keys_path, + } + + +def _self_check(paths: dict[str, Path]) -> None: + """用项目自身的扫描与解密链路验证样本可被真实读取。""" + from wechat_decrypt_tool.wechat_decrypt import ( + decrypt_wechat_databases, + scan_account_databases_from_path, + ) + + db_storage = paths["account_root"] / "db_storage" + scanned = scan_account_databases_from_path(str(db_storage)) + if not scanned: + raise AssertionError("扫描演示账号时没有发现任何数据库") + + result = decrypt_wechat_databases(str(db_storage), DEMO_KEY) + if result.get("status") != "success" or not result.get("successful_count"): + raise AssertionError(f"演示账号解密失败: {result}") + + total = result.get("total_databases") + success = result.get("successful_count") + if result.get("failed_count"): + raise AssertionError(f"演示账号存在解密失败的库: {result.get('failed_files')}") + + # 解密产物必须真的是可读 SQLite,且包含我们写入的表。 + output_dir = Path(str(result.get("output_directory") or "")) + for label, expected_table in (("message", "Name2Id"), ("contact", "Contact")): + candidates = sorted(output_dir.rglob(f"{label}*.db")) + sorted( + output_dir.rglob(f"{label}.db") + ) + if not candidates: + raise AssertionError(f"解密输出中找不到 {label} 库: {output_dir}") + conn = sqlite3.connect(candidates[0]) + try: + integrity = conn.execute("PRAGMA integrity_check").fetchone()[0] + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + finally: + conn.close() + if integrity != "ok": + raise AssertionError(f"{label} 解密后完整性检查失败: {integrity}") + if expected_table not in tables: + raise AssertionError(f"{label} 解密后缺少表 {expected_table}: {tables}") + + print(f"自校验通过:扫描到 {len(scanned)} 个数据库,成功解密 {success}/{total} 个") + + +def main() -> int: + parser = argparse.ArgumentParser(description="生成虚构的微信 4.x 加密演示账号") + parser.add_argument( + "--output", + type=Path, + default=REPO_ROOT / "output" / "demo", + help="输出目录(默认 output/demo,已随 /output/ 一起被忽略)", + ) + parser.add_argument( + "--check", action="store_true", help="生成后用项目解密器自校验" + ) + args = parser.parse_args() + + output = args.output.resolve() + paths = seed(output) + + print(f"演示账号: {DEMO_ACCOUNT}(显示名 {DEMO_DISPLAY_NAME})") + print(f"账号目录: {paths['account_root']}") + print(f"消息库: {paths['message_db']}") + print(f"联系人库: {paths['contact_db']}") + print(f"密钥文件: {paths['keys']}") + print(f"演示密钥: {DEMO_KEY}") + + if args.check: + _self_check(paths) + return 0 + + +if __name__ == "__main__": + sys.exit(main())