ChatDatabase gains a second AEAD key — the local vault key (setSensitiveKey / lockSensitive) — and a sensitivity predicate. Rows of conversations the predicate marks sensitive are encrypted under the vault key instead of the seed-derived key, and are persisted only while the vault is unlocked (never stored under the seed key). load() tries the seed key then the vault key, so a sensitive row is WITHHELD whenever the vault key is absent — including on a fresh instance that holds only the seed (the "fresh seed import can't read sensitive conversations" guarantee); a wrong vault key withholds too, never yields garbage. encrypt/decrypt are now key-parametrized; lock() also wipes the vault key. Unit-tested end to end. Phase B, step 3 (DB dual-key). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
6.4 KiB
C++
121 lines
6.4 KiB
C++
#pragma once
|
|
|
|
// DragonX Wallet - HushChat persistent message store (Phase 2).
|
|
//
|
|
// Sqlite-backed, encrypted at rest with a SEED-DERIVED key (no wallet passphrase). Every record —
|
|
// message bodies, peer z-addresses, threading (conversation id), and timestamps — is AEAD-encrypted
|
|
// under a key derived from the wallet's own seed secret (the same secret used for the chat
|
|
// identity), and even the per-message dedup key is a KEYED hash of the txid — so the database
|
|
// reveals nothing about your conversations to disk-level access without the seed. Rows are
|
|
// partitioned by a seed-derived wallet tag so one file can hold several wallets, each readable only
|
|
// with its own seed. Not thread-safe — drive from the main thread. Mirrors the lifecycle of
|
|
// data::TransactionHistoryCache.
|
|
|
|
#include "chat_message.h"
|
|
|
|
#include <array>
|
|
#include <cstddef>
|
|
#include <functional>
|
|
#include <string>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
struct sqlite3;
|
|
|
|
namespace dragonx::chat {
|
|
|
|
class ChatDatabase {
|
|
public:
|
|
ChatDatabase();
|
|
explicit ChatDatabase(std::string databasePath);
|
|
~ChatDatabase();
|
|
|
|
ChatDatabase(const ChatDatabase&) = delete;
|
|
ChatDatabase& operator=(const ChatDatabase&) = delete;
|
|
|
|
static std::string defaultDatabasePath();
|
|
|
|
// Derive the storage key + wallet tag from the wallet's seed secret and open the DB. The caller
|
|
// still owns and must wipe `secret`. Returns false on sodium/db failure (DB then stays locked).
|
|
bool unlockWithSecret(const std::string& secret);
|
|
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
|
|
bool hasKey() const { return key_ready_; }
|
|
|
|
// --- Sensitive conversations (per-conversation local-key encryption) ---
|
|
// Provide the local vault key (from ChatSensitiveVault). Rows of conversations the sensitive
|
|
// predicate marks sensitive are AEAD-encrypted under THIS key instead of the seed-derived key, so
|
|
// they can't be read without it (even by the seed). The caller still owns and should wipe `key`.
|
|
void setSensitiveKey(const std::array<unsigned char, 32>& key);
|
|
void lockSensitive(); // wipe the sensitive key; sensitive rows then withheld
|
|
bool hasSensitiveKey() const { return sensitive_ready_; }
|
|
// Predicate: true for a conversation id that should be stored under the sensitive key. Consulted on
|
|
// WRITE to pick the key; on READ both keys are tried, so a predicate isn't needed to decrypt.
|
|
void setSensitivePredicate(std::function<bool(const std::string&)> pred) { sensitive_pred_ = std::move(pred); }
|
|
|
|
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
|
|
// Returns true if newly inserted; false on duplicate or while locked.
|
|
bool append(const ChatMessage& message);
|
|
|
|
// Persist many messages in ONE transaction (INSERT OR IGNORE, same dedup key as append()).
|
|
// Serializes+encrypts every message BEFORE opening the transaction, so a non-UTF-8 body is
|
|
// skipped without leaving a transaction open. Preserves input order (load() relies on rowid).
|
|
// Returns the number of rows actually inserted. No-op (returns 0) while locked or on empty input.
|
|
int appendBatch(const std::vector<ChatMessage>& messages);
|
|
|
|
// Persist-or-overwrite one message by its (txid+position) dedup key. Unlike append(), this
|
|
// updates an existing row's payload — used for outgoing echoes whose delivery status changes
|
|
// (Sending → Sent/Failed). Returns true on success; false while locked / on error.
|
|
bool upsert(const ChatMessage& message);
|
|
|
|
// Decrypt and return every stored message for the unlocked wallet, in insertion order. Empty
|
|
// while locked or if none. Rows that fail to decrypt/parse are skipped.
|
|
std::vector<ChatMessage> load();
|
|
|
|
void clearWallet(); // delete the unlocked wallet's rows
|
|
|
|
// Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
|
|
// records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
|
|
// "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
|
|
// removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
|
|
// user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
|
|
bool deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone);
|
|
|
|
// True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
|
|
// in-memory cache loaded on unlock — O(1), no SQL. False while locked.
|
|
bool isTombstoned(const ChatMessage& message) const;
|
|
|
|
private:
|
|
bool ensureOpen();
|
|
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
|
|
bool exec(const char* sql);
|
|
bool createSchema();
|
|
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
|
std::string serialize(const ChatMessage& message) const;
|
|
bool deserialize(const std::string& json, ChatMessage& out) const;
|
|
bool encrypt(const unsigned char* key,
|
|
const std::string& plain,
|
|
std::vector<unsigned char>& nonce,
|
|
std::vector<unsigned char>& cipher) const;
|
|
bool decrypt(const unsigned char* key,
|
|
const std::vector<unsigned char>& nonce,
|
|
const std::vector<unsigned char>& cipher,
|
|
std::string& plain) const;
|
|
// The key a row for `cid` is written under. Returns nullptr and sets skip=true when the conversation
|
|
// is sensitive but the vault is locked — the row must NOT be persisted (under the seed key it would be
|
|
// readable without the vault, defeating the point); it re-imports from chain once the vault is unlocked.
|
|
const unsigned char* writeKeyFor(const std::string& cid, bool& skip) const;
|
|
void close();
|
|
|
|
sqlite3* db_ = nullptr;
|
|
std::string database_path_;
|
|
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
|
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
|
bool key_ready_ = false;
|
|
std::array<unsigned char, 32> sensitive_key_{}; // local vault key for sensitive conversations' rows
|
|
bool sensitive_ready_ = false;
|
|
std::function<bool(const std::string&)> sensitive_pred_; // true => store this cid under the sensitive key
|
|
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
|
};
|
|
|
|
} // namespace dragonx::chat
|