feat(chat): seed-derived identity + secretstream crypto core
Phase 1 crypto foundation (gated by DRAGONX_ENABLE_CHAT; the pure primitives are always compiled + unit-tested, the feature gate lives at the future service layer). - chat_identity: derive the DragonX-native X25519 (crypto_kx) identity from a stable per-wallet secret via a domain-separated keyed BLAKE2b KDF (crypto_generichash, context "DragonX-HushChat-Identity-v1") into a clean 32-byte crypto_kx seed. Deterministic; skips SDXL's UTF-8-hex-seed quirk (that's the Phase-4 import path). Per §5.6. - chat_crypto: encryptOutgoing (server_tx) / decryptIncoming (client_rx) via crypto_secretstream_xchacha20poly1305, byte-exact per Appendix A.3/A.4 so DragonX interoperates with SilentDragonXLite. Single chunk, TAG_FINAL; decrypt enforces both the Poly1305 tag and TAG_FINAL (stricter than SDXL). Every session key / seed / stream state / plaintext scratch is sodium_memzero'd on all paths; no secret is ever logged. - Tests: encrypt->decrypt round-trip (incl. empty + UTF-8), identity determinism, feature-gate + empty-secret handling, and malformed/tampered/ wrong-key inputs all fail safely. Adversarially security-reviewed (3 lenses: secret hygiene, crypto correctness, SDXL wire-interop) — confirmed byte-compatible with the SDXL reference in both directions. Fixes from review: check the secretstream_push return before reporting Ok; allow the empty-plaintext ciphertext (== ABYTES) so encrypt/decrypt round-trip symmetrically; document the caller-owns-and-wipes secret contract. Interop caveat: round-trip proves self-consistency; a real captured SDXL message is still needed to prove wire-compat end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -411,6 +411,8 @@ set(APP_SOURCES
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/chat/chat_crypto.cpp
|
||||
src/chat/chat_identity.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
@@ -557,6 +559,8 @@ set(APP_HEADERS
|
||||
src/wallet/lite_wallet_server_selection_adapter.h
|
||||
src/wallet/lite_wallet_lifecycle_service.h
|
||||
src/chat/chat_protocol.h
|
||||
src/chat/chat_crypto.h
|
||||
src/chat/chat_identity.h
|
||||
src/config/version.h
|
||||
src/data/wallet_state.h
|
||||
src/data/transaction_history_cache.h
|
||||
@@ -1005,6 +1009,8 @@ if(BUILD_TESTING)
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/chat/chat_crypto.cpp
|
||||
src/chat/chat_identity.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
|
||||
173
src/chat/chat_crypto.cpp
Normal file
173
src/chat/chat_crypto.cpp
Normal file
@@ -0,0 +1,173 @@
|
||||
// DragonX Wallet - HushChat crypto primitives (implementation).
|
||||
|
||||
#include "chat_crypto.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
namespace {
|
||||
|
||||
// Local constants tied to the libsodium primitive. (The dev-only chat_fixture_tooling.h
|
||||
// declares equivalents, but that header is not linked into the app.)
|
||||
constexpr std::size_t kStreamHeaderBytes = 24; // crypto_secretstream_xchacha20poly1305_HEADERBYTES
|
||||
constexpr std::size_t kStreamABytes = 17; // crypto_secretstream_xchacha20poly1305_ABYTES
|
||||
|
||||
static_assert(kChatKeyBytes == crypto_kx_PUBLICKEYBYTES, "kx public key size mismatch");
|
||||
static_assert(kChatKeyBytes == crypto_kx_SECRETKEYBYTES, "kx secret key size mismatch");
|
||||
|
||||
// Decode exactly outLen bytes from a lowercase/uppercase hex string; reject any other length.
|
||||
bool hexToFixed(const std::string& hex, unsigned char* out, std::size_t outLen) {
|
||||
if (hex.size() != outLen * 2) return false;
|
||||
std::size_t binLen = 0;
|
||||
if (sodium_hex2bin(out, outLen, hex.data(), hex.size(), nullptr, &binLen, nullptr) != 0) {
|
||||
return false;
|
||||
}
|
||||
return binLen == outLen;
|
||||
}
|
||||
|
||||
// Decode a variable-length hex string into bytes.
|
||||
bool hexToBytes(const std::string& hex, std::vector<unsigned char>& out) {
|
||||
if (hex.empty() || (hex.size() % 2) != 0) return false;
|
||||
out.resize(hex.size() / 2);
|
||||
std::size_t binLen = 0;
|
||||
if (sodium_hex2bin(out.data(), out.size(), hex.data(), hex.size(), nullptr, &binLen, nullptr) != 0) {
|
||||
return false;
|
||||
}
|
||||
out.resize(binLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string bytesToHex(const unsigned char* bytes, std::size_t n) {
|
||||
std::string hex(n * 2 + 1, '\0');
|
||||
sodium_bin2hex(&hex[0], hex.size(), bytes, n);
|
||||
hex.resize(n * 2); // drop the NUL sodium_bin2hex appends
|
||||
return hex;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* chatCryptoStatusName(ChatCryptoStatus status) {
|
||||
switch (status) {
|
||||
case ChatCryptoStatus::Ok: return "Ok";
|
||||
case ChatCryptoStatus::SodiumInitFailed: return "SodiumInitFailed";
|
||||
case ChatCryptoStatus::BadPeerKey: return "BadPeerKey";
|
||||
case ChatCryptoStatus::BadHeaderHex: return "BadHeaderHex";
|
||||
case ChatCryptoStatus::BadCiphertextHex: return "BadCiphertextHex";
|
||||
case ChatCryptoStatus::CiphertextTooShort: return "CiphertextTooShort";
|
||||
case ChatCryptoStatus::SessionKeyFailed: return "SessionKeyFailed";
|
||||
case ChatCryptoStatus::EncryptFailed: return "EncryptFailed";
|
||||
case ChatCryptoStatus::DecryptFailed: return "DecryptFailed";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
void wipeChatKeyPair(ChatKeyPair& keys) {
|
||||
sodium_memzero(keys.public_key.data(), keys.public_key.size());
|
||||
sodium_memzero(keys.secret_key.data(), keys.secret_key.size());
|
||||
}
|
||||
|
||||
ChatCryptoStatus encryptOutgoing(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& plaintext,
|
||||
std::string& outStreamHeaderHex,
|
||||
std::string& outCiphertextHex) {
|
||||
static_assert(kStreamHeaderBytes == crypto_secretstream_xchacha20poly1305_HEADERBYTES, "");
|
||||
static_assert(kStreamABytes == crypto_secretstream_xchacha20poly1305_ABYTES, "");
|
||||
|
||||
if (sodium_init() < 0) return ChatCryptoStatus::SodiumInitFailed;
|
||||
|
||||
unsigned char peerPk[crypto_kx_PUBLICKEYBYTES];
|
||||
if (!hexToFixed(peerPublicKeyHex, peerPk, sizeof peerPk)) return ChatCryptoStatus::BadPeerKey;
|
||||
|
||||
unsigned char rx[crypto_kx_SESSIONKEYBYTES];
|
||||
unsigned char tx[crypto_kx_SESSIONKEYBYTES];
|
||||
if (crypto_kx_server_session_keys(rx, tx, mine.public_key.data(), mine.secret_key.data(), peerPk) != 0) {
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
return ChatCryptoStatus::SessionKeyFailed;
|
||||
}
|
||||
|
||||
crypto_secretstream_xchacha20poly1305_state state;
|
||||
unsigned char header[crypto_secretstream_xchacha20poly1305_HEADERBYTES];
|
||||
ChatCryptoStatus result = ChatCryptoStatus::EncryptFailed;
|
||||
|
||||
if (crypto_secretstream_xchacha20poly1305_init_push(&state, header, tx) == 0) {
|
||||
std::vector<unsigned char> ciphertext(plaintext.size() + crypto_secretstream_xchacha20poly1305_ABYTES);
|
||||
unsigned long long ctLen = 0;
|
||||
// Only report Ok if the push actually succeeded — otherwise ctLen stays 0 and we would
|
||||
// ship a valid header with an empty ciphertext.
|
||||
if (crypto_secretstream_xchacha20poly1305_push(
|
||||
&state, ciphertext.data(), &ctLen,
|
||||
reinterpret_cast<const unsigned char*>(plaintext.data()), plaintext.size(),
|
||||
nullptr, 0, crypto_secretstream_xchacha20poly1305_TAG_FINAL) == 0) {
|
||||
outStreamHeaderHex = bytesToHex(header, sizeof header);
|
||||
outCiphertextHex = bytesToHex(ciphertext.data(), static_cast<std::size_t>(ctLen));
|
||||
result = ChatCryptoStatus::Ok;
|
||||
}
|
||||
}
|
||||
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
sodium_memzero(&state, sizeof state);
|
||||
return result;
|
||||
}
|
||||
|
||||
ChatCryptoStatus decryptIncoming(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& streamHeaderHex,
|
||||
const std::string& ciphertextHex,
|
||||
std::string& outPlaintext) {
|
||||
if (sodium_init() < 0) return ChatCryptoStatus::SodiumInitFailed;
|
||||
|
||||
unsigned char peerPk[crypto_kx_PUBLICKEYBYTES];
|
||||
if (!hexToFixed(peerPublicKeyHex, peerPk, sizeof peerPk)) return ChatCryptoStatus::BadPeerKey;
|
||||
|
||||
unsigned char header[crypto_secretstream_xchacha20poly1305_HEADERBYTES];
|
||||
if (!hexToFixed(streamHeaderHex, header, sizeof header)) return ChatCryptoStatus::BadHeaderHex;
|
||||
|
||||
std::vector<unsigned char> ciphertext;
|
||||
if (!hexToBytes(ciphertextHex, ciphertext)) return ChatCryptoStatus::BadCiphertextHex;
|
||||
// Guard the size_t subtraction below (a ciphertext shorter than the auth tag can't be
|
||||
// authentic). Exactly ABYTES is the valid empty-plaintext case, so it round-trips
|
||||
// symmetrically with encryptOutgoing (message-content policy belongs to the caller).
|
||||
if (ciphertext.size() < crypto_secretstream_xchacha20poly1305_ABYTES) {
|
||||
return ChatCryptoStatus::CiphertextTooShort;
|
||||
}
|
||||
|
||||
unsigned char rx[crypto_kx_SESSIONKEYBYTES];
|
||||
unsigned char tx[crypto_kx_SESSIONKEYBYTES];
|
||||
if (crypto_kx_client_session_keys(rx, tx, mine.public_key.data(), mine.secret_key.data(), peerPk) != 0) {
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
return ChatCryptoStatus::SessionKeyFailed;
|
||||
}
|
||||
|
||||
crypto_secretstream_xchacha20poly1305_state state;
|
||||
ChatCryptoStatus result = ChatCryptoStatus::DecryptFailed;
|
||||
|
||||
if (crypto_secretstream_xchacha20poly1305_init_pull(&state, header, rx) == 0) {
|
||||
std::vector<unsigned char> plain(ciphertext.size() - crypto_secretstream_xchacha20poly1305_ABYTES);
|
||||
unsigned long long plainLen = 0;
|
||||
unsigned char tag = 0;
|
||||
if (crypto_secretstream_xchacha20poly1305_pull(
|
||||
&state, plain.data(), &plainLen, &tag,
|
||||
ciphertext.data(), ciphertext.size(), nullptr, 0) == 0 &&
|
||||
tag == crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
|
||||
outPlaintext.assign(reinterpret_cast<const char*>(plain.data()),
|
||||
static_cast<std::size_t>(plainLen));
|
||||
result = ChatCryptoStatus::Ok;
|
||||
}
|
||||
sodium_memzero(plain.data(), plain.size()); // wipe the decrypted scratch
|
||||
}
|
||||
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
sodium_memzero(&state, sizeof state);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
65
src/chat/chat_crypto.h
Normal file
65
src/chat/chat_crypto.h
Normal file
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat crypto primitives.
|
||||
//
|
||||
// crypto_kx (X25519) session-key agreement + crypto_secretstream_xchacha20poly1305
|
||||
// message encryption, byte-exact per the HushChat wire format so DragonX interoperates
|
||||
// with SilentDragonXLite. See docs/_archive/contacts-chat-phase1-detail-2026-07-05.md
|
||||
// (Appendix A.3/A.4). Pure crypto — no gating, no I/O; the feature gate lives at the
|
||||
// service layer. NEVER logs plaintext, ciphertext, keys, or session material.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
// crypto_kx key sizes (== crypto_kx_PUBLICKEYBYTES / SECRETKEYBYTES == 32).
|
||||
constexpr std::size_t kChatKeyBytes = 32;
|
||||
|
||||
using ChatPublicKey = std::array<unsigned char, kChatKeyBytes>;
|
||||
using ChatSecretKey = std::array<unsigned char, kChatKeyBytes>;
|
||||
|
||||
struct ChatKeyPair {
|
||||
ChatPublicKey public_key{};
|
||||
ChatSecretKey secret_key{};
|
||||
};
|
||||
|
||||
enum class ChatCryptoStatus {
|
||||
Ok,
|
||||
SodiumInitFailed,
|
||||
BadPeerKey, // peer public-key hex missing / wrong length / not hex
|
||||
BadHeaderHex, // secretstream header hex missing / wrong length / not hex
|
||||
BadCiphertextHex, // ciphertext hex malformed
|
||||
CiphertextTooShort, // ciphertext shorter than the auth tag
|
||||
SessionKeyFailed, // crypto_kx_*_session_keys rejected the peer key
|
||||
EncryptFailed,
|
||||
DecryptFailed // init_pull / pull / tag mismatch — the single neutral auth failure
|
||||
};
|
||||
|
||||
const char* chatCryptoStatusName(ChatCryptoStatus status);
|
||||
|
||||
// Encrypt `plaintext` addressed to peer `peerPublicKeyHex` (64 lowercase hex chars).
|
||||
// Sender takes the crypto_kx "server" role (server_tx), matching SDXL's send path.
|
||||
// Outputs the secretstream header hex (the memo "e" field) and the ciphertext hex
|
||||
// (the payload memo). Returns Ok on success.
|
||||
ChatCryptoStatus encryptOutgoing(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& plaintext,
|
||||
std::string& outStreamHeaderHex,
|
||||
std::string& outCiphertextHex);
|
||||
|
||||
// Decrypt an incoming message addressed to us from peer `peerPublicKeyHex`.
|
||||
// Receiver takes the crypto_kx "client" role (client_rx), matching SDXL's receive path.
|
||||
// Requires the memo "e" (streamHeaderHex) and the payload ciphertext hex. Enforces the
|
||||
// Poly1305 auth tag AND that the stream tag is TAG_FINAL. Returns Ok + fills outPlaintext.
|
||||
ChatCryptoStatus decryptIncoming(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& streamHeaderHex,
|
||||
const std::string& ciphertextHex,
|
||||
std::string& outPlaintext);
|
||||
|
||||
// Zero both key arrays (call when discarding an identity's keys).
|
||||
void wipeChatKeyPair(ChatKeyPair& keys);
|
||||
|
||||
} // namespace dragonx::chat
|
||||
70
src/chat/chat_identity.cpp
Normal file
70
src/chat/chat_identity.cpp
Normal file
@@ -0,0 +1,70 @@
|
||||
// DragonX Wallet - HushChat identity derivation (implementation).
|
||||
|
||||
#include "chat_identity.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
// The KDF context is used as the BLAKE2b key, so its length must sit within the primitive's
|
||||
// key bounds.
|
||||
static_assert(kChatIdentityKdfContextLen >= crypto_generichash_KEYBYTES_MIN,
|
||||
"chat identity KDF context is shorter than BLAKE2b's minimum key length");
|
||||
static_assert(kChatIdentityKdfContextLen <= crypto_generichash_KEYBYTES_MAX,
|
||||
"chat identity KDF context is longer than BLAKE2b's maximum key length");
|
||||
|
||||
const char* chatIdentityStatusName(ChatIdentityStatus status) {
|
||||
switch (status) {
|
||||
case ChatIdentityStatus::Ready: return "Ready";
|
||||
case ChatIdentityStatus::FeatureDisabled: return "FeatureDisabled";
|
||||
case ChatIdentityStatus::SecretUnavailable: return "SecretUnavailable";
|
||||
case ChatIdentityStatus::DerivationFailed: return "DerivationFailed";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
std::string chatIdentityPublicKeyHex(const ChatKeyPair& keys) {
|
||||
char hex[crypto_kx_PUBLICKEYBYTES * 2 + 1];
|
||||
sodium_bin2hex(hex, sizeof hex, keys.public_key.data(), keys.public_key.size());
|
||||
return std::string(hex);
|
||||
}
|
||||
|
||||
ChatIdentityResult deriveChatIdentityFromSecret(const std::string& stableSecret,
|
||||
ChatKeyPair& outKeys,
|
||||
bool featureEnabled) {
|
||||
ChatIdentityResult result;
|
||||
auto finish = [&result](ChatIdentityStatus status) -> ChatIdentityResult {
|
||||
result.status = status;
|
||||
result.error_name = chatIdentityStatusName(status);
|
||||
return result;
|
||||
};
|
||||
|
||||
if (!featureEnabled) return finish(ChatIdentityStatus::FeatureDisabled);
|
||||
if (stableSecret.empty()) return finish(ChatIdentityStatus::SecretUnavailable);
|
||||
if (sodium_init() < 0) return finish(ChatIdentityStatus::DerivationFailed);
|
||||
|
||||
unsigned char kxSeed[crypto_kx_SEEDBYTES];
|
||||
static_assert(sizeof(kxSeed) == kChatKeyBytes, "kx seed size mismatch");
|
||||
|
||||
const int hashStatus = crypto_generichash(
|
||||
kxSeed, sizeof kxSeed,
|
||||
reinterpret_cast<const unsigned char*>(stableSecret.data()), stableSecret.size(),
|
||||
reinterpret_cast<const unsigned char*>(kChatIdentityKdfContext), kChatIdentityKdfContextLen);
|
||||
if (hashStatus != 0) {
|
||||
sodium_memzero(kxSeed, sizeof kxSeed);
|
||||
return finish(ChatIdentityStatus::DerivationFailed);
|
||||
}
|
||||
|
||||
const int keypairStatus =
|
||||
crypto_kx_seed_keypair(outKeys.public_key.data(), outKeys.secret_key.data(), kxSeed);
|
||||
sodium_memzero(kxSeed, sizeof kxSeed); // wipe the seed immediately, success or failure
|
||||
if (keypairStatus != 0) {
|
||||
wipeChatKeyPair(outKeys);
|
||||
return finish(ChatIdentityStatus::DerivationFailed);
|
||||
}
|
||||
|
||||
result.public_key_hex = chatIdentityPublicKeyHex(outKeys);
|
||||
return finish(ChatIdentityStatus::Ready);
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
57
src/chat/chat_identity.h
Normal file
57
src/chat/chat_identity.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat identity derivation.
|
||||
//
|
||||
// The DragonX-native chat identity is an X25519 (crypto_kx) keypair derived from a stable
|
||||
// per-wallet secret via a domain-separated keyed BLAKE2b KDF. This deliberately does NOT
|
||||
// use SDXL's UTF-8-hex-seed quirk (that quirk is only for the Phase-4 "import an existing
|
||||
// SDXL identity" path). Interop is unaffected: identity derivation is local — peers only
|
||||
// exchange public keys. See docs/_archive/contacts-chat-tab-plan-2026-07-05.md §5.6.
|
||||
|
||||
#include "chat_crypto.h" // ChatKeyPair
|
||||
#include "chat_protocol.h" // hushChatFeatureEnabledAtBuild()
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
// Domain-separation label — used as the BLAKE2b key so a different app/version cannot
|
||||
// derive the same identity from the same wallet secret. A char[] (not const char*) so its
|
||||
// length is a compile-time constant for the KEYBYTES-bounds static_assert.
|
||||
inline constexpr char kChatIdentityKdfContext[] = "DragonX-HushChat-Identity-v1";
|
||||
inline constexpr std::size_t kChatIdentityKdfContextLen = sizeof(kChatIdentityKdfContext) - 1;
|
||||
|
||||
enum class ChatIdentityStatus {
|
||||
Ready,
|
||||
FeatureDisabled, // DRAGONX_ENABLE_CHAT off (or caller passed featureEnabled=false)
|
||||
SecretUnavailable, // no stable secret (wallet locked / not open / empty)
|
||||
DerivationFailed // libsodium init or KDF/keypair failure
|
||||
};
|
||||
|
||||
const char* chatIdentityStatusName(ChatIdentityStatus status);
|
||||
|
||||
struct ChatIdentityResult {
|
||||
ChatIdentityStatus status = ChatIdentityStatus::FeatureDisabled;
|
||||
std::string public_key_hex; // 64 lowercase hex chars when Ready
|
||||
const char* error_name = "FeatureDisabled"; // == chatIdentityStatusName(status)
|
||||
};
|
||||
|
||||
// Pure, no-I/O derivation: hashes `stableSecret` (variable-length: a mnemonic on lite, a
|
||||
// spending key on full-node) with the KDF context as the BLAKE2b key into a clean 32-byte
|
||||
// crypto_kx seed, then crypto_kx_seed_keypair() into `outKeys`. Deterministic for a given
|
||||
// secret. On any non-Ready result `outKeys` is left wiped. featureEnabled defaults to the
|
||||
// build predicate but tests pass true to exercise the crypto in an OFF build.
|
||||
//
|
||||
// OWNERSHIP: `stableSecret` is BORROWED (const&) and is NOT wiped here — the caller owns it
|
||||
// and MUST sodium_memzero its backing buffer after this returns. The per-variant provider
|
||||
// that fetches the wallet secret (mnemonic / spending key) should hold it in a wipeable
|
||||
// buffer, not a plain std::string literal, in production.
|
||||
ChatIdentityResult deriveChatIdentityFromSecret(const std::string& stableSecret,
|
||||
ChatKeyPair& outKeys,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
|
||||
// 64-char lowercase hex of the public key.
|
||||
std::string chatIdentityPublicKeyHex(const ChatKeyPair& keys);
|
||||
|
||||
} // namespace dragonx::chat
|
||||
@@ -1,3 +1,5 @@
|
||||
#include "chat/chat_crypto.h"
|
||||
#include "chat/chat_identity.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
@@ -5552,6 +5554,69 @@ void testPoolWeightedSelection()
|
||||
EXPECT_TRUE(stay > 1000); // >50% thanks to kIncumbentStayBias
|
||||
}
|
||||
|
||||
// HushChat crypto core: seed-derived identity + secretstream encrypt/decrypt round-trip.
|
||||
// Pass featureEnabled=true so the crypto is exercised even in the default (chat-OFF) build.
|
||||
void testHushChatCrypto()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-wallet-secret", alice, /*featureEnabled=*/true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-wallet-secret", bob, /*featureEnabled=*/true);
|
||||
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready);
|
||||
EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready);
|
||||
EXPECT_EQ((int)ra.public_key_hex.size(), 64);
|
||||
EXPECT_TRUE(ra.public_key_hex != rb.public_key_hex);
|
||||
|
||||
// Deterministic: the same secret always yields the same identity.
|
||||
ChatKeyPair alice2;
|
||||
ChatIdentityResult ra2 = deriveChatIdentityFromSecret("alice-wallet-secret", alice2, true);
|
||||
EXPECT_EQ(ra.public_key_hex, ra2.public_key_hex);
|
||||
|
||||
// Feature gate: featureEnabled=false yields FeatureDisabled (and error_name matches).
|
||||
ChatKeyPair off;
|
||||
ChatIdentityResult roff = deriveChatIdentityFromSecret("x", off, false);
|
||||
EXPECT_TRUE(roff.status == ChatIdentityStatus::FeatureDisabled);
|
||||
EXPECT_EQ(std::string(roff.error_name), std::string("FeatureDisabled"));
|
||||
// Empty secret is rejected.
|
||||
ChatKeyPair none;
|
||||
EXPECT_TRUE(deriveChatIdentityFromSecret("", none, true).status == ChatIdentityStatus::SecretUnavailable);
|
||||
|
||||
// Round-trip: Alice (server role) encrypts to Bob; Bob (client role) decrypts from Alice.
|
||||
std::string headerHex, cipherHex;
|
||||
ChatCryptoStatus es = encryptOutgoing(alice, rb.public_key_hex, "hello bob \xF0\x9F\x90\x89", headerHex, cipherHex);
|
||||
EXPECT_TRUE(es == ChatCryptoStatus::Ok);
|
||||
EXPECT_EQ((int)headerHex.size(), 48); // 24-byte secretstream header as hex
|
||||
|
||||
std::string plain;
|
||||
ChatCryptoStatus ds = decryptIncoming(bob, ra.public_key_hex, headerHex, cipherHex, plain);
|
||||
EXPECT_TRUE(ds == ChatCryptoStatus::Ok);
|
||||
EXPECT_EQ(plain, std::string("hello bob \xF0\x9F\x90\x89"));
|
||||
|
||||
// Empty plaintext round-trips symmetrically (the crypto layer imposes no content policy).
|
||||
std::string emptyHeader, emptyCipher, emptyPlain;
|
||||
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "", emptyHeader, emptyCipher) == ChatCryptoStatus::Ok);
|
||||
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, emptyHeader, emptyCipher, emptyPlain) == ChatCryptoStatus::Ok);
|
||||
EXPECT_EQ(emptyPlain, std::string(""));
|
||||
|
||||
// Tampered ciphertext fails the auth tag (neutral DecryptFailed, not a crash).
|
||||
std::string tampered = cipherHex;
|
||||
tampered.back() = (tampered.back() == '0') ? '1' : '0';
|
||||
std::string plain2;
|
||||
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, tampered, plain2) != ChatCryptoStatus::Ok);
|
||||
|
||||
// Wrong recipient key can't derive the session and/or fails auth.
|
||||
std::string plain3;
|
||||
ChatKeyPair mallory;
|
||||
deriveChatIdentityFromSecret("mallory-secret", mallory, true);
|
||||
EXPECT_TRUE(decryptIncoming(mallory, ra.public_key_hex, headerHex, cipherHex, plain3) != ChatCryptoStatus::Ok);
|
||||
|
||||
// Malformed inputs are rejected without touching the tag path.
|
||||
std::string plain4;
|
||||
EXPECT_TRUE(decryptIncoming(bob, "not-hex", headerHex, cipherHex, plain4) == ChatCryptoStatus::BadPeerKey);
|
||||
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, "00", plain4) == ChatCryptoStatus::CiphertextTooShort);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -5642,6 +5707,7 @@ int main()
|
||||
testPoolHashrateParsing();
|
||||
testPoolWeightedSelection();
|
||||
testAtomicFileWrite();
|
||||
testHushChatCrypto();
|
||||
testAddressChecksumValidation();
|
||||
testLiteServerProbeLive();
|
||||
testXmrigLiveInstall();
|
||||
|
||||
Reference in New Issue
Block a user