feat(chat): per-conversation local-key encryption in ChatDatabase

ChatDatabase gains a second AEAD key — the local vault key (setSensitiveKey /
lockSensitive) — and a sensitivity predicate. Rows of conversations the
predicate marks sensitive are encrypted under the vault key instead of the
seed-derived key, and are persisted only while the vault is unlocked (never
stored under the seed key). load() tries the seed key then the vault key, so a
sensitive row is WITHHELD whenever the vault key is absent — including on a
fresh instance that holds only the seed (the "fresh seed import can't read
sensitive conversations" guarantee); a wrong vault key withholds too, never
yields garbage. encrypt/decrypt are now key-parametrized; lock() also wipes the
vault key. Unit-tested end to end.

Phase B, step 3 (DB dual-key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 17:36:50 -05:00
co-authored by Claude Opus 4.8
parent 84ca1a5cbe
commit 6b9bda3ee4
3 changed files with 138 additions and 12 deletions
+52 -10
View File
@@ -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<unsigned char> nonce;
std::vector<unsigned char> 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<ChatMessage>& 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<unsigned char> 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<unsigned char> nonce;
std::vector<unsigned char> 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<ChatMessage> ChatDatabase::load()
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
// 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<unsigned char, 32>& 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<unsigned char>& nonce,
std::vector<unsigned char>& 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<const unsigned char*>(plain.data()), plain.size(),
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
nullptr, nonce.data(), key_.data()) != 0) {
nullptr, nonce.data(), key) != 0) {
return false;
}
cipher.resize(static_cast<std::size_t>(cipherLen));
return true;
}
bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
bool ChatDatabase::decrypt(const unsigned char* key,
const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& 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<unsigned char>& nonce,
out.data(), &outLen, nullptr,
cipher.data(), cipher.size(),
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
nonce.data(), key_.data()) != 0) {
nonce.data(), key) != 0) {
return false;
}
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
+23 -2
View File
@@ -15,6 +15,7 @@
#include <array>
#include <cstddef>
#include <functional>
#include <string>
#include <unordered_set>
#include <vector>
@@ -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<unsigned char, 32>& 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<bool(const std::string&)> 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<unsigned char>& nonce,
std::vector<unsigned char>& cipher) const;
bool decrypt(const std::vector<unsigned char>& nonce,
bool decrypt(const unsigned char* key,
const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& 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<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;
std::array<unsigned char, 32> sensitive_key_{}; // local vault key for sensitive conversations' rows
bool sensitive_ready_ = false;
std::function<bool(const std::string&)> sensitive_pred_; // true => store this cid under the sensitive key
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
};
+63
View File
@@ -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<unsigned char, 32> vkey;
for (std::size_t i = 0; i < vkey.size(); ++i) vkey[i] = static_cast<unsigned char>(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<unsigned char, 32> 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();