feat(chat): persistent, seed-encrypted message store (Phase 2)

Swap the in-memory-only chat store for durable sqlite persistence with real
per-transaction timestamps, encrypted at rest under a key derived from the
wallet's own seed (no passphrase, works on encrypted and unencrypted wallets).

- ChatDatabase (src/chat/chat_database.{h,cpp}): sqlite store mirroring
  data::TransactionHistoryCache. unlockWithSecret(seed) derives a 32-byte AEAD
  storage key and a wallet-partition tag via domain-separated keyed BLAKE2b
  (generichash) contexts. Every record — bodies, peer z-addrs, threading,
  timestamps — is crypto_aead_xchacha20poly1305_ietf-encrypted with a random
  nonce and the wallet tag as associated data; even the dedup key is a keyed
  hash of txid+position, so nothing about your conversations is in cleartext on
  disk. Rows are partitioned per-wallet; a different seed sees nothing. Messages
  are decrypted once at ingest then re-encrypted under the storage key, so
  load() needs only the storage key, not the chat identity.
- ChatService: ingest() now stamps each message with its own transaction time
  (txid->time map + fallback) and writes new (store-deduped) messages through to
  the database; loadFromDatabase() rehydrates the in-memory read model on unlock.
- App: unlock the chat DB with the same seed in provisionChatIdentityFromSecret
  and load prior history; lock the DB + clear the decrypted in-memory store on
  relock and on lite-controller rebuild.

Adversarial review (4 confirmed findings, all fixed): don't provision if the
wallet locks mid-fetch (re-check isLocked at completion); wipe the serialized
plaintext temporary in append(); trim the seed into a separate fully-wiped
buffer (no residue past a shrunk size()); scrub the mnemonic copy in the RPC
json result.

Tests: ChatDatabase round-trip (persist/reload, field + order fidelity), dedup,
per-wallet isolation, lock inertness, and ChatService write-through + reload
without an identity. Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified:
Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean; caches
restored to the OFF default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:49:33 -05:00
parent 1738468f8c
commit 46eec37013
11 changed files with 619 additions and 29 deletions

View File

@@ -1484,7 +1484,9 @@ void App::refreshTransactionData()
// the store dedups (txid+position) so the full + recent refresh paths ingest safely.
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
!result.hushChatMetadata.empty()) {
chat_service_.ingest(result.hushChatMetadata, std::time(nullptr));
std::unordered_map<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
}
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
@@ -1540,7 +1542,9 @@ void App::refreshRecentTransactionData()
// full-refresh path above for rationale; the store dedups across both paths).
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
!result.hushChatMetadata.empty()) {
chat_service_.ingest(result.hushChatMetadata, std::time(nullptr));
std::unordered_map<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
}
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
@@ -2355,23 +2359,32 @@ void App::provisionChatIdentityFromSecret(std::string secret)
{
// Defensive: strip surrounding whitespace so a stray newline can't change the identity — the
// same wallet must derive the SAME identity on full-node and lite (both return the canonical
// single-space phrase today, so this is belt-and-suspenders).
// single-space phrase today, so this is belt-and-suspenders). Trim into a SEPARATE buffer so
// both the original (kept full-size) and the trimmed copy (size == content) are fully wiped —
// an in-place erase/pop_back would shrink size() and leave un-scrubbed seed bytes past it.
auto isws = [](char c) { return std::isspace(static_cast<unsigned char>(c)) != 0; };
while (!secret.empty() && isws(secret.back())) secret.pop_back();
std::size_t start = 0;
while (start < secret.size() && isws(secret[start])) ++start;
if (start) secret.erase(0, start);
std::size_t begin = 0, end = secret.size();
while (begin < end && isws(secret[begin])) ++begin;
while (end > begin && isws(secret[end - 1])) --end;
std::string trimmed = secret.substr(begin, end - begin);
chat::ChatKeyPair keys;
const auto result = chat::deriveChatIdentityFromSecret(secret, keys);
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
const auto result = chat::deriveChatIdentityFromSecret(trimmed, keys);
if (result.status == chat::ChatIdentityStatus::Ready) {
chat_service_.setIdentity(keys); // copies the keypair
chat_identity_provisioned_ = true;
// Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store
// with prior messages (decrypted at rest under a key only this seed can derive).
chat_service_.setPersistence(&chat_db_);
if (chat_db_.unlockWithSecret(trimmed)) {
chat_service_.loadFromDatabase();
}
} else {
chat_identity_unavailable_ = true;
}
if (!trimmed.empty()) sodium_memzero(&trimmed[0], trimmed.size());
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
chat::wipeChatKeyPair(keys);
}
@@ -2389,6 +2402,8 @@ void App::maybeProvisionChatIdentity()
if (state_.isLocked()) {
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) {
chat_service_.clearIdentity();
chat_service_.store().clear(); // decrypted plaintext in RAM — drop it; DB reloads on unlock
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_unavailable_ = false;
}
@@ -2422,7 +2437,14 @@ void App::maybeProvisionChatIdentity()
bool transientFail = false; // connection blip — allow a later retry
try {
rpc::RPCClient::TraceScope trace("HushChat / identity");
mnemonic = rpc_->call("z_exportmnemonic").value("mnemonic", std::string());
auto response = rpc_->call("z_exportmnemonic");
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
// Scrub the json's own copy of the seed after taking ours (the rest of the RPC
// response chain is unmanaged — a fuller fix belongs in the RPC layer).
auto& phrase = response["mnemonic"].get_ref<std::string&>();
mnemonic = phrase;
if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size());
}
} catch (const rpc::RpcError&) {
definitiveFail = true;
} catch (const std::exception&) {
@@ -2430,9 +2452,14 @@ void App::maybeProvisionChatIdentity()
}
return [this, mnemonic = std::move(mnemonic), definitiveFail, transientFail]() mutable {
chat_identity_fetch_in_flight_ = false;
if (definitiveFail) { chat_identity_unavailable_ = true; return; }
if (transientFail || mnemonic.empty()) return; // retry a later tick
provisionChatIdentityFromSecret(mnemonic); // copies internally
if (definitiveFail) chat_identity_unavailable_ = true;
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
// land during the blocking fetch, and provisioning then would unlock the chat DB +
// load decrypted history while the wallet is locked. Leaving the flag cleared here
// re-arms a fresh fetch on the next unlock.
if (!definitiveFail && !transientFail && !mnemonic.empty() && !state_.isLocked()) {
provisionChatIdentityFromSecret(mnemonic); // copies internally
}
if (!mnemonic.empty()) sodium_memzero(&mnemonic[0], mnemonic.size());
};
});