From 84ca1a5cbe6744a08f70da75106cfb5daca1dd99 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 23 Oct 2026 17:31:30 -0500 Subject: [PATCH] feat(chat): passphrase-wrapped local key vault for sensitive conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CMakeLists.txt | 3 + src/chat/chat_sensitive_vault.cpp | 147 ++++++++++++++++++++++++++++++ src/chat/chat_sensitive_vault.h | 59 ++++++++++++ tests/test_phase4.cpp | 42 +++++++++ 4 files changed, 251 insertions(+) create mode 100644 src/chat/chat_sensitive_vault.cpp create mode 100644 src/chat/chat_sensitive_vault.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 232f9cd..0a57b21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -499,6 +499,7 @@ set(APP_SOURCES src/chat/chat_service.cpp src/chat/chat_database.cpp src/chat/chat_outgoing.cpp + src/chat/chat_sensitive_vault.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp @@ -669,6 +670,7 @@ set(APP_HEADERS src/chat/chat_service.h src/chat/chat_database.h src/chat/chat_outgoing.h + src/chat/chat_sensitive_vault.h src/config/version.h src/data/wallet_state.h src/data/transaction_history_cache.h @@ -1224,6 +1226,7 @@ if(BUILD_TESTING) src/chat/chat_service.cpp src/chat/chat_database.cpp src/chat/chat_outgoing.cpp + src/chat/chat_sensitive_vault.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp diff --git a/src/chat/chat_sensitive_vault.cpp b/src/chat/chat_sensitive_vault.cpp new file mode 100644 index 0000000..f81a5c8 --- /dev/null +++ b/src/chat/chat_sensitive_vault.cpp @@ -0,0 +1,147 @@ +// DragonX Wallet - HushChat sensitive-conversation local key vault (implementation). + +#include "chat_sensitive_vault.h" + +#include + +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +namespace dragonx::chat { + +namespace { +constexpr unsigned char kVersion = 0x01; +constexpr std::size_t kSaltLen = 16; // crypto_pwhash_SALTBYTES +constexpr std::size_t kNonceLen = 24; // crypto_secretbox_NONCEBYTES +constexpr std::size_t kKeyLen = 32; // the vault key / crypto_secretbox_KEYBYTES +constexpr std::size_t kMacLen = 16; // crypto_secretbox_MACBYTES +constexpr std::size_t kCipherLen = kKeyLen + kMacLen; // 48 +constexpr std::size_t kFileLen = 1 + kSaltLen + kNonceLen + kCipherLen; // 89 +} // namespace + +ChatSensitiveVault::ChatSensitiveVault(std::string vaultPath) : path_(std::move(vaultPath)) {} + +void ChatSensitiveVault::setPath(std::string vaultPath) { path_ = std::move(vaultPath); } + +bool ChatSensitiveVault::deriveKek(const std::string& passphrase, const unsigned char* salt, + unsigned char* kek) const { + // Fixed MODERATE limits (≈0.7s / 256 MiB) resist brute-forcing a weak passphrase; because they are + // fixed, the file carries no KDF parameters and unlock derives with exactly the same cost. + return crypto_pwhash(kek, kKeyLen, passphrase.c_str(), passphrase.size(), salt, + crypto_pwhash_OPSLIMIT_MODERATE, crypto_pwhash_MEMLIMIT_MODERATE, + crypto_pwhash_ALG_ARGON2ID13) == 0; +} + +bool ChatSensitiveVault::writeVault(const unsigned char* salt, const unsigned char* nonce, + const unsigned char* cipher, std::size_t cipherLen) const { + if (path_.empty()) return false; + const std::string tmp = path_ + ".tmp"; + { + std::ofstream f(tmp, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.put(static_cast(kVersion)); + f.write(reinterpret_cast(salt), kSaltLen); + f.write(reinterpret_cast(nonce), kNonceLen); + f.write(reinterpret_cast(cipher), static_cast(cipherLen)); + if (!f) { f.close(); std::remove(tmp.c_str()); return false; } + } +#if !defined(_WIN32) + ::chmod(tmp.c_str(), 0600); // best-effort: the key is passphrase-wrapped, so this is defense-in-depth +#endif + if (std::rename(tmp.c_str(), path_.c_str()) != 0) { std::remove(tmp.c_str()); return false; } + return true; +} + +bool ChatSensitiveVault::exists() const { + if (path_.empty()) return false; + std::ifstream f(path_, std::ios::binary); + if (!f) return false; + char ver = 0; + if (!f.get(ver) || static_cast(ver) != kVersion) return false; + f.seekg(0, std::ios::end); + return f.good() && static_cast(f.tellg()) == kFileLen; +} + +bool ChatSensitiveVault::create(const std::string& passphrase, std::array& keyOut) { + keyOut.fill(0); + if (path_.empty() || passphrase.empty()) return false; + if (exists()) return false; // never clobber an existing vault + if (sodium_init() < 0) return false; + + unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen]; + unsigned char cipher[kCipherLen]; + randombytes_buf(salt, kSaltLen); + randombytes_buf(nonce, kNonceLen); + randombytes_buf(keyOut.data(), kKeyLen); // the random vault key + + bool ok = deriveKek(passphrase, salt, kek); + if (ok) { + crypto_secretbox_easy(cipher, keyOut.data(), kKeyLen, nonce, kek); + ok = writeVault(salt, nonce, cipher, kCipherLen); + } + sodium_memzero(kek, sizeof kek); + sodium_memzero(cipher, sizeof cipher); + if (!ok) keyOut.fill(0); + return ok; +} + +bool ChatSensitiveVault::unlock(const std::string& passphrase, std::array& keyOut) const { + keyOut.fill(0); + if (path_.empty() || passphrase.empty()) return false; + if (sodium_init() < 0) return false; + + std::ifstream f(path_, std::ios::binary); + if (!f) return false; + std::vector buf((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (buf.size() != kFileLen || buf[0] != kVersion) return false; + + const unsigned char* salt = buf.data() + 1; + const unsigned char* nonce = salt + kSaltLen; + const unsigned char* cipher = nonce + kNonceLen; + + unsigned char kek[kKeyLen]; + if (!deriveKek(passphrase, salt, kek)) { sodium_memzero(kek, sizeof kek); return false; } + int rc = crypto_secretbox_open_easy(keyOut.data(), cipher, kCipherLen, nonce, kek); + sodium_memzero(kek, sizeof kek); + if (rc != 0) { keyOut.fill(0); return false; } // wrong passphrase or corrupt file + return true; +} + +bool ChatSensitiveVault::changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase) { + if (newPassphrase.empty()) return false; + std::array key{}; + if (!unlock(oldPassphrase, key)) return false; // wrong old passphrase + + unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen], cipher[kCipherLen]; + randombytes_buf(salt, kSaltLen); + randombytes_buf(nonce, kNonceLen); + bool ok = deriveKek(newPassphrase, salt, kek); + if (ok) { + crypto_secretbox_easy(cipher, key.data(), kKeyLen, nonce, kek); + ok = writeVault(salt, nonce, cipher, kCipherLen); + } + sodium_memzero(kek, sizeof kek); + sodium_memzero(cipher, sizeof cipher); + sodium_memzero(key.data(), key.size()); + return ok; +} + +bool ChatSensitiveVault::remove() { + if (path_.empty()) return true; + std::error_code ec; + (void)ec; + if (std::remove(path_.c_str()) != 0) { + // Treat "already absent" as success; anything else is a real failure only if the file is still there. + std::ifstream f(path_, std::ios::binary); + return !f.good(); + } + return true; +} + +} // namespace dragonx::chat diff --git a/src/chat/chat_sensitive_vault.h b/src/chat/chat_sensitive_vault.h new file mode 100644 index 0000000..32dd464 --- /dev/null +++ b/src/chat/chat_sensitive_vault.h @@ -0,0 +1,59 @@ +#pragma once + +// DragonX Wallet - HushChat "sensitive conversation" local key vault. +// +// A per-wallet random 32-byte key, wrapped under a USER PASSPHRASE (Argon2id-derived KEK + +// XSalsa20-Poly1305), stored in a small local file. It encrypts the at-rest rows of conversations the +// user marks "sensitive" in ChatDatabase, INDEPENDENTLY of the seed-derived chat key — so those rows +// cannot be read by someone who has the wallet seed but not this passphrase, and a wallet freshly +// restored from the seed alone cannot read them at all. Losing the passphrase (or the file) makes the +// sensitive messages unrecoverable BY DESIGN. Not thread-safe; drive from the main thread. +// +// File format (version 1), 89 bytes: +// [1] version (0x01) +// [16] Argon2id salt +// [24] XSalsa20-Poly1305 nonce +// [48] ciphertext = 32-byte vault key + 16-byte Poly1305 tag +// Argon2id uses MODERATE ops/mem limits (fixed), so the format needs to carry no KDF parameters. + +#include +#include +#include + +namespace dragonx::chat { + +class ChatSensitiveVault { +public: + ChatSensitiveVault() = default; + explicit ChatSensitiveVault(std::string vaultPath); + + void setPath(std::string vaultPath); + const std::string& path() const { return path_; } + + // True if a well-formed vault file exists (version byte + exact size). No passphrase / Argon2. + bool exists() const; + + // Create a NEW vault: generate a random 32-byte key, wrap it under `passphrase`, write the file, and + // return the key in keyOut. Refuses (returns false) if a vault already exists, the passphrase is + // empty, or on IO/crypto error. keyOut is zeroed on any failure. + bool create(const std::string& passphrase, std::array& keyOut); + + // Unlock: derive the KEK from `passphrase`, decrypt the stored key into keyOut. Returns false on a + // wrong passphrase, a missing/corrupt file, or crypto failure; keyOut is zeroed unless it succeeds. + bool unlock(const std::string& passphrase, std::array& keyOut) const; + + // Re-wrap the existing key under a new passphrase (the key is unchanged). False if old is wrong. + bool changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase); + + // Delete the vault file. Returns true if removed or already absent. + bool remove(); + +private: + bool deriveKek(const std::string& passphrase, const unsigned char* salt, unsigned char* kek) const; + bool writeVault(const unsigned char* salt, const unsigned char* nonce, + const unsigned char* cipher, std::size_t cipherLen) const; + + std::string path_; +}; + +} // namespace dragonx::chat diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index eec7838..f8c1347 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(std::time(nullptr))) + ".vault"; + std::remove(path.c_str()); + + ChatSensitiveVault v(path); + EXPECT_TRUE(!v.exists()); + + std::array 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 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();