feat(chat): composer + contact requests — outgoing construction (Phase 4)

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>
This commit is contained in:
2026-07-06 15:30:41 -05:00
parent 980a100edd
commit 4db609fb52
10 changed files with 509 additions and 19 deletions

View File

@@ -0,0 +1,99 @@
// 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

53
src/chat/chat_outgoing.h Normal file
View File

@@ -0,0 +1,53 @@
#pragma once
// DragonX Wallet - HushChat outgoing memo construction (the inverse of the receive parser).
//
// Given the sender's identity and the peer, produce the header memo JSON + payload memo that,
// sent as two 0-value memo outputs to the peer's z-address (header at the LOWER memo position),
// another HushChat client parses and decrypts. The byte format matches SilentDragonXLite: the
// header keys serialize alphabetically (nlohmann default) to cid,e,h,p,t,v,z. Pure — no I/O, no
// network; broadcasting the memos is the caller's job (the transport lands in a later phase).
#include "chat_crypto.h" // ChatKeyPair
#include <string>
namespace dragonx::chat {
struct OutgoingChatMemos {
std::string recipientZaddr; // the peer's z-address (recipient of both memo outputs)
std::string headerMemo; // JSON header — MUST occupy the lower memo position on the wire
std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest)
};
enum class ChatComposeStatus {
Ok,
EmptyBody,
MissingField,
BadPeerKey,
BadRequestText, // a contact request text must not start with '{' (parser would read it as a header)
EncryptFailed,
TooLong // a resulting memo exceeds the HushChat 512-byte memo limit
};
// Build an ENCRYPTED message to a peer whose public key you already learned from a memo they sent
// you. `mine` is the sender's identity keypair; `myPublicKeyHex` its public half (goes in header p).
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);
// Build a plaintext contact request — no peer public key needed yet; this is how the peer first
// learns your public key + reply address. The payload is the (plaintext) request text.
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
const std::string& myReplyZaddr,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& requestText,
OutgoingChatMemos& out);
} // namespace dragonx::chat

View File

@@ -3,6 +3,7 @@
#include "chat_service.h"
#include "chat_database.h"
#include "chat_identity.h" // chatIdentityPublicKeyHex
#include <utility>
@@ -70,4 +71,38 @@ void ChatService::loadFromDatabase() {
}
}
std::string ChatService::identityPublicKeyHex() const {
if (!has_identity_) return {};
return chatIdentityPublicKeyHex(identity_);
}
ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
OutgoingChatMemos& out) const {
if (!has_identity_) return ChatComposeStatus::MissingField;
return buildOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr,
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
}
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& requestText,
OutgoingChatMemos& out) const {
if (!has_identity_) return ChatComposeStatus::MissingField;
return buildOutgoingContactRequest(chatIdentityPublicKeyHex(identity_), myReplyZaddr,
peerZaddr, conversationId, requestText, out);
}
bool ChatService::recordOutgoing(const ChatMessage& message) {
if (store_.append(message)) {
if (db_) db_->append(message);
return true;
}
return false;
}
} // namespace dragonx::chat

View File

@@ -7,6 +7,7 @@
#include "chat_crypto.h" // ChatKeyPair
#include "chat_protocol.h" // HushChatTransactionMetadata
#include "chat_outgoing.h" // OutgoingChatMemos, ChatComposeStatus
#include "chat_store.h"
#include <cstdint>
@@ -50,6 +51,27 @@ public:
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
void loadFromDatabase();
// --- Outgoing (compose) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
// Construct the outgoing memos for an ENCRYPTED message, using the held identity to encrypt.
ChatComposeStatus composeMessage(const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
OutgoingChatMemos& out) const;
// Construct the outgoing memos for a plaintext contact request (no peer key needed yet).
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& requestText,
OutgoingChatMemos& out) const;
// Echo a locally-composed outgoing message into the store (and DB). Returns true if new. (We
// never harvest our own sent memos — they land on the peer's address — so this echo is the
// only local record of what we sent.)
bool recordOutgoing(const ChatMessage& message);
const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; }