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>
268 lines
13 KiB
C++
268 lines
13 KiB
C++
// DragonX Wallet - HushChat outgoing memo construction (implementation).
|
|
|
|
#include "chat_outgoing.h"
|
|
|
|
#include "chat_protocol.h" // kHushChat* constants
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <sodium.h>
|
|
|
|
#include <algorithm>
|
|
#include <ctime>
|
|
|
|
namespace dragonx::chat {
|
|
|
|
namespace {
|
|
|
|
// Serialize the HushChat header. nlohmann emits object keys in sorted (alphabetical) order —
|
|
// cid,e,h,p,t,v,z — which is exactly SilentDragonXLite's on-wire key order.
|
|
std::string buildHeaderMemo(const std::string& replyZaddr,
|
|
const std::string& conversationId,
|
|
const char* type,
|
|
const std::string& streamHeaderHex,
|
|
const std::string& publicKeyHex,
|
|
std::int64_t sentAt,
|
|
const std::string& op = "",
|
|
int parts = 1,
|
|
const std::string& messageId = "")
|
|
{
|
|
nlohmann::json header;
|
|
header["h"] = 1; // header number (>= 1)
|
|
header["v"] = kHushChatSupportedVersion; // 0
|
|
header["z"] = replyZaddr; // where the peer should reply (my address)
|
|
header["cid"] = conversationId;
|
|
header["t"] = type; // "Memo" / "Cont" / "Ctrl"
|
|
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 (!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();
|
|
}
|
|
|
|
// 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(); }
|
|
|
|
// 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)
|
|
{
|
|
const std::string prefix = utf8Prefix ? "utf8:" : "";
|
|
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,
|
|
const std::string& myPublicKeyHex,
|
|
const std::string& myReplyZaddr,
|
|
const std::string& peerPublicKeyHex,
|
|
const std::string& peerZaddr,
|
|
const std::string& conversationId,
|
|
const std::string& plaintext,
|
|
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;
|
|
|
|
std::string streamHeaderHex;
|
|
std::string ciphertextHex;
|
|
if (encryptOutgoing(mine, peerPublicKeyHex, plaintext, streamHeaderHex, ciphertextHex)
|
|
!= ChatCryptoStatus::Ok) {
|
|
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)), "", 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;
|
|
}
|
|
|
|
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,
|
|
const std::string& conversationId,
|
|
const std::string& requestText,
|
|
OutgoingChatMemos& out)
|
|
{
|
|
if (requestText.empty()) return ChatComposeStatus::EmptyBody;
|
|
// The receive parser treats any memo starting with '{' as a header, so a request payload must
|
|
// not start with one (see isContactPayloadCandidate in chat_protocol.cpp).
|
|
if (requestText.front() == '{') return ChatComposeStatus::BadRequestText;
|
|
if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) ||
|
|
!present(conversationId)) {
|
|
return ChatComposeStatus::MissingField;
|
|
}
|
|
|
|
OutgoingChatMemos memos;
|
|
memos.recipientZaddr = peerZaddr;
|
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
|
static_cast<std::int64_t>(std::time(nullptr)));
|
|
memos.payloadMemos = { requestText };
|
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
|
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
|
return ChatComposeStatus::TooLong;
|
|
}
|
|
out = std::move(memos);
|
|
return ChatComposeStatus::Ok;
|
|
}
|
|
|
|
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
|
|
const std::string& selfZaddr,
|
|
const std::string& conversationId,
|
|
const std::string& op,
|
|
const std::string& label,
|
|
OutgoingChatMemos& out)
|
|
{
|
|
// A control signal is addressed to SELF: only this wallet (any device holding the seed) harvests
|
|
// it, and it never reaches the peer. op carries the action; the label is a plaintext hint (peer
|
|
// address / display name) so a fresh import can show the conversation it refers to.
|
|
if (label.empty()) return ChatComposeStatus::EmptyBody;
|
|
if (label.front() == '{') return ChatComposeStatus::BadRequestText; // would parse as a header
|
|
if (!present(op) || !present(myPublicKeyHex) || !present(selfZaddr) || !present(conversationId)) {
|
|
return ChatComposeStatus::MissingField;
|
|
}
|
|
|
|
OutgoingChatMemos memos;
|
|
memos.recipientZaddr = selfZaddr;
|
|
memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex,
|
|
static_cast<std::int64_t>(std::time(nullptr)), op);
|
|
memos.payloadMemos = { label };
|
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
|
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
|
return ChatComposeStatus::TooLong;
|
|
}
|
|
out = std::move(memos);
|
|
return ChatComposeStatus::Ok;
|
|
}
|
|
|
|
} // namespace dragonx::chat
|