feat(chat): passphrase-wrapped local key vault for sensitive conversations

Adds ChatSensitiveVault: a per-wallet random 32-byte key wrapped under a user
passphrase (Argon2id MODERATE KEK + XSalsa20-Poly1305) in an 89-byte local file.
This key encrypts the at-rest rows of "sensitive" conversations INDEPENDENTLY of
the seed-derived chat key, so those rows can't be read by someone who has the
seed but not this passphrase — and a wallet restored from the seed alone can't
read them at all. Losing the passphrase/file makes them unrecoverable by design.
create/unlock/changePassphrase/remove, atomic write + 0600, keyOut zeroed on any
failure. Unit-tested (round-trip, wrong-passphrase zeroing, rekey, no-clobber).

Phase B, step 2 (local vault key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 17:31:30 -05:00
co-authored by Claude Opus 4.8
parent 669b8b7ca5
commit 84ca1a5cbe
4 changed files with 251 additions and 0 deletions
+42
View File
@@ -19,6 +19,7 @@
#include "chat/chat_identity.h"
#include "chat/chat_service.h"
#include "chat/chat_database.h"
#include "chat/chat_sensitive_vault.h"
#include "daemon/daemon_controller.h"
#include "daemon/daemon_busy_detect.h"
#include "daemon/embedded_daemon.h"
@@ -15009,6 +15010,46 @@ void testHushChatReceivePath()
EXPECT_EQ((int)off.metadata.size(), 0);
}
// Sensitive-conversation local key vault: a random 32-byte key wrapped under a user passphrase
// (Argon2id). Create -> unlock round-trips the SAME key; a wrong passphrase fails (and zeroes out);
// changing the passphrase preserves the key; create refuses to clobber an existing vault.
void testChatSensitiveVault()
{
using namespace dragonx::chat;
const std::string path = std::string("/tmp/dragonx_sensvault_test_") +
std::to_string(static_cast<long>(std::time(nullptr))) + ".vault";
std::remove(path.c_str());
ChatSensitiveVault v(path);
EXPECT_TRUE(!v.exists());
std::array<unsigned char, 32> k1{}, k2{}, k3{};
EXPECT_TRUE(v.create("correct horse battery staple", k1));
EXPECT_TRUE(v.exists());
EXPECT_TRUE(!std::all_of(k1.begin(), k1.end(), [](unsigned char b) { return b == 0; })); // a real key
EXPECT_TRUE(!v.create("other", k2)); // refuses to clobber
// A fresh instance reads the real file: the right passphrase recovers the SAME key.
ChatSensitiveVault v2(path);
EXPECT_TRUE(v2.unlock("correct horse battery staple", k2));
EXPECT_TRUE(std::equal(k1.begin(), k1.end(), k2.begin()));
// Wrong passphrase fails and leaves the out buffer zeroed (no partial key leak).
std::array<unsigned char, 32> kbad; kbad.fill(0x5A);
EXPECT_TRUE(!v2.unlock("wrong passphrase", kbad));
EXPECT_TRUE(std::all_of(kbad.begin(), kbad.end(), [](unsigned char b) { return b == 0; }));
// Change passphrase: the key is preserved, the old passphrase stops working.
EXPECT_TRUE(v2.changePassphrase("correct horse battery staple", "a new sensitive passphrase"));
EXPECT_TRUE(!v2.unlock("correct horse battery staple", k3));
EXPECT_TRUE(v2.unlock("a new sensitive passphrase", k3));
EXPECT_TRUE(std::equal(k1.begin(), k1.end(), k3.begin()));
EXPECT_TRUE(v2.remove());
EXPECT_TRUE(!v2.exists());
std::remove(path.c_str());
}
// Control memos (self-addressed delete/restore/sensitive signal): a new header type carrying an `op`,
// parsed + surfaced like a message pair, with unknown future types ignored (forward-compat).
void testHushChatControlMemo()
@@ -20129,6 +20170,7 @@ int main()
#endif
testHushChatCrypto();
testHushChatReceivePath();
testChatSensitiveVault();
testHushChatControlMemo();
testHushChatControlReducer();
testHushChatService();