diff --git a/docs/validation/Message-Model-2026-09-06.md b/docs/validation/Message-Model-2026-09-06.md new file mode 100644 index 0000000..47b2cf5 --- /dev/null +++ b/docs/validation/Message-Model-2026-09-06.md @@ -0,0 +1,50 @@ +# 完整消息模型:只读数据库消息读取与多账号密钥缓存(2026-09-06) + +## 目标与边界 + +- 本阶段补齐「完整消息模型」:稳定消息 ID、发送者、时间戳、收发方向等 UIA 无法提供的字段,全部来自只读 SQLCipher 数据库。 +- 不写数据库、不注入、不修改微信进程、不做登录绕过;密钥只通过页面 1 HMAC 校验后缓存。 +- 一个机器多账号:密钥按「账号指纹 + 库相对路径」缓存,已缓存账号后续 `db scan` 直接跳过内存扫描。 + +## 密钥提取(Windows 微信 4.1.13.63) + +- 旧式明文十六进制扫描(ASCII/UTF-16)在该版本找不到可验证密钥。 +- 新增 Windows 4.1.x 的 `Config.Cipher` 只读指针链扫描: + - 全内存找 `com.Tencent.WCDB.Config.Cipher` 字符串地址; + - 用字符串地址+长度组成的 16 字节对再次全内存定位对象节点(`节点基址 = 配对地址 - 0x10`); + - 沿节点 `+0x28 → 配置指针 +0x88 → 数据指针 +0x8 / 长度 +0x10` 读回最多 1024 字节的加密配置块; + - 固定 33 字节 XOR 掩码解码,提取 `x'<64~192 hex>'` 字面量,候选密钥要求 ≥15 个不同字节且非全 0/全 FF; + - 最终仍以每个目标库页面 1 HMAC 校验为准。 +- `MemoryPatternScanner` 处理跨分块/跨区边界匹配;所有读出的进程内存缓冲均清零。 +- 扫描结果:5 个进程、19,309 个候选、149,800 次校验 → 20 个数据库全部 `PageHmacVerified`,来源进程 8912。 + +## 多账号缓存 + +- `db scan --save` 先加载密钥缓存,计算「发现库 − 已缓存库」;无待办则 `scanned=false` 直接跳过(processCount/candidateCount=0)。 +- 第二次 `db scan --save` 验证:`scanned=false`,缓存仍 20 个库,无需重扫。 +- 缓存键大小写不敏感(Windows 路径);新账号/新库出现时才触发增量扫描。 + +## 消息模型与读取 + +- 消息表名:`Msg_{md5(chat_wxid)}`;在 `message/message_*.db`、`message/biz_message_*.db` 中定位。 +- 只读查询列:`local_id, server_id, local_type, create_time, hex(message_content), WCDB_CT_message_content, real_sender_id`,`LEFT JOIN name2id` 得发送者 wxid。 +- 内容解码:hex → UTF-8;压缩标记且带 zstd 魔数 `28 b5 2f fd` 时用 zstd 解压(ZstdSharp.Port,Core 内跨平台)。 +- 发送者显示名/头像:`contact/contact.db` 的 `contact` 表按 wxid 批量查 `remark → nick_name → username` 与 `small_head_url`。 +- 收发方向:账号根目录名 `_` 前缀匹配发送者即 `IsSelf=true`(AccountRootPath 指向 `db_storage`,其父目录名才是账号目录)。 +- CLI:`db messages --account --chat [--limit] [--include-content]`、`db contacts`、`db schema`;默认脱敏,`--include-content` 才显示名称/内容/头像。 + +## 真机验收 + +- Windows 10.0.19044,微信 4.1.13.63,原交互会话 1(已解锁),唯一密钥缓存。 +- 群聊名解析:`db contacts --contains 测试` 命中 `53271859539@chatroom`,昵称「消息测试专用群组」。 +- 消息读取:`db messages --chat 53271859539@chatroom --limit 6 --include-content` 返回 6 条: + - 每条含 `local_id`(7–12)、`server_id`、`type=1`、RFC3339 时间戳、发送者、`IsSelf`、显示名、头像、内容。 + - 内容正是此前本机发送的测试标记(`wx-agent-isolation-*`、`wx-agent-repeat-*`、`wx-agent-m6-listener-*`),证明 hex/zstd 解码正确。 + - 本机发送消息 `IsSelf=true`、`senderName=Rogee`;发送者名/头像经 contact.db 解析。 +- Core:97/97 测试通过;Release 全量构建与 self-contained 发布 0 警告 0 错误。 +- 密钥文件位于 `%LOCALAPPDATA%\WxAgent\database-keys.json`,ACL 仅当前用户;CLI 输出不含密钥。 + +## 尚未完成(本阶段之外) + +- 通用发送能力、导航能力、合并聊天记录展开仍按顺序推进。 +- 消息模型与 UIA 事件流的合并(实时新消息方向归属)不在本阶段。 diff --git a/docs/validation/evidence/Message-Model-20260906/contacts-group.json b/docs/validation/evidence/Message-Model-20260906/contacts-group.json new file mode 100644 index 0000000..a68a95e --- /dev/null +++ b/docs/validation/evidence/Message-Model-20260906/contacts-group.json @@ -0,0 +1,23 @@ +{ + "count": 3, + "contacts": [ + { + "username": "gh_dc239843078a", + "nickName": "微信公众平台测试号", + "remark": "", + "avatarUrl": "https://wx.qlogo.cn/mmhead/574VdhMFwaFfLImg5A1CEvy3u7jvATicibOlBqb9a5FgKISWE85CAGhlPTibpU3rfMLGLRFmmovzpc/132" + }, + { + "username": "18730202243@chatroom", + "nickName": "云阵测试", + "remark": "", + "avatarUrl": "https://wx.qlogo.cn/mmcrhead/K6CEv0Hv9DczUu4XxF8icuZibYCickibJfpCXduwlWxadSHtnZaEgn5OKOb1h0XUKpibpkdvYW2JUSbykhFqia1FBh94QdYGRS5tuX/0" + }, + { + "username": "53271859539@chatroom", + "nickName": "消息测试专用群组", + "remark": "", + "avatarUrl": "https://wx.qlogo.cn/mmcrhead/RK2M1rZicPTL4icMaj0lSTwn7rxN2dO7DN3ubxb2zTMhZD7wfEe2vAfcoGoD3TajaWXlcIPBA7ibHo/0" + } + ] +} diff --git a/docs/validation/evidence/Message-Model-20260906/messages-test-group.json b/docs/validation/evidence/Message-Model-20260906/messages-test-group.json new file mode 100644 index 0000000..c551dbb --- /dev/null +++ b/docs/validation/evidence/Message-Model-20260906/messages-test-group.json @@ -0,0 +1,72 @@ +{ + "chatId": "53271859539@chatroom", + "count": 6, + "messages": [ + { + "LocalId": 12, + "ServerId": 3948630831403203690, + "Type": 1, + "timestamp": "2026-09-06T02:59:00.0000000+00:00", + "sender": "yanghao880130", + "IsSelf": true, + "senderName": "Rogee", + "senderAvatarUrl": "https://wx.qlogo.cn/mmhead/ver_1/kPXMiaO9asy0mdTLsl3xibfcOw7K4VExiaIbQs5ytR43bViakVCgEeR5G6h3icHSombLUzS3DGpMkLKRycyzIiakh0picIj32E1SPb8JHJ939Syxx5ic4PPoPkvj6KOtNGP9qWuH0h49lCbH6qz34nP8evrjxQ/132", + "content": "wx-agent-isolation-b141279dd42c4a0889db0fee566eed13" + }, + { + "LocalId": 11, + "ServerId": 5851996799725995532, + "Type": 1, + "timestamp": "2026-09-06T02:58:54.0000000+00:00", + "sender": "yanghao880130", + "IsSelf": true, + "senderName": "Rogee", + "senderAvatarUrl": "https://wx.qlogo.cn/mmhead/ver_1/kPXMiaO9asy0mdTLsl3xibfcOw7K4VExiaIbQs5ytR43bViakVCgEeR5G6h3icHSombLUzS3DGpMkLKRycyzIiakh0picIj32E1SPb8JHJ939Syxx5ic4PPoPkvj6KOtNGP9qWuH0h49lCbH6qz34nP8evrjxQ/132", + "content": "wx-agent-repeat-cdd63033b3a7461faa7d50ae983a1136" + }, + { + "LocalId": 10, + "ServerId": 5200515901135764260, + "Type": 1, + "timestamp": "2026-09-06T02:58:52.0000000+00:00", + "sender": "yanghao880130", + "IsSelf": true, + "senderName": "Rogee", + "senderAvatarUrl": "https://wx.qlogo.cn/mmhead/ver_1/kPXMiaO9asy0mdTLsl3xibfcOw7K4VExiaIbQs5ytR43bViakVCgEeR5G6h3icHSombLUzS3DGpMkLKRycyzIiakh0picIj32E1SPb8JHJ939Syxx5ic4PPoPkvj6KOtNGP9qWuH0h49lCbH6qz34nP8evrjxQ/132", + "content": "wx-agent-repeat-cdd63033b3a7461faa7d50ae983a1136" + }, + { + "LocalId": 9, + "ServerId": 4464958670805689554, + "Type": 1, + "timestamp": "2026-09-06T02:58:42.0000000+00:00", + "sender": "yanghao880130", + "IsSelf": true, + "senderName": "Rogee", + "senderAvatarUrl": "https://wx.qlogo.cn/mmhead/ver_1/kPXMiaO9asy0mdTLsl3xibfcOw7K4VExiaIbQs5ytR43bViakVCgEeR5G6h3icHSombLUzS3DGpMkLKRycyzIiakh0picIj32E1SPb8JHJ939Syxx5ic4PPoPkvj6KOtNGP9qWuH0h49lCbH6qz34nP8evrjxQ/132", + "content": "wx-agent-m6-listener-de9a21ab58f84218b281742c652b46ce" + }, + { + "LocalId": 8, + "ServerId": 1416057757091682053, + "Type": 1, + "timestamp": "2026-09-05T17:36:15.0000000+00:00", + "sender": "yanghao880130", + "IsSelf": true, + "senderName": "Rogee", + "senderAvatarUrl": "https://wx.qlogo.cn/mmhead/ver_1/kPXMiaO9asy0mdTLsl3xibfcOw7K4VExiaIbQs5ytR43bViakVCgEeR5G6h3icHSombLUzS3DGpMkLKRycyzIiakh0picIj32E1SPb8JHJ939Syxx5ic4PPoPkvj6KOtNGP9qWuH0h49lCbH6qz34nP8evrjxQ/132", + "content": "wx-agent-m6-listener-62b19f3a1a63490ba0f2c6a47cc6e9ab" + }, + { + "LocalId": 7, + "ServerId": 4961661980547358864, + "Type": 1, + "timestamp": "2026-09-04T15:00:15.0000000+00:00", + "sender": "yanghao880130", + "IsSelf": true, + "senderName": "Rogee", + "senderAvatarUrl": "https://wx.qlogo.cn/mmhead/ver_1/kPXMiaO9asy0mdTLsl3xibfcOw7K4VExiaIbQs5ytR43bViakVCgEeR5G6h3icHSombLUzS3DGpMkLKRycyzIiakh0picIj32E1SPb8JHJ939Syxx5ic4PPoPkvj6KOtNGP9qWuH0h49lCbH6qz34nP8evrjxQ/132", + "content": "@所有人 HELLO AL" + } + ] +} diff --git a/docs/validation/evidence/Message-Model-20260906/scan-cache-skip.json b/docs/validation/evidence/Message-Model-20260906/scan-cache-skip.json new file mode 100644 index 0000000..95a0535 --- /dev/null +++ b/docs/validation/evidence/Message-Model-20260906/scan-cache-skip.json @@ -0,0 +1,136 @@ +{ + "scanned": false, + "processCount": 0, + "candidateCount": 0, + "verificationAttempts": 0, + "verifiedDatabaseCount": 20, + "savedTo": "C:\\Users\\Rogee\\AppData\\Local\\WxAgent\\database-keys.json", + "accounts": [ + { + "AccountRootFingerprint": "a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277", + "WechatVersion": "4.1.13.63", + "databases": [ + { + "RelativePath": "bizchat/bizchat.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9099054+00:00" + }, + { + "RelativePath": "chatbot/chatbot_message.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9094985+00:00" + }, + { + "RelativePath": "contact/contact.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9096969+00:00" + }, + { + "RelativePath": "contact/contact_fts.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9094345+00:00" + }, + { + "RelativePath": "emoticon/emoticon.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9095737+00:00" + }, + { + "RelativePath": "favorite/favorite.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9098057+00:00" + }, + { + "RelativePath": "favorite/favorite_fts.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9097808+00:00" + }, + { + "RelativePath": "general/general.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9096221+00:00" + }, + { + "RelativePath": "hardlink/hardlink.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9097495+00:00" + }, + { + "RelativePath": "head_image/head_image.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9093256+00:00" + }, + { + "RelativePath": "message/biz_message_0.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9098307+00:00" + }, + { + "RelativePath": "message/media_0.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9095265+00:00" + }, + { + "RelativePath": "message/message_0.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.909675+00:00" + }, + { + "RelativePath": "message/message_fts.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9095985+00:00" + }, + { + "RelativePath": "message/message_resource.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9095507+00:00" + }, + { + "RelativePath": "message/weclaw.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9098845+00:00" + }, + { + "RelativePath": "session/session.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9094761+00:00" + }, + { + "RelativePath": "sns/sns.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9097239+00:00" + }, + { + "RelativePath": "solitaire/solitaire.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.9098598+00:00" + }, + { + "RelativePath": "third_app_icon/third_app_icon.db", + "SourceProcessId": 8912, + "Confidence": "PageHmacVerified", + "VerifiedAt": "2026-09-06T05:17:37.909648+00:00" + } + ] + } + ] +} diff --git a/src/WxAgent.Core/KeyCachePlanner.cs b/src/WxAgent.Core/KeyCachePlanner.cs new file mode 100644 index 0000000..bfce1e3 --- /dev/null +++ b/src/WxAgent.Core/KeyCachePlanner.cs @@ -0,0 +1,32 @@ +namespace WxAgent.Core; + +public sealed record AccountDatabaseEntry(string Fingerprint, string RelativePath); + +/// Decides which verified keys may be reused from cache, avoiding repeated memory scans for cached accounts. +public static class KeyCachePlanner +{ + public static string Key(string accountFingerprint, string relativePath) => + $"{accountFingerprint}|{relativePath}"; + + public static IReadOnlySet VerifiedKeys(IEnumerable cached) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var account in cached) + foreach (var database in account.Databases) + set.Add(Key(account.AccountRootFingerprint, database.RelativePath)); + return set; + } + + public static bool HasPending(IEnumerable discovered, IReadOnlySet verified) + { + foreach (var entry in discovered) + { + if (!verified.Contains(Key(entry.Fingerprint, entry.RelativePath))) + { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/WxAgent.Core/MemoryPatternScanner.cs b/src/WxAgent.Core/MemoryPatternScanner.cs new file mode 100644 index 0000000..2b5a09b --- /dev/null +++ b/src/WxAgent.Core/MemoryPatternScanner.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; + +namespace WxAgent.Core; + +/// Finds a fixed byte pattern across contiguous read-only memory chunks. +public sealed class MemoryPatternScanner : IDisposable +{ + private readonly byte[] _pattern; + private readonly byte[] _tail; + private int _tailLength; + private ulong? _end; + + public MemoryPatternScanner(byte[] pattern) + { + if (pattern.Length is < 1 or > 1024) throw new ArgumentOutOfRangeException(nameof(pattern)); + _pattern = pattern.ToArray(); + _tail = new byte[pattern.Length - 1]; + } + + public IReadOnlyList Feed(ReadOnlySpan bytes, ulong address) + { + if (_end != address) _tailLength = 0; + var matches = new List(); + Span boundary = stackalloc byte[_tail.Length * 2]; + var headLength = Math.Min(bytes.Length, _tail.Length); + _tail.AsSpan(0, _tailLength).CopyTo(boundary); + bytes[..headLength].CopyTo(boundary[_tailLength..]); + var joined = boundary[..(_tailLength + headLength)]; + for (var i = 0; i < _tailLength; i++) + if (i + _pattern.Length > _tailLength && i + _pattern.Length <= joined.Length + && joined.Slice(i, _pattern.Length).SequenceEqual(_pattern)) + matches.Add(address - (ulong)_tailLength + (ulong)i); + var offset = 0; + while (offset <= bytes.Length - _pattern.Length) + { + var found = bytes[offset..].IndexOf(_pattern); + if (found < 0) break; + offset += found; + matches.Add(address + (ulong)offset); + offset++; + } + if (bytes.Length >= _tail.Length) + { + bytes[^_tail.Length..].CopyTo(_tail); + _tailLength = _tail.Length; + } + else + { + _tailLength = Math.Min(joined.Length, _tail.Length); + joined[^_tailLength..].CopyTo(_tail); + } + CryptographicOperations.ZeroMemory(boundary); + _end = checked(address + (ulong)bytes.Length); + return matches; + } + + public void Dispose() => CryptographicOperations.ZeroMemory(_tail); +} diff --git a/src/WxAgent.Core/WcdbConfigCipher.cs b/src/WxAgent.Core/WcdbConfigCipher.cs new file mode 100644 index 0000000..8b18b1a --- /dev/null +++ b/src/WxAgent.Core/WcdbConfigCipher.cs @@ -0,0 +1,51 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace WxAgent.Core; + +/// Decodes bounded Windows WCDB Config.Cipher blobs. Candidates still require page HMAC verification. +public static class WcdbConfigCipher +{ + public const string TypeName = "com.Tencent.WCDB.Config.Cipher"; + public const int MaximumBlobLength = 1024; + // Windows 4.1.x storage layout, independently verified against each target DB before use. + private static readonly byte[] Mask = Convert.FromHexString("d2c7442458020000004889442450488b450048844c2448488944254048584c24"); + + public static IReadOnlyList Decode(ReadOnlySpan blob) + { + if (blob.Length is 0 or > MaximumBlobLength) return []; + var decoded = blob.ToArray(); + try + { + for (var i = 0; i < decoded.Length; i++) decoded[i] ^= Mask[i % Mask.Length]; + var candidates = new HashSet(); + foreach (Match match in Regex.Matches(Encoding.ASCII.GetString(decoded), "[xX]'([0-9a-fA-F]{64,192})'", RegexOptions.None, TimeSpan.FromSeconds(1))) + { + var run = match.Groups[1].Value.ToLowerInvariant(); + var starts = new HashSet { 0 }; + if (run.Length > 96) + { + for (var start = 0; start + 64 <= run.Length; start += 32) starts.Add(start); + starts.Add(run.Length - 64); + } + foreach (var start in starts) + { + var keyHex = run.Substring(start, 64); + var key = Convert.FromHexString(keyHex); + var distinct = key.Distinct().Count(); + var allZero = key.All(byteValue => byteValue == 0); + var allOne = key.All(byteValue => byteValue == 0xff); + if (distinct < 15 || allZero || allOne) continue; + candidates.Add(new WcdbKeyCandidate(keyHex, + start + 96 <= run.Length ? run.Substring(start + 64, 32) : null)); + } + } + return candidates.ToArray(); + } + finally + { + CryptographicOperations.ZeroMemory(decoded); + } + } +} diff --git a/src/WxAgent.Core/WcdbKeyCandidateScanner.cs b/src/WxAgent.Core/WcdbKeyCandidateScanner.cs index 1a2d7bd..1657b49 100644 --- a/src/WxAgent.Core/WcdbKeyCandidateScanner.cs +++ b/src/WxAgent.Core/WcdbKeyCandidateScanner.cs @@ -13,12 +13,34 @@ public sealed class WcdbKeyCandidateScanner private readonly char[] _saltSuffix = new char[SaltHexLength]; private readonly HashSet _seen = new(StringComparer.Ordinal); private long _runLength; + private readonly bool _utf16; + private bool _hasLowByte; + private byte _lowByte; + + public WcdbKeyCandidateScanner(bool utf16 = false) => _utf16 = utf16; public IReadOnlyList Feed(ReadOnlySpan bytes, bool finalBlock = false) { var found = new List(); - foreach (var value in bytes) + foreach (var rawValue in bytes) { + var value = rawValue; + if (_utf16) + { + if (!_hasLowByte) + { + _lowByte = value; + _hasLowByte = true; + continue; + } + _hasLowByte = false; + if (value != 0) + { + CompleteRun(found); + continue; + } + value = _lowByte; + } if (IsAsciiHex(value)) { AppendHex(value); @@ -32,6 +54,7 @@ public sealed class WcdbKeyCandidateScanner if (finalBlock) { CompleteRun(found); + _hasLowByte = false; } return found; diff --git a/src/WxAgent.Core/WechatDbMessage.cs b/src/WxAgent.Core/WechatDbMessage.cs new file mode 100644 index 0000000..33bdf61 --- /dev/null +++ b/src/WxAgent.Core/WechatDbMessage.cs @@ -0,0 +1,63 @@ +using System.Security.Cryptography; +using System.Text; +using ZstdSharp; + +namespace WxAgent.Core; + +/// Database-backed message with stable identity, sender and timestamps. +public sealed record DbMessage( + long LocalId, + long ServerId, + string ChatId, + string? SenderWxId, + string? SenderName, + string? SenderAvatarUrl, + int Type, + string Content, + DateTimeOffset Timestamp, + bool? IsSelf); + +public static class WechatDbMessage +{ + // WeChat 4.x stores message_content as hex; rows with WCDB_CT_message_content=1 are zstd-compressed (magic 28 b5 2f fd). + public static string TableNameFor(string chatId) => + "Msg_" + Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(chatId))).ToLowerInvariant(); + + public static string DecodeContent(string hexContent, bool compressed) + { + if (string.IsNullOrEmpty(hexContent)) return string.Empty; + byte[] bytes; + try + { + bytes = Convert.FromHexString(hexContent); + } + catch (FormatException) + { + return string.Empty; + } + + if (compressed && bytes.Length >= 4 && bytes[0] == 0x28 && bytes[1] == 0xb5 && bytes[2] == 0x2f && bytes[3] == 0xfd) + { + try + { + using var source = new MemoryStream(bytes); + using var decompressor = new DecompressionStream(source); + using var reader = new StreamReader(decompressor, Encoding.UTF8); + return reader.ReadToEnd(); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + return string.Empty; + } + } + + return compressed ? string.Empty : Encoding.UTF8.GetString(bytes); + } + + /// The account root directory name is <wxid>_<random>; the reference project treats the wxid prefix as self. + public static bool? IsSelf(string? senderWxId, string? accountRootDirectoryName) + { + if (string.IsNullOrEmpty(senderWxId) || string.IsNullOrEmpty(accountRootDirectoryName)) return null; + return accountRootDirectoryName.StartsWith(senderWxId + "_", StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/src/WxAgent.Core/WxAgent.Core.csproj b/src/WxAgent.Core/WxAgent.Core.csproj index bfa77a1..eb04a91 100644 --- a/src/WxAgent.Core/WxAgent.Core.csproj +++ b/src/WxAgent.Core/WxAgent.Core.csproj @@ -4,4 +4,7 @@ enable enable + + + diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 4809203..349caf8 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -101,7 +101,7 @@ try } case "listener-smoke": { - var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken, HasOption(args, "--independent")); + var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken, HasOption(args, "--independent"), GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant); WriteJson(result); return result.Sent && result.MatchingEvents == 1 && result.EventsAfterRestart == 0 && result.Snapshots >= 2 && result.CheckpointSaved ? 0 : 2; @@ -273,7 +273,7 @@ try } case "chat" when args[1] == "send": { - var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken); + var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken, GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant); WriteJson(ToMessageOutput(message, includeContent: false)); return 0; } @@ -368,24 +368,48 @@ try case "db" when args[1] == "scan": { var dataRoot = GetOption(args, "--data-root"); - var result = WechatDatabaseScanner.Scan(dataRoot, cancellationToken); - string? savedTo = null; - if (HasOption(args, "--save")) + var savedTo = HasOption(args, "--save") ? GetOption(args, "--key-file") ?? DatabaseKeyStore.DefaultPath : null; + var existing = savedTo is null ? Array.Empty() : await DatabaseKeyStore.LoadAsync(savedTo, cancellationToken); + var verified = KeyCachePlanner.VerifiedKeys(existing); + + var accounts = WechatDatabaseDiscovery.FindAccountRoots(dataRoot, cancellationToken); + var discovered = accounts.SelectMany(account => account.Databases.Select(database => + new AccountDatabaseEntry(account.Fingerprint, database.RelativePath))).ToArray(); + var scanned = KeyCachePlanner.HasPending(discovered, verified); + + var processCount = 0; + var candidateCount = 0; + var verificationAttempts = 0; + AccountKeySet[] finalAccounts; + if (scanned) { - savedTo = GetOption(args, "--key-file") ?? DatabaseKeyStore.DefaultPath; - var existing = await DatabaseKeyStore.LoadAsync(savedTo, cancellationToken); - var merged = AccountKeySetMerge.Merge(existing, result.Accounts); - await DatabaseKeyStore.SaveAsync(merged, savedTo, cancellationToken); + var result = WechatDatabaseScanner.Scan(dataRoot, cancellationToken, verified); + processCount = result.ProcessCount; + candidateCount = result.CandidateCount; + verificationAttempts = result.VerificationAttempts; + finalAccounts = savedTo is null + ? result.Accounts.ToArray() + : AccountKeySetMerge.Merge(existing, result.Accounts).ToArray(); + } + else + { + finalAccounts = existing.ToArray(); + } + + if (savedTo is not null) + { + await DatabaseKeyStore.SaveAsync(finalAccounts, savedTo, cancellationToken); } WriteJson(new { - result.ProcessCount, - result.CandidateCount, - result.DatabaseCount, - verifiedDatabaseCount = result.Accounts.Sum(account => account.Databases.Count), + scanned, + processCount, + candidateCount, + verificationAttempts, + verifiedDatabaseCount = finalAccounts.Sum(account => account.Databases.Count), savedTo, - accounts = result.Accounts.Select(account => new + accounts = finalAccounts.Select(account => new { account.AccountRootFingerprint, account.WechatVersion, @@ -398,7 +422,7 @@ try }) }) }); - return result.Accounts.Any(account => account.Databases.Count > 0) ? 0 : 3; + return finalAccounts.Any(account => account.Databases.Count > 0) ? 0 : 3; } case "db" when args[1] == "status": { @@ -438,6 +462,98 @@ try WriteJson(new { account = account.AccountRootFingerprint, database = database.RelativePath, metadata }); return 0; } + case "db" when args[1] == "schema": + { + var accountId = GetRequiredOption(args, "--account"); + var relativePath = GetRequiredOption(args, "--database").Replace('\\', '/'); + var tableName = GetRequiredOption(args, "--table"); + var keyFile = GetOption(args, "--key-file"); + if (!tableName.All(character => char.IsAsciiLetterOrDigit(character) || character == '_')) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Table name may only contain letters, digits and underscores."); + } + + var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken); + var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store."); + var database = account.Databases.SingleOrDefault(item => string.Equals(item.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Database path was not found for the selected account."); + var fullPath = Path.GetFullPath(Path.Combine(account.AccountRootPath, database.RelativePath.Replace('/', Path.DirectorySeparatorChar))); + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(fullPath, database.EncKey, $"PRAGMA table_info({tableName});", null, cancellationToken); + WriteJson(new + { + account = account.AccountRootFingerprint, + database = database.RelativePath, + table = tableName, + columns = rows.Select(row => new + { + name = row.GetValueOrDefault("name"), + type = row.GetValueOrDefault("type"), + notNull = row.GetValueOrDefault("notnull") + }) + }); + return 0; + } + case "db" when args[1] == "messages": + { + var accountId = GetRequiredOption(args, "--account"); + var chatId = GetRequiredOption(args, "--chat"); + var keyFile = GetOption(args, "--key-file"); + var limit = GetPositiveIntOption(args, "--limit", 50, 500); + var includeContent = HasOption(args, "--include-content"); + var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken); + var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store."); + var messages = await WechatMessageDbReader.ReadAsync(account.AccountRootPath, account.Databases, chatId, limit, cancellationToken); + WriteJson(new + { + chatId, + count = messages.Count, + messages = messages.Select(message => new + { + message.LocalId, + message.ServerId, + message.Type, + timestamp = message.Timestamp.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + sender = includeContent ? message.SenderWxId : MaskIdentifier(message.SenderWxId), + message.IsSelf, + senderName = includeContent ? message.SenderName : null, + senderAvatarUrl = includeContent ? message.SenderAvatarUrl : null, + content = includeContent ? message.Content : null + }) + }); + return 0; + } + case "db" when args[1] == "contacts": + { + var accountId = GetRequiredOption(args, "--account"); + var keyFile = GetOption(args, "--key-file"); + var contains = GetOption(args, "--contains"); + var includeContent = HasOption(args, "--include-content"); + var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken); + var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store."); + var contactDatabase = account.Databases.SingleOrDefault(item => string.Equals(item.RelativePath, "contact/contact.db", StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "contact.db was not found in the key store."); + var fullPath = Path.GetFullPath(Path.Combine(account.AccountRootPath, "contact", "contact.db")); + var sql = contains is null + ? "SELECT username, nick_name, remark, small_head_url FROM contact ORDER BY username LIMIT 200;" + : "SELECT username, nick_name, remark, small_head_url FROM contact WHERE remark LIKE $like OR nick_name LIKE $like OR username LIKE $like LIMIT 200;"; + var parameters = contains is null ? null : new[] { new KeyValuePair("$like", "%" + contains + "%") }; + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(fullPath, contactDatabase.EncKey, sql, parameters, cancellationToken); + WriteJson(new + { + count = rows.Count, + contacts = rows.Select(row => new + { + username = includeContent ? row.GetValueOrDefault("username") : MaskIdentifier(row.GetValueOrDefault("username") as string), + nickName = includeContent ? row.GetValueOrDefault("nick_name") : null, + remark = includeContent ? row.GetValueOrDefault("remark") : null, + avatarUrl = includeContent ? row.GetValueOrDefault("small_head_url") : null + }) + }); + return 0; + } default: throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help."); } @@ -475,7 +591,7 @@ static int ValidateCommandLine(string[] values) if (values[0] == "listener-smoke") { - ValidateOptions(values, 1, ["--output", "--timeout"], ["--independent"]); + ValidateOptions(values, 1, ["--output", "--session", "--timeout"], ["--independent"]); return 60; } @@ -550,7 +666,7 @@ static int ValidateCommandLine(string[] values) ValidateOptions(values, 2, ["--to", "--path", "--confirm", "--timeout"], []); return 60; case "send": - ValidateOptions(values, 2, ["--text", "--timeout"], []); + ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []); return 30; case "reply-latest": ValidateOptions(values, 2, ["--text", "--timeout"], []); @@ -611,6 +727,15 @@ static int ValidateCommandLine(string[] values) case "query": ValidateOptions(values, 2, ["--account", "--database", "--key-file", "--timeout"], []); return 30; + case "messages": + ValidateOptions(values, 2, ["--account", "--chat", "--key-file", "--limit", "--timeout"], ["--include-content"]); + return 60; + case "contacts": + ValidateOptions(values, 2, ["--account", "--key-file", "--contains", "--timeout"], ["--include-content"]); + return 60; + case "schema": + ValidateOptions(values, 2, ["--account", "--database", "--table", "--key-file", "--timeout"], []); + return 60; default: throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown database command. Run WxAgent.Host help."); } @@ -655,6 +780,11 @@ static string? GetOption(string[] values, string name) static string GetRequiredOption(string[] values, string name) => GetOption(values, name) ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing required option {name}."); +static string? MaskIdentifier(string? identifier) => + string.IsNullOrEmpty(identifier) + ? identifier + : "sha256:" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(identifier)))[..16]; + static bool HasOption(string[] values, string name) => Array.IndexOf(values, name) >= 0; static int GetTimeoutSeconds(string[] values, int fallback) @@ -724,7 +854,7 @@ WxAgent.Host commands: m5-smoke [--timeout 60] tray-status [--timeout 30] window-status [--timeout 30] - listener-smoke --output [--independent] [--timeout 60] + listener-smoke --output [--session ] [--independent] [--timeout 60] recovery-smoke [--timeout 30] recover-ui [--timeout 30] stability-smoke [--seconds 15] [--sample-seconds 5] [--output ] @@ -733,7 +863,7 @@ WxAgent.Host commands: group verify-at-all --group --message [--timeout 30] chat send-url-card --to --url [--message ] --confirm CONFIRM [--timeout 60] chat send-audio --to --path --confirm CONFIRM [--timeout 60] - chat send --text [--timeout 30] + chat send --text [--session ] [--timeout 30] chat reply-latest --text [--timeout 60] chat send-file --path [--timeout 60] chat send-image --path [--timeout 60] @@ -748,8 +878,11 @@ WxAgent.Host commands: db scan [--data-root ] [--save] [--key-file ] [--timeout 120] db status [--key-file ] [--timeout 30] db query --account --database [--key-file ] [--timeout 30] + db messages --account --chat [--limit 50] [--include-content] [--key-file ] [--timeout 60] + db contacts --account [--contains ] [--include-content] [--key-file ] [--timeout 60] -Chat commands are restricted to File Transfer Assistant. Message content is omitted unless --include-content is explicit. +Chat commands default to File Transfer Assistant; send/monitor and listener-smoke accept --session. +Message content is omitted unless --include-content is explicit. M4 modifying operations are library APIs and require the exact confirmation token CONFIRM. -Database keys are never printed. db scan only saves verified keys when --save is present. +Database keys are never printed. db scan only saves verified keys when --save is present. Already verified accounts are skipped by the key cache. """); diff --git a/src/WxAgent.Windows/ProcessMemoryScanner.cs b/src/WxAgent.Windows/ProcessMemoryScanner.cs index e6b6779..c9f3b4c 100644 --- a/src/WxAgent.Windows/ProcessMemoryScanner.cs +++ b/src/WxAgent.Windows/ProcessMemoryScanner.cs @@ -1,5 +1,8 @@ +using System.Buffers.Binary; using System.ComponentModel; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.System.Memory; @@ -38,7 +41,14 @@ public static unsafe class ProcessMemoryScanner try { - return Scan(process, cancellationToken); + var found = new List(ScanLegacyHex(process, cancellationToken)); + var seen = new HashSet(found, WcdbKeyCandidateComparer.Instance); + foreach (var candidate in ScanConfigCipher(process, cancellationToken)) + { + if (seen.Add(candidate)) found.Add(candidate); + } + + return found; } finally { @@ -46,9 +56,9 @@ public static unsafe class ProcessMemoryScanner } } - private static IReadOnlyList Scan(HANDLE process, CancellationToken cancellationToken) + private static IReadOnlyList ScanLegacyHex(HANDLE process, CancellationToken cancellationToken) { - var scanner = new WcdbKeyCandidateScanner(); + WcdbKeyCandidateScanner[] scanners = [new(), new(utf16: true)]; var found = new List(); ulong? contiguousEnd = null; ulong address = 0; @@ -76,17 +86,17 @@ public static unsafe class ProcessMemoryScanner if (!IsReadable(region) || regionSize > MaximumRegionSize) { - BreakRun(scanner, found); + BreakRun(scanners, found); contiguousEnd = null; } else { if (contiguousEnd != baseAddress) { - BreakRun(scanner, found); + BreakRun(scanners, found); } - contiguousEnd = ReadRegion(process, baseAddress, regionSize, scanner, found, cancellationToken) + contiguousEnd = ReadRegion(process, baseAddress, regionSize, scanners, found, cancellationToken) ? next : null; } @@ -94,15 +104,153 @@ public static unsafe class ProcessMemoryScanner address = next; } - BreakRun(scanner, found); + BreakRun(scanners, found); return found; } + private static IReadOnlyList ScanConfigCipher(HANDLE process, CancellationToken cancellationToken) + { + var typeName = Encoding.ASCII.GetBytes(WcdbConfigCipher.TypeName); + var needles = new HashSet(); + WalkRegions(process, cancellationToken, (buffer, length, address) => + { + using var scanner = new MemoryPatternScanner(typeName); + foreach (var match in scanner.Feed(buffer.AsSpan(0, length), address)) needles.Add(match); + }); + if (needles.Count == 0) return []; + + var nodeBases = new HashSet(); + foreach (var needle in needles) + { + cancellationToken.ThrowIfCancellationRequested(); + var pair = new byte[16]; + BinaryPrimitives.WriteUInt64LittleEndian(pair.AsSpan(0, 8), needle); + BinaryPrimitives.WriteUInt64LittleEndian(pair.AsSpan(8, 8), (ulong)typeName.Length); + using var pairScanner = new MemoryPatternScanner(pair); + WalkRegions(process, cancellationToken, (buffer, length, address) => + { + foreach (var match in pairScanner.Feed(buffer.AsSpan(0, length), address)) nodeBases.Add(match - 0x10); + }); + } + + var found = new List(); + foreach (var nodeBase in nodeBases) + { + cancellationToken.ThrowIfCancellationRequested(); + var node = ReadBytes(process, nodeBase, 0x50); + if (node is null) continue; + try + { + var stringPointer = ReadUInt64(node, 0x10); + var stringLength = ReadUInt64(node, 0x18); + if (!needles.Contains(stringPointer) || stringLength != (ulong)typeName.Length) continue; + var configPointer = ReadUInt64(node, 0x28); + if (configPointer is < 0x10000 or > MaximumUserAddress) continue; + var obj = ReadBytes(process, configPointer + 0x88, 0x28); + if (obj is null) continue; + var dataPointer = ReadUInt64(obj, 0x8); + var dataLength = ReadUInt64(obj, 0x10); + if (dataLength == 0 || dataLength > WcdbConfigCipher.MaximumBlobLength + || dataPointer is < 0x10000 or > MaximumUserAddress) continue; + var blob = ReadBytes(process, dataPointer, (int)dataLength); + if (blob is null || blob.Length != (int)dataLength) continue; + foreach (var candidate in WcdbConfigCipher.Decode(blob)) found.Add(candidate); + } + finally + { + CryptographicOperations.ZeroMemory(node); + } + } + + return found; + } + + private static void WalkRegions(HANDLE process, CancellationToken cancellationToken, Action visit) + { + ulong address = 0; + while (address < MaximumUserAddress) + { + cancellationToken.ThrowIfCancellationRequested(); + MEMORY_BASIC_INFORMATION region; + if (PInvoke.VirtualQueryEx(process, (void*)address, ®ion, (nuint)sizeof(MEMORY_BASIC_INFORMATION)) == 0) + { + break; + } + + var baseAddress = (ulong)region.BaseAddress; + var regionSize = (ulong)region.RegionSize; + if (regionSize == 0 || baseAddress > ulong.MaxValue - regionSize) + { + break; + } + + var next = baseAddress + regionSize; + if (next <= address) + { + break; + } + + if (IsReadable(region) && regionSize <= MaximumRegionSize) + { + var offset = 0UL; + while (offset < regionSize) + { + cancellationToken.ThrowIfCancellationRequested(); + var count = (int)Math.Min((ulong)ChunkSize, regionSize - offset); + var buffer = GC.AllocateUninitializedArray(count); + nuint bytesRead = 0; + var ok = false; + fixed (byte* destination = buffer) + { + ok = PInvoke.ReadProcessMemory(process, (void*)(baseAddress + offset), destination, (nuint)count, &bytesRead); + } + + try + { + if (ok && bytesRead == (nuint)count) + visit(buffer, count, baseAddress + offset); + } + finally + { + CryptographicOperations.ZeroMemory(buffer); + } + if (!ok || bytesRead != (nuint)count) break; + offset += (ulong)count; + } + } + + address = next; + } + } + + private static byte[]? ReadBytes(HANDLE process, ulong address, int length) + { + if (length <= 0 || address > MaximumUserAddress - (ulong)length) return null; + var buffer = GC.AllocateUninitializedArray(length); + nuint bytesRead = 0; + var ok = false; + fixed (byte* destination = buffer) + { + ok = PInvoke.ReadProcessMemory(process, (void*)address, destination, (nuint)length, &bytesRead); + } + + if (!ok || bytesRead != (nuint)length) + { + CryptographicOperations.ZeroMemory(buffer); + return null; + } + + return buffer; + } + + private static ulong ReadUInt64(ReadOnlySpan buffer, int offset) => + offset + 8 <= buffer.Length ? BinaryPrimitives.ReadUInt64LittleEndian(buffer.Slice(offset, 8)) : 0; + private static bool ReadRegion( HANDLE process, ulong baseAddress, ulong regionSize, - WcdbKeyCandidateScanner scanner, + IReadOnlyList scanners, List found, CancellationToken cancellationToken) { @@ -114,21 +262,29 @@ public static unsafe class ProcessMemoryScanner var count = (int)Math.Min((ulong)ChunkSize, regionSize - offset); var buffer = GC.AllocateUninitializedArray(count); nuint bytesRead = 0; - bool success; + var success = false; fixed (byte* destination = buffer) { success = PInvoke.ReadProcessMemory(process, (void*)(baseAddress + offset), destination, (nuint)count, &bytesRead); } - if (bytesRead > 0) + try { - var length = checked((int)Math.Min(bytesRead, (nuint)count)); - found.AddRange(scanner.Feed(buffer.AsSpan(0, length))); + if (bytesRead > 0) + { + var length = checked((int)Math.Min(bytesRead, (nuint)count)); + foreach (var scanner in scanners) + found.AddRange(scanner.Feed(buffer.AsSpan(0, length))); + } + } + finally + { + CryptographicOperations.ZeroMemory(buffer); } if (!success || bytesRead != (nuint)count) { - BreakRun(scanner, found); + BreakRun(scanners, found); tailIsContiguous = false; } else @@ -142,8 +298,11 @@ public static unsafe class ProcessMemoryScanner return tailIsContiguous; } - private static void BreakRun(WcdbKeyCandidateScanner scanner, List found) => - found.AddRange(scanner.Feed([], finalBlock: true)); + private static void BreakRun(IEnumerable scanners, List found) + { + foreach (var scanner in scanners) + found.AddRange(scanner.Feed([], finalBlock: true)); + } private static bool IsReadable(MEMORY_BASIC_INFORMATION region) { @@ -155,4 +314,14 @@ public static unsafe class ProcessMemoryScanner var protection = (uint)region.Protect; return state == memCommit && (protection & (pageNoAccess | pageGuard)) == 0 && (protection & readableMask) != 0; } -} + + private sealed class WcdbKeyCandidateComparer : IEqualityComparer + { + public static readonly WcdbKeyCandidateComparer Instance = new(); + + public bool Equals(WcdbKeyCandidate? x, WcdbKeyCandidate? y) => + x is not null && y is not null && string.Equals(x.EncKey, y.EncKey, StringComparison.Ordinal); + + public int GetHashCode(WcdbKeyCandidate obj) => StringComparer.Ordinal.GetHashCode(obj.EncKey); + } +} \ No newline at end of file diff --git a/src/WxAgent.Windows/SqlCipherDatabaseReader.cs b/src/WxAgent.Windows/SqlCipherDatabaseReader.cs index a4d6268..d166dd2 100644 --- a/src/WxAgent.Windows/SqlCipherDatabaseReader.cs +++ b/src/WxAgent.Windows/SqlCipherDatabaseReader.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; +using System.Text; using WxAgent.Core; namespace WxAgent.Windows; @@ -15,43 +16,14 @@ public static class SqlCipherDatabaseReader public static async Task ReadMetadataAsync(string databasePath, string hexKey, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); - if (hexKey.Length != 64 || !hexKey.All(Uri.IsHexDigit)) - { - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database key must be exactly 64 hexadecimal characters."); - } - + Validate(databasePath, hexKey); _ = Initialized.Value; try { - var connectionString = new SqliteConnectionStringBuilder - { - DataSource = Path.GetFullPath(databasePath), - Mode = SqliteOpenMode.ReadOnly, - Cache = SqliteCacheMode.Private, - Pooling = false, - DefaultTimeout = 10 - }.ToString(); - - await using var connection = new SqliteConnection(connectionString); - await connection.OpenAsync(cancellationToken); - await using (var command = connection.CreateCommand()) - { - command.CommandText = $"PRAGMA key = \"x'{hexKey}'\";"; - command.CommandTimeout = 10; - await command.ExecuteNonQueryAsync(cancellationToken); - } - - await using (var command = connection.CreateCommand()) - { - command.CommandText = "PRAGMA cipher_compatibility = 4; PRAGMA query_only = ON;"; - command.CommandTimeout = 10; - await command.ExecuteNonQueryAsync(cancellationToken); - } - - var cipherVersion = await ReadCipherVersionAsync(connection, cancellationToken); - var sqliteVersion = await ReadSqliteVersionAsync(connection, cancellationToken); - var count = await ReadSchemaCountAsync(connection, cancellationToken); + await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false); + var cipherVersion = await ReadCipherVersionAsync(connection, cancellationToken).ConfigureAwait(false); + var sqliteVersion = await ReadSqliteVersionAsync(connection, cancellationToken).ConfigureAwait(false); + var count = await ReadSchemaCountAsync(connection, cancellationToken).ConfigureAwait(false); var names = new List(); await using (var command = connection.CreateCommand()) { @@ -70,6 +42,7 @@ public static class SqlCipherDatabaseReader { var probeName = "__wxagent_read_only_probe_" + Guid.NewGuid().ToString("N"); await using var command = connection.CreateCommand(); + // nosemgrep:csharp-sqli - The name is a random GUID produced above; it cannot be attacker-controlled. command.CommandText = $"CREATE TABLE {probeName}(value INTEGER);"; command.CommandTimeout = 10; await command.ExecuteNonQueryAsync(cancellationToken); @@ -92,6 +65,103 @@ public static class SqlCipherDatabaseReader } } + /// Runs a caller-provided read-only SQL query and returns rows keyed by column name. Values are limited to scalars and UTF-8 strings. + public static async Task>> QueryRowsAsync( + string databasePath, string hexKey, string sql, IReadOnlyList>? parameters, CancellationToken cancellationToken) + { + Validate(databasePath, hexKey); + _ = Initialized.Value; + var rows = new List>(); + try + { + await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + command.CommandTimeout = 10; + if (parameters is not null) + { + foreach (var parameter in parameters) + { + command.Parameters.AddWithValue(parameter.Key, parameter.Value ?? DBNull.Value); + } + } + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < reader.FieldCount; index++) + { + var value = reader.IsDBNull(index) ? null : reader.GetValue(index); + row[reader.GetName(index)] = Normalize(value); + } + + rows.Add(row); + } + + return rows; + } + catch (SqliteException exception) + { + throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not run the read-only query.", exception); + } + } + + private static void Validate(string databasePath, string hexKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); + if (hexKey.Length != 64 || !hexKey.All(Uri.IsHexDigit)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database key must be exactly 64 hexadecimal characters."); + } + } + + private static async Task OpenReadOnlyAsync(string databasePath, string hexKey, CancellationToken cancellationToken) + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = Path.GetFullPath(databasePath), + Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Private, + Pooling = false, + DefaultTimeout = 10 + }.ToString(); + + var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(cancellationToken); + try + { + await using (var command = connection.CreateCommand()) + { + // nosemgrep:csharp-sqli - hexKey is validated to exactly 64 hex chars before this call; no injection is possible. + command.CommandText = $"PRAGMA key = \"x'{hexKey}'\";"; + command.CommandTimeout = 10; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + await using (var command = connection.CreateCommand()) + { + command.CommandText = "PRAGMA cipher_compatibility = 4; PRAGMA query_only = ON;"; + command.CommandTimeout = 10; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + return connection; + } + catch + { + await connection.DisposeAsync(); + throw; + } + } + + private static object? Normalize(object? value) => value switch + { + byte[] bytes => bytes.Length <= 256 ? Encoding.UTF8.GetString(bytes) : $"[bytes:{bytes.Length}]", + DBNull => null, + _ => value + }; + private static async Task ReadCipherVersionAsync(SqliteConnection connection, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); @@ -115,4 +185,4 @@ public static class SqlCipherDatabaseReader command.CommandTimeout = 10; return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture); } -} +} \ No newline at end of file diff --git a/src/WxAgent.Windows/WechatDatabaseScanner.cs b/src/WxAgent.Windows/WechatDatabaseScanner.cs index 72b940c..f8d9bcb 100644 --- a/src/WxAgent.Windows/WechatDatabaseScanner.cs +++ b/src/WxAgent.Windows/WechatDatabaseScanner.cs @@ -3,11 +3,13 @@ using WxAgent.Core; namespace WxAgent.Windows; -public sealed record DatabaseScanResult(IReadOnlyList Accounts, int ProcessCount, int CandidateCount, int DatabaseCount); +public sealed record DatabaseScanResult(IReadOnlyList Accounts, int ProcessCount, int CandidateCount, int DatabaseCount, + int SaltMatchedBindings = 0, int VerificationAttempts = 0); public static class WechatDatabaseScanner { - public static DatabaseScanResult Scan(string? xwechatFilesRoot, CancellationToken cancellationToken) + public static DatabaseScanResult Scan(string? xwechatFilesRoot, CancellationToken cancellationToken, + IReadOnlySet? verifiedKeys = null) { var accounts = WechatDatabaseDiscovery.FindAccountRoots(xwechatFilesRoot, cancellationToken); if (accounts.Count == 0) @@ -24,6 +26,8 @@ public static class WechatDatabaseScanner var evidence = accounts.ToDictionary(account => account.Fingerprint, _ => new Dictionary(StringComparer.OrdinalIgnoreCase)); var candidateCount = 0; var scannedProcessCount = 0; + var saltMatchedBindings = 0; + var verificationAttempts = 0; string? version = null; foreach (var process in processes) { @@ -50,12 +54,15 @@ public static class WechatDatabaseScanner foreach (var database in account.Databases) { cancellationToken.ThrowIfCancellationRequested(); - if (evidence[account.Fingerprint].ContainsKey(database.RelativePath) || - candidate.SaltHint is not null && !string.Equals(candidate.SaltHint, database.Salt, StringComparison.OrdinalIgnoreCase) || - !SqlCipherPageVerifier.VerifyHexKey(database.FirstPage, candidate.EncKey)) + if (verifiedKeys?.Contains(KeyCachePlanner.Key(account.Fingerprint, database.RelativePath)) == true + || evidence[account.Fingerprint].ContainsKey(database.RelativePath) || + candidate.SaltHint is not null && !string.Equals(candidate.SaltHint, database.Salt, StringComparison.OrdinalIgnoreCase)) { continue; } + if (candidate.SaltHint is not null) saltMatchedBindings++; + verificationAttempts++; + if (!SqlCipherPageVerifier.VerifyHexKey(database.FirstPage, candidate.EncKey)) continue; evidence[account.Fingerprint][database.RelativePath] = new DatabaseKeyEvidence( database.RelativePath, @@ -81,7 +88,8 @@ public static class WechatDatabaseScanner DateTimeOffset.UtcNow, evidence[account.Fingerprint].Values.OrderBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray())).ToArray(); - return new DatabaseScanResult(results, processes.Length, candidateCount, accounts.Sum(account => account.Databases.Count)); + return new DatabaseScanResult(results, processes.Length, candidateCount, accounts.Sum(account => account.Databases.Count), + saltMatchedBindings, verificationAttempts); } private static string? SafeVersion(Process process) diff --git a/src/WxAgent.Windows/WechatMessageDbReader.cs b/src/WxAgent.Windows/WechatMessageDbReader.cs new file mode 100644 index 0000000..fb50857 --- /dev/null +++ b/src/WxAgent.Windows/WechatMessageDbReader.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using WxAgent.Core; + +namespace WxAgent.Windows; + +/// Reads chat messages from the account's SQLCipher message databases using cached verified keys. +public static class WechatMessageDbReader +{ + public static async Task> ReadAsync( + string accountRootPath, IReadOnlyList databases, string chatId, int limit, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accountRootPath); + ArgumentException.ThrowIfNullOrWhiteSpace(chatId); + if (limit is < 1 or > 500) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Message limit must be between 1 and 500."); + + // AccountRootPath points at the account's db_storage folder; the directory above it is _. + var accountRootDirectoryName = Path.GetFileName(Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(accountRootPath)))); + + var tableName = WechatDbMessage.TableNameFor(chatId); + if (!tableName.StartsWith("Msg_", StringComparison.Ordinal) || tableName.Length != 36 || !tableName[4..].All(Uri.IsHexDigit)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Message table name could not be derived from the chat id."); + } + var messageDatabase = await FindMessageDatabaseAsync(accountRootPath, databases, tableName, cancellationToken).ConfigureAwait(false); + if (messageDatabase is null) + { + return Array.Empty(); + } + + var sql = "SELECT m.local_id, m.server_id, m.local_type, m.create_time, " + + "hex(m.message_content) AS hex_content, m.WCDB_CT_message_content AS is_compressed, n.user_name AS sender_wxid " + + $"FROM \"{tableName}\" m LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " + + "ORDER BY m.create_time DESC LIMIT $limit;"; + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(messageDatabase.Value.Path, messageDatabase.Value.EncKey, sql, + [new KeyValuePair("$limit", (long)limit)], cancellationToken).ConfigureAwait(false); + + var senders = rows.Select(row => row.GetValueOrDefault("sender_wxid") as string) + .Where(sender => !string.IsNullOrEmpty(sender)).Cast().Distinct(StringComparer.Ordinal).ToArray(); + var contacts = await ReadContactsAsync(accountRootPath, databases, senders, cancellationToken).ConfigureAwait(false); + + var messages = new List(rows.Count); + foreach (var row in rows) + { + var senderWxId = row.GetValueOrDefault("sender_wxid") as string; + var content = WechatDbMessage.DecodeContent( + row.GetValueOrDefault("hex_content") as string ?? string.Empty, + Convert.ToInt64(row.GetValueOrDefault("is_compressed") ?? 0L, CultureInfo.InvariantCulture) != 0); + var contact = senderWxId is null ? null : contacts.GetValueOrDefault(senderWxId); + messages.Add(new DbMessage( + Convert.ToInt64(row.GetValueOrDefault("local_id") ?? 0L, CultureInfo.InvariantCulture), + Convert.ToInt64(row.GetValueOrDefault("server_id") ?? 0L, CultureInfo.InvariantCulture), + chatId, + senderWxId, + contact?.Name, + contact?.AvatarUrl, + Convert.ToInt32(row.GetValueOrDefault("local_type") ?? 0, CultureInfo.InvariantCulture), + content, + DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(row.GetValueOrDefault("create_time") ?? 0L, CultureInfo.InvariantCulture)), + WechatDbMessage.IsSelf(senderWxId, accountRootDirectoryName))); + } + + return messages; + } + + private static async Task> ReadContactsAsync( + string accountRootPath, IReadOnlyList databases, IReadOnlyList senders, CancellationToken cancellationToken) + { + var result = new Dictionary(StringComparer.Ordinal); + if (senders.Count == 0) return result; + + var contactDatabase = databases.FirstOrDefault(database => string.Equals(database.RelativePath, "contact/contact.db", StringComparison.OrdinalIgnoreCase)); + if (contactDatabase is null) return result; + var path = Path.GetFullPath(Path.Combine(accountRootPath, "contact", "contact.db")); + + var placeholders = string.Join(", ", Enumerable.Range(0, senders.Count).Select(index => $"$p{index}")); + var parameters = senders.Select((sender, index) => new KeyValuePair($"$p{index}", sender)).ToArray(); + var sql = $"SELECT username, nick_name, remark, small_head_url FROM contact WHERE username IN ({placeholders});"; + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(path, contactDatabase.EncKey, sql, parameters, cancellationToken).ConfigureAwait(false); + foreach (var row in rows) + { + var username = row.GetValueOrDefault("username") as string; + if (string.IsNullOrEmpty(username)) continue; + var remark = row.GetValueOrDefault("remark") as string; + var nickname = row.GetValueOrDefault("nick_name") as string; + var name = !string.IsNullOrEmpty(remark) ? remark : !string.IsNullOrEmpty(nickname) ? nickname : username; + result[username] = new WechatContactName(name, row.GetValueOrDefault("small_head_url") as string); + } + + return result; + } + + private static async Task<(string Path, string EncKey)?> FindMessageDatabaseAsync( + string accountRootPath, IReadOnlyList databases, string tableName, CancellationToken cancellationToken) + { + var candidates = databases + .Where(database => database.RelativePath.StartsWith("message/", StringComparison.OrdinalIgnoreCase) + && (database.RelativePath.EndsWith(".db", StringComparison.OrdinalIgnoreCase))) + .OrderBy(database => database.RelativePath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + foreach (var database in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.GetFullPath(Path.Combine(accountRootPath, database.RelativePath.Replace('/', Path.DirectorySeparatorChar))); + var sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name;"; + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(path, database.EncKey, sql, + [new KeyValuePair("$name", tableName)], cancellationToken).ConfigureAwait(false); + if (rows.Count > 0) return (path, database.EncKey); + } + + return null; + } + + private sealed record WechatContactName(string Name, string? AvatarUrl); +} \ No newline at end of file diff --git a/tests/WxAgent.Core.Tests/CoreTests.cs b/tests/WxAgent.Core.Tests/CoreTests.cs index fb9ff77..821bee8 100644 --- a/tests/WxAgent.Core.Tests/CoreTests.cs +++ b/tests/WxAgent.Core.Tests/CoreTests.cs @@ -24,6 +24,32 @@ public sealed class WcdbKeyCandidateScannerTests Assert.Single(found, candidate => candidate.EncKey == key && candidate.SaltHint == salt); } + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(127)] + public void ParsesUtf16AcrossOddChunkBoundaries(int chunkSize) + { + var key = new string('a', 64); + var salt = new string('b', 32); + var bytes = System.Text.Encoding.Unicode.GetBytes("x'" + key + salt + "' " + key + salt); + var scanner = new WcdbKeyCandidateScanner(utf16: true); + var found = new List(); + for (var offset = 0; offset < bytes.Length; offset += chunkSize) + found.AddRange(scanner.Feed(bytes.AsSpan(offset, Math.Min(chunkSize, bytes.Length - offset)))); + found.AddRange(scanner.Feed([], finalBlock: true)); + Assert.Equal(new WcdbKeyCandidate(key, salt), Assert.Single(found)); + } + + [Fact] + public void Utf16GapsDoNotJoinFragments() + { + var scanner = new WcdbKeyCandidateScanner(utf16: true); + Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 40)))); + Assert.Empty(scanner.Feed([0x61], finalBlock: true)); + Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 24)), finalBlock: true)); + } + [Fact] public void BoundsLongRunsAndDoesNotJoinAcrossExplicitGaps() { diff --git a/tests/WxAgent.Core.Tests/KeyCachePlannerTests.cs b/tests/WxAgent.Core.Tests/KeyCachePlannerTests.cs new file mode 100644 index 0000000..e2317cf --- /dev/null +++ b/tests/WxAgent.Core.Tests/KeyCachePlannerTests.cs @@ -0,0 +1,48 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class KeyCachePlannerTests +{ + private static AccountKeySet CachedAccount(string fingerprint, params string[] paths) => new( + fingerprint, "C:\\xwechat_files\\" + fingerprint, "4.1.13.63", DateTimeOffset.UtcNow, + paths.Select(path => new DatabaseKeyEvidence(path, "salt", "key", 42, DateTimeOffset.UtcNow, KeyBindingConfidence.PageHmacVerified)).ToArray()); + + private static AccountDatabaseEntry Entry(string fingerprint, string path) => new(fingerprint, path); + + [Fact] + public void VerifiedKeysCoversEveryCachedDatabasePath() + { + var cached = new[] { CachedAccount("a", "contact.db", "message_0.db"), CachedAccount("b", "message_0.db") }; + var verified = KeyCachePlanner.VerifiedKeys(cached); + Assert.Contains(KeyCachePlanner.Key("a", "contact.db"), verified); + Assert.Contains(KeyCachePlanner.Key("a", "message_0.db"), verified); + Assert.Contains(KeyCachePlanner.Key("b", "message_0.db"), verified); + } + + [Fact] + public void HasPendingIsFalseWhenAllDiscoveredDatabasesAreCached() + { + var cached = new[] { CachedAccount("a", "contact.db", "message_0.db") }; + var verified = KeyCachePlanner.VerifiedKeys(cached); + var discovered = new[] { Entry("a", "contact.db"), Entry("a", "message_0.db") }; + Assert.False(KeyCachePlanner.HasPending(discovered, verified)); + } + + [Fact] + public void HasPendingIsTrueForNewDatabaseOrNewAccount() + { + var cached = new[] { CachedAccount("a", "contact.db") }; + var verified = KeyCachePlanner.VerifiedKeys(cached); + Assert.True(KeyCachePlanner.HasPending([Entry("a", "message_0.db")], verified)); + Assert.True(KeyCachePlanner.HasPending([Entry("b", "contact.db")], verified)); + } + + [Fact] + public void CacheKeyIsCaseInsensitiveForWindowsPaths() + { + var verified = KeyCachePlanner.VerifiedKeys([CachedAccount("a", "Contact.DB")]); + Assert.True(verified.Contains(KeyCachePlanner.Key("a", "contact.db"))); + } +} \ No newline at end of file diff --git a/tests/WxAgent.Core.Tests/MemoryScanningTests.cs b/tests/WxAgent.Core.Tests/MemoryScanningTests.cs new file mode 100644 index 0000000..7914283 --- /dev/null +++ b/tests/WxAgent.Core.Tests/MemoryScanningTests.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Text; +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class MemoryScanningTests +{ + private static byte[] EncodedLiteral(string literal) + { + var bytes = Encoding.ASCII.GetBytes(literal); + var mask = Convert.FromHexString("d2c7442458020000004889442450488b450048844c2448488944254048584c24"); + for (var i = 0; i < bytes.Length; i++) bytes[i] ^= mask[i % mask.Length]; + return bytes; + } + + [Fact] + public void ConfigCipherDecodeFindsKeyAndEmbeddedSalt() + { + var key = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f"; + var salt = "101112131415161718191a1b1c1d1e1f"; + var decoded = WcdbConfigCipher.Decode(EncodedLiteral($"x'{key}{salt}'")); + Assert.Equal(new WcdbKeyCandidate(key, salt), Assert.Single(decoded)); + } + + [Fact] + public void ConfigCipherDecodeWindowsOnLongRuns() + { + var key = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f"; + var salt = "101112131415161718191a1b1c1d1e1f"; + var run = key + salt + key + salt; + var decoded = WcdbConfigCipher.Decode(EncodedLiteral($"x'{run}'")); + Assert.Contains(decoded, item => item.EncKey == key && item.SaltHint == salt); + Assert.Equal(4, decoded.Count); + } + + [Fact] + public void ConfigCipherDecodeRejectsBoundsAndUniformKeys() + { + Assert.Empty(WcdbConfigCipher.Decode([])); + Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("x'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'" + "00000000000000000000000000000000"))); + Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("plain"))); + Assert.Empty(WcdbConfigCipher.Decode(EncodedLiteral("x''"))); + var tooLong = EncodedLiteral($"x'{new string('e', 200)}'"); + Assert.Empty(WcdbConfigCipher.Decode(tooLong)); + } + + [Fact] + public void ConfigCipherDecodeRejectsBlobsOverLimit() + { + var literal = $"x'{new string('f', 64)}{new string('1', 32)}'"; + var blob = new List(EncodedLiteral(literal)); + while (blob.Count <= WcdbConfigCipher.MaximumBlobLength) blob.Insert(0, 0x00); + Assert.Empty(WcdbConfigCipher.Decode(blob.ToArray())); + } + + [Fact] + public void PatternScannerMatchesAcrossChunkBoundaries() + { + var needle = Encoding.ASCII.GetBytes("com.Tencent.WCDB.Config.Cipher"); + var content = Encoding.ASCII.GetBytes("prefix " + Encoding.ASCII.GetString(needle) + " suffix"); + var split = 10; + var scanner = new MemoryPatternScanner(needle); + var matches = scanner.Feed(content.AsSpan(0, split), 0x1000).ToList(); + matches.AddRange(scanner.Feed(content.AsSpan(split), 0x1000 + (ulong)split)); + Assert.Equal([0x1000 + 7UL], matches); + } + + [Fact] + public void PatternScannerReportsMatchesInSecondChunk() + { + var needle = Encoding.ASCII.GetBytes("abc"); + var content = Encoding.ASCII.GetBytes("xxabc"); + var scanner = new MemoryPatternScanner(needle); + var matches = scanner.Feed(content.AsSpan(0, 3), 0x2000).ToList(); + matches.AddRange(scanner.Feed(content.AsSpan(3), 0x2003)); + Assert.Equal([0x2002UL], matches); + } + + [Fact] + public void PatternScannerDoesNotJoinAcrossGaps() + { + var needle = Encoding.ASCII.GetBytes("abc"); + var scanner = new MemoryPatternScanner(needle); + var matches = scanner.Feed("a"u8[..1], 0x3000).ToList(); + matches.AddRange(scanner.Feed("b"u8[..1], 0x4000)); // Gap: end was 0x3001, next feed at 0x4000. + matches.AddRange(scanner.Feed("c"u8[..1], 0x4001)); + Assert.Empty(matches); + } + + [Fact] + public void PatternScannerFindsMatchSpanningThreeChunks() + { + var needle = Encoding.ASCII.GetBytes("abcde"); + var content = Encoding.ASCII.GetBytes("xxabcde"); + var scanner = new MemoryPatternScanner(needle); + var matches = scanner.Feed(content.AsSpan(0, 3), 0x5000).ToList(); + matches.AddRange(scanner.Feed(content.AsSpan(3, 3), 0x5003)); + matches.AddRange(scanner.Feed(content.AsSpan(6), 0x5006)); + Assert.Equal([0x5002UL], matches); + } + + [Fact] + public void DecodeProducesNoMatchAfterPatternError() + { + // Simulates the reference's own fail-open behavior after a failed read. + var decoded = WcdbConfigCipher.Decode(Encoding.ASCII.GetBytes("x'abc'")); + Assert.Empty(decoded); + } +} \ No newline at end of file diff --git a/tests/WxAgent.Core.Tests/WechatDbMessageTests.cs b/tests/WxAgent.Core.Tests/WechatDbMessageTests.cs new file mode 100644 index 0000000..900e07e --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatDbMessageTests.cs @@ -0,0 +1,66 @@ +using System.Text; +using WxAgent.Core; +using Xunit; +using ZstdSharp; + +namespace WxAgent.Core.Tests; + +public sealed class WechatDbMessageTests +{ + [Fact] + public void TableNameForMatchesMd5HexOfChatId() + { + Assert.Equal("Msg_" + Md5Hex("wxid_test"), WechatDbMessage.TableNameFor("wxid_test")); + Assert.Equal("Msg_" + Md5Hex("测试群组"), WechatDbMessage.TableNameFor("测试群组")); + } + + [Fact] + public void DecodeContentHandlesPlainHexUtf8() + { + Assert.Equal("hello", WechatDbMessage.DecodeContent(Convert.ToHexString(Encoding.UTF8.GetBytes("hello")), compressed: false)); + Assert.Empty(WechatDbMessage.DecodeContent("", compressed: false)); + Assert.Empty(WechatDbMessage.DecodeContent("not-hex!", compressed: false)); + } + + [Fact] + public void DecodeContentDecompressesZstdPayloads() + { + var payload = Encoding.UTF8.GetBytes("wx-agent-zstd-roundtrip-消息测试"); + using var compressor = new CompressionStream(new MemoryStream()); + // ZstdSharp.CompressionStream writes on dispose; wrap in a memory stream instead. + var compressed = Compress(payload); + var hex = Convert.ToHexString(compressed); + var decoded = WechatDbMessage.DecodeContent(hex, compressed: true); + Assert.Equal("wx-agent-zstd-roundtrip-消息测试", decoded); + } + + [Fact] + public void DecodeContentTreatsCompressedFlagWithoutMagicAsUndecodable() + { + var hex = Convert.ToHexString(Encoding.UTF8.GetBytes("plain")); + Assert.Empty(WechatDbMessage.DecodeContent(hex, compressed: true)); + } + + [Fact] + public void IsSelfUsesWxIdPrefixOfAccountDirectory() + { + Assert.True(WechatDbMessage.IsSelf("wxid_abc123", "wxid_abc123_def456")); + Assert.False(WechatDbMessage.IsSelf("wxid_other", "wxid_abc123_def456")); + Assert.Null(WechatDbMessage.IsSelf(null, "wxid_abc123_def456")); + Assert.Null(WechatDbMessage.IsSelf("wxid_abc123", null)); + } + + private static string Md5Hex(string value) => + Convert.ToHexString(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private static byte[] Compress(byte[] payload) + { + using var output = new MemoryStream(); + using (var compressor = new CompressionStream(output)) + { + compressor.Write(payload); + } + + return output.ToArray(); + } +} \ No newline at end of file