feat: database-backed message model with multi-account key cache

- Add Windows 4.1.x Config.Cipher pointer-chain key extraction with cross-chunk
  MemoryPatternScanner and bounded blob XOR decoding; keys still require per-DB
  page-1 HMAC verification before caching.
- Cache keys by account fingerprint + database relative path; db scan --save
  skips memory scanning entirely once all discovered databases are cached.
- Add DbMessage model (localId, serverId, type, timestamp, sender, display name,
  avatar, isSelf) read read-only from Msg_{md5(chat)} via SQLCipher with hex/zstd
  content decoding and contact.db name resolution.
- CLI: db messages / db contacts / db schema with masked defaults; include the
  earlier chat send --session wiring. 97 Core tests pass; real-machine validated.
This commit is contained in:
2026-09-06 14:35:19 +08:00
parent 1494094651
commit fb8c25c83c
19 changed files with 1335 additions and 79 deletions
+32
View File
@@ -0,0 +1,32 @@
namespace WxAgent.Core;
public sealed record AccountDatabaseEntry(string Fingerprint, string RelativePath);
/// <summary>Decides which verified keys may be reused from cache, avoiding repeated memory scans for cached accounts.</summary>
public static class KeyCachePlanner
{
public static string Key(string accountFingerprint, string relativePath) =>
$"{accountFingerprint}|{relativePath}";
public static IReadOnlySet<string> VerifiedKeys(IEnumerable<AccountKeySet> cached)
{
var set = new HashSet<string>(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<AccountDatabaseEntry> discovered, IReadOnlySet<string> verified)
{
foreach (var entry in discovered)
{
if (!verified.Contains(Key(entry.Fingerprint, entry.RelativePath)))
{
return true;
}
}
return false;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Security.Cryptography;
namespace WxAgent.Core;
/// <summary>Finds a fixed byte pattern across contiguous read-only memory chunks.</summary>
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<ulong> Feed(ReadOnlySpan<byte> bytes, ulong address)
{
if (_end != address) _tailLength = 0;
var matches = new List<ulong>();
Span<byte> 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);
}
+51
View File
@@ -0,0 +1,51 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace WxAgent.Core;
/// <summary>Decodes bounded Windows WCDB Config.Cipher blobs. Candidates still require page HMAC verification.</summary>
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<WcdbKeyCandidate> Decode(ReadOnlySpan<byte> 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<WcdbKeyCandidate>();
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<int> { 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);
}
}
}
+24 -1
View File
@@ -13,12 +13,34 @@ public sealed class WcdbKeyCandidateScanner
private readonly char[] _saltSuffix = new char[SaltHexLength];
private readonly HashSet<string> _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<WcdbKeyCandidate> Feed(ReadOnlySpan<byte> bytes, bool finalBlock = false)
{
var found = new List<WcdbKeyCandidate>();
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;
+63
View File
@@ -0,0 +1,63 @@
using System.Security.Cryptography;
using System.Text;
using ZstdSharp;
namespace WxAgent.Core;
/// <summary>Database-backed message with stable identity, sender and timestamps.</summary>
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);
}
/// <summary>The account root directory name is &lt;wxid&gt;_&lt;random&gt;; the reference project treats the wxid prefix as self.</summary>
public static bool? IsSelf(string? senderWxId, string? accountRootDirectoryName)
{
if (string.IsNullOrEmpty(senderWxId) || string.IsNullOrEmpty(accountRootDirectoryName)) return null;
return accountRootDirectoryName.StartsWith(senderWxId + "_", StringComparison.Ordinal);
}
}
+3
View File
@@ -4,4 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ZstdSharp.Port" Version="0.8.4" />
</ItemGroup>
</Project>