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
+154 -21
View File
@@ -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<AccountKeySet>() : 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<string, object?>("$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 <new-dir> [--independent] [--timeout 60]
listener-smoke --output <new-dir> [--session <name>] [--independent] [--timeout 60]
recovery-smoke [--timeout 30]
recover-ui [--timeout 30]
stability-smoke [--seconds 15] [--sample-seconds 5] [--output <dir>]
@@ -733,7 +863,7 @@ WxAgent.Host commands:
group verify-at-all --group <name> --message <text> [--timeout 30]
chat send-url-card --to <name> --url <http-url> [--message <text>] --confirm CONFIRM [--timeout 60]
chat send-audio --to <name> --path <audio-file> --confirm CONFIRM [--timeout 60]
chat send --text <one-line-text> [--timeout 30]
chat send --text <one-line-text> [--session <name>] [--timeout 30]
chat reply-latest --text <one-line-text> [--timeout 60]
chat send-file --path <file> [--timeout 60]
chat send-image --path <image> [--timeout 60]
@@ -748,8 +878,11 @@ WxAgent.Host commands:
db scan [--data-root <xwechat_files>] [--save] [--key-file <path>] [--timeout 120]
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 contacts --account <fingerprint> [--contains <text>] [--include-content] [--key-file <path>] [--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.
""");