feat(chat): larger messages — multi-output chunking in one transaction (tier 2)
A message whose ciphertext exceeds one 512-byte memo is now split across up to kHushChatMaxMessageParts (16) payload outputs in a SINGLE transaction, lifting the ~239-byte cap to a few KB while staying atomic, instant (0-conf), and one-fee. The crypto is untouched (one secretstream push/pull); chunking is a transport concern: - Header gains an optional part count `n` (emitted only when >1, so a 1-part message stays byte-identical on the wire); bounded in the parser. - Each chunk is a 2-hex part-index prefix + its ciphertext slice, so the receiver reorders them after the daemon's output shuffle. - OutgoingChatMemos carries a payload vector; chatSendOutputs emits header + N payloads (both broadcast call sites already iterate the result). - The grouper gains a multi-part pre-scan: a lone Message header with n>1 → collect the N indexed chunks, sort, strip, concat → one pair; anything else falls through to the classic single-pair path (older clients mis-pair and drop — graceful). A body beyond the cap is refused as TooLong (tier 3 later). Round-trip (shuffled outputs), 1-part byte-identical, and cap all unit-tested. Larger-messages tier 2; tier 3 (multi-tx) + the denser raw-memo encoding remain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+47
-16
@@ -18,7 +18,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
||||
const std::string& streamHeaderHex,
|
||||
const std::string& publicKeyHex,
|
||||
std::int64_t sentAt,
|
||||
const std::string& op = "")
|
||||
const std::string& op = "",
|
||||
int parts = 1)
|
||||
{
|
||||
nlohmann::json header;
|
||||
header["h"] = 1; // header number (>= 1)
|
||||
@@ -29,21 +30,34 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
||||
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl)
|
||||
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 (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
||||
return header.dump();
|
||||
}
|
||||
|
||||
// The N 2-hex index prefix for a multi-part ciphertext chunk (index 0..kHushChatMaxMessageParts-1).
|
||||
std::string partIndexPrefix(std::size_t i)
|
||||
{
|
||||
static const char* kHex = "0123456789abcdef";
|
||||
std::string p;
|
||||
p += kHex[(i >> 4) & 0xF];
|
||||
p += kHex[i & 0xF];
|
||||
return p;
|
||||
}
|
||||
|
||||
bool present(const std::string& value) { return !value.empty(); }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
|
||||
std::vector<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
|
||||
{
|
||||
const std::string prefix = utf8Prefix ? "utf8:" : "";
|
||||
return {{
|
||||
{ memos.recipientZaddr, prefix + memos.headerMemo }, // header — the lower memo position
|
||||
{ memos.recipientZaddr, prefix + memos.payloadMemo },
|
||||
}};
|
||||
std::vector<ChatSendOutput> outs;
|
||||
outs.reserve(1 + memos.payloadMemos.size());
|
||||
outs.push_back({ memos.recipientZaddr, prefix + memos.headerMemo }); // header (position doesn't matter;
|
||||
for (const auto& p : memos.payloadMemos) // the receiver reassembles by index)
|
||||
outs.push_back({ memos.recipientZaddr, prefix + p });
|
||||
return outs;
|
||||
}
|
||||
|
||||
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
||||
@@ -69,15 +83,32 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
||||
return ChatComposeStatus::EncryptFailed;
|
||||
}
|
||||
|
||||
// Chunk the ciphertext hex across payload memos. A single memo → the classic 1-part message, byte-
|
||||
// identical on the wire (no "n", no index prefix). Longer → N index-prefixed chunks (tier 2), capped
|
||||
// at kHushChatMaxMessageParts; beyond that it is refused as TooLong (tier 3 would lift the ceiling).
|
||||
std::vector<std::string> payloads;
|
||||
int parts = 1;
|
||||
if (ciphertextHex.size() <= kHushChatMemoByteLimit) {
|
||||
payloads.push_back(ciphertextHex);
|
||||
} else {
|
||||
const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1);
|
||||
const std::size_t n = (ciphertextHex.size() + chunkHex - 1) / chunkHex;
|
||||
if (static_cast<int>(n) > kHushChatMaxMessageParts) return ChatComposeStatus::TooLong;
|
||||
parts = static_cast<int>(n);
|
||||
payloads.reserve(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
payloads.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex));
|
||||
}
|
||||
}
|
||||
|
||||
OutgoingChatMemos memos;
|
||||
memos.recipientZaddr = peerZaddr;
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
|
||||
static_cast<std::int64_t>(std::time(nullptr)));
|
||||
memos.payloadMemo = ciphertextHex;
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||
return ChatComposeStatus::TooLong;
|
||||
}
|
||||
static_cast<std::int64_t>(std::time(nullptr)), "", parts);
|
||||
memos.payloadMemos = std::move(payloads);
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||
for (const auto& p : memos.payloadMemos)
|
||||
if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||
out = std::move(memos);
|
||||
return ChatComposeStatus::Ok;
|
||||
}
|
||||
@@ -102,9 +133,9 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
||||
memos.recipientZaddr = peerZaddr;
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
||||
static_cast<std::int64_t>(std::time(nullptr)));
|
||||
memos.payloadMemo = requestText;
|
||||
memos.payloadMemos = { requestText };
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
||||
return ChatComposeStatus::TooLong;
|
||||
}
|
||||
out = std::move(memos);
|
||||
@@ -131,9 +162,9 @@ ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
|
||||
memos.recipientZaddr = selfZaddr;
|
||||
memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex,
|
||||
static_cast<std::int64_t>(std::time(nullptr)), op);
|
||||
memos.payloadMemo = label;
|
||||
memos.payloadMemos = { label };
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
||||
return ChatComposeStatus::TooLong;
|
||||
}
|
||||
out = std::move(memos);
|
||||
|
||||
Reference in New Issue
Block a user