Add the outgoing side of HushChat: compose messages and start conversations.
The wire construction is the byte-exact inverse of the receive parser and is
proven by a self-consistent round-trip (build → parse → decrypt).
- src/chat/chat_outgoing.{h,cpp} (pure): buildOutgoingMessage encrypts via
encryptOutgoing and buildOutgoingContactRequest carries plaintext; both emit
the header memo JSON ({h,v,z,cid,t,e,p}, which nlohmann serializes
alphabetically to match SilentDragonXLite) + the payload memo, validating the
512-byte memo limit, the 64-hex peer key, and the "no leading '{'" rule for
request text. My public key goes in header "p"; the peer's key is the
encryption recipient.
- ChatService: composeMessage/composeContactRequest (encrypt with the held
identity) + recordOutgoing (echo an Outgoing ChatMessage into the store + DB —
we never harvest our own sends, so this is the only local record).
- App: sendChatMessage(cid,text) sources the peer z-addr + public key from the
conversation, composes, and records a random-id echo; startChatConversation
(zaddr,text) mints a random cid + composes a plaintext contact request;
chatReplyZaddr() picks a spendable z-addr. broadcastChatMemos() is the Phase-5
transport seam (network delivery + real-SDXL interop verification land there).
- Chat tab: a message composer (shown once the peer's key is known, else a
"waiting for reply" hint) + a "New conversation" modal that sends a contact
request to a z-address.
- Tests: outgoing round-trip through the receive parser + decrypt, contact-request
passthrough, validation guards, and ChatService compose + recordOutgoing echo.
Adversarially reviewed (crypto/interop, secret hygiene, logic, UI) — 0 findings.
Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw)
build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
4.3 KiB
C++
100 lines
4.3 KiB
C++
// DragonX Wallet - HushChat outgoing memo construction (implementation).
|
|
|
|
#include "chat_outgoing.h"
|
|
|
|
#include "chat_protocol.h" // kHushChat* constants
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
namespace dragonx::chat {
|
|
|
|
namespace {
|
|
|
|
// Serialize the HushChat header. nlohmann emits object keys in sorted (alphabetical) order —
|
|
// cid,e,h,p,t,v,z — which is exactly SilentDragonXLite's on-wire key order.
|
|
std::string buildHeaderMemo(const std::string& replyZaddr,
|
|
const std::string& conversationId,
|
|
const char* type,
|
|
const std::string& streamHeaderHex,
|
|
const std::string& publicKeyHex)
|
|
{
|
|
nlohmann::json header;
|
|
header["h"] = 1; // header number (>= 1)
|
|
header["v"] = kHushChatSupportedVersion; // 0
|
|
header["z"] = replyZaddr; // where the peer should reply (my address)
|
|
header["cid"] = conversationId;
|
|
header["t"] = type; // "Memo" or "Cont"
|
|
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
|
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
|
return header.dump();
|
|
}
|
|
|
|
bool present(const std::string& value) { return !value.empty(); }
|
|
|
|
} // namespace
|
|
|
|
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
|
const std::string& myPublicKeyHex,
|
|
const std::string& myReplyZaddr,
|
|
const std::string& peerPublicKeyHex,
|
|
const std::string& peerZaddr,
|
|
const std::string& conversationId,
|
|
const std::string& plaintext,
|
|
OutgoingChatMemos& out)
|
|
{
|
|
if (plaintext.empty()) return ChatComposeStatus::EmptyBody;
|
|
if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) ||
|
|
!present(conversationId)) {
|
|
return ChatComposeStatus::MissingField;
|
|
}
|
|
if (peerPublicKeyHex.size() != kHushChatPublicKeyHexLength) return ChatComposeStatus::BadPeerKey;
|
|
|
|
std::string streamHeaderHex;
|
|
std::string ciphertextHex;
|
|
if (encryptOutgoing(mine, peerPublicKeyHex, plaintext, streamHeaderHex, ciphertextHex)
|
|
!= ChatCryptoStatus::Ok) {
|
|
return ChatComposeStatus::EncryptFailed;
|
|
}
|
|
|
|
OutgoingChatMemos memos;
|
|
memos.recipientZaddr = peerZaddr;
|
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex);
|
|
memos.payloadMemo = ciphertextHex;
|
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
|
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
|
return ChatComposeStatus::TooLong;
|
|
}
|
|
out = std::move(memos);
|
|
return ChatComposeStatus::Ok;
|
|
}
|
|
|
|
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|
const std::string& myReplyZaddr,
|
|
const std::string& peerZaddr,
|
|
const std::string& conversationId,
|
|
const std::string& requestText,
|
|
OutgoingChatMemos& out)
|
|
{
|
|
if (requestText.empty()) return ChatComposeStatus::EmptyBody;
|
|
// The receive parser treats any memo starting with '{' as a header, so a request payload must
|
|
// not start with one (see isContactPayloadCandidate in chat_protocol.cpp).
|
|
if (requestText.front() == '{') return ChatComposeStatus::BadRequestText;
|
|
if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) ||
|
|
!present(conversationId)) {
|
|
return ChatComposeStatus::MissingField;
|
|
}
|
|
|
|
OutgoingChatMemos memos;
|
|
memos.recipientZaddr = peerZaddr;
|
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex);
|
|
memos.payloadMemo = requestText;
|
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
|
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
|
return ChatComposeStatus::TooLong;
|
|
}
|
|
out = std::move(memos);
|
|
return ChatComposeStatus::Ok;
|
|
}
|
|
|
|
} // namespace dragonx::chat
|