From 4d6005b646ee54b0daab982bab967b3d07eff8f1 Mon Sep 17 00:00:00 2001 From: Dhyey Mistri Date: Wed, 9 Sep 2026 00:07:29 +0530 Subject: [PATCH] Implement admin signature handling and pinning functionality for posted items --- src/gxs/gxssecurity.cc | 25 ++++ src/gxs/gxssecurity.h | 7 ++ src/gxs/rsgenexchange.cc | 16 ++- src/gxs/rsgenexchange.h | 11 +- src/retroshare/rsposted.h | 20 +++- src/rsitems/rsposteditems.cc | 15 ++- src/rsitems/rsposteditems.h | 1 + src/services/p3posted.cc | 150 +++++++++++++++++++++++- src/services/p3posted.h | 10 +- src/services/postedversions.h | 53 +++++++++ tests/posted/CMakeLists.txt | 64 ++++++++++ tests/posted/README.md | 58 ++++++++++ tests/posted/posted_tests.cc | 212 ++++++++++++++++++++++++++++++++++ 13 files changed, 629 insertions(+), 13 deletions(-) create mode 100644 src/services/postedversions.h create mode 100644 tests/posted/CMakeLists.txt create mode 100644 tests/posted/README.md create mode 100644 tests/posted/posted_tests.cc diff --git a/src/gxs/gxssecurity.cc b/src/gxs/gxssecurity.cc index 4874eeeb9..c98de26eb 100644 --- a/src/gxs/gxssecurity.cc +++ b/src/gxs/gxssecurity.cc @@ -531,6 +531,31 @@ bool GxsSecurity::validateNxsMsg(const RsNxsMsg& msg, const RsTlvKeySignature& s return false; } +bool GxsSecurity::getAdminSignature(const char* data, uint32_t size, + const RsTlvSecurityKeySet& keys, + RsTlvKeySignature& signature) +{ + for(const auto& entry : keys.private_keys) + if(entry.second.keyFlags & RSTLV_KEY_DISTRIB_ADMIN) + return getSignature(data, size, entry.second, signature); + return false; +} + +bool GxsSecurity::validateAdminSignature(const RsNxsMsg& msg, + const RsTlvSecurityKeySet& keys) +{ + if(!msg.metaData) return false; + const auto found = msg.metaData->signSet.keySignSet.find(ADMIN_SIGNATURE_INDEX); + if(found == msg.metaData->signSet.keySignSet.end()) return false; + // validateNxsMsg temporarily clears and restores the metadata signatures. + const RsTlvKeySignature signature = found->second; + for(const auto& entry : keys.public_keys) + if((entry.second.keyFlags & RSTLV_KEY_DISTRIB_ADMIN) + && validateNxsMsg(msg, signature, entry.second)) + return true; + return false; +} + bool GxsSecurity::encrypt(uint8_t *& out, uint32_t &outlen, const uint8_t *in, uint32_t inlen, const RsTlvPublicRSAKey& key) { #ifdef DISTRIB_DEBUG diff --git a/src/gxs/gxssecurity.h b/src/gxs/gxssecurity.h index 7b607c916..02904104d 100644 --- a/src/gxs/gxssecurity.h +++ b/src/gxs/gxssecurity.h @@ -95,6 +95,13 @@ class GxsSecurity */ static bool validateNxsMsg(const RsNxsMsg& msg, const RsTlvKeySignature& sign, const RsTlvPublicRSAKey &key); + static constexpr uint32_t ADMIN_SIGNATURE_INDEX = 0x00000040; + static bool getAdminSignature(const char* data, uint32_t size, + const RsTlvSecurityKeySet& keys, + RsTlvKeySignature& signature); + static bool validateAdminSignature(const RsNxsMsg& msg, + const RsTlvSecurityKeySet& keys); + /*! * @param data data to be signed diff --git a/src/gxs/rsgenexchange.cc b/src/gxs/rsgenexchange.cc index 15edd3208..9680ddd9c 100644 --- a/src/gxs/rsgenexchange.cc +++ b/src/gxs/rsgenexchange.cc @@ -61,7 +61,7 @@ static const uint32_t INDEX_AUTHEN_IDENTITY = 0x00000010; // identity static const uint32_t INDEX_AUTHEN_PUBLISH = 0x00000020; // publish key -static const uint32_t INDEX_AUTHEN_ADMIN = 0x00000040; // admin key +static const uint32_t INDEX_AUTHEN_ADMIN = GxsSecurity::ADMIN_SIGNATURE_INDEX; static const uint32_t MSG_CLEANUP_PERIOD = 60*59; // 59 minutes static const uint32_t INTEGRITY_CHECK_PERIOD = 60*31; // 31 minutes @@ -643,6 +643,16 @@ int RsGenExchange::createGroupSignatures(RsTlvKeySignatureSet& signSet, RsTlvBin int RsGenExchange::createMsgSignatures(RsTlvKeySignatureSet& signSet, RsTlvBinaryData& msgData, const RsGxsMsgMetaData& msgMeta, const RsGxsGrpMetaData& grpMeta) { + if(service_requiresAdminSignature(msgMeta)) + { + RsTlvKeySignature signature; + if(!GxsSecurity::getAdminSignature( + static_cast(msgData.bin_data), msgData.bin_len, + grpMeta.keys, signature)) + return SIGN_FAIL; + signSet.keySignSet[INDEX_AUTHEN_ADMIN] = signature; + } + uint32_t grpFlag = grpMeta.mGroupFlags; #ifdef GEN_EXCH_DEBUG @@ -835,6 +845,10 @@ int RsGenExchange::createMessage(RsNxsMsg* msg) int RsGenExchange::validateMsg(RsNxsMsg *msg, const uint32_t& grpFlag, const uint32_t& /*signFlag*/, RsTlvSecurityKeySet& grpKeySet) { + if(service_requiresAdminSignature(*msg->metaData) + && !GxsSecurity::validateAdminSignature(*msg, grpKeySet)) + return VALIDATE_FAIL; + // 1 - determine which signatures are needed, by looking for the flags corresponding to the // type of message we have, in the authentication policy of the service diff --git a/src/gxs/rsgenexchange.h b/src/gxs/rsgenexchange.h index 257b7bc5f..e10eec7f5 100644 --- a/src/gxs/rsgenexchange.h +++ b/src/gxs/rsgenexchange.h @@ -636,9 +636,14 @@ public: */ void deleteMsgs(uint32_t& token, const GxsMsgReq& msgs); -protected: - /*! - * This represents the group before its signature is calculated +protected: + /** Additional message authorization, enforced on both publishing and + * receiving. Services opt in for operations reserved to group admins. */ + virtual bool service_requiresAdminSignature(const RsGxsMsgMetaData&) const + { return false; } + + /*! + * This represents the group before its signature is calculated * Reimplement this function if you need to access keys to further extend * security of your group items using keyset properties * Derived service should return one of three ServiceCreate_Return enum values below diff --git a/src/retroshare/rsposted.h b/src/retroshare/rsposted.h index f5128cbd5..26fe262aa 100644 --- a/src/retroshare/rsposted.h +++ b/src/retroshare/rsposted.h @@ -32,6 +32,7 @@ #include "retroshare/rsgxscommon.h" #include "retroshare/rsgxscircles.h" #include "serialiser/rsserializable.h" +#include "serialiser/rstlvidset.h" class RsPosted; @@ -45,6 +46,7 @@ struct RsPostedGroup: public RsSerializable, RsGxsGenericGroupData { std::string mDescription; RsGxsImage mGroupImage; + RsTlvGxsMsgIdSet mPinnedPosts; /// @see RsSerializable virtual void serial_process( RsGenericSerializer::SerializeJob j, @@ -53,6 +55,7 @@ struct RsPostedGroup: public RsSerializable, RsGxsGenericGroupData RS_SERIAL_PROCESS(mMeta); RS_SERIAL_PROCESS(mDescription); RS_SERIAL_PROCESS(mGroupImage); + RS_SERIAL_PROCESS(mPinnedPosts); } }; @@ -66,6 +69,10 @@ struct RsPostedPost: public RsSerializable, RsGxsGenericMsgData std::string mLink; std::string mNotes; + // Latest revision shown by the blocking content APIs; mMeta retains the + // original thread identity, author, timestamp, read status and vote cache. + RsGxsMessageId mRevisionId; + bool mHaveVoted; // Calculated. @@ -89,6 +96,7 @@ struct RsPostedPost: public RsSerializable, RsGxsGenericMsgData RS_SERIAL_PROCESS(mMeta); RS_SERIAL_PROCESS(mLink); RS_SERIAL_PROCESS(mNotes); + RS_SERIAL_PROCESS(mRevisionId); RS_SERIAL_PROCESS(mHaveVoted); RS_SERIAL_PROCESS(mUpVotes); RS_SERIAL_PROCESS(mDownVotes); @@ -310,6 +318,8 @@ public: * @param[in] image optional post image. * @param[out] postId id of the post after it's been generated * @param[out] error_message possible error message if the method returns false + * @param[in] origPostId Original post to edit; null creates a new post. + * Editing requires the board administrator key. * @return true if ok, false if an error occured (see error_message) */ virtual bool createPostV2(const RsGxsGroupId& boardId, @@ -319,7 +329,15 @@ public: const RsGxsId& authorId, const RsGxsImage& image, RsGxsMessageId& postId, - std::string& error_message) =0; + std::string& error_message, + const RsGxsMessageId& origPostId = RsGxsMessageId()) =0; + + /** Pin or unpin a post for everyone. Blocking, administrator-only API. + * @jsonapi{development} + */ + virtual bool setPostPinned(const RsGxsGroupId& boardId, + const RsGxsMessageId& postId, bool pinned, + std::string& errorMessage) =0; /** @brief Add a comment on a post or on another comment. Blocking API. * @jsonapi{development} diff --git a/src/rsitems/rsposteditems.cc b/src/rsitems/rsposteditems.cc index 3c16ff5bd..018c0e5d7 100644 --- a/src/rsitems/rsposteditems.cc +++ b/src/rsitems/rsposteditems.cc @@ -49,10 +49,20 @@ void RsGxsPostedGroupItem::serial_process(RsGenericSerializer::SerializeJob j,Rs if(j == RsGenericSerializer::DESERIALIZE && ctx.mOffset == ctx.mSize) return ; - if((j == RsGenericSerializer::SIZE_ESTIMATE || j == RsGenericSerializer::SERIALIZE) && mGroupImage.empty()) + if((j == RsGenericSerializer::SIZE_ESTIMATE || j == RsGenericSerializer::SERIALIZE) + && mGroupImage.empty() && mPinnedPosts.ids.empty()) return ; RsTypeSerializer::serial_process(j,ctx,mGroupImage,"mGroupImage") ; + + // Older groups end after the description or image. With pins, even an + // empty image must be written so the trailing fields remain unambiguous. + if(j == RsGenericSerializer::DESERIALIZE && ctx.mOffset == ctx.mSize) + return; + if((j == RsGenericSerializer::SIZE_ESTIMATE || j == RsGenericSerializer::SERIALIZE) + && mPinnedPosts.ids.empty()) + return; + RsTypeSerializer::serial_process(j,ctx,mPinnedPosts,"mPinnedPosts"); } RsItem *RsGxsPostedSerialiser::create_item(uint16_t service_id,uint8_t item_subtype) const @@ -119,6 +129,7 @@ void RsGxsPostedGroupItem::clear() { mDescription.clear(); mGroupImage.TlvClear(); + mPinnedPosts.TlvClear(); } bool RsGxsPostedGroupItem::fromPostedGroup(RsPostedGroup &group, bool moveImage) @@ -126,6 +137,7 @@ bool RsGxsPostedGroupItem::fromPostedGroup(RsPostedGroup &group, bool moveImage) clear(); meta = group.mMeta; mDescription = group.mDescription; + mPinnedPosts = group.mPinnedPosts; if (moveImage) { @@ -144,6 +156,7 @@ bool RsGxsPostedGroupItem::toPostedGroup(RsPostedGroup &group, bool moveImage) { group.mMeta = meta; group.mDescription = mDescription; + group.mPinnedPosts = mPinnedPosts; if (moveImage) { group.mGroupImage.take((uint8_t *) mGroupImage.binData.bin_data, mGroupImage.binData.bin_len); diff --git a/src/rsitems/rsposteditems.h b/src/rsitems/rsposteditems.h index 910471c19..a9dc05cfd 100644 --- a/src/rsitems/rsposteditems.h +++ b/src/rsitems/rsposteditems.h @@ -48,6 +48,7 @@ public: std::string mDescription; RsTlvImage mGroupImage; + RsTlvGxsMsgIdSet mPinnedPosts; }; diff --git a/src/services/p3posted.cc b/src/services/p3posted.cc index 87785e616..51015e17c 100644 --- a/src/services/p3posted.cc +++ b/src/services/p3posted.cc @@ -20,6 +20,8 @@ * * *******************************************************************************/ #include "services/p3posted.h" +#include "services/postedversions.h" +#include "gxs/gxssecurity.h" #include "retroshare/rsgxscircles.h" #include "retroshare/rspeers.h" #include "rsitems/rsposteditems.h" @@ -69,6 +71,12 @@ bool p3Posted::groupShareKeys(const RsGxsGroupId& groupId,const std::set &groups) { std::vector grpData; @@ -120,6 +128,32 @@ bool p3Posted::getPostData( for(; mit != msgData.end(); ++mit) { std::vector& msgItems = mit->second; + + // Recheck revisions already in the database too: older clients could + // store edits before admin signatures were mandatory on receipt. + GxsMsgReq revisionIds; + for(const auto* item : msgItems) + if(dynamic_cast(item) + && !item->meta.mOrigMsgId.isNull() + && item->meta.mOrigMsgId != item->meta.mMsgId) + revisionIds[mit->first].insert(item->meta.mMsgId); + std::set authenticated; + if(!revisionIds.empty()) + { + RsTlvSecurityKeySet keys; + if(getGroupKeys(mit->first, keys)) + { + GxsMsgResult revisions; + getDataStore()->retrieveNxsMsgs(revisionIds, revisions, true); + for(auto& group : revisions) + for(auto* revision : group.second) + { + if(revision && GxsSecurity::validateAdminSignature(*revision, keys)) + authenticated.insert(revision->msgId); + delete revision; + } + } + } std::vector::iterator vit = msgItems.begin(); for(; vit != msgItems.end(); ++vit) @@ -129,6 +163,14 @@ bool p3Posted::getPostData( if(postItem) { + const auto& meta = postItem->meta; + if(!meta.mParentId.isNull() + || (!meta.mOrigMsgId.isNull() && meta.mOrigMsgId != meta.mMsgId + && !authenticated.count(meta.mMsgId))) + { + delete postItem; + continue; + } // TODO Really needed all of these lines? RsPostedPost msg = postItem->mPost; msg.mMeta = postItem->meta; @@ -380,7 +422,9 @@ bool p3Posted::getBoardAllContent( const RsGxsGroupId& groupId, if( !requestMsgInfo(token, opts, std::list({groupId})) || waitToken(token) != RsTokenService::COMPLETE ) return false; - return getPostData(token, posts, comments, votes); + if(!getPostData(token, posts, comments, votes)) return false; + PostedVersions::resolve(posts); + return true; } bool p3Posted::getRelatedComments( const RsGxsGroupId& gid,const std::set& messageIds, std::vector &comments ) @@ -405,6 +449,7 @@ bool p3Posted::getBoardContent( const RsGxsGroupId& groupId, std::vector& comments, std::vector& votes ) { + if(contentsIds.empty()) return getBoardAllContent(groupId, posts, comments, votes); uint32_t token; RsTokReqOptions opts; opts.mReqType = GXS_REQUEST_TYPE_MSG_DATA; @@ -412,10 +457,24 @@ bool p3Posted::getBoardContent( const RsGxsGroupId& groupId, GxsMsgReq msgIds; msgIds[groupId] = contentsIds; + // Include every revision of requested posts, but leave comment/vote + // requests alone. Fetch metadata first to avoid loading the whole board. + std::vector summaries; + if(!getBoardPostSummaries(groupId, summaries)) return false; + std::set roots; + for(const auto& meta : summaries) + if(contentsIds.count(meta.mMsgId)) + roots.insert(meta.mOrigMsgId.isNull() ? meta.mMsgId : meta.mOrigMsgId); + msgIds[groupId].insert(roots.begin(), roots.end()); + for(const auto& meta : summaries) + if(roots.count(meta.mOrigMsgId)) msgIds[groupId].insert(meta.mMsgId); + if( !requestMsgInfo(token, opts, msgIds) || waitToken(token) != RsTokenService::COMPLETE ) return false; - return getPostData(token, posts, comments, votes); + if(!getPostData(token, posts, comments, votes)) return false; + PostedVersions::resolve(posts); + return true; } bool p3Posted::getBoardPostSummaries( @@ -798,13 +857,16 @@ bool p3Posted::createPostV2(const RsGxsGroupId& boardId, const RsGxsId& authorId, const RsGxsImage& image, RsGxsMessageId& postId, - std::string& error_message) + std::string& error_message, + const RsGxsMessageId& origPostId) { + error_message.clear(); // check boardId std::vector groupsInfo; - if(!getBoardsInfo( { boardId }, groupsInfo)) + if(boardId.isNull() || !getBoardsInfo( { boardId }, groupsInfo) + || groupsInfo.size() != 1) { error_message = "Board with Id " + boardId.toStdString() + " does not exist."; RsErr() << error_message; @@ -821,6 +883,31 @@ bool p3Posted::createPostV2(const RsGxsGroupId& boardId, } RsPostedPost post; + if(title.empty()) + { + error_message = "Please add a title."; + return false; + } + + if(!origPostId.isNull()) + { + if(!IS_GROUP_ADMIN(groupsInfo.front().mMeta.mSubscribeFlags)) + { + error_message = "Only a board administrator can edit posts."; + return false; + } + + std::vector originals; + std::vector comments; + std::vector votes; + if(!getBoardContent(boardId, {origPostId}, originals, comments, votes) + || originals.size() != 1) + { + error_message = "The original post is not available locally."; + return false; + } + post.mMeta.mOrigMsgId = originals.front().mMeta.mMsgId; + } post.mMeta.mGroupId = boardId; post.mLink = link.toString(); post.mImage = image; @@ -833,13 +920,64 @@ bool p3Posted::createPostV2(const RsGxsGroupId& boardId, RsGenericSerializer::SerializeContext ctx; post.serial_process(RsGenericSerializer::SIZE_ESTIMATE,ctx); - if(ctx.mSize > 200000) { + if(!ctx.mOk || ctx.mOffset > 200000) { error_message = "Maximum size of 200000 bytes exceeded for board post."; RsErr() << error_message; return false; } - return createPost(post,postId); + if(!createPost(post,postId)) + { + error_message = "Failed to publish the post. Check your signing identity and board permissions."; + return false; + } + return true; +} + +bool p3Posted::setPostPinned(const RsGxsGroupId& boardId, + const RsGxsMessageId& postId, bool pinned, + std::string& errorMessage) +{ + RS_STACK_MUTEX(mPinUpdateMutex); + errorMessage.clear(); + std::vector groups; + if(boardId.isNull() || postId.isNull() + || !getBoardsInfo({boardId}, groups) || groups.size() != 1) + { + errorMessage = "Board or post not found."; + return false; + } + auto& board = groups.front(); + if(!IS_GROUP_ADMIN(board.mMeta.mSubscribeFlags)) + { + errorMessage = "Only a board administrator can pin posts."; + return false; + } + + RsGxsMessageId originalId = postId; + if(pinned) + { + std::vector posts; + std::vector comments; + std::vector votes; + if(!getBoardContent(boardId, {postId}, posts, comments, votes) + || posts.size() != 1) + { + errorMessage = "The post is not available locally."; + return false; + } + originalId = posts.front().mMeta.mMsgId; + } + // Unpinning remains possible even after a post expires locally. + if(pinned == (board.mPinnedPosts.ids.count(originalId) != 0)) return true; + if(pinned) board.mPinnedPosts.ids.insert(originalId); + else board.mPinnedPosts.ids.erase(originalId); + if(!editBoard(board)) + { + errorMessage = "Failed to update the board's pinned posts."; + return false; + } + return true; } bool p3Posted::createCommentV2( diff --git a/src/services/p3posted.h b/src/services/p3posted.h index ed86af27e..a3afca631 100644 --- a/src/services/p3posted.h +++ b/src/services/p3posted.h @@ -101,7 +101,12 @@ virtual void receiveHelperChanges(std::vector& changes) const RsGxsId& authorId, const RsGxsImage& image, RsGxsMessageId& postId, - std::string& error_message) override; + std::string& error_message, + const RsGxsMessageId& origPostId = RsGxsMessageId()) override; + + bool setPostPinned(const RsGxsGroupId& boardId, + const RsGxsMessageId& postId, bool pinned, + std::string& errorMessage) override; bool voteForPost(const RsGxsGroupId& boardId, const RsGxsMessageId& postMsgId, @@ -195,6 +200,8 @@ virtual void receiveHelperChanges(std::vector& changes) } protected: + bool service_requiresAdminSignature(const RsGxsMsgMetaData& meta) const override; + virtual void notifyChanges(std::vector& changes) override { return p3PostBase::notifyChanges(changes); @@ -202,6 +209,7 @@ protected: private: + RsMutex mPinUpdateMutex{"Posted pin updates"}; // private part of blocking API bool vote(const RsGxsVote& vote,RsGxsMessageId& voteId,std::string& errorMessage); }; diff --git a/src/services/postedversions.h b/src/services/postedversions.h new file mode 100644 index 000000000..0be6cda24 --- /dev/null +++ b/src/services/postedversions.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma once + +#include "retroshare/rsposted.h" +#include + +namespace PostedVersions +{ +/** Resolve already authenticated revisions without moving comments or votes + * to a new thread. An orphan revision is hidden until its original arrives. */ +inline void resolve(std::vector& posts) +{ + using Key = RsGxsGrpMsgIdPair; + std::map originals; + std::map latest; + for(const auto& post : posts) + { + const auto& meta = post.mMeta; + if(!meta.mParentId.isNull() || meta.mMsgId.isNull()) continue; + if(meta.mOrigMsgId.isNull() || meta.mOrigMsgId == meta.mMsgId) + originals[{meta.mGroupId, meta.mMsgId}] = &post; + else + { + auto& revision = latest[{meta.mGroupId, meta.mOrigMsgId}]; + if(!revision || revision->mMeta.mPublishTs < meta.mPublishTs + || (revision->mMeta.mPublishTs == meta.mPublishTs + && revision->mMeta.mMsgId < meta.mMsgId)) + revision = &post; + } + } + + std::vector resolved; + resolved.reserve(originals.size()); + for(const auto& entry : originals) + { + auto post = *entry.second; + post.mRevisionId = post.mMeta.mMsgId; + const auto revision = latest.find(entry.first); + if(revision != latest.end() + && revision->second->mMeta.mPublishTs >= post.mMeta.mPublishTs) + { + const auto& edit = *revision->second; + post.mMeta.mMsgName = edit.mMeta.mMsgName; + post.mLink = edit.mLink; + post.mNotes = edit.mNotes; + post.mImage = edit.mImage; + post.mRevisionId = edit.mMeta.mMsgId; + } + resolved.push_back(std::move(post)); + } + posts.swap(resolved); +} +} diff --git a/tests/posted/CMakeLists.txt b/tests/posted/CMakeLists.txt new file mode 100644 index 000000000..1befd3ad3 --- /dev/null +++ b/tests/posted/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.18) +project(posted_regression_tests LANGUAGES CXX) +enable_testing() +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +get_filename_component(RS_ROOT "../.." ABSOLUTE) +set(RAPIDJSON_INCLUDE_DIR "${RS_ROOT}/../supportlibs/rapidjson/include" CACHE PATH "RapidJSON headers") +set(RNP_INCLUDE_DIR "${RS_ROOT}/../supportlibs/librnp/include" CACHE PATH "RNP headers") +find_package(OpenSSL REQUIRED) + +add_library(posted_test_support STATIC + ${RS_ROOT}/src/gxs/gxssecurity.cc + ${RS_ROOT}/src/gxs/rsgxsdata.cc + ${RS_ROOT}/src/rsitems/rsnxsitems.cc + ${RS_ROOT}/src/services/p3gxscommon.cc + ${RS_ROOT}/src/rsitems/rsposteditems.cc + ${RS_ROOT}/src/rsitems/rsgxscommentitems.cc + ${RS_ROOT}/src/rsitems/rsgxsitems.cc + ${RS_ROOT}/src/serialiser/rsserializable.cc + ${RS_ROOT}/src/serialiser/rsbaseserial.cc + ${RS_ROOT}/src/serialiser/rsserial.cc + ${RS_ROOT}/src/serialiser/rsserializer.cc + ${RS_ROOT}/src/serialiser/rstypeserializer.cc + ${RS_ROOT}/src/serialiser/rstlvbase.cc + ${RS_ROOT}/src/serialiser/rstlvbinary.cc + ${RS_ROOT}/src/serialiser/rstlvimage.cc + ${RS_ROOT}/src/serialiser/rstlvitem.cc + ${RS_ROOT}/src/serialiser/rstlvkeys.cc + ${RS_ROOT}/src/util/rsdebug.cc + ${RS_ROOT}/src/util/rsjson.cc + ${RS_ROOT}/src/util/rsstacktrace.cc + ${RS_ROOT}/src/util/rsbase64.cc + ${RS_ROOT}/src/util/rsprint.cc + ${RS_ROOT}/src/util/rsthreads.cc + ${RS_ROOT}/src/util/rsrandom.cc + ${RS_ROOT}/src/util/rsdir.cc + ${RS_ROOT}/src/util/rstime.cc + ${RS_ROOT}/src/util/smallobject.cc) +target_include_directories(posted_test_support PUBLIC ${RS_ROOT}/src ${RAPIDJSON_INCLUDE_DIR} + ${RNP_INCLUDE_DIR} ${CMAKE_CURRENT_BINARY_DIR}) +target_link_libraries(posted_test_support PUBLIC OpenSSL::Crypto) +target_compile_options(posted_test_support PUBLIC + -Wno-deprecated-declarations -Wno-inconsistent-missing-override -Wno-cpp) +target_compile_options(posted_test_support PRIVATE -ffunction-sections -fdata-sections) + +# RNP's public headers include this generated export header, even though +# these tests use only RetroShare's OpenSSL message-signature implementation. +include(GenerateExportHeader) +generate_export_header(posted_test_support BASE_NAME rnp EXPORT_MACRO_NAME RNP_API + EXPORT_FILE_NAME rnp/rnp_export.h STATIC_DEFINE RNP_STATIC INCLUDE_GUARD_NAME RNP_EXPORT) + +add_library(posted_service_compile_check OBJECT + ${RS_ROOT}/src/services/p3posted.cc ${RS_ROOT}/src/gxs/rsgenexchange.cc) +target_link_libraries(posted_service_compile_check PRIVATE posted_test_support) + +add_executable(posted_tests posted_tests.cc) +target_link_libraries(posted_tests PRIVATE posted_test_support) +if(APPLE) + target_link_options(posted_tests PRIVATE -Wl,-dead_strip) +else() + target_link_options(posted_tests PRIVATE -Wl,--gc-sections) +endif() +add_test(NAME posted_regressions COMMAND posted_tests) diff --git a/tests/posted/README.md b/tests/posted/README.md new file mode 100644 index 000000000..22baeb0b6 --- /dev/null +++ b/tests/posted/README.md @@ -0,0 +1,58 @@ +# Posted Regression Tests + +These tests build the Posted item serializer, revision resolver and GXS admin +signature implementation without Qt or a running RetroShare node. They also +compile the Posted service and shared GXS exchange implementation. + +From the RetroShare checkout, initialize the header dependencies and run: + +```sh +git submodule update --init supportlibs/rapidjson supportlibs/librnp +cmake -S libretroshare/tests/posted -B build-posted-tests +cmake --build build-posted-tests -j4 +ctest --test-dir build-posted-tests --output-on-failure +``` + +OpenSSL development files and a C++17 compiler are required. Set +`OPENSSL_ROOT_DIR`, `RAPIDJSON_INCLUDE_DIR` or `RNP_INCLUDE_DIR` if necessary. + +Coverage includes legacy groups with and without images, pinned groups with and +without images, truncated pin data, deterministic latest-revision selection, +missing originals, group isolation, original thread state and image retention, +valid admin signatures, absent signatures, tampered messages, publisher-only +keys and incorrect admin keys. + +## Behavior and Compatibility + +`createPostV2` accepts an optional final `origPostId` argument, preserving existing +call sites. Edits require both an owned signing identity and the board admin key. +Revisions receive an additional admin signature that updated peers verify. +Unsigned revisions in pre-existing local databases are excluded from post data. + +The blocking content APIs return one post per original thread. Its title, body, +link and image come from the newest authenticated revision. `mRevisionId` +identifies that revision; `mMeta.mMsgId`, author, timestamp, read state, comments +and votes remain associated with the original. Revisions without a locally +available original are hidden until it arrives. Low-level token APIs still +return individual authenticated versions. + +Pins live in the admin-signed board group and refer to original message IDs. +Old groups remain readable, and unpinned groups retain their old wire layout. +Older clients do not implement pinning or revision display; use updated clients +on both peers to verify these features. + +## GUI Checks + +After building the Qt GUI, use an admin node and a subscribed non-admin node: + +1. Create posts containing text, links and static or animated images. Edit each + field, remove an image, and edit a post a second time. Confirm only one post + appears and existing comments, votes and links still work. +2. Right-click a post while another post is selected. Confirm Edit Post and + Pin Post affect the right-clicked post. Non-admins must not see those actions. +3. Pin several posts, select New, Top and Hot, and use search and pagination. + Matching pinned posts must precede matching unpinned posts in every sort. +4. Unpin a post, restart both nodes and edit the board description. Confirm the + pin list survives reload, synchronization and unrelated board edits. +5. Cause a publish failure. Confirm the dialog preserves text and image data, + displays an error, and allows retrying without duplicate submissions. diff --git a/tests/posted/posted_tests.cc b/tests/posted/posted_tests.cc new file mode 100644 index 000000000..94db423e7 --- /dev/null +++ b/tests/posted/posted_tests.cc @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#include "rsitems/rsposteditems.h" +#include "services/postedversions.h" +#include "gxs/gxssecurity.h" +#include "gxs/rsgxsdata.h" +#include "rsitems/rsnxsitems.h" +#include +#include +#include + +#define CHECK(condition) do { if(!(condition)) { \ + std::cerr << __LINE__ << ": " << #condition << std::endl; std::exit(1); \ +} } while(false) + +static RsGxsMessageId id(char c) { return RsGxsMessageId(std::string(40, c)); } + +static std::vector encode(RsGxsPostedGroupItem& item) +{ + RsGenericSerializer::SerializeContext size; + item.serial_process(RsGenericSerializer::SIZE_ESTIMATE, size); + std::vector bytes(size.mOffset); + RsGenericSerializer::SerializeContext out(bytes.data(), bytes.size()); + item.serial_process(RsGenericSerializer::SERIALIZE, out); + CHECK(out.mOk && out.mOffset == bytes.size()); + return bytes; +} + +static void serialization() +{ + for(bool image : {false, true}) + for(bool pins : {false, true}) + { + RsPostedGroup group; + group.mDescription = "Board description"; + uint8_t data[] = {1, 2, 3, 4}; + if(image) group.mGroupImage.copy(data, sizeof(data)); + if(pins) group.mPinnedPosts.ids = {id('1'), id('2')}; + RsGxsPostedGroupItem item; + CHECK(item.fromPostedGroup(group, false)); + const auto bytes = encode(item); + if(!pins) + CHECK(bytes.size() == GetTlvStringSize(group.mDescription) + + (image ? item.mGroupImage.TlvSize() : 0)); + + RsGxsPostedGroupItem decoded; + RsGenericSerializer::SerializeContext in( + const_cast(bytes.data()), bytes.size()); + decoded.serial_process(RsGenericSerializer::DESERIALIZE, in); + CHECK(in.mOk && in.mOffset == bytes.size()); + RsPostedGroup restored; + CHECK(decoded.toPostedGroup(restored, true)); + CHECK(restored.mDescription == group.mDescription); + CHECK(restored.mPinnedPosts.ids == group.mPinnedPosts.ids); + CHECK(restored.mGroupImage.mSize == group.mGroupImage.mSize); + if(image) CHECK(!memcmp(restored.mGroupImage.mData, data, sizeof(data))); + item.clear(); + CHECK(item.mPinnedPosts.ids.empty()); + } + + RsGxsPostedGroupItem pinned; + pinned.mPinnedPosts.ids.insert(id('1')); + auto bytes = encode(pinned); + bytes.pop_back(); + RsGxsPostedGroupItem truncated; + RsGenericSerializer::SerializeContext in(bytes.data(), bytes.size()); + truncated.serial_process(RsGenericSerializer::DESERIALIZE, in); + CHECK(!in.mOk); +} + +static void revisions() +{ + RsPostedPost original; + original.mMeta.mGroupId = RsGxsGroupId(std::string(32, '1')); + original.mMeta.mMsgId = id('1'); + original.mMeta.mPublishTs = 100; + original.mMeta.mAuthorId = RsGxsId(std::string(32, '1')); + original.mMeta.mMsgName = "Original"; + original.mMeta.mMsgStatus = GXS_SERV::GXS_MSG_STATUS_VOTE_UP; + original.mUpVotes = 9; + original.mComments = 3; + original.mHaveVoted = true; + + auto edit = original; + edit.mMeta.mMsgId = id('2'); + edit.mMeta.mOrigMsgId = original.mMeta.mMsgId; + edit.mMeta.mAuthorId = RsGxsId(std::string(32, '2')); + edit.mMeta.mPublishTs = 101; + edit.mMeta.mMsgName = "Edited"; + edit.mNotes = "New notes"; + edit.mLink = "https://example.org/edited"; + uint8_t data[] = {5, 6, 7}; + edit.mImage.copy(data, sizeof(data)); + edit.mUpVotes = edit.mComments = 0; + + auto newest = edit; + newest.mMeta.mMsgId = id('3'); + newest.mMeta.mMsgName = "Newest"; + // A deterministic tie-break is required when edits share a timestamp. + std::vector input = {original, edit, newest}; + do + { + auto posts = input; + PostedVersions::resolve(posts); + CHECK(posts.size() == 1); + const auto& post = posts.front(); + CHECK(post.mMeta.mMsgId == original.mMeta.mMsgId); + CHECK(post.mMeta.mAuthorId == original.mMeta.mAuthorId); + CHECK(post.mMeta.mPublishTs == original.mMeta.mPublishTs); + CHECK(post.mMeta.mMsgStatus == original.mMeta.mMsgStatus); + CHECK(post.mRevisionId == newest.mMeta.mMsgId); + CHECK(post.mMeta.mMsgName == "Newest"); + CHECK(post.mNotes == edit.mNotes && post.mLink == edit.mLink); + CHECK(post.mImage.mSize == sizeof(data)); + CHECK(!memcmp(post.mImage.mData, data, sizeof(data))); + CHECK(post.mUpVotes == 9 && post.mComments == 3 && post.mHaveVoted); + } while(std::next_permutation(input.begin(), input.end(), + [](const RsPostedPost& a, const RsPostedPost& b) + { return a.mMeta.mMsgId < b.mMeta.mMsgId; })); + + std::vector orphan = {edit}; + PostedVersions::resolve(orphan); + CHECK(orphan.empty()); + edit.mMeta.mParentId = id('4'); + orphan = {original, edit}; + PostedVersions::resolve(orphan); + CHECK(orphan.size() == 1 && orphan.front().mMeta.mMsgName == "Original"); + + edit.mMeta.mParentId.clear(); + edit.mMeta.mGroupId = RsGxsGroupId(std::string(32, '2')); + orphan = {original, edit}; + PostedVersions::resolve(orphan); + CHECK(orphan.size() == 1 && orphan.front().mMeta.mMsgName == "Original"); + + original.mMeta.mOrigMsgId = original.mMeta.mMsgId; + orphan = {original}; + PostedVersions::resolve(orphan); + CHECK(orphan.size() == 1); +} + +static void adminSignatures() +{ + RsTlvPublicRSAKey publicKey; + RsTlvPrivateRSAKey privateKey; + CHECK(GxsSecurity::generateKeyPair(publicKey, privateKey)); + publicKey.keyFlags |= RSTLV_KEY_DISTRIB_ADMIN; + privateKey.keyFlags |= RSTLV_KEY_DISTRIB_ADMIN; + RsTlvSecurityKeySet keys; + keys.public_keys[publicKey.keyId] = publicKey; + keys.private_keys[privateKey.keyId] = privateKey; + + RsNxsMsg msg(RS_SERVICE_GXS_TYPE_POSTED); + msg.metaData = new RsGxsMsgMetaData; + msg.metaData->mGroupId = RsGxsGroupId(std::string(32, '1')); + msg.metaData->mOrigMsgId = id('1'); + msg.metaData->mAuthorId = RsGxsId(std::string(32, '2')); + msg.metaData->mMsgName = "Admin edit"; + msg.metaData->mPublishTs = time(nullptr); + const char payload[] = "Edited body"; + msg.msg.setBinData(payload, sizeof(payload)); + + uint32_t metaSize = msg.metaData->serial_size(); + std::vector data(sizeof(payload) + metaSize); + memcpy(data.data(), payload, sizeof(payload)); + CHECK(msg.metaData->serialise(data.data() + sizeof(payload), &metaSize)); + RsTlvKeySignature signature; + CHECK(GxsSecurity::getAdminSignature(data.data(), data.size(), keys, signature)); + CHECK(!GxsSecurity::validateAdminSignature(msg, keys)); + msg.metaData->signSet.keySignSet[GxsSecurity::ADMIN_SIGNATURE_INDEX] = signature; + msg.metaData->mMsgId = id('3'); + CHECK(GxsSecurity::validateAdminSignature(msg, keys)); + // Validation must preserve metadata and be repeatable. + CHECK(GxsSecurity::validateAdminSignature(msg, keys)); + CHECK(msg.metaData->mMsgId == id('3') && msg.metaData->mOrigMsgId == id('1')); + + msg.metaData->mMsgName = "Tampered"; + CHECK(!GxsSecurity::validateAdminSignature(msg, keys)); + msg.metaData->mMsgName = "Admin edit"; + msg.metaData->mOrigMsgId = id('4'); + CHECK(!GxsSecurity::validateAdminSignature(msg, keys)); + msg.metaData->mOrigMsgId = id('1'); + msg.metaData->mAuthorId = RsGxsId(std::string(32, '3')); + CHECK(!GxsSecurity::validateAdminSignature(msg, keys)); + msg.metaData->mAuthorId = RsGxsId(std::string(32, '2')); + static_cast(msg.msg.bin_data)[0] ^= 1; + CHECK(!GxsSecurity::validateAdminSignature(msg, keys)); + static_cast(msg.msg.bin_data)[0] ^= 1; + + auto publisherKeys = keys; + publisherKeys.private_keys.begin()->second.keyFlags &= ~RSTLV_KEY_DISTRIB_ADMIN; + publisherKeys.private_keys.begin()->second.keyFlags |= RSTLV_KEY_DISTRIB_PUBLISH; + publisherKeys.public_keys.begin()->second.keyFlags &= ~RSTLV_KEY_DISTRIB_ADMIN; + publisherKeys.public_keys.begin()->second.keyFlags |= RSTLV_KEY_DISTRIB_PUBLISH; + CHECK(!GxsSecurity::getAdminSignature(data.data(), data.size(), publisherKeys, signature)); + CHECK(!GxsSecurity::validateAdminSignature(msg, publisherKeys)); + keys.private_keys.clear(); + CHECK(!GxsSecurity::getAdminSignature(data.data(), data.size(), keys, signature)); + CHECK(GxsSecurity::validateAdminSignature(msg, keys)); + + CHECK(GxsSecurity::generateKeyPair(publicKey, privateKey)); + publicKey.keyFlags |= RSTLV_KEY_DISTRIB_ADMIN; + keys.public_keys.clear(); + keys.public_keys[publicKey.keyId] = publicKey; + CHECK(!GxsSecurity::validateAdminSignature(msg, keys)); +} + +int main() +{ + serialization(); + revisions(); + adminSignatures(); + std::cout << "Posted serialization, revision and admin-signature regressions passed" << std::endl; +}