Files
ObsidianDragon/src/chat/chat_protocol.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

318 lines
12 KiB
C++

#include "chat_protocol.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <optional>
#include <utility>
namespace dragonx::chat {
namespace {
bool isHexString(const std::string& value)
{
for (unsigned char ch : value) {
if (!std::isxdigit(ch)) return false;
}
return true;
}
bool isCiphertextPayloadCandidate(const std::string& value)
{
return !value.empty() && value.size() <= kHushChatMemoByteLimit &&
value.size() % 2 == 0 && isHexString(value);
}
bool isContactPayloadCandidate(const std::string& value)
{
return !value.empty() && value.size() <= kHushChatMemoByteLimit && value.front() != '{';
}
bool readRequiredString(const nlohmann::json& object,
const char* key,
std::string& value,
std::string& error)
{
auto it = object.find(key);
if (it == object.end()) {
error = std::string("missing field: ") + key;
return false;
}
if (!it->is_string()) {
error = std::string("field is not a string: ") + key;
return false;
}
value = it->get<std::string>();
return true;
}
bool readRequiredInt(const nlohmann::json& object,
const char* key,
int& value,
std::string& error)
{
auto it = object.find(key);
if (it == object.end()) {
error = std::string("missing field: ") + key;
return false;
}
if (!it->is_number_integer()) {
error = std::string("field is not an integer: ") + key;
return false;
}
value = it->get<int>();
return true;
}
HushChatHeaderParseResult fail(std::string error)
{
HushChatHeaderParseResult result;
result.error = std::move(error);
return result;
}
} // namespace
const char* hushChatHeaderTypeName(HushChatHeaderType type)
{
switch (type) {
case HushChatHeaderType::Message:
return "Memo";
case HushChatHeaderType::ContactRequest:
return "Cont";
}
return "Unknown";
}
const char* hushChatMemoGroupingIssueName(HushChatMemoGroupingIssue issue)
{
switch (issue) {
case HushChatMemoGroupingIssue::InvalidHeader:
return "InvalidHeader";
case HushChatMemoGroupingIssue::MissingPayload:
return "MissingPayload";
case HushChatMemoGroupingIssue::DuplicateHeader:
return "DuplicateHeader";
case HushChatMemoGroupingIssue::OversizedMemo:
return "OversizedMemo";
}
return "Unknown";
}
HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
{
if (memo.empty()) return fail("empty memo");
if (memo.size() > kHushChatMemoByteLimit) return fail("memo exceeds HushChat memo byte limit");
if (memo.front() != '{') return fail("memo is not a HushChat header JSON object");
nlohmann::json object;
try {
object = nlohmann::json::parse(memo);
} catch (const nlohmann::json::parse_error& e) {
return fail(std::string("invalid JSON: ") + e.what());
}
if (!object.is_object()) return fail("header memo JSON is not an object");
HushChatHeader header;
std::string type;
std::string error;
if (!readRequiredInt(object, "h", header.header_number, error)) return fail(error);
if (!readRequiredInt(object, "v", header.version, error)) return fail(error);
if (!readRequiredString(object, "z", header.reply_zaddr, error)) return fail(error);
if (!readRequiredString(object, "cid", header.conversation_id, error)) return fail(error);
if (!readRequiredString(object, "t", type, error)) return fail(error);
if (!readRequiredString(object, "e", header.secretstream_header_hex, error)) return fail(error);
if (!readRequiredString(object, "p", header.public_key_hex, error)) return fail(error);
// Optional sender compose time (Unix seconds). Absent on older senders — leave sent_at = 0 so the
// receiver falls back to the tx/receive time. Read leniently; never fail the header on a bad value.
if (auto it = object.find("ts"); it != object.end() && it->is_number_integer())
header.sent_at = it->get<std::int64_t>();
if (header.header_number < 1) return fail("header number must be positive");
if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version");
if (header.reply_zaddr.empty()) return fail("reply z-address is empty");
if (header.conversation_id.empty()) return fail("conversation id is empty");
if (type == "Memo") {
header.type = HushChatHeaderType::Message;
} else if (type == "Cont") {
header.type = HushChatHeaderType::ContactRequest;
} else {
return fail("unknown HushChat header type");
}
if (header.public_key_hex.size() != kHushChatPublicKeyHexLength || !isHexString(header.public_key_hex)) {
return fail("public key must be 32 bytes encoded as hex");
}
if (header.type == HushChatHeaderType::Message) {
if (header.secretstream_header_hex.size() != kHushChatSecretstreamHeaderHexLength ||
!isHexString(header.secretstream_header_hex)) {
return fail("message header must include a 24 byte secretstream header encoded as hex");
}
} else if (!header.secretstream_header_hex.empty()) {
return fail("contact request header must not include a secretstream header");
}
HushChatHeaderParseResult result;
result.ok = true;
result.header = std::move(header);
return result;
}
HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMemoOutput>& outputs)
{
struct OrderedOutput {
std::size_t input_index = 0;
HushChatMemoOutput output;
};
std::vector<OrderedOutput> ordered;
ordered.reserve(outputs.size());
for (std::size_t index = 0; index < outputs.size(); ++index) {
ordered.push_back(OrderedOutput{index, outputs[index]});
}
std::stable_sort(ordered.begin(), ordered.end(), [](const OrderedOutput& left, const OrderedOutput& right) {
if (left.output.position == right.output.position) return left.input_index < right.input_index;
return left.output.position < right.output.position;
});
HushChatMemoGroupingResult result;
std::optional<HushChatMemoOutput> pending_header_output;
std::optional<HushChatHeader> pending_header;
// A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo
// outputs of a chat tx (transaction_builder ShuffleOutputs), so the header is NOT guaranteed to
// occupy the lower position. We hold an orphan payload until its header arrives and pair them
// regardless of on-chain order (a chat tx carries exactly one header + one payload).
std::optional<HushChatMemoOutput> pending_payload_output;
auto addIssue = [&](HushChatMemoGroupingIssue issue, std::size_t position, std::string detail) {
result.issues.push_back(HushChatMemoGroupingIssueInfo{issue, position, std::move(detail)});
};
auto payloadMatchesHeader = [&](const std::string& memo) {
return pending_header->type == HushChatHeaderType::Message
? isCiphertextPayloadCandidate(memo)
: isContactPayloadCandidate(memo);
};
auto emitPair = [&](const HushChatMemoOutput& payloadOutput) {
HushChatMemoPair pair;
pair.header = std::move(*pending_header);
pair.header_position = pending_header_output->position;
pair.payload_position = payloadOutput.position;
pair.payload_memo = payloadOutput.memo;
result.pairs.push_back(std::move(pair));
pending_header_output.reset();
pending_header.reset();
};
// Pair a held (early) payload with the now-pending header, if it matches the header's type.
auto tryPairHeldPayload = [&]() {
if (!pending_header || !pending_payload_output) return;
if (payloadMatchesHeader(pending_payload_output->memo)) {
emitPair(*pending_payload_output);
pending_payload_output.reset();
}
};
auto clearPendingAsMissing = [&]() {
if (!pending_header_output) return;
addIssue(HushChatMemoGroupingIssue::MissingPayload,
pending_header_output->position,
"header did not have a matching payload memo");
pending_header_output.reset();
pending_header.reset();
};
for (const auto& entry : ordered) {
const auto& output = entry.output;
if (output.memo.size() > kHushChatMemoByteLimit) {
addIssue(HushChatMemoGroupingIssue::OversizedMemo,
output.position,
"memo exceeds HushChat memo byte limit");
continue;
}
if (!output.memo.empty() && output.memo.front() == '{') {
auto parsed = parseHushChatHeaderMemo(output.memo);
if (!parsed.ok) {
addIssue(HushChatMemoGroupingIssue::InvalidHeader, output.position, parsed.error);
continue;
}
if (pending_header_output) {
addIssue(HushChatMemoGroupingIssue::DuplicateHeader,
output.position,
"encountered another HushChat header before a payload");
clearPendingAsMissing();
}
pending_header_output = output;
pending_header = std::move(parsed.header);
tryPairHeldPayload(); // the payload may have arrived first (shuffled output order)
continue;
}
// Non-header memo.
if (pending_header_output && pending_header) {
if (payloadMatchesHeader(output.memo)) {
emitPair(output);
} else {
++result.ignored_memo_count;
}
} else if (!pending_payload_output && !output.memo.empty()) {
// No header yet — hold this as a candidate payload for a header still to come.
pending_payload_output = output;
} else {
++result.ignored_memo_count;
}
}
clearPendingAsMissing();
if (pending_payload_output) ++result.ignored_memo_count; // orphan payload, no header arrived
return result;
}
HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
const HushChatTransactionInput& transaction,
bool featureEnabled)
{
HushChatTransactionExtractionResult result;
result.feature_enabled = featureEnabled;
if (!featureEnabled || transaction.txid.empty()) return result;
auto grouped = groupHushChatMemoOutputs(transaction.outputs);
result.ignored_memo_count = grouped.ignored_memo_count;
result.issues.reserve(grouped.issues.size());
for (const auto& issue : grouped.issues) {
result.issues.push_back(HushChatMemoGroupingIssueInfo{
issue.issue,
issue.position,
hushChatMemoGroupingIssueName(issue.issue)
});
}
result.metadata.reserve(grouped.pairs.size());
for (const auto& pair : grouped.pairs) {
HushChatTransactionMetadata metadata;
metadata.txid = transaction.txid;
metadata.type = pair.header.type;
metadata.conversation_id = pair.header.conversation_id;
metadata.reply_zaddr = pair.header.reply_zaddr;
metadata.header_position = pair.header_position;
metadata.payload_position = pair.payload_position;
metadata.payload_size = pair.payload_memo.size();
metadata.sender_public_key_hex = pair.header.public_key_hex;
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)
result.metadata.push_back(std::move(metadata));
}
return result;
}
} // namespace dragonx::chat