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