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

@@ -1,6 +1,7 @@
#include "chat/chat_crypto.h"
#include "chat/chat_identity.h"
#include "chat/chat_service.h"
#include "chat/chat_database.h"
#include "daemon/daemon_controller.h"
#include "data/transaction_history_cache.h"
#include "daemon/lifecycle_adapters.h"
@@ -5691,13 +5692,13 @@ void testHushChatService()
ChatService svc;
EXPECT_TRUE(!svc.hasIdentity());
EXPECT_EQ(svc.ingest(batch), 0); // no identity -> nothing ingested
EXPECT_EQ(svc.ingest(batch, {}), 0); // no identity -> nothing ingested
svc.setIdentity(bob);
EXPECT_TRUE(svc.hasIdentity());
EXPECT_EQ(svc.ingest(batch, 1000), 2);
EXPECT_EQ(svc.ingest(batch, {}, 1000), 2);
EXPECT_EQ((int)svc.store().size(), 2);
EXPECT_EQ(svc.ingest(batch, 1000), 0); // re-scan dedups
EXPECT_EQ(svc.ingest(batch, {}, 1000), 0); // re-scan dedups
EXPECT_EQ((int)svc.store().size(), 2);
std::vector<ChatMessage> conv = svc.store().conversation("conv-x");
@@ -5716,7 +5717,7 @@ void testHushChatService()
creq[0].sender_public_key_hex = ra.public_key_hex;
creq[0].payload_memo = "hi, add me";
creq[0].payload_position = 1;
EXPECT_EQ(svc.ingest(creq), 1);
EXPECT_EQ(svc.ingest(creq, {}), 1);
std::vector<ChatMessage> cy = svc.store().conversation("conv-y");
EXPECT_EQ((int)cy.size(), 1);
EXPECT_TRUE(cy[0].kind == ChatMessageKind::ContactRequest);
@@ -5727,10 +5728,127 @@ void testHushChatService()
ChatKeyPair mallory;
deriveChatIdentityFromSecret("mallory-svc", mallory, true);
svc2.setIdentity(mallory);
EXPECT_EQ(svc2.ingest(batch, 1000), 0);
EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0);
EXPECT_TRUE(svc2.store().empty());
}
// Phase 2: persistent, seed-encrypted store — round-trip, dedup, per-wallet isolation, and
// ChatService write-through + reload (reload needs only the storage key, not the chat identity).
void testHushChatDatabase()
{
using namespace dragonx::chat;
namespace fs = std::filesystem;
const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_db_test.sqlite").string();
const std::string dbPath2 = (fs::temp_directory_path() / "drgx_chat_db_test2.sqlite").string();
auto scrub = [](const std::string& p) {
fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm");
};
scrub(dbPath); scrub(dbPath2);
const std::string seedA = "wallet A seed phrase words here";
const std::string seedB = "a completely different wallet B seed";
auto makeMsg = [](const std::string& txid, std::size_t pos, const std::string& cid,
const std::string& body, ChatMessageKind kind, std::int64_t ts) {
ChatMessage m;
m.direction = ChatDirection::Incoming;
m.kind = kind;
m.txid = txid;
m.conversation_id = cid;
m.peer_zaddr = "zs-peer";
m.peer_public_key_hex = "deadbeef";
m.body = body;
m.timestamp = ts;
m.payload_position = pos;
return m;
};
// Locked DB is inert; unlock, then write three (with a duplicate that is ignored).
{
ChatDatabase db(dbPath);
EXPECT_TRUE(!db.hasKey());
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false);
EXPECT_TRUE(db.unlockWithSecret(seedA));
EXPECT_TRUE(db.hasKey());
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)));
EXPECT_TRUE(db.append(makeMsg("t2", 1, "c", "world", ChatMessageKind::Message, 222)));
EXPECT_TRUE(db.append(makeMsg("t3", 0, "c", "add me", ChatMessageKind::ContactRequest, 333)));
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false); // dup
}
// Reopen with the SAME seed → messages persist, fields + order intact.
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret(seedA));
std::vector<ChatMessage> all = db.load();
EXPECT_EQ((int)all.size(), 3);
EXPECT_EQ(all[0].body, std::string("hello"));
EXPECT_EQ(all[0].txid, std::string("t1"));
EXPECT_EQ(all[0].timestamp, (std::int64_t)111);
EXPECT_EQ(all[1].body, std::string("world"));
EXPECT_TRUE(all[2].kind == ChatMessageKind::ContactRequest);
EXPECT_EQ(all[2].body, std::string("add me"));
EXPECT_EQ(all[2].peer_public_key_hex, std::string("deadbeef"));
}
// A DIFFERENT seed is partitioned + can't decrypt → sees nothing, writes in isolation.
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret(seedB));
EXPECT_TRUE(db.load().empty());
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "B-secret", ChatMessageKind::Message, 999)));
EXPECT_EQ((int)db.load().size(), 1);
}
// ...and wallet A still sees exactly its three.
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret(seedA));
EXPECT_EQ((int)db.load().size(), 3);
db.lock();
EXPECT_TRUE(!db.hasKey());
EXPECT_TRUE(db.load().empty()); // inert once locked
}
// End-to-end: ChatService write-through on ingest, then reload into a fresh service WITHOUT an
// identity (proves stored messages are decryptable with the storage key alone).
{
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("db-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("db-bob", bob, true);
std::string e, ct;
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "persisted!", e, ct) == ChatCryptoStatus::Ok);
HushChatTransactionMetadata m;
m.txid = "txp"; m.type = HushChatHeaderType::Message; m.conversation_id = "cp";
m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex;
m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1;
std::vector<HushChatTransactionMetadata> batch{m};
std::unordered_map<std::string, std::int64_t> times{{"txp", 4242}};
{
ChatDatabase db(dbPath2);
EXPECT_TRUE(db.unlockWithSecret("e2e-seed"));
ChatService svc;
svc.setPersistence(&db);
svc.setIdentity(bob);
EXPECT_EQ(svc.ingest(batch, times), 1);
}
{
ChatDatabase db(dbPath2);
EXPECT_TRUE(db.unlockWithSecret("e2e-seed"));
ChatService svc;
svc.setPersistence(&db);
svc.loadFromDatabase(); // no setIdentity() — reload doesn't need it
std::vector<ChatMessage> conv = svc.store().conversation("cp");
EXPECT_EQ((int)conv.size(), 1);
EXPECT_EQ(conv[0].body, std::string("persisted!"));
EXPECT_EQ(conv[0].timestamp, (std::int64_t)4242);
}
}
scrub(dbPath); scrub(dbPath2);
}
} // namespace
int main()
@@ -5824,6 +5942,7 @@ int main()
testHushChatCrypto();
testHushChatReceivePath();
testHushChatService();
testHushChatDatabase();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();