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,
+15
View File
@@ -58,6 +58,21 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
const std::string& plaintext,
OutgoingChatMemos& out);
// Build an ENCRYPTED message, splitting it across as many transactions as its length needs (larger-
// messages tier 3). Returns one OutgoingChatMemos per transaction to broadcast (in order): a short
// message yields a single element identical to buildOutgoingMessage (tier 1/2); a long one yields
// several, each a self-contained chat send carrying a slice of the one ciphertext under a shared
// message id. The receiver reassembles across the siblings. Refused as TooLong beyond
// kHushChatMaxMessageChunks total chunks. The caller broadcasts each element as its own chat tx.
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);
// 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,
+43 -1
View File
@@ -163,11 +163,20 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
}
// Optional part count ("n"); absent/1 = classic single-payload message. Bound it so a garbage or
// future-oversized header can't make the grouper try to collect an unbounded number of payloads.
// A single-tx (tier 2) message chunks at most kHushChatMaxMessageParts; a multi-tx (tier 3)
// message carries the larger TOTAL chunk count here alongside a "mid", capped at the chunk ceiling.
if (auto it = object.find("n"); it != object.end() && it->is_number_integer()) {
const int n = it->get<int>();
if (n < 1 || n > kHushChatMaxMessageParts) return fail("message part count out of range");
if (n < 1 || n > kHushChatMaxMessageChunks) return fail("message part count out of range");
header.parts = n;
}
// Optional tier-3 message id ("mid"): present only when the message is split across several
// transactions, tying this tx's chunks to its siblings. Short hex (the sender stamps 8 bytes).
if (auto it = object.find("mid"); it != object.end() && it->is_string()) {
header.message_id = it->get<std::string>();
if (header.message_id.empty() || header.message_id.size() > 64 || !isHexString(header.message_id))
return fail("message id must be short hex");
}
} else if (!header.secretstream_header_hex.empty()) {
return fail("contact request header must not include a secretstream header");
}
@@ -233,6 +242,36 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
// --- Tier 3: this transaction carries only a SLICE of a larger message (message_id set) ---
// Surface its chunks with their GLOBAL indices as a partial; the chat service accumulates
// slices across the sibling txs that share this message_id and reassembles once all `parts`
// chunks are present. One tx holds at most kHushChatMaxMessageParts of them.
if (!multiHeader->message_id.empty()) {
HushChatMemoPair pair;
pair.header_position = multiHeaderPos;
pair.message_id = multiHeader->message_id;
pair.total_parts = want;
bool okp = true;
std::size_t lowPos = 0; bool haveLow = false;
for (const auto* p : payloads) {
const std::string& m = p->memo;
if (m.size() < kHushChatPartIndexHexLen || (m.size() % 2) != 0 || !isHexString(m)) { okp = false; break; }
const int idx = hexVal(m[0]) * 16 + hexVal(m[1]);
if (idx < 0 || idx >= want) { okp = false; break; }
pair.partial_chunks.emplace_back(idx, m.substr(kHushChatPartIndexHexLen));
if (!haveLow || p->position < lowPos) { lowPos = p->position; haveLow = true; }
}
if (okp && !pair.partial_chunks.empty()) {
pair.header = std::move(*multiHeader);
pair.payload_position = haveLow ? lowPos : 0;
result.pairs.push_back(std::move(pair));
return result;
}
result.issues.push_back(HushChatMemoGroupingIssueInfo{
HushChatMemoGroupingIssue::MissingPayload, multiHeaderPos,
"tier-3 message slice did not parse"});
return result;
}
std::vector<std::string> chunks(static_cast<std::size_t>(want));
std::vector<bool> seen(static_cast<std::size_t>(want), false);
bool ok = (static_cast<int>(payloads.size()) == want);
@@ -396,6 +435,9 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
metadata.payload_memo = pair.payload_memo;
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
metadata.message_id = pair.message_id; // tier 3: set when this tx is a slice of a bigger message
metadata.total_parts = pair.total_parts; // (payload_memo empty; chunks below)
metadata.partial_chunks = pair.partial_chunks;
result.metadata.push_back(std::move(metadata));
}
+29 -4
View File
@@ -1,7 +1,9 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#ifndef DRAGONX_ENABLE_CHAT
@@ -27,8 +29,13 @@ struct HushChatHeader {
std::string public_key_hex;
// Message only: how many payload outputs this message's ciphertext is split across (header "n").
// 1 = the classic single-payload message (field omitted on the wire). >1 means the payloads are
// index-prefixed ciphertext chunks reassembled in order (larger-messages tier 2, one transaction).
// index-prefixed ciphertext chunks reassembled in order. For tier 2 all N chunks ride ONE tx; for
// tier 3 (message_id set) "n" is the TOTAL chunk count spread across several sibling transactions.
int parts = 1;
// Message only, larger-messages tier 3: when a message needs more chunks than one tx can carry, it
// is split across several transactions that all share this random id (header "mid"); each tx's
// payloads carry their GLOBAL chunk index (0..parts-1). Empty = a single-tx message (tier 1/2).
std::string message_id;
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
std::int64_t sent_at = 0;
@@ -50,6 +57,12 @@ struct HushChatMemoPair {
std::size_t header_position = 0;
std::size_t payload_position = 0;
std::string payload_memo;
// Larger-messages tier 3: a single transaction that carries only PART of a bigger message (the rest
// ride sibling txs sharing message_id). payload_memo is then empty; partial_chunks holds this tx's
// (global index, ciphertext-hex chunk) pairs and total_parts is the whole message's chunk count.
std::string message_id;
int total_parts = 0;
std::vector<std::pair<int, std::string>> partial_chunks;
};
enum class HushChatMemoGroupingIssue {
@@ -91,6 +104,13 @@ struct HushChatTransactionMetadata {
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
// Larger-messages tier 3 (a multi-transaction Message): message_id ties this tx's slice to its
// siblings, total_parts is the whole message's chunk count, and partial_chunks are this tx's
// (global index, ciphertext-hex) chunks. Empty message_id = an ordinary single-tx message (payload
// in payload_memo). The chat service accumulates partials by message_id and reassembles when whole.
std::string message_id;
int total_parts = 0;
std::vector<std::pair<int, std::string>> partial_chunks;
};
struct HushChatTransactionExtractionResult {
@@ -102,10 +122,15 @@ struct HushChatTransactionExtractionResult {
constexpr int kHushChatSupportedVersion = 0;
constexpr std::size_t kHushChatMemoByteLimit = 512;
// Larger-messages tier 2: the most payload outputs a single message may be split across (one tx).
// Keeps the chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost.
// A message that would need more parts than this is refused at compose time (tier 3 would lift it).
// Larger-messages tier 2: the most payload outputs a single transaction may carry (one tx). Keeps the
// chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost. A message
// needing more chunks than this spills into sibling transactions (tier 3), up to kHushChatMaxMessageChunks.
constexpr int kHushChatMaxMessageParts = 16;
// Larger-messages tier 3: the most chunks a single message may span IN TOTAL, across all its sibling
// transactions (ceil(chunks / kHushChatMaxMessageParts) txs, each a separate 0-value chat send + fee).
// Bounds the on-chain cost / broadcast time of one message; beyond this compose returns TooLong. The
// 2-hex global part index (below) tops out at 256, so this must stay ≤ 256.
constexpr int kHushChatMaxMessageChunks = 64;
// Each multi-part payload chunk is prefixed with a 2-hex-char (1-byte) part index so the receiver can
// reorder chunks after the daemon shuffles a tx's outputs. 2 hex covers indices 0..255 (>> the cap).
constexpr std::size_t kHushChatPartIndexHexLen = 2;
+82
View File
@@ -15746,6 +15746,87 @@ void testHushChatLargeMessage()
cid, huge, tooBig) == ChatComposeStatus::TooLong);
}
// Larger messages (tier 3): a message too big for one tx is split across SEVERAL transactions that
// share a message id; each carries a slice of the one ciphertext with GLOBAL chunk indices. The
// receiver accumulates slices across the sibling txs (shuffled outputs, any tx order) and reassembles.
void testHushChatLargeMessageTier3()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("lm3-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("lm3-bob", bob, true);
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready && rb.status == ChatIdentityStatus::Ready);
const std::string cid = "conv-lm3";
// ~9000 chars → ~36 chunks → 3 transactions (16 + 16 + 4), well past the single-tx cap of 16.
std::string big;
for (int i = 0; i < 9000; ++i) big += static_cast<char>('a' + (i % 26));
std::vector<OutgoingChatMemos> txs;
EXPECT_TRUE(splitOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, big, txs) == ChatComposeStatus::Ok);
EXPECT_TRUE((int)txs.size() > 1); // actually multi-transaction
// Every sibling header carries the SAME message id and the SAME total chunk count.
std::string midField;
for (const auto& m : txs) {
EXPECT_TRUE(m.headerMemo.find("\"mid\":") != std::string::npos);
EXPECT_TRUE(m.headerMemo.find("\"n\":") != std::string::npos);
}
// Extract each transaction (with its outputs REVERSED to mimic the daemon's per-tx ShuffleOutputs),
// gather every slice, and confirm they reassemble into the original plaintext.
std::unordered_map<int, std::string> chunks; // global index -> ciphertext-hex chunk
std::string mid, e, p;
int total = 0;
for (std::size_t t = 0; t < txs.size(); ++t) {
std::vector<std::string> wire;
wire.push_back(txs[t].headerMemo);
for (const auto& pm : txs[t].payloadMemos) wire.push_back(pm);
std::reverse(wire.begin(), wire.end());
HushChatTransactionInput tx; tx.txid = "lm3-" + std::to_string(t);
for (std::size_t i = 0; i < wire.size(); ++i) tx.outputs.push_back({i, wire[i]});
auto ext = extractHushChatTransactionMetadata(tx, true);
EXPECT_EQ((int)ext.metadata.size(), 1);
const auto& md = ext.metadata[0];
EXPECT_TRUE(md.type == HushChatHeaderType::Message);
EXPECT_TRUE(!md.message_id.empty());
EXPECT_TRUE(md.payload_memo.empty()); // a slice carries chunks, not a whole payload
EXPECT_TRUE(!md.partial_chunks.empty());
if (mid.empty()) { mid = md.message_id; e = md.secretstream_header_hex; p = md.sender_public_key_hex; total = md.total_parts; }
EXPECT_EQ(md.message_id, mid); // siblings agree
EXPECT_EQ(md.total_parts, total);
EXPECT_EQ(md.secretstream_header_hex, e);
for (const auto& c : md.partial_chunks) {
EXPECT_TRUE(chunks.find(c.first) == chunks.end()); // no duplicate index
chunks[c.first] = c.second;
}
}
EXPECT_EQ((int)chunks.size(), total); // every chunk arrived exactly once
std::string full;
for (int i = 0; i < total; ++i) {
EXPECT_TRUE(chunks.find(i) != chunks.end()); // contiguous 0..total-1
full += chunks[i];
}
std::string plain;
EXPECT_TRUE(decryptIncoming(bob, p, e, full, plain) == ChatCryptoStatus::Ok);
EXPECT_EQ(plain, big);
// A short message still yields a single tier-1/2 transaction with no message id.
std::vector<OutgoingChatMemos> one;
EXPECT_TRUE(splitOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, "hi there", one) == ChatComposeStatus::Ok);
EXPECT_EQ((int)one.size(), 1);
EXPECT_TRUE(one[0].headerMemo.find("\"mid\":") == std::string::npos);
// Cap: beyond kHushChatMaxMessageChunks total chunks it is refused even across transactions.
std::string tooHuge(20000, 'z');
std::vector<OutgoingChatMemos> over;
EXPECT_TRUE(splitOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, tooHuge, over) == ChatComposeStatus::TooLong);
}
void testHushChatOutgoing()
{
using namespace dragonx::chat;
@@ -20356,6 +20437,7 @@ int main()
testChatStoreLatestAndMove();
testHushChatOutgoing();
testHushChatLargeMessage();
testHushChatLargeMessageTier3();
testHushChatTransport();
testHushChatShuffledReceive();
testWalletFileProbe();