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