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:
@@ -415,6 +415,7 @@ set(APP_SOURCES
|
||||
src/chat/chat_identity.cpp
|
||||
src/chat/chat_store.cpp
|
||||
src/chat/chat_service.cpp
|
||||
src/chat/chat_database.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
@@ -566,6 +567,7 @@ set(APP_HEADERS
|
||||
src/chat/chat_message.h
|
||||
src/chat/chat_store.h
|
||||
src/chat/chat_service.h
|
||||
src/chat/chat_database.h
|
||||
src/config/version.h
|
||||
src/data/wallet_state.h
|
||||
src/data/transaction_history_cache.h
|
||||
@@ -1018,6 +1020,7 @@ if(BUILD_TESTING)
|
||||
src/chat/chat_identity.cpp
|
||||
src/chat/chat_store.cpp
|
||||
src/chat/chat_service.cpp
|
||||
src/chat/chat_database.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
|
||||
@@ -604,8 +604,11 @@ void App::rebuildLiteWallet(bool force)
|
||||
lite_open_error_.clear();
|
||||
|
||||
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
|
||||
// identity and re-arm provisioning so it re-derives from the newly opened wallet's seed.
|
||||
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
|
||||
// reloads the right wallet's history) from the newly opened wallet's seed.
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "util/pool_stats_service.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
#include "chat/chat_service.h"
|
||||
#include "chat/chat_database.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/console_tab.h"
|
||||
#include "imgui.h"
|
||||
@@ -577,6 +578,7 @@ private:
|
||||
// The identity is derived from the wallet's OWN SDXLite-compatible seed phrase (full-node
|
||||
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
|
||||
chat::ChatService chat_service_;
|
||||
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
||||
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||
bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet)
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
});
|
||||
|
||||
328
src/chat/chat_database.cpp
Normal file
328
src/chat/chat_database.cpp
Normal file
@@ -0,0 +1,328 @@
|
||||
// DragonX Wallet - HushChat persistent message store (implementation).
|
||||
|
||||
#include "chat_database.h"
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sodium.h>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
namespace {
|
||||
|
||||
// Domain-separated KDF contexts (used as the keyed-BLAKE2b key, like chat_identity). Both lengths
|
||||
// sit inside crypto_generichash's key-length bounds. Bumping a context rotates that derivation.
|
||||
constexpr char kStorageKeyContext[] = "DragonX-HushChat-Storage-v1";
|
||||
constexpr char kWalletTagContext[] = "DragonX-HushChat-WalletId-v1";
|
||||
constexpr std::size_t kStorageKeyContextLen = sizeof(kStorageKeyContext) - 1;
|
||||
constexpr std::size_t kWalletTagContextLen = sizeof(kWalletTagContext) - 1;
|
||||
|
||||
std::string toHex(const unsigned char* data, std::size_t len)
|
||||
{
|
||||
static const char* kHex = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(len * 2);
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
out.push_back(kHex[data[i] >> 4]);
|
||||
out.push_back(kHex[data[i] & 0x0F]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// keyed-BLAKE2b: out = generichash(in=secret, key=context). Deterministic, so the same seed always
|
||||
// derives the same storage key + wallet tag across sessions.
|
||||
bool deriveKeyed(const std::string& secret, const char* context, std::size_t contextLen,
|
||||
unsigned char* out, std::size_t outLen)
|
||||
{
|
||||
return crypto_generichash(out, outLen,
|
||||
reinterpret_cast<const unsigned char*>(secret.data()), secret.size(),
|
||||
reinterpret_cast<const unsigned char*>(context), contextLen) == 0;
|
||||
}
|
||||
|
||||
std::string associatedData(const std::string& walletTag)
|
||||
{
|
||||
return std::string("obsidian-dragon-hushchat-v1:") + walletTag;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatDatabase::ChatDatabase() : database_path_(defaultDatabasePath()) {}
|
||||
ChatDatabase::ChatDatabase(std::string databasePath) : database_path_(std::move(databasePath)) {}
|
||||
|
||||
ChatDatabase::~ChatDatabase()
|
||||
{
|
||||
lock();
|
||||
close();
|
||||
}
|
||||
|
||||
std::string ChatDatabase::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "chat_messages.sqlite").string();
|
||||
}
|
||||
|
||||
bool ChatDatabase::unlockWithSecret(const std::string& secret)
|
||||
{
|
||||
if (sodium_init() < 0) return false;
|
||||
|
||||
if (!deriveKeyed(secret, kStorageKeyContext, kStorageKeyContextLen, key_.data(), key_.size()))
|
||||
return false;
|
||||
|
||||
unsigned char tag[32];
|
||||
if (!deriveKeyed(secret, kWalletTagContext, kWalletTagContextLen, tag, sizeof(tag))) {
|
||||
sodium_memzero(key_.data(), key_.size());
|
||||
return false;
|
||||
}
|
||||
wallet_tag_ = toHex(tag, sizeof(tag));
|
||||
sodium_memzero(tag, sizeof(tag));
|
||||
key_ready_ = true;
|
||||
|
||||
if (!ensureOpen()) {
|
||||
lock();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChatDatabase::lock()
|
||||
{
|
||||
sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
wallet_tag_.clear();
|
||||
}
|
||||
|
||||
bool ChatDatabase::append(const ChatMessage& message)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
|
||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
||||
if (!encrypted) return false;
|
||||
|
||||
const std::string dedup = dedupHash(message.txid, message.payload_position);
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT OR IGNORE INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 3, nonce.data(), static_cast<int>(nonce.size()), SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 4, cipher.data(), static_cast<int>(cipher.size()), SQLITE_TRANSIENT);
|
||||
const bool done = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
if (!done) return false;
|
||||
return sqlite3_changes(db_) > 0;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatDatabase::load()
|
||||
{
|
||||
std::vector<ChatMessage> out;
|
||||
if (!key_ready_ || !ensureOpen()) return out;
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"SELECT nonce, payload FROM chat_messages WHERE wallet_tag = ? ORDER BY rowid",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return out;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const auto* noncePtr = static_cast<const unsigned char*>(sqlite3_column_blob(stmt, 0));
|
||||
const int nonceLen = sqlite3_column_bytes(stmt, 0);
|
||||
const auto* cipherPtr = static_cast<const unsigned char*>(sqlite3_column_blob(stmt, 1));
|
||||
const int cipherLen = sqlite3_column_bytes(stmt, 1);
|
||||
if (!noncePtr || !cipherPtr) continue;
|
||||
|
||||
std::vector<unsigned char> nonce(noncePtr, noncePtr + nonceLen);
|
||||
std::vector<unsigned char> cipher(cipherPtr, cipherPtr + cipherLen);
|
||||
std::string plain;
|
||||
if (!decrypt(nonce, cipher, plain)) continue; // wrong wallet / tampered — skip
|
||||
|
||||
ChatMessage message;
|
||||
if (deserialize(plain, message)) out.push_back(std::move(message));
|
||||
sodium_memzero(&plain[0], plain.size());
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
void ChatDatabase::clearWallet()
|
||||
{
|
||||
if (wallet_tag_.empty() || !ensureOpen()) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
|
||||
!= SQLITE_OK) {
|
||||
return;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
bool ChatDatabase::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& exception) {
|
||||
DEBUG_LOGF("Failed to create chat database directory: %s\n", exception.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open chat database: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatDatabase::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
if (sqlite3_exec(db_, sql, nullptr, nullptr, &error) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Chat database SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatDatabase::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
||||
"wallet_tag TEXT NOT NULL, "
|
||||
"dedup_hash TEXT NOT NULL, "
|
||||
"nonce BLOB NOT NULL, "
|
||||
"payload BLOB NOT NULL, "
|
||||
"PRIMARY KEY (wallet_tag, dedup_hash))");
|
||||
}
|
||||
|
||||
std::string ChatDatabase::dedupHash(const std::string& txid, std::size_t position) const
|
||||
{
|
||||
const std::string input = txid + ":" + std::to_string(position);
|
||||
unsigned char hash[32];
|
||||
crypto_generichash(hash, sizeof(hash),
|
||||
reinterpret_cast<const unsigned char*>(input.data()), input.size(),
|
||||
key_.data(), key_.size()); // keyed by the storage key → txid stays private
|
||||
return toHex(hash, sizeof(hash));
|
||||
}
|
||||
|
||||
std::string ChatDatabase::serialize(const ChatMessage& message) const
|
||||
{
|
||||
nlohmann::json json;
|
||||
json["d"] = static_cast<int>(message.direction);
|
||||
json["k"] = static_cast<int>(message.kind);
|
||||
json["txid"] = message.txid;
|
||||
json["cid"] = message.conversation_id;
|
||||
json["z"] = message.peer_zaddr;
|
||||
json["p"] = message.peer_public_key_hex;
|
||||
json["b"] = message.body;
|
||||
json["ts"] = message.timestamp;
|
||||
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
||||
return json.dump();
|
||||
}
|
||||
|
||||
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
||||
{
|
||||
try {
|
||||
const auto parsed = nlohmann::json::parse(json);
|
||||
out.direction = static_cast<ChatDirection>(parsed.value("d", 0));
|
||||
out.kind = static_cast<ChatMessageKind>(parsed.value("k", 0));
|
||||
out.txid = parsed.value("txid", std::string());
|
||||
out.conversation_id = parsed.value("cid", std::string());
|
||||
out.peer_zaddr = parsed.value("z", std::string());
|
||||
out.peer_public_key_hex = parsed.value("p", std::string());
|
||||
out.body = parsed.value("b", std::string());
|
||||
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
|
||||
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatDatabase::encrypt(const std::string& plain,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipher) const
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
|
||||
const std::string ad = associatedData(wallet_tag_);
|
||||
cipher.resize(plain.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long cipherLen = 0;
|
||||
if (crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
cipher.data(), &cipherLen,
|
||||
reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),
|
||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||
nullptr, nonce.data(), key_.data()) != 0) {
|
||||
return false;
|
||||
}
|
||||
cipher.resize(static_cast<std::size_t>(cipherLen));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipher,
|
||||
std::string& plain) const
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false;
|
||||
if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false;
|
||||
|
||||
const std::string ad = associatedData(wallet_tag_);
|
||||
std::vector<unsigned char> out(cipher.size());
|
||||
unsigned long long outLen = 0;
|
||||
if (crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
out.data(), &outLen, nullptr,
|
||||
cipher.data(), cipher.size(),
|
||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||
nonce.data(), key_.data()) != 0) {
|
||||
return false;
|
||||
}
|
||||
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
|
||||
sodium_memzero(out.data(), out.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChatDatabase::close()
|
||||
{
|
||||
if (db_) {
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
74
src/chat/chat_database.h
Normal file
74
src/chat/chat_database.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat persistent message store (Phase 2).
|
||||
//
|
||||
// Sqlite-backed, encrypted at rest with a SEED-DERIVED key (no wallet passphrase). Every record —
|
||||
// message bodies, peer z-addresses, threading (conversation id), and timestamps — is AEAD-encrypted
|
||||
// under a key derived from the wallet's own seed secret (the same secret used for the chat
|
||||
// identity), and even the per-message dedup key is a KEYED hash of the txid — so the database
|
||||
// reveals nothing about your conversations to disk-level access without the seed. Rows are
|
||||
// partitioned by a seed-derived wallet tag so one file can hold several wallets, each readable only
|
||||
// with its own seed. Not thread-safe — drive from the main thread. Mirrors the lifecycle of
|
||||
// data::TransactionHistoryCache.
|
||||
|
||||
#include "chat_message.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
class ChatDatabase {
|
||||
public:
|
||||
ChatDatabase();
|
||||
explicit ChatDatabase(std::string databasePath);
|
||||
~ChatDatabase();
|
||||
|
||||
ChatDatabase(const ChatDatabase&) = delete;
|
||||
ChatDatabase& operator=(const ChatDatabase&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
|
||||
// Derive the storage key + wallet tag from the wallet's seed secret and open the DB. The caller
|
||||
// still owns and must wipe `secret`. Returns false on sodium/db failure (DB then stays locked).
|
||||
bool unlockWithSecret(const std::string& secret);
|
||||
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
|
||||
bool hasKey() const { return key_ready_; }
|
||||
|
||||
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
|
||||
// Returns true if newly inserted; false on duplicate or while locked.
|
||||
bool append(const ChatMessage& message);
|
||||
|
||||
// Decrypt and return every stored message for the unlocked wallet, in insertion order. Empty
|
||||
// while locked or if none. Rows that fail to decrypt/parse are skipped.
|
||||
std::vector<ChatMessage> load();
|
||||
|
||||
void clearWallet(); // delete the unlocked wallet's rows
|
||||
|
||||
private:
|
||||
bool ensureOpen();
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
||||
std::string serialize(const ChatMessage& message) const;
|
||||
bool deserialize(const std::string& json, ChatMessage& out) const;
|
||||
bool encrypt(const std::string& plain,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipher) const;
|
||||
bool decrypt(const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipher,
|
||||
std::string& plain) const;
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||
bool key_ready_ = false;
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat in-memory message model (no libsodium, no persistence).
|
||||
// Phase 1 keeps decrypted messages in memory only; sqlite persistence is Phase 2.
|
||||
// DragonX Wallet - HushChat decrypted message model. Held in memory by ChatStore and persisted at
|
||||
// rest (encrypted under a seed-derived key) by ChatDatabase.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "chat_service.h"
|
||||
|
||||
#include "chat_database.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx::chat {
|
||||
@@ -21,7 +23,8 @@ void ChatService::clearIdentity() {
|
||||
}
|
||||
|
||||
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
std::int64_t txTimestamp) {
|
||||
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
|
||||
std::int64_t fallbackTimestamp) {
|
||||
if (!has_identity_) return 0;
|
||||
|
||||
int added = 0;
|
||||
@@ -32,7 +35,8 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
message.conversation_id = meta.conversation_id;
|
||||
message.peer_zaddr = meta.reply_zaddr;
|
||||
message.peer_public_key_hex = meta.sender_public_key_hex;
|
||||
message.timestamp = txTimestamp;
|
||||
const auto timeIt = txTimestamps.find(meta.txid);
|
||||
message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
|
||||
message.payload_position = meta.payload_position;
|
||||
|
||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||
@@ -48,9 +52,22 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
message.body = std::move(plaintext);
|
||||
}
|
||||
|
||||
if (store_.append(message)) ++added;
|
||||
// In-memory store dedups (txid+position); only persist the genuinely new ones. On the next
|
||||
// session loadFromDatabase() repopulates the store, so re-scanning the chain re-ingests but
|
||||
// the store dedup prevents a duplicate write.
|
||||
if (store_.append(message)) {
|
||||
if (db_) db_->append(message);
|
||||
++added;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
void ChatService::loadFromDatabase() {
|
||||
if (!db_) return;
|
||||
for (const auto& message : db_->load()) {
|
||||
store_.append(message);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
#include "chat_store.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
class ChatDatabase; // optional persistent backing (Phase 2); set via setPersistence
|
||||
|
||||
class ChatService {
|
||||
public:
|
||||
ChatService() = default;
|
||||
@@ -30,10 +34,21 @@ public:
|
||||
void clearIdentity(); // wipes the held secret key
|
||||
|
||||
// Decrypt/record each metadata entry (a Message is decrypted; a ContactRequest carries its
|
||||
// plaintext through) and thread it into the store. `txTimestamp` is stamped onto every
|
||||
// message in this batch. Returns the number of NEW messages added. Returns 0 with no
|
||||
// identity. Undecryptable Messages are dropped silently (no logging of memo/plaintext).
|
||||
int ingest(const std::vector<HushChatTransactionMetadata>& metadata, std::int64_t txTimestamp = 0);
|
||||
// plaintext through) and thread it into the store. Each message is stamped with its own
|
||||
// transaction time via `txTimestamps` (keyed by txid), falling back to `fallbackTimestamp`
|
||||
// when the txid isn't present. Newly-added messages are also persisted (if a database is
|
||||
// attached). Returns the number of NEW messages added; 0 with no identity. Undecryptable
|
||||
// Messages are dropped silently (no logging of memo/plaintext).
|
||||
int ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
|
||||
std::int64_t fallbackTimestamp = 0);
|
||||
|
||||
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
|
||||
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
|
||||
void setPersistence(ChatDatabase* db) { db_ = db; }
|
||||
// Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under
|
||||
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||
void loadFromDatabase();
|
||||
|
||||
const ChatStore& store() const { return store_; }
|
||||
ChatStore& store() { return store_; }
|
||||
@@ -42,6 +57,7 @@ private:
|
||||
ChatKeyPair identity_{};
|
||||
bool has_identity_ = false;
|
||||
ChatStore store_;
|
||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat in-memory message store (Phase 1; sqlite persistence is Phase 2).
|
||||
// DragonX Wallet - HushChat in-memory message store: the fast read model / dedup view. Durable
|
||||
// persistence lives in ChatDatabase; ChatService rehydrates this store from it on unlock.
|
||||
|
||||
#include "chat_message.h"
|
||||
|
||||
|
||||
@@ -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