feat: expand merged chat records directly from read-only databases

Add db merged with exact account/chat/local-ID selection and shard ambiguity checks. Preserve Int64 composite message types and parse bounded, DTD-free embedded record XML, including duplicate messages and source metadata. Revalidate cached keys against current page-1 HMAC; replace the SQL write probe with sqlite3_db_readonly. Core tests: 129 passed. Windows Session 0 database validation: 5/5; all 16 text/sender/source-ID/timestamp entries matched independent XML parsing. Read-only UI baseline: 3/3; zero sends.
This commit is contained in:
2026-09-06 20:10:04 +08:00
parent e5548bd471
commit 30df7ae433
15 changed files with 775 additions and 24 deletions
@@ -0,0 +1,41 @@
# 数据库合并聊天记录展开 — 2026-09-06
## 调用方式
```text
WxAgent.Host db merged --account <account-fingerprint> --chat filehelper --local-id 149
WxAgent.Host db merged --account <account-fingerprint> --chat filehelper --local-id 149 --include-content
```
可选 `--database <relative-path>` 消除不同消息分库 local ID 重复时的歧义;支持 `--key-file` 与 `--timeout`。只使用既有账户密钥缓存,不调用内存扫描器,也不打开聊天窗口。
默认返回父记录身份、条目路径、类型、脱敏发送人标识、原消息 ID、时间戳和正文长度;正文、显示名称、合并标题和描述为 null。`--include-content` 显式启用正文/名称。源 `hashusername` 不是可确认的真实 wxid,因此不把它伪装为 wxid。
## 实现与安全
- 将数据库 `local_type` 模型和转换从 Int32 修正为 **Int64**;实测合并类型为 `81604378673`。原先直接读取此记录会发生 Int32 溢出。
- 精确按聊天表及 `local_id` 参数查询,逐个消息分库检查;多处命中必须显式选择分库,不返回第一个碰巧匹配的消息。
- 解析 `appmsg/type=19 → recorditem → recordinfo/datalist/dataitem`,保留顺序、重复正文、原始字符串 ID、秒级 Unix 时间、显示时间和发送人名称。嵌套记录拥有独立路径,不把重复 dataid 当作去重依据。
- XML 禁止 DTD/外部实体,限制文档字符数、深度、嵌套记录层数及总条目数。声明数量与实际不符、文本缺失、非法时间等明确报错,不用空结果掩盖损坏。
- Hex/Zstd 解码有尺寸上限,避免压缩消息无限膨胀。
- 每次 SQLCipher 打开前,缓存密钥重新通过**当前目标数据库 page-1 HMAC**校验;连接仍为 `Mode=ReadOnly` 与 `query_only=ON`。
- 移除原来的 `CREATE TABLE` 写拒绝探针,改为读取 `sqlite3_db_readonly`,不尝试任何数据库写入。打开失败时也释放连接。
- 仅输出允许的附件元数据,不导出 CDN 地址/AES 密钥,不下载附件或绕过协议。
## 真机验收
- 微信 `4.1.13.63`,Windows `10.1.1.101`。
- 最终发布:`C:\Users\Rogee\wx-agent\releases\db-merged-20260906T120104`。
- EXE SHA-256:`5DB6A6422F6DAD9AE76C6C4D5A522576C7B7EB0534396434A536190D4E0DEEF8`。
- Linux **129/129 Core 测试通过**,完整 Release build 无警告/错误。
- `Test-DatabaseMerged.ps1` 在 **SSH Session 0** 运行,5/5 通过:默认脱敏输出、显式正文输出、数据库只读状态、非法 ID、缺失 ID。数据库读操作无需微信所在交互桌面;没有在 Session 0 执行 UI 自动化。
- 既有交互会话另行执行 doctor、inspect-ui、`smoke --read-only`,3/3 通过。
- 文件传输助手中父记录 local ID **149** 展开 **16 条文本消息**。
- 用独立 Python XML 解析器逐条对照数据库原始嵌入 XML:正文、发送人、原始消息 ID、时间戳 **各 16/16 完全一致**。
- 全程发送/转发次数 **0**。完整正文仅保留于忽略的私有验证产物,不进入 Git。
脱敏证据:[`evidence/Database-Merged-20260906/`](evidence/Database-Merged-20260906/)。第一次验证脚本误读 `metadata.WritesRejected` 的嵌套层次,修正脚本后同一二进制通过;并非数据库接受了写入。
## 验收范围
真实样本为 16 条文本;嵌套记录、附件元数据、恶意/截断 XML、重复条目和解压上限有离线测试。尚无混合图片/视频/文件、嵌套合并的真机样本;附件正文/原文件下载不属于本次展开结果。字段缺失保留 null,不补造身份或时间。
@@ -0,0 +1,18 @@
{
"Checks": [
{
"Stage": "doctor",
"ExitCode": 0
},
{
"Stage": "inspect-ui",
"ExitCode": 0
},
{
"Stage": "smoke-readonly",
"ExitCode": 0
}
],
"SendsAttempted": 0,
"Success": true
}
@@ -0,0 +1,4 @@
{
"error": "InvalidArgument",
"Message": "--local-id must be a positive 64-bit integer."
}
@@ -0,0 +1,307 @@
{
"account": "a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277",
"database": "message/message_0.db",
"chatId": "sha256:7F7D698D0AE00B0D",
"recordId": "a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277:message/message_0.db:149",
"parent": {
"LocalId": 149,
"ServerId": 7498992953238207040,
"Type": 81604378673,
"Timestamp": "2026-09-06T09:18:20+00:00"
},
"record": {
"title": null,
"description": null,
"count": 16,
"messages": [
{
"path": "0",
"dataId": "d7a3cf5d308ef865ededc7cda76cdf06",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "5158611296847210182",
"timestamp": "2026-08-28T08:13:35+00:00",
"displayTime": "2026-08-28 16:13",
"text": null,
"length": 14,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "1",
"dataId": "a55bf6920207cc484914173ca7ec98ff",
"dataType": 1,
"senderId": "sha256:2F0AF7C1B8E3D684",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "2505042190386030576",
"timestamp": "2026-08-28T08:14:27+00:00",
"displayTime": "2026-08-28 16:14",
"text": null,
"length": 1,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "2",
"dataId": "30831307ce3e4909af357ca3a0d36492",
"dataType": 1,
"senderId": "sha256:2F0AF7C1B8E3D684",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "4314853433147875167",
"timestamp": "2026-08-28T08:14:31+00:00",
"displayTime": "2026-08-28 16:14",
"text": null,
"length": 3,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "3",
"dataId": "835abcae449f3e156d2e4961c42138ff",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "6850076042613883819",
"timestamp": "2026-08-28T08:15:35+00:00",
"displayTime": "2026-08-28 16:15",
"text": null,
"length": 41,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "4",
"dataId": "fd7fcef26f3408b3e24fcf526f8c101b",
"dataType": 1,
"senderId": "sha256:2F0AF7C1B8E3D684",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "6621524893657137135",
"timestamp": "2026-08-28T08:16:12+00:00",
"displayTime": "2026-08-28 16:16",
"text": null,
"length": 3,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "5",
"dataId": "6fe59e85d75f439e66c9871458e322c1",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "6108206286854864988",
"timestamp": "2026-08-28T08:16:59+00:00",
"displayTime": "2026-08-28 16:16",
"text": null,
"length": 58,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "6",
"dataId": "b0195dfb34d6552e9697717c85e3244d",
"dataType": 1,
"senderId": "sha256:2F0AF7C1B8E3D684",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "6291818813835930172",
"timestamp": "2026-08-28T08:17:32+00:00",
"displayTime": "2026-08-28 16:17",
"text": null,
"length": 9,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "7",
"dataId": "7865e3c4ef4610909cbda0034897852b",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "8805680531905882697",
"timestamp": "2026-08-28T08:17:57+00:00",
"displayTime": "2026-08-28 16:17",
"text": null,
"length": 19,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "8",
"dataId": "ec62b6595e5f3391381b82b847d47de3",
"dataType": 1,
"senderId": "sha256:2F0AF7C1B8E3D684",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "7935299570123740393",
"timestamp": "2026-08-28T08:18:07+00:00",
"displayTime": "2026-08-28 16:18",
"text": null,
"length": 3,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "9",
"dataId": "8871d9bfe9c261850b155f737b9e0fc4",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "5334061395816435285",
"timestamp": "2026-08-28T08:18:11+00:00",
"displayTime": "2026-08-28 16:18",
"text": null,
"length": 8,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "10",
"dataId": "06987bf3be56f93fe26f516ee9fce712",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "3775467287450770804",
"timestamp": "2026-08-28T08:18:21+00:00",
"displayTime": "2026-08-28 16:18",
"text": null,
"length": 5,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "11",
"dataId": "811bbaf41661bc93de162cb79051d30c",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "5606979100728674670",
"timestamp": "2026-08-28T08:18:33+00:00",
"displayTime": "2026-08-28 16:18",
"text": null,
"length": 4,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "12",
"dataId": "1b54a0acfa9af3f87612f1ad1d899615",
"dataType": 1,
"senderId": "sha256:2F0AF7C1B8E3D684",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "8768702616063460855",
"timestamp": "2026-08-28T08:18:34+00:00",
"displayTime": "2026-08-28 16:18",
"text": null,
"length": 1,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "13",
"dataId": "8f995c7506723cb9bbac6428de6e48da",
"dataType": 1,
"senderId": "sha256:2F0AF7C1B8E3D684",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "540359944811197130",
"timestamp": "2026-08-28T08:18:39+00:00",
"displayTime": "2026-08-28 16:18",
"text": null,
"length": 2,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "14",
"dataId": "6444ca65742edd89728ec3872ad10bef",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "3039346887632908543",
"timestamp": "2026-08-28T08:19:05+00:00",
"displayTime": "2026-08-28 16:19",
"text": null,
"length": 9,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
},
{
"path": "15",
"dataId": "2489f0db398df842a99962035db3f82e",
"dataType": 1,
"senderId": "sha256:6221F48E5D88469F",
"senderName": null,
"sourceLocalId": null,
"sourceServerId": "679561649162720019",
"timestamp": "2026-08-28T08:19:16+00:00",
"displayTime": "2026-08-28 16:19",
"text": null,
"length": 8,
"contentAvailable": true,
"title": null,
"format": null,
"sizeBytes": null,
"nestedRecord": null
}
]
}
}
@@ -0,0 +1,4 @@
{
"error": "ControlNotFound",
"Message": "The selected database message was not found."
}
@@ -0,0 +1,3 @@
{
"WritesRejected": true
}
@@ -0,0 +1,34 @@
{
"BinarySha256": "5DB6A6422F6DAD9AE76C6C4D5A522576C7B7EB0534396434A536190D4E0DEEF8",
"SendsAttempted": 0,
"Checks": [
{
"ExpectedExit": 0,
"ExitCode": 0,
"Stage": "merged-default"
},
{
"ExpectedExit": 0,
"ExitCode": 0,
"Stage": "merged-content.private"
},
{
"ExpectedExit": 0,
"ExitCode": 0,
"Stage": "database-readonly"
},
{
"ExpectedExit": 1,
"ExitCode": 1,
"Stage": "invalid-id"
},
{
"ExpectedExit": 1,
"ExitCode": 1,
"Stage": "missing-id"
}
],
"Success": true,
"ProcessSessionId": 0,
"RecordCount": 16
}
@@ -0,0 +1,9 @@
{
"MessageCount": 16,
"ExactTextMatches": 16,
"ExactSenderMatches": 16,
"ExactSourceIdMatches": 16,
"ExactTimestampMatches": 16,
"IndependentParser": "Python xml.etree.ElementTree",
"ContentIncluded": false
}
+43
View File
@@ -0,0 +1,43 @@
param(
[Parameter(Mandatory=$true)][string]$Executable,
[Parameter(Mandatory=$true)][string]$OutputDirectory,
[Parameter(Mandatory=$true)][string]$Account,
[string]$ChatId='filehelper',
[Parameter(Mandatory=$true)][long]$LocalId,
[Parameter(Mandatory=$true)][int]$ExpectedCount
)
$ErrorActionPreference='Stop'
$exe=(Resolve-Path $Executable).Path
if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'}
$out=(New-Item -ItemType Directory $OutputDirectory).FullName
$checks=New-Object System.Collections.Generic.List[object]
function Check([string]$Name,[string[]]$Arguments,[int]$ExpectedExit=0){
$raw=(& $exe @Arguments 2> "$out\$Name.stderr.txt" | Out-String)
$code=$LASTEXITCODE
$raw | Set-Content "$out\$Name.json" -Encoding UTF8
$checks.Add(@{Stage=$Name;ExitCode=$code;ExpectedExit=$ExpectedExit})
if($code -ne $ExpectedExit){throw "$Name returned unexpected exit code."}
return ($raw | ConvertFrom-Json)
}
try {
$default=Check 'merged-default' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id',"$LocalId",'--timeout','60')
if($default.record.count -ne $ExpectedCount){throw 'Unexpected record count.'}
if($null -ne $default.record.title -or $null -ne $default.record.description -or @($default.record.messages | Where-Object {$null -ne $_.text -or $null -ne $_.senderName}).Count){throw 'Default output leaked record content.'}
$full=Check 'merged-content.private' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id',"$LocalId",'--include-content','--timeout','60')
if($full.record.count -ne $ExpectedCount -or $full.parent.LocalId -ne $LocalId){throw 'Selected record mismatch.'}
if(@($full.record.messages | Where-Object {$_.dataType -ne 1 -or $null -eq $_.text -or !$_.senderName -or !$_.timestamp -or !$_.sourceServerId}).Count){throw 'Expected complete text-message metadata in this fixture.'}
if(@($full.record.messages.path | Select-Object -Unique).Count -ne $ExpectedCount){throw 'Message paths are not unique.'}
$metadata=Check 'database-readonly' @('db','query','--account',$Account,'--database',$full.database,'--timeout','60')
if(!$metadata.metadata.WritesRejected){throw 'Database is not reported read-only.'}
$invalid=Check 'invalid-id' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id','0','--timeout','30') 1
if($invalid.error -ne 'InvalidArgument'){throw 'Wrong invalid-ID error.'}
$missing=Check 'missing-id' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id','9223372036854775807','--timeout','60') 1
if($missing.error -ne 'ControlNotFound'){throw 'Wrong missing-ID error.'}
} catch {
@{Message=$_.Exception.Message;Line=$_.InvocationInfo.ScriptLineNumber} | ConvertTo-Json | Set-Content "$out\error.json" -Encoding UTF8
} finally {
$success=($checks.Count -eq 5 -and @($checks | Where-Object {$_.ExitCode -ne $_.ExpectedExit}).Count -eq 0 -and !(Test-Path "$out\error.json"))
@{Success=$success;Checks=@($checks.ToArray());ProcessSessionId=[Diagnostics.Process]::GetCurrentProcess().SessionId;RecordCount=$ExpectedCount;SendsAttempted=0;BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} |
ConvertTo-Json -Depth 6 | Set-Content "$out\summary.json" -Encoding UTF8
}
if(!$success){exit 1}
+14 -3
View File
@@ -12,7 +12,7 @@ public sealed record DbMessage(
string? SenderWxId,
string? SenderName,
string? SenderAvatarUrl,
int Type,
long Type,
string Content,
DateTimeOffset Timestamp,
bool? IsSelf);
@@ -26,6 +26,8 @@ public static class WechatDbMessage
public static string DecodeContent(string hexContent, bool compressed)
{
if (string.IsNullOrEmpty(hexContent)) return string.Empty;
if (hexContent.Length > WechatMergedChatParser.MaxXmlCharacters * 2)
throw new InvalidDataException("Database message content exceeds the decoding limit.");
byte[] bytes;
try
{
@@ -43,9 +45,18 @@ public static class WechatDbMessage
using var source = new MemoryStream(bytes);
using var decompressor = new DecompressionStream(source);
using var reader = new StreamReader(decompressor, Encoding.UTF8);
return reader.ReadToEnd();
var decoded = new StringBuilder();
var buffer = new char[4096];
int read;
while ((read = reader.Read(buffer, 0, buffer.Length)) != 0)
{
if (decoded.Length + read > WechatMergedChatParser.MaxXmlCharacters)
throw new InvalidDataException("Decompressed message exceeds the decoding limit.");
decoded.Append(buffer, 0, read);
}
return decoded.ToString();
}
catch (Exception exception) when (exception is not OutOfMemoryException)
catch (Exception exception) when (exception is not (OutOfMemoryException or InvalidDataException))
{
return string.Empty;
}
+117
View File
@@ -0,0 +1,117 @@
using System.Globalization;
using System.Xml;
using System.Xml.Linq;
namespace WxAgent.Core;
public sealed record WechatMergedMessage(
string Path, string? DataId, int DataType, string? SenderName, string? SenderHash,
string? SourceLocalId, string? SourceServerId, DateTimeOffset? Timestamp, string? DisplayTime,
string? Text, string? Title, string? Format, long? SizeBytes, WechatMergedChat? NestedRecord);
public sealed record WechatMergedChat(string? Title, string? Description, IReadOnlyList<WechatMergedMessage> Messages);
public sealed record DbMergedChat(string DatabaseRelativePath, DbMessage Parent, WechatMergedChat Record);
public static class WechatMergedChatParser
{
public const int MaxXmlCharacters = 4 * 1024 * 1024;
public const int MaxMessages = 5000;
public const int MaxNesting = 8;
public static WechatMergedChat Parse(string xml)
{
var budget = MaxXmlCharacters * 2;
var remaining = MaxMessages;
try
{
var root = Load(xml, ref budget);
if (root.Name == "msg")
{
var app = root.Element("appmsg") ?? throw Invalid("Missing appmsg.");
if (app.Element("type")?.Value != "19") throw Invalid("Not a merged-chat app message.");
root = ReadRecord(app.Element("recorditem") ?? throw Invalid("Missing recorditem."), ref budget);
}
return ParseRecord(root, "", 0, ref remaining, ref budget);
}
catch (XmlException ex) { throw new InvalidDataException("Invalid merged-chat XML.", ex); }
catch (ArgumentOutOfRangeException ex) { throw new InvalidDataException("Invalid merged-chat timestamp.", ex); }
}
private static WechatMergedChat ParseRecord(XElement record, string parentPath, int depth, ref int remaining, ref int budget)
{
if (depth >= MaxNesting) throw Invalid("Nested record limit exceeded.");
if (record.Name != "recordinfo") throw Invalid("Expected recordinfo.");
var list = record.Element("datalist") ?? throw Invalid("Missing datalist.");
var items = list.Elements().ToArray();
if (items.Any(item => item.Name != "dataitem")) throw Invalid("Unknown datalist element.");
if (items.Length > remaining) throw Invalid("Merged-message count limit exceeded.");
remaining -= items.Length;
var count = (string?)list.Attribute("count");
if (count is not null && (!int.TryParse(count, NumberStyles.None, CultureInfo.InvariantCulture, out var expected) || expected != items.Length))
throw Invalid("Declared merged-message count does not match the embedded record.");
var messages = new List<WechatMergedMessage>(items.Length);
for (var index = 0; index < items.Length; index++)
{
var item = items[index];
if (!int.TryParse((string?)item.Attribute("datatype"), NumberStyles.None, CultureInfo.InvariantCulture, out var type))
throw Invalid("Missing or invalid merged-message datatype.");
var path = parentPath.Length == 0 ? index.ToString(CultureInfo.InvariantCulture) : $"{parentPath}/{index}";
var text = Value(item, "datadesc");
if (type == 1 && text is null) throw Invalid("Text record has no datadesc.");
var seconds = Integer(item, "srcMsgCreateTime");
var source = item.Element("dataitemsource");
var nestedElement = item.Element("recordinfo") ?? item.Element("recorditem");
var nested = nestedElement is null ? null : ParseRecord(ReadRecord(nestedElement, ref budget), path, depth + 1, ref remaining, ref budget);
messages.Add(new WechatMergedMessage(path, (string?)item.Attribute("dataid"), type,
Value(item, "sourcename"), source is null ? null : Value(source, "hashusername"),
Value(item, "srcMsgLocalId"), Value(item, "fromnewmsgid"),
seconds is null ? null : DateTimeOffset.FromUnixTimeSeconds(seconds.Value), Value(item, "sourcetime"),
text, Value(item, "datatitle"), Value(item, "datafmt"), Integer(item, "datasize"), nested));
}
return new WechatMergedChat(Value(record, "title"), Value(record, "desc"), messages);
}
private static XElement ReadRecord(XElement element, ref int budget)
{
if (element.Name == "recordinfo" && element.HasElements) return element;
if (element.HasElements)
{
var children = element.Elements().ToArray();
if (children.Length != 1 || children[0].Name != "recordinfo") throw Invalid("Ambiguous embedded record.");
return children[0];
}
return Load(element.Value, ref budget);
}
private static XElement Load(string xml, ref int budget)
{
if (string.IsNullOrWhiteSpace(xml) || xml.Length > MaxXmlCharacters || xml.Length > budget)
throw Invalid("Merged-chat XML is empty or exceeds the size limit.");
budget -= xml.Length;
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null,
MaxCharactersInDocument = MaxXmlCharacters, MaxCharactersFromEntities = 0
};
// Preflight depth before building a DOM; untrusted forwarded XML must stay bounded.
using (var check = XmlReader.Create(new StringReader(xml), settings))
while (check.Read())
if (check.Depth > 64) throw Invalid("XML depth limit exceeded.");
using var reader = XmlReader.Create(new StringReader(xml), settings);
return XElement.Load(reader, LoadOptions.PreserveWhitespace);
}
private static string? Value(XElement element, string name) => element.Element(name)?.Value;
private static long? Integer(XElement element, string name)
{
var text = Value(element, name);
if (string.IsNullOrEmpty(text)) return null;
if (!long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var number))
throw Invalid($"Invalid {name}.");
return number;
}
private static InvalidDataException Invalid(string message) => new(message);
}
+54
View File
@@ -545,6 +545,30 @@ try
});
return 0;
}
case "db" when args[1] == "merged":
{
var accountId = GetRequiredOption(args, "--account");
var chatId = GetRequiredOption(args, "--chat");
if (!long.TryParse(GetRequiredOption(args, "--local-id"), System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture, out var localId) || localId <= 0)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "--local-id must be a positive 64-bit integer.");
var accounts = await DatabaseKeyStore.LoadAsync(GetOption(args, "--key-file"), 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 result = await WechatMessageDbReader.ReadMergedAsync(account.AccountRootPath, account.Databases, chatId, localId,
cancellationToken, GetOption(args, "--database"));
var includeContent = HasOption(args, "--include-content");
WriteJson(new
{
account = account.AccountRootFingerprint,
database = result.DatabaseRelativePath,
chatId = includeContent ? chatId : MaskIdentifier(chatId),
recordId = $"{account.AccountRootFingerprint}:{result.DatabaseRelativePath}:{localId}",
parent = new { result.Parent.LocalId, result.Parent.ServerId, result.Parent.Type, result.Parent.Timestamp },
record = ToMergedOutput(result.Record, includeContent)
});
return 0;
}
case "db" when args[1] == "messages":
{
var accountId = GetRequiredOption(args, "--account");
@@ -793,6 +817,9 @@ static int ValidateCommandLine(string[] values)
case "query":
ValidateOptions(values, 2, ["--account", "--database", "--key-file", "--timeout"], []);
return 30;
case "merged":
ValidateOptions(values, 2, ["--account", "--chat", "--local-id", "--database", "--key-file", "--timeout"], ["--include-content"]);
return 60;
case "messages":
ValidateOptions(values, 2, ["--account", "--chat", "--key-file", "--limit", "--timeout"], ["--include-content"]);
return 60;
@@ -846,6 +873,32 @@ 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 object ToMergedOutput(WechatMergedChat record, bool includeContent) => new
{
title = includeContent ? record.Title : null,
description = includeContent ? record.Description : null,
count = record.Messages.Count,
messages = record.Messages.Select(message => new
{
path = message.Path,
dataId = message.DataId,
dataType = message.DataType,
senderId = MaskIdentifier(message.SenderHash ?? message.SenderName),
senderName = includeContent ? message.SenderName : null,
sourceLocalId = message.SourceLocalId,
sourceServerId = message.SourceServerId,
timestamp = message.Timestamp,
displayTime = message.DisplayTime,
text = includeContent ? message.Text : null,
length = message.Text?.Length,
contentAvailable = message.Text is not null,
title = includeContent ? message.Title : null,
format = message.Format,
sizeBytes = message.SizeBytes,
nestedRecord = message.NestedRecord is null ? null : ToMergedOutput(message.NestedRecord, includeContent)
})
};
static string? MaskIdentifier(string? identifier) =>
string.IsNullOrEmpty(identifier)
? identifier
@@ -950,6 +1003,7 @@ WxAgent.Host commands:
db status [--key-file <path>] [--timeout 30]
db query --account <fingerprint> --database <relative-path> [--key-file <path>] [--timeout 30]
db messages --account <fingerprint> --chat <wxid> [--limit 50] [--include-content] [--key-file <path>] [--timeout 60]
db merged --account <fingerprint> --chat <wxid> --local-id <id> [--database <relative-path>] [--include-content] [--key-file <path>] [--timeout 60]
db contacts --account <fingerprint> [--contains <text>] [--include-content] [--key-file <path>] [--timeout 60]
Chat commands default to File Transfer Assistant; send/monitor and listener-smoke accept --session.
+10 -16
View File
@@ -37,24 +37,12 @@ public static class SqlCipherDatabaseReader
}
}
var writesRejected = false;
try
{
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);
}
catch (SqliteException exception) when (exception.SqliteErrorCode == 8)
{
writesRejected = true;
}
// Inspect SQLite's connection state; never attempt writes against the user's database.
var writesRejected = SQLitePCL.raw.sqlite3_db_readonly(connection.Handle, "main") == 1;
if (!writesRejected)
{
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "Read-only SQLCipher connection unexpectedly accepted a write.");
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher connection did not report read-only mode.");
}
return new DatabaseMetadata(cipherVersion, sqliteVersion, count, names, writesRejected);
@@ -118,6 +106,12 @@ public static class SqlCipherDatabaseReader
private static async Task<SqliteConnection> OpenReadOnlyAsync(string databasePath, string hexKey, CancellationToken cancellationToken)
{
var page = new byte[SqlCipherPageVerifier.PageSize];
await using (var file = new FileStream(databasePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
SqlCipherPageVerifier.PageSize, FileOptions.Asynchronous))
await file.ReadExactlyAsync(page, cancellationToken).ConfigureAwait(false);
if (!SqlCipherPageVerifier.VerifyHexKey(page, hexKey))
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "The cached key failed current database page-1 HMAC verification; refresh the selected account key.");
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = Path.GetFullPath(databasePath),
@@ -128,9 +122,9 @@ public static class SqlCipherDatabaseReader
}.ToString();
var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using (var command = connection.CreateCommand())
{
// nosemgrep:csharp-sqli - hexKey is validated to exactly 64 hex chars before this call; no injection is possible.
+33 -5
View File
@@ -7,7 +7,7 @@ namespace WxAgent.Windows;
public static class WechatMessageDbReader
{
public static async Task<IReadOnlyList<DbMessage>> ReadAsync(
string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> databases, string chatId, int limit, CancellationToken cancellationToken)
string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> databases, string chatId, int limit, CancellationToken cancellationToken, long? localId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accountRootPath);
ArgumentException.ThrowIfNullOrWhiteSpace(chatId);
@@ -30,9 +30,10 @@ public static class WechatMessageDbReader
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;";
"WHERE ($localId IS NULL OR m.local_id = $localId) " +
"ORDER BY m.create_time DESC, m.local_id DESC LIMIT $limit;";
var rows = await SqlCipherDatabaseReader.QueryRowsAsync(messageDatabase.Value.Path, messageDatabase.Value.EncKey, sql,
[new KeyValuePair<string, object?>("$limit", (long)limit)], cancellationToken).ConfigureAwait(false);
[new KeyValuePair<string, object?>("$limit", (long)limit), new KeyValuePair<string, object?>("$localId", localId)], cancellationToken).ConfigureAwait(false);
var senders = rows.Select(row => row.GetValueOrDefault("sender_wxid") as string)
.Where(sender => !string.IsNullOrEmpty(sender)).Cast<string>().Distinct(StringComparer.Ordinal).ToArray();
@@ -53,7 +54,7 @@ public static class WechatMessageDbReader
senderWxId,
contact?.Name,
contact?.AvatarUrl,
Convert.ToInt32(row.GetValueOrDefault("local_type") ?? 0, CultureInfo.InvariantCulture),
Convert.ToInt64(row.GetValueOrDefault("local_type") ?? 0L, CultureInfo.InvariantCulture),
content,
DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(row.GetValueOrDefault("create_time") ?? 0L, CultureInfo.InvariantCulture)),
WechatDbMessage.IsSelf(senderWxId, accountRootDirectoryName)));
@@ -62,6 +63,29 @@ public static class WechatMessageDbReader
return messages;
}
public static async Task<DbMergedChat> ReadMergedAsync(string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> databases,
string chatId, long localId, CancellationToken cancellationToken, string? databaseRelativePath = null)
{
if (localId <= 0) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A positive local message ID is required.");
var candidates = databases.Where(database => database.RelativePath.StartsWith("message/", StringComparison.OrdinalIgnoreCase)
&& (databaseRelativePath is null || string.Equals(database.RelativePath, databaseRelativePath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))).ToArray();
if (candidates.Length == 0) throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No matching message database has a cached key.");
(string Path, DbMessage Message)? found = null;
foreach (var database in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var selected = databases.Where(entry => !entry.RelativePath.StartsWith("message/", StringComparison.OrdinalIgnoreCase) || entry == database).ToArray();
var rows = await ReadAsync(accountRootPath, selected, chatId, 2, cancellationToken, localId).ConfigureAwait(false);
if (rows.Count == 0) continue;
if (rows.Count != 1 || found is not null)
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The local ID is ambiguous across message shards; specify --database.");
found = (database.RelativePath, rows[0]);
}
if (found is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The selected database message was not found.");
try { return new DbMergedChat(found.Value.Path, found.Value.Message, WechatMergedChatParser.Parse(found.Value.Message.Content)); }
catch (InvalidDataException ex) { throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, ex.Message, ex); }
}
private static async Task<IReadOnlyDictionary<string, WechatContactName>> ReadContactsAsync(
string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> databases, IReadOnlyList<string> senders, CancellationToken cancellationToken)
{
@@ -100,7 +124,11 @@ public static class WechatMessageDbReader
foreach (var database in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var path = Path.GetFullPath(Path.Combine(accountRootPath, database.RelativePath.Replace('/', Path.DirectorySeparatorChar)));
var root = Path.GetFullPath(accountRootPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var relative = database.RelativePath.Replace('/', Path.DirectorySeparatorChar);
var path = Path.GetFullPath(Path.Combine(root, relative));
if (Path.IsPathRooted(relative) || !path.StartsWith(root, StringComparison.OrdinalIgnoreCase))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The database path must remain inside the selected account root.");
var sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name;";
var rows = await SqlCipherDatabaseReader.QueryRowsAsync(path, database.EncKey, sql,
[new KeyValuePair<string, object?>("$name", tableName)], cancellationToken).ConfigureAwait(false);
@@ -0,0 +1,84 @@
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using WxAgent.Core;
using Xunit;
using ZstdSharp;
namespace WxAgent.Core.Tests;
public sealed class WechatMergedChatTests
{
[Fact]
public void ParsesEmbeddedTextMetadataWithoutDeduplicatingRepeatedMessages()
{
var result = WechatMergedChatParser.Parse(Wrap(Record(Item("same\n\ntext"), Item("same\n\ntext"))));
Assert.Equal(2, result.Messages.Count);
Assert.Equal("0", result.Messages[0].Path);
Assert.Equal("1", result.Messages[1].Path);
Assert.Equal("same\n\ntext", result.Messages[0].Text);
Assert.Equal("Synthetic sender", result.Messages[0].SenderName);
Assert.Equal("opaque-hash", result.Messages[0].SenderHash);
Assert.Equal("18446744073709551615", result.Messages[0].SourceServerId);
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1700000000), result.Messages[0].Timestamp);
}
[Fact]
public void SupportsNestedRecordsAndExplicitAttachmentMetadata()
{
var attachment = new XElement("dataitem", new XAttribute("datatype", 8),
new XElement("datatitle", "example.txt"), new XElement("datafmt", "txt"), new XElement("datasize", 42),
new XElement("cdn_dataurl", "must-not-be-exported"), new XElement("aeskey", "must-not-be-exported"));
var nested = new XElement("dataitem", new XAttribute("datatype", 17), Record(Item("nested")));
var result = WechatMergedChatParser.Parse(Record(attachment, nested).ToString());
Assert.Null(result.Messages[0].Text);
Assert.Equal(42, result.Messages[0].SizeBytes);
Assert.Equal("1/0", result.Messages[1].NestedRecord!.Messages[0].Path);
Assert.DoesNotContain("must-not-be-exported", JsonSerializer.Serialize(result));
}
[Fact]
public void RejectsTruncationMissingPayloadAndInvalidTimestamp()
{
var record = Record(Item("hello"));
record.Element("datalist")!.SetAttributeValue("count", 2);
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(record.ToString()));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(new XElement("dataitem", new XAttribute("datatype", 1))).ToString()));
var item = Item("hello");
item.Element("srcMsgCreateTime")!.Value = long.MaxValue.ToString();
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(item).ToString()));
}
[Fact]
public void RejectsDtdAndBoundViolations()
{
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse("<!DOCTYPE recordinfo [<!ENTITY x SYSTEM 'file:///not-read'>]><recordinfo>&x;</recordinfo>"));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1)));
var nested = Record(Item("innermost"));
for (var i = 0; i < WechatMergedChatParser.MaxNesting; i++)
nested = Record(new XElement("dataitem", new XAttribute("datatype", 17), nested));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(nested.ToString()));
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(Enumerable.Range(0, WechatMergedChatParser.MaxMessages + 1).Select(_ => Item("x")).ToArray()).ToString()));
}
[Fact]
public void PreservesComposite64BitMessageTypeAndBoundsZstdDecoding()
{
var message = new DbMessage(149, 123, "filehelper", null, null, null, 81604378673L, "xml", DateTimeOffset.UnixEpoch, null);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(message));
Assert.Equal(81604378673L, json.RootElement.GetProperty("Type").GetInt64());
using var output = new MemoryStream();
using (var compressed = new CompressionStream(output))
compressed.Write(Encoding.UTF8.GetBytes(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1)));
Assert.Throws<InvalidDataException>(() => WechatDbMessage.DecodeContent(Convert.ToHexString(output.ToArray()), true));
}
private static XElement Item(string text) => new("dataitem", new XAttribute("datatype", 1), new XAttribute("dataid", "repeated-id"),
new XElement("datadesc", text), new XElement("sourcename", "Synthetic sender"), new XElement("srcMsgLocalId", "7"),
new XElement("srcMsgCreateTime", "1700000000"), new XElement("fromnewmsgid", "18446744073709551615"),
new XElement("dataitemsource", new XElement("hashusername", "opaque-hash")));
private static XElement Record(params XElement[] items) => new("recordinfo", new XElement("title", "Synthetic record"),
new XElement("datalist", new XAttribute("count", items.Length), items));
private static string Wrap(XElement record) => new XElement("msg", new XElement("appmsg", new XElement("type", 19),
new XElement("recorditem", new XCData(record.ToString(SaveOptions.DisableFormatting))))).ToString();
}