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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user