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
+3
View File
@@ -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
+147
View File
@@ -0,0 +1,147 @@
// DragonX Wallet - HushChat sensitive-conversation local key vault (implementation).
#include "chat_sensitive_vault.h"
#include <sodium.h>
#include <cstdio>
#include <fstream>
#include <iterator>
#include <vector>
#if !defined(_WIN32)
#include <sys/stat.h>
#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<char>(kVersion));
f.write(reinterpret_cast<const char*>(salt), kSaltLen);
f.write(reinterpret_cast<const char*>(nonce), kNonceLen);
f.write(reinterpret_cast<const char*>(cipher), static_cast<std::streamsize>(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<unsigned char>(ver) != kVersion) return false;
f.seekg(0, std::ios::end);
return f.good() && static_cast<std::size_t>(f.tellg()) == kFileLen;
}
bool ChatSensitiveVault::create(const std::string& passphrase, std::array<unsigned char, 32>& 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<unsigned char, 32>& 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<unsigned char> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
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<unsigned char, 32> 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
+59
View File
@@ -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 <array>
#include <cstddef>
#include <string>
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<unsigned char, 32>& 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<unsigned char, 32>& 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
+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();