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