Files
ObsidianDragon/src/chat/chat_outgoing.cpp
DanS 4471f54842 feat(chat): stamp sender compose-time in message header (clock-clamped)
Carry the sender's compose time as an optional "ts" (Unix seconds) in the
plaintext header JSON that rides outside the AEAD, and prefer it as the
displayed message time so both ends show the same send time regardless of when
the tx confirms. Parse "ts" leniently. On ingest, clamp: reject a "ts"
implausibly in the future vs the receive/block time (1h skew tolerated) so a
wrong/ahead peer clock can't pin messages to the bottom of a thread; a past
compose time is fine (the note buffer may broadcast a queued message later, and
a confirmed tx's block time is always >= compose time). Tests cover the
round-trip and the future-clock clamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:59:46 -05:00

113 lines
4.9 KiB
C++

// DragonX Wallet - HushChat outgoing memo construction (implementation).
#include "chat_outgoing.h"
#include "chat_protocol.h" // kHushChat* constants
#include <nlohmann/json.hpp>
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)
{
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" or "Cont"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
return header.dump();
}
bool present(const std::string& value) { return !value.empty(); }
} // namespace
std::array<ChatSendOutput, 2> 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 },
}};
}
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;
}
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;
}
out = std::move(memos);
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.payloadMemo = requestText;
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
return ChatComposeStatus::TooLong;
}
out = std::move(memos);
return ChatComposeStatus::Ok;
}
} // namespace dragonx::chat