feat(chat): tier-3 larger messages — protocol + split + cross-tx reassembly core

Foundation for messages too large for a single transaction (tier 2's 16-output
ceiling, ~4000 chars). A long message is now split across SEVERAL transactions
that share a random message id; each carries a slice of the one ciphertext with
GLOBAL chunk indices, and the receiver reassembles across the siblings.

Protocol: the Message header gains an optional "mid" (message id); "n" is reused
as the TOTAL chunk count and its bound raised to kHushChatMaxMessageChunks (64 ⇒
up to ~16 KB / 4 txs, tunable, ≤256 for the 2-hex index). The grouper, when a
tx's header carries "mid", emits a PARTIAL (its global-indexed chunks + message
id + total) instead of reassembling in-place; HushChatTransactionMetadata
carries those partials through. Crypto is untouched — one secretstream session
for the whole message, chunking is transport-only.

Compose: splitOutgoingMessage() returns one OutgoingChatMemos per transaction
(one element = the classic tier-1/2 single tx, byte-identical; several = tier 3).
buildOutgoingMessage (single-tx) is unchanged. Old clients bound "n" to 16 so a
tier-3 header fails to parse and its memos are dropped — graceful degradation.

Unit-tested (testHushChatLargeMessageTier3): a ~9000-char message splits into 3
txs, every output shuffled, slices gathered across siblings, reassembled and
decrypted back to the original; short message stays single-tx (no mid); the
total-chunk cap is enforced. The stateful accumulator (persisted, confirm-gated),
sequential broadcast, and progressive/incomplete UI follow in later commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:20:11 -05:00
co-authored by Claude Opus 4.8
parent 1729f7dfad
commit cc08854237
5 changed files with 263 additions and 6 deletions
+94 -1
View File
@@ -5,6 +5,10 @@
#include "chat_protocol.h" // kHushChat* constants
#include <nlohmann/json.hpp>
#include <sodium.h>
#include <algorithm>
#include <ctime>
namespace dragonx::chat {
@@ -19,7 +23,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
const std::string& publicKeyHex,
std::int64_t sentAt,
const std::string& op = "",
int parts = 1)
int parts = 1,
const std::string& messageId = "")
{
nlohmann::json header;
header["h"] = 1; // header number (>= 1)
@@ -31,6 +36,7 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
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();
}
@@ -47,6 +53,18 @@ std::string partIndexPrefix(std::size_t i)
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<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
@@ -113,6 +131,81 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
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<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;
// 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::int64_t>(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<int>(nChunks) > kHushChatMaxMessageChunks) return ChatComposeStatus::TooLong;
std::vector<OutgoingChatMemos> 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<int>(nChunks) > kHushChatMaxMessageParts;
const std::string mid = multiTx ? newMessageId() : std::string();
const std::size_t perTx = static_cast<std::size_t>(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<int>(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,