Implement admin signature handling and pinning functionality for posted items

This commit is contained in:
Dhyey Mistri 2026-09-09 00:07:29 +05:30
parent 2f093fd7b3
commit 4d6005b646
13 changed files with 629 additions and 13 deletions

View File

@ -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

View File

@ -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

View File

@ -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<const char*>(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

View File

@ -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

View File

@ -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}

View File

@ -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<RsTlvItem>(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<RsTlvItem>(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);

View File

@ -48,6 +48,7 @@ public:
std::string mDescription;
RsTlvImage mGroupImage;
RsTlvGxsMsgIdSet mPinnedPosts;
};

View File

@ -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<RsPeerI
return true ;
}
bool p3Posted::service_requiresAdminSignature(const RsGxsMsgMetaData& meta) const
{
return meta.mParentId.isNull() && !meta.mOrigMsgId.isNull()
&& meta.mOrigMsgId != meta.mMsgId;
}
bool p3Posted::getGroupData(const uint32_t &token, std::vector<RsPostedGroup> &groups)
{
std::vector<RsGxsGrpItem*> grpData;
@ -120,6 +128,32 @@ bool p3Posted::getPostData(
for(; mit != msgData.end(); ++mit)
{
std::vector<RsGxsMsgItem*>& 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<const RsGxsPostedPostItem*>(item)
&& !item->meta.mOrigMsgId.isNull()
&& item->meta.mOrigMsgId != item->meta.mMsgId)
revisionIds[mit->first].insert(item->meta.mMsgId);
std::set<RsGxsMessageId> 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<RsGxsMsgItem*>::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<RsGxsGroupId>({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<RsGxsMessageId>& messageIds, std::vector<RsGxsComment> &comments )
@ -405,6 +449,7 @@ bool p3Posted::getBoardContent( const RsGxsGroupId& groupId,
std::vector<RsGxsComment>& comments,
std::vector<RsGxsVote>& 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<RsMsgMetaData> summaries;
if(!getBoardPostSummaries(groupId, summaries)) return false;
std::set<RsGxsMessageId> 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<RsPostedGroup> 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<RsPostedPost> originals;
std::vector<RsGxsComment> comments;
std::vector<RsGxsVote> 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<RsPostedGroup> 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<RsPostedPost> posts;
std::vector<RsGxsComment> comments;
std::vector<RsGxsVote> 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(

View File

@ -101,7 +101,12 @@ virtual void receiveHelperChanges(std::vector<RsGxsNotify*>& 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<RsGxsNotify*>& changes)
}
protected:
bool service_requiresAdminSignature(const RsGxsMsgMetaData& meta) const override;
virtual void notifyChanges(std::vector<RsGxsNotify*>& 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);
};

View File

@ -0,0 +1,53 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
#pragma once
#include "retroshare/rsposted.h"
#include <map>
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<RsPostedPost>& posts)
{
using Key = RsGxsGrpMsgIdPair;
std::map<Key, const RsPostedPost*> originals;
std::map<Key, const RsPostedPost*> 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<RsPostedPost> 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);
}
}

View File

@ -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)

58
tests/posted/README.md Normal file
View File

@ -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.

View File

@ -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 <algorithm>
#include <cstdlib>
#include <iostream>
#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<uint8_t> encode(RsGxsPostedGroupItem& item)
{
RsGenericSerializer::SerializeContext size;
item.serial_process(RsGenericSerializer::SIZE_ESTIMATE, size);
std::vector<uint8_t> 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<uint8_t*>(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<RsPostedPost> 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<RsPostedPost> 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<char> 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<char*>(msg.msg.bin_data)[0] ^= 1;
CHECK(!GxsSecurity::validateAdminSignature(msg, keys));
static_cast<char*>(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;
}