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

@@ -5849,6 +5849,89 @@ void testHushChatDatabase()
scrub(dbPath); scrub(dbPath2);
}
// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and
// ChatService compose/recordOutgoing echoes into the store.
void testHushChatOutgoing()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("out-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("out-bob", bob, true);
const std::string aliceZ = "zs-alice-reply";
const std::string bobZ = "zs-bob";
const std::string cid = "conv-out";
// Encrypted message Alice -> Bob, then round-trip through the receive path.
OutgoingChatMemos memos;
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid,
"hello bob", memos) == ChatComposeStatus::Ok);
EXPECT_EQ(memos.recipientZaddr, bobZ);
EXPECT_TRUE(!memos.headerMemo.empty() && memos.headerMemo.front() == '{');
// Header keys serialize alphabetically (cid before e).
EXPECT_TRUE(memos.headerMemo.find("\"cid\"") < memos.headerMemo.find("\"e\""));
HushChatTransactionInput tx;
tx.txid = "txout1";
tx.outputs.push_back({0, memos.headerMemo}); // header at the lower position
tx.outputs.push_back({1, memos.payloadMemo});
auto extracted = extractHushChatTransactionMetadata(tx, true);
EXPECT_EQ((int)extracted.metadata.size(), 1);
ChatService bobSvc;
bobSvc.setIdentity(bob);
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 5), 1);
std::vector<ChatMessage> conv = bobSvc.store().conversation(cid);
EXPECT_EQ((int)conv.size(), 1);
EXPECT_EQ(conv[0].body, std::string("hello bob"));
EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key
EXPECT_EQ(conv[0].peer_zaddr, aliceZ);
// Plaintext contact request Alice -> Bob.
OutgoingChatMemos creq;
EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "conv-cr", "add me?",
creq) == ChatComposeStatus::Ok);
HushChatTransactionInput tx2;
tx2.txid = "txout2";
tx2.outputs.push_back({0, creq.headerMemo});
tx2.outputs.push_back({1, creq.payloadMemo});
auto ex2 = extractHushChatTransactionMetadata(tx2, true);
EXPECT_EQ((int)ex2.metadata.size(), 1);
EXPECT_TRUE(ex2.metadata[0].type == HushChatHeaderType::ContactRequest);
bobSvc.ingest(ex2.metadata, {}, 6);
EXPECT_EQ((int)bobSvc.store().conversation("conv-cr").size(), 1);
EXPECT_EQ(bobSvc.store().conversation("conv-cr")[0].body, std::string("add me?"));
// Validation guards.
OutgoingChatMemos dummy;
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, "tooshort", bobZ, cid, "x", dummy)
== ChatComposeStatus::BadPeerKey);
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid, "", dummy)
== ChatComposeStatus::EmptyBody);
EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "c", "{bad", dummy)
== ChatComposeStatus::BadRequestText);
// ChatService compose + recordOutgoing echo.
OutgoingChatMemos svcMemos;
EXPECT_TRUE(bobSvc.composeMessage(bobZ, ra.public_key_hex, aliceZ, cid, "reply!", svcMemos)
== ChatComposeStatus::Ok);
ChatMessage echo;
echo.direction = ChatDirection::Outgoing;
echo.kind = ChatMessageKind::Message;
echo.conversation_id = cid;
echo.peer_zaddr = aliceZ;
echo.peer_public_key_hex = ra.public_key_hex;
echo.body = "reply!";
echo.timestamp = 7;
echo.txid = "out:local1";
echo.payload_position = 0;
EXPECT_TRUE(bobSvc.recordOutgoing(echo));
std::vector<ChatMessage> conv2 = bobSvc.store().conversation(cid);
EXPECT_EQ((int)conv2.size(), 2);
EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing);
EXPECT_EQ(conv2.back().body, std::string("reply!"));
}
} // namespace
int main()
@@ -5943,6 +6026,7 @@ int main()
testHushChatReceivePath();
testHushChatService();
testHushChatDatabase();
testHushChatOutgoing();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();