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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <wxid>_<random>; 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);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZstdSharp.Port" Version="0.8.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user