// DragonX Wallet - HushChat outgoing memo construction (implementation). #include "chat_outgoing.h" #include "chat_protocol.h" // kHushChat* constants #include #include #include #include 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, std::int64_t sentAt, const std::string& op = "", int parts = 1, const std::string& messageId = "") { 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" / "Cont" / "Ctrl" header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl) header["p"] = publicKeyHex; // my 64-hex crypto_kx public key if (!op.empty()) header["op"] = op; // Control only; alphabetically sorts to cid,e,h,op,p,t,v,z if (parts > 1) header["n"] = parts; // multi-part Message only; omitted (==1) keeps 1-part byte-identical if (!messageId.empty()) header["mid"] = messageId; // tier 3 only: ties this tx's slice to its siblings if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this return header.dump(); } // The N 2-hex index prefix for a multi-part ciphertext chunk (index 0..kHushChatMaxMessageParts-1). std::string partIndexPrefix(std::size_t i) { static const char* kHex = "0123456789abcdef"; std::string p; p += kHex[(i >> 4) & 0xF]; p += kHex[i & 0xF]; return p; } bool present(const std::string& value) { return !value.empty(); } // A random 8-byte message id as 16 hex chars — ties a tier-3 message's sibling transactions together. std::string newMessageId() { unsigned char buf[8]; randombytes_buf(buf, sizeof buf); static const char* kHex = "0123456789abcdef"; std::string s; s.reserve(sizeof(buf) * 2); for (unsigned char b : buf) { s += kHex[b >> 4]; s += kHex[b & 0xF]; } return s; } } // namespace std::vector chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix) { const std::string prefix = utf8Prefix ? "utf8:" : ""; std::vector outs; outs.reserve(1 + memos.payloadMemos.size()); outs.push_back({ memos.recipientZaddr, prefix + memos.headerMemo }); // header (position doesn't matter; for (const auto& p : memos.payloadMemos) // the receiver reassembles by index) outs.push_back({ memos.recipientZaddr, prefix + p }); return outs; } 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; } // Chunk the ciphertext hex across payload memos. A single memo → the classic 1-part message, byte- // identical on the wire (no "n", no index prefix). Longer → N index-prefixed chunks (tier 2), capped // at kHushChatMaxMessageParts; beyond that it is refused as TooLong (tier 3 would lift the ceiling). std::vector payloads; int parts = 1; if (ciphertextHex.size() <= kHushChatMemoByteLimit) { payloads.push_back(ciphertextHex); } else { const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1); const std::size_t n = (ciphertextHex.size() + chunkHex - 1) / chunkHex; if (static_cast(n) > kHushChatMaxMessageParts) return ChatComposeStatus::TooLong; parts = static_cast(n); payloads.reserve(n); for (std::size_t i = 0; i < n; ++i) { payloads.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex)); } } OutgoingChatMemos memos; memos.recipientZaddr = peerZaddr; memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex, static_cast(std::time(nullptr)), "", parts); memos.payloadMemos = std::move(payloads); if (memos.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong; for (const auto& p : memos.payloadMemos) if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong; out = std::move(memos); return ChatComposeStatus::Ok; } ChatComposeStatus splitOutgoingMessage(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, std::vector& 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; // Encrypt the whole message ONCE (one secretstream session). Chunking is transport-only, so every // transaction of a tier-3 message carries the same secretstream header "e" and its slice of the one // ciphertext — the crypto is byte-identical to the single-tx path. std::string streamHeaderHex; std::string ciphertextHex; if (encryptOutgoing(mine, peerPublicKeyHex, plaintext, streamHeaderHex, ciphertextHex) != ChatCryptoStatus::Ok) { return ChatComposeStatus::EncryptFailed; } const std::int64_t now = static_cast(std::time(nullptr)); // 1 chunk if the ciphertext fits one memo, else ceil(hex / chunkHex) chunks (chunkHex = the 512-byte // memo limit minus the 2-hex global index prefix, rounded to an even hex length). const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1); const std::size_t nChunks = (ciphertextHex.size() <= kHushChatMemoByteLimit) ? 1 : (ciphertextHex.size() + chunkHex - 1) / chunkHex; if (static_cast(nChunks) > kHushChatMaxMessageChunks) return ChatComposeStatus::TooLong; std::vector txs; if (nChunks <= 1) { // Tier 1: one classic payload — no index prefix, no "n", no "mid" (byte-identical on the wire). OutgoingChatMemos m; m.recipientZaddr = peerZaddr; m.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex, now, "", 1, ""); m.payloadMemos = { ciphertextHex }; txs.push_back(std::move(m)); } else { // Tier 2/3: index each chunk with its GLOBAL index and pack up to kHushChatMaxMessageParts chunks // per transaction. One tx (≤ the per-tx cap) is tier 2 (no "mid"); several is tier 3 (shared "mid", // same "n" = total chunk count in every sibling header). const bool multiTx = static_cast(nChunks) > kHushChatMaxMessageParts; const std::string mid = multiTx ? newMessageId() : std::string(); const std::size_t perTx = static_cast(kHushChatMaxMessageParts); for (std::size_t base = 0; base < nChunks; base += perTx) { OutgoingChatMemos m; m.recipientZaddr = peerZaddr; m.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex, now, "", static_cast(nChunks), mid); const std::size_t end = std::min(nChunks, base + perTx); m.payloadMemos.reserve(end - base); for (std::size_t i = base; i < end; ++i) m.payloadMemos.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex)); txs.push_back(std::move(m)); } } for (const auto& m : txs) { if (m.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong; for (const auto& p : m.payloadMemos) if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong; } out = std::move(txs); 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, static_cast(std::time(nullptr))); memos.payloadMemos = { requestText }; if (memos.headerMemo.size() > kHushChatMemoByteLimit || memos.payloadMemos[0].size() > kHushChatMemoByteLimit) { return ChatComposeStatus::TooLong; } out = std::move(memos); return ChatComposeStatus::Ok; } ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex, const std::string& selfZaddr, const std::string& conversationId, const std::string& op, const std::string& label, OutgoingChatMemos& out) { // A control signal is addressed to SELF: only this wallet (any device holding the seed) harvests // it, and it never reaches the peer. op carries the action; the label is a plaintext hint (peer // address / display name) so a fresh import can show the conversation it refers to. if (label.empty()) return ChatComposeStatus::EmptyBody; if (label.front() == '{') return ChatComposeStatus::BadRequestText; // would parse as a header if (!present(op) || !present(myPublicKeyHex) || !present(selfZaddr) || !present(conversationId)) { return ChatComposeStatus::MissingField; } OutgoingChatMemos memos; memos.recipientZaddr = selfZaddr; memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex, static_cast(std::time(nullptr)), op); memos.payloadMemos = { label }; if (memos.headerMemo.size() > kHushChatMemoByteLimit || memos.payloadMemos[0].size() > kHushChatMemoByteLimit) { return ChatComposeStatus::TooLong; } out = std::move(memos); return ChatComposeStatus::Ok; } } // namespace dragonx::chat