From 4471f548420953cb2f48120390a8d407db0b57d2 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 20 Jul 2026 21:59:46 -0500 Subject: [PATCH] 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) --- src/chat/chat_outgoing.cpp | 10 +++++++--- src/chat/chat_protocol.cpp | 5 +++++ src/chat/chat_protocol.h | 4 ++++ src/chat/chat_service.cpp | 15 ++++++++++++++- tests/test_phase4.cpp | 32 ++++++++++++++++++++++++++++++-- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/chat/chat_outgoing.cpp b/src/chat/chat_outgoing.cpp index cba1764..3350d0c 100644 --- a/src/chat/chat_outgoing.cpp +++ b/src/chat/chat_outgoing.cpp @@ -16,7 +16,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr, const std::string& conversationId, const char* type, const std::string& streamHeaderHex, - const std::string& publicKeyHex) + const std::string& publicKeyHex, + std::int64_t sentAt) { nlohmann::json header; header["h"] = 1; // header number (>= 1) @@ -26,6 +27,7 @@ std::string buildHeaderMemo(const std::string& replyZaddr, 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(); } @@ -67,7 +69,8 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine, OutgoingChatMemos memos; memos.recipientZaddr = peerZaddr; - memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex); + memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex, + static_cast(std::time(nullptr))); memos.payloadMemo = ciphertextHex; if (memos.headerMemo.size() > kHushChatMemoByteLimit || memos.payloadMemo.size() > kHushChatMemoByteLimit) { @@ -95,7 +98,8 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex, OutgoingChatMemos memos; memos.recipientZaddr = peerZaddr; - memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex); + memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex, + static_cast(std::time(nullptr))); memos.payloadMemo = requestText; if (memos.headerMemo.size() > kHushChatMemoByteLimit || memos.payloadMemo.size() > kHushChatMemoByteLimit) { diff --git a/src/chat/chat_protocol.cpp b/src/chat/chat_protocol.cpp index c23d38b..13b4b92 100644 --- a/src/chat/chat_protocol.cpp +++ b/src/chat/chat_protocol.cpp @@ -124,6 +124,10 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo) 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(); if (header.header_number < 1) return fail("header number must be positive"); if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version"); @@ -303,6 +307,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata( 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)); } diff --git a/src/chat/chat_protocol.h b/src/chat/chat_protocol.h index 4105748..e7bc77f 100644 --- a/src/chat/chat_protocol.h +++ b/src/chat/chat_protocol.h @@ -23,6 +23,9 @@ struct HushChatHeader { HushChatHeaderType type = HushChatHeaderType::Message; std::string secretstream_header_hex; std::string public_key_hex; + // 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; }; struct HushChatHeaderParseResult { @@ -80,6 +83,7 @@ struct HushChatTransactionMetadata { std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex) 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 }; struct HushChatTransactionExtractionResult { diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index 0388960..df97bff 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -45,8 +45,21 @@ int ChatService::ingest(const std::vector& metadata message.conversation_id = meta.conversation_id; message.peer_zaddr = meta.reply_zaddr; message.peer_public_key_hex = meta.sender_public_key_hex; + // Reference time: the tx/receive time (block time if confirmed, else the receiver's wall clock for + // a mempool receive). const auto timeIt = txTimestamps.find(meta.txid); - message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp; + const std::int64_t refTime = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp; + // Prefer the sender's stamped compose time (header "ts") — the true send time, shown identically on + // both ends. But REJECT a value implausibly in the FUTURE vs the reference: a wrong/ahead peer clock + // would otherwise pin their messages to the bottom of the thread forever. A compose time in the + // PAST is fine — the note buffer can broadcast a queued message long after it was composed, and a + // confirmed tx's block time is always >= the compose time. + constexpr std::int64_t kSenderTsFutureToleranceSec = 3600; // 1 hour of clock skew tolerated + if (meta.sent_at > 0 && (refTime <= 0 || meta.sent_at <= refTime + kSenderTsFutureToleranceSec)) { + message.timestamp = meta.sent_at; + } else { + message.timestamp = refTime; + } message.payload_position = meta.payload_position; if (meta.type == HushChatHeaderType::ContactRequest) { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 22727fb..cd826f5 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -6198,12 +6198,17 @@ void testHushChatOutgoing() ChatService bobSvc; bobSvc.setIdentity(bob); - EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 5), 1); + // Realistic receive-time fallback (the header "ts" is a real std::time stamp; an artificially-tiny + // fallback would trip the future-plausibility clamp in ingest and reject the real ts). + EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, static_cast(std::time(nullptr))), 1); std::vector conv = bobSvc.store().conversation(cid); EXPECT_EQ((int)conv.size(), 1); EXPECT_EQ(conv[0].body, std::string("hello bob")); EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key EXPECT_EQ(conv[0].peer_zaddr, aliceZ); + // The header now carries the sender's compose time (header "ts"); ingest prefers it over the fallback + // (5) so both ends show the same send time. A real std::time() stamp is well past year-2001. + EXPECT_TRUE(conv[0].timestamp > 1000000000); // Plaintext contact request Alice -> Bob. OutgoingChatMemos creq; @@ -6240,7 +6245,9 @@ void testHushChatOutgoing() echo.peer_zaddr = aliceZ; echo.peer_public_key_hex = ra.public_key_hex; echo.body = "reply!"; - echo.timestamp = 7; + // A reply is sent AFTER the received message; the incoming "hello bob" now carries a real compose-time + // "ts" (~now), so stamp the echo just after it (not the old artificial 7, which would sort before it). + echo.timestamp = static_cast(std::time(nullptr)) + 10; echo.txid = "out:local1"; echo.payload_position = 0; EXPECT_TRUE(bobSvc.recordOutgoing(echo)); @@ -6248,6 +6255,27 @@ void testHushChatOutgoing() EXPECT_EQ((int)conv2.size(), 2); EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing); EXPECT_EQ(conv2.back().body, std::string("reply!")); + + // Future-clock clamp: a header "ts" implausibly ahead of the receive time is rejected (falls back to + // the receive time), so a wrong/ahead-clocked peer can't pin their messages to the bottom of a thread. + { + const std::int64_t nowSec = static_cast(std::time(nullptr)); + const std::int64_t futureTs = nowSec + 10 * 24 * 3600; // 10 days ahead — well past the 1h tolerance + nlohmann::json h; + h["h"] = 1; h["v"] = 0; h["z"] = aliceZ; h["cid"] = "conv-clamp"; + h["t"] = "Cont"; h["e"] = ""; h["p"] = ra.public_key_hex; h["ts"] = futureTs; + HushChatTransactionInput ctx; + ctx.txid = "txclamp"; + ctx.outputs.push_back({0, h.dump()}); // header at the lower position + ctx.outputs.push_back({1, "please add"}); // contact-request plaintext payload (must not start with '{') + auto exc = extractHushChatTransactionMetadata(ctx, true); + EXPECT_EQ((int)exc.metadata.size(), 1); + EXPECT_EQ(exc.metadata[0].sent_at, futureTs); // the future ts parses through + bobSvc.ingest(exc.metadata, {}, nowSec); // realistic receive time + auto cc = bobSvc.store().conversation("conv-clamp"); + EXPECT_EQ((int)cc.size(), 1); + EXPECT_EQ(cc[0].timestamp, nowSec); // clamped to receive time, NOT the future ts + } } // Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for