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