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