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

74
src/chat/chat_database.h Normal file
View 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