From 2502487e4448ca1f66ea40dae004334b900f689a Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 5 Jul 2026 19:54:47 -0500 Subject: [PATCH] feat(chat): seed-derived identity + secretstream crypto core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CMakeLists.txt | 6 ++ src/chat/chat_crypto.cpp | 173 +++++++++++++++++++++++++++++++++++++ src/chat/chat_crypto.h | 65 ++++++++++++++ src/chat/chat_identity.cpp | 70 +++++++++++++++ src/chat/chat_identity.h | 57 ++++++++++++ tests/test_phase4.cpp | 66 ++++++++++++++ 6 files changed, 437 insertions(+) create mode 100644 src/chat/chat_crypto.cpp create mode 100644 src/chat/chat_crypto.h create mode 100644 src/chat/chat_identity.cpp create mode 100644 src/chat/chat_identity.h diff --git a/CMakeLists.txt b/CMakeLists.txt index dcf4c87..c39b801 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/chat/chat_crypto.cpp b/src/chat/chat_crypto.cpp new file mode 100644 index 0000000..4c2b610 --- /dev/null +++ b/src/chat/chat_crypto.cpp @@ -0,0 +1,173 @@ +// DragonX Wallet - HushChat crypto primitives (implementation). + +#include "chat_crypto.h" + +#include + +#include +#include + +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& 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 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(plaintext.data()), plaintext.size(), + nullptr, 0, crypto_secretstream_xchacha20poly1305_TAG_FINAL) == 0) { + outStreamHeaderHex = bytesToHex(header, sizeof header); + outCiphertextHex = bytesToHex(ciphertext.data(), static_cast(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 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 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(plain.data()), + static_cast(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 diff --git a/src/chat/chat_crypto.h b/src/chat/chat_crypto.h new file mode 100644 index 0000000..1cb0523 --- /dev/null +++ b/src/chat/chat_crypto.h @@ -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 +#include +#include + +namespace dragonx::chat { + +// crypto_kx key sizes (== crypto_kx_PUBLICKEYBYTES / SECRETKEYBYTES == 32). +constexpr std::size_t kChatKeyBytes = 32; + +using ChatPublicKey = std::array; +using ChatSecretKey = std::array; + +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 diff --git a/src/chat/chat_identity.cpp b/src/chat/chat_identity.cpp new file mode 100644 index 0000000..9945013 --- /dev/null +++ b/src/chat/chat_identity.cpp @@ -0,0 +1,70 @@ +// DragonX Wallet - HushChat identity derivation (implementation). + +#include "chat_identity.h" + +#include + +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(stableSecret.data()), stableSecret.size(), + reinterpret_cast(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 diff --git a/src/chat/chat_identity.h b/src/chat/chat_identity.h new file mode 100644 index 0000000..a819544 --- /dev/null +++ b/src/chat/chat_identity.h @@ -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 +#include + +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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index c2956f9..4812665 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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();