diff --git a/src/chat/chat_outgoing.cpp b/src/chat/chat_outgoing.cpp index 747cf7a..61f95c7 100644 --- a/src/chat/chat_outgoing.cpp +++ b/src/chat/chat_outgoing.cpp @@ -5,6 +5,10 @@ #include "chat_protocol.h" // kHushChat* constants #include +#include + +#include +#include 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 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& 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, diff --git a/src/chat/chat_outgoing.h b/src/chat/chat_outgoing.h index 22c78f3..5c9447c 100644 --- a/src/chat/chat_outgoing.h +++ b/src/chat/chat_outgoing.h @@ -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& 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, diff --git a/src/chat/chat_protocol.cpp b/src/chat/chat_protocol.cpp index 8e8c41c..39122d0 100644 --- a/src/chat/chat_protocol.cpp +++ b/src/chat/chat_protocol.cpp @@ -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(); - 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(); + 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= '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 chunks(static_cast(want)); std::vector seen(static_cast(want), false); bool ok = (static_cast(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)); } diff --git a/src/chat/chat_protocol.h b/src/chat/chat_protocol.h index 6663c2e..7fa62d7 100644 --- a/src/chat/chat_protocol.h +++ b/src/chat/chat_protocol.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #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> 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> 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; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index a449188..df7306a 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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('a' + (i % 26)); + + std::vector 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 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 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 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 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();