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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user