diff --git a/src/chat/chat_database.cpp b/src/chat/chat_database.cpp index 08b91b8..7b73586 100644 --- a/src/chat/chat_database.cpp +++ b/src/chat/chat_database.cpp @@ -97,6 +97,7 @@ void ChatDatabase::lock() { sodium_memzero(key_.data(), key_.size()); key_ready_ = false; + lockSensitive(); // also drop the sensitive-conversation vault key wallet_tag_.clear(); tombstones_.clear(); } @@ -106,6 +107,10 @@ bool ChatDatabase::append(const ChatMessage& message) if (!key_ready_ || !ensureOpen()) return false; if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan + bool skip = false; + const unsigned char* wkey = writeKeyFor(message.conversation_id, skip); + if (skip) return false; // sensitive conversation, vault locked — don't persist (re-imports when unlocked) + std::vector nonce; std::vector cipher; std::string plain; @@ -114,7 +119,7 @@ bool ChatDatabase::append(const ChatMessage& message) // serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here; // skip the row instead of letting it escape the write path and crash (appendBatch already does). plain = serialize(message); - encrypted = encrypt(plain, nonce, cipher); + encrypted = encrypt(wkey, plain, nonce, cipher); } catch (const std::exception& e) { if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what()); @@ -152,10 +157,13 @@ int ChatDatabase::appendBatch(const std::vector& messages) rows.reserve(messages.size()); for (const auto& m : messages) { if (isTombstoned(m)) continue; // locally deleted — don't re-persist + bool skip = false; + const unsigned char* wkey = writeKeyFor(m.conversation_id, skip); + if (skip) continue; // sensitive + vault locked — don't persist try { std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body std::vector nonce, cipher; - const bool ok = encrypt(plain, nonce, cipher); + const bool ok = encrypt(wkey, plain, nonce, cipher); if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); if (!ok) continue; rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) }); @@ -195,13 +203,17 @@ bool ChatDatabase::upsert(const ChatMessage& message) { if (!key_ready_ || !ensureOpen()) return false; + bool skip = false; + const unsigned char* wkey = writeKeyFor(message.conversation_id, skip); + if (skip) return false; // sensitive conversation, vault locked — don't persist the echo under the seed key + std::vector nonce; std::vector cipher; std::string plain; bool encrypted = false; try { plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash - encrypted = encrypt(plain, nonce, cipher); + encrypted = encrypt(wkey, plain, nonce, cipher); } catch (const std::exception& e) { if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what()); @@ -250,7 +262,13 @@ std::vector ChatDatabase::load() std::vector nonce(noncePtr, noncePtr + nonceLen); std::vector cipher(cipherPtr, cipherPtr + cipherLen); std::string plain; - if (!decrypt(nonce, cipher, plain)) continue; // wrong wallet / tampered — skip + // Try the seed key first; a sensitive row only decrypts under the vault key (and is silently + // withheld when the vault is locked / on a fresh seed import that has no vault key). A wrong + // wallet / tampered row fails both and is skipped. + if (!decrypt(key_.data(), nonce, cipher, plain) && + !(sensitive_ready_ && decrypt(sensitive_key_.data(), nonce, cipher, plain))) { + continue; + } ChatMessage message; if (deserialize(plain, message)) out.push_back(std::move(message)); @@ -460,11 +478,34 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const } } -bool ChatDatabase::encrypt(const std::string& plain, +void ChatDatabase::setSensitiveKey(const std::array& key) +{ + sensitive_key_ = key; // copy; the caller still owns & wipes its own copy + sensitive_ready_ = true; +} + +void ChatDatabase::lockSensitive() +{ + sodium_memzero(sensitive_key_.data(), sensitive_key_.size()); + sensitive_ready_ = false; +} + +const unsigned char* ChatDatabase::writeKeyFor(const std::string& cid, bool& skip) const +{ + skip = false; + if (sensitive_pred_ && sensitive_pred_(cid)) { + if (!sensitive_ready_) { skip = true; return nullptr; } // don't persist under the seed key + return sensitive_key_.data(); + } + return key_.data(); +} + +bool ChatDatabase::encrypt(const unsigned char* key, + const std::string& plain, std::vector& nonce, std::vector& cipher) const { - if (!key_ready_) return false; + if (!key) return false; nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); randombytes_buf(nonce.data(), nonce.size()); @@ -475,18 +516,19 @@ bool ChatDatabase::encrypt(const std::string& plain, cipher.data(), &cipherLen, reinterpret_cast(plain.data()), plain.size(), reinterpret_cast(ad.data()), ad.size(), - nullptr, nonce.data(), key_.data()) != 0) { + nullptr, nonce.data(), key) != 0) { return false; } cipher.resize(static_cast(cipherLen)); return true; } -bool ChatDatabase::decrypt(const std::vector& nonce, +bool ChatDatabase::decrypt(const unsigned char* key, + const std::vector& nonce, const std::vector& cipher, std::string& plain) const { - if (!key_ready_) return false; + if (!key) return false; if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false; if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false; @@ -497,7 +539,7 @@ bool ChatDatabase::decrypt(const std::vector& nonce, out.data(), &outLen, nullptr, cipher.data(), cipher.size(), reinterpret_cast(ad.data()), ad.size(), - nonce.data(), key_.data()) != 0) { + nonce.data(), key) != 0) { return false; } plain.assign(reinterpret_cast(out.data()), static_cast(outLen)); diff --git a/src/chat/chat_database.h b/src/chat/chat_database.h index 2da3152..aed89c1 100644 --- a/src/chat/chat_database.h +++ b/src/chat/chat_database.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -40,6 +41,17 @@ public: void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op bool hasKey() const { return key_ready_; } + // --- Sensitive conversations (per-conversation local-key encryption) --- + // Provide the local vault key (from ChatSensitiveVault). Rows of conversations the sensitive + // predicate marks sensitive are AEAD-encrypted under THIS key instead of the seed-derived key, so + // they can't be read without it (even by the seed). The caller still owns and should wipe `key`. + void setSensitiveKey(const std::array& key); + void lockSensitive(); // wipe the sensitive key; sensitive rows then withheld + bool hasSensitiveKey() const { return sensitive_ready_; } + // Predicate: true for a conversation id that should be stored under the sensitive key. Consulted on + // WRITE to pick the key; on READ both keys are tried, so a predicate isn't needed to decrypt. + void setSensitivePredicate(std::function pred) { sensitive_pred_ = std::move(pred); } + // 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); @@ -80,12 +92,18 @@ private: 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, + bool encrypt(const unsigned char* key, + const std::string& plain, std::vector& nonce, std::vector& cipher) const; - bool decrypt(const std::vector& nonce, + bool decrypt(const unsigned char* key, + const std::vector& nonce, const std::vector& cipher, std::string& plain) const; + // The key a row for `cid` is written under. Returns nullptr and sets skip=true when the conversation + // is sensitive but the vault is locked — the row must NOT be persisted (under the seed key it would be + // readable without the vault, defeating the point); it re-imports from chain once the vault is unlocked. + const unsigned char* writeKeyFor(const std::string& cid, bool& skip) const; void close(); sqlite3* db_ = nullptr; @@ -93,6 +111,9 @@ private: std::array key_{}; // AEAD storage key (seed-derived) std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex) bool key_ready_ = false; + std::array sensitive_key_{}; // local vault key for sensitive conversations' rows + bool sensitive_ready_ = false; + std::function sensitive_pred_; // true => store this cid under the sensitive key std::unordered_set tombstones_; // dedup_hash cache of locally-deleted messages }; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index f8c1347..0a2b5ff 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15320,6 +15320,68 @@ void testHushChatService() // 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). +// Per-conversation local-key encryption at the DB layer: sensitive rows are stored under the vault key +// (not the seed key), persisted only when the vault is unlocked, and WITHHELD from load() without it — +// including on a fresh instance holding only the seed key (the "fresh seed import" case). +void testChatSensitiveDatabase() +{ + using namespace dragonx::chat; + namespace fs = std::filesystem; + const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_sensdb_test.sqlite").string(); + auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); }; + scrub(dbPath); + + auto makeMsg = [](const std::string& txid, const std::string& cid, const std::string& body) { + ChatMessage m; m.direction = ChatDirection::Incoming; m.kind = ChatMessageKind::Message; + m.txid = txid; m.conversation_id = cid; m.peer_zaddr = "zs-peer"; m.peer_public_key_hex = "dead"; + m.body = body; m.timestamp = 1000; m.payload_position = 0; return m; + }; + std::array vkey; + for (std::size_t i = 0; i < vkey.size(); ++i) vkey[i] = static_cast(i * 7 + 1); + + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret("seed-secret")); + db.setSensitivePredicate([](const std::string& cid) { return cid == "sens"; }); + + EXPECT_TRUE(db.append(makeMsg("n1", "normal", "hello normal"))); + // A sensitive row with no vault key is NOT persisted (never stored under the seed key). + EXPECT_TRUE(!db.append(makeMsg("s1", "sens", "top secret"))); + EXPECT_EQ((int)db.load().size(), 1); + + // With the vault key the sensitive row persists; load (vault unlocked) returns both. + db.setSensitiveKey(vkey); + EXPECT_TRUE(db.append(makeMsg("s1", "sens", "top secret"))); + EXPECT_EQ((int)db.load().size(), 2); + + // Locking the vault withholds the sensitive row; the normal row still loads. + db.lockSensitive(); + auto locked = db.load(); + EXPECT_EQ((int)locked.size(), 1); + EXPECT_EQ(locked[0].conversation_id, std::string("normal")); + } + + // Fresh instance over the same file: the SEED key alone withholds the sensitive row; the vault key + // reveals it; a WRONG vault key does not (withheld, never garbage). + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret("seed-secret")); + EXPECT_EQ((int)db.load().size(), 1); // fresh-seed-import view: only the normal row + db.setSensitiveKey(vkey); + auto all = db.load(); + EXPECT_EQ((int)all.size(), 2); + bool sawSecret = false; + for (const auto& m : all) if (m.body == "top secret") sawSecret = true; + EXPECT_TRUE(sawSecret); + + db.lockSensitive(); + std::array wrong; wrong.fill(0x11); + db.setSensitiveKey(wrong); + EXPECT_EQ((int)db.load().size(), 1); // wrong key → sensitive row stays withheld + } + scrub(dbPath); +} + void testHushChatDatabase() { using namespace dragonx::chat; @@ -20175,6 +20237,7 @@ int main() testHushChatControlReducer(); testHushChatService(); testHushChatDatabase(); + testChatSensitiveDatabase(); testHushChatDeleteConversation(); testChatStoreTxidIndex(); testChatStoreLatestAndMove();