Skip to content

Commit

Permalink
feat: encrypt CommunityDescription fields
Browse files Browse the repository at this point in the history
Extended `CommunityDescription` with a `privateData` map. This map
associates each hash ratchet `key_id` and `seq_no` with an encrypted
`CommunityDescription`. Each encrypted instance includes only data
requiring encryption.

This commit introduces a description encryption for closed communities.
The map is pupulated with a single entry. The key is hash ratchet for
community-level encryption, and the value is an ecnrypted
`CommunityDescription` defining only two fields: `chats` and `members`.

As a follow-up, channel-level description encryption will be
implemented. Each channel will have its unique entry in the map, with
the key being a hash ratchet specific to channel-level encryption.

closes: status-im/status-desktop#12851
closes: status-im/status-desktop#12852
  • Loading branch information
osmaczko committed Dec 15, 2023
1 parent 93aeefc commit b95e178
Show file tree
Hide file tree
Showing 17 changed files with 2,008 additions and 2,837 deletions.
65 changes: 37 additions & 28 deletions protocol/communities/community.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ type Community struct {
config *Config
mutex sync.Mutex
timesource common.TimeSource
encryptor DescriptionEncryptor
}

func New(config Config, timesource common.TimeSource) (*Community, error) {
func New(config Config, timesource common.TimeSource, encryptor DescriptionEncryptor) (*Community, error) {
if config.MemberIdentity == nil {
return nil, errors.New("no member identity")
}
Expand All @@ -70,6 +71,10 @@ func New(config Config, timesource common.TimeSource) (*Community, error) {
return nil, errors.New("no timesource")
}

if encryptor == nil {
return nil, errors.New("no encryptor")
}

if config.Logger == nil {
logger, err := zap.NewDevelopment()
if err != nil {
Expand All @@ -78,9 +83,11 @@ func New(config Config, timesource common.TimeSource) (*Community, error) {
config.Logger = logger
}

community := &Community{config: &config, timesource: timesource}
community.initialize()
return community, nil
if config.CommunityDescription == nil {
config.CommunityDescription = &protobuf.CommunityDescription{}
}

return &Community{config: &config, timesource: timesource, encryptor: encryptor}, nil
}

type CommunityAdminSettings struct {
Expand Down Expand Up @@ -494,13 +501,6 @@ func (o *Community) GetMemberPubkeys() []*ecdsa.PublicKey {
return nil
}

func (o *Community) initialize() {
if o.config.CommunityDescription == nil {
o.config.CommunityDescription = &protobuf.CommunityDescription{}

}
}

type CommunitySettings struct {
CommunityID string `json:"communityId"`
HistoryArchiveSupportEnabled bool `json:"historyArchiveSupportEnabled"`
Expand Down Expand Up @@ -1370,11 +1370,20 @@ func (o *Community) Description() *protobuf.CommunityDescription {
}

func (o *Community) marshaledDescription() ([]byte, error) {
clone := proto.Clone(o.config.CommunityDescription).(*protobuf.CommunityDescription)

// This is only workaround to lower the size of the message that goes over the wire,
// see https://github.com/status-im/status-desktop/issues/12188
clone := o.CreateDeepCopy()
clone.DehydrateChannelsMembers()
return proto.Marshal(clone.config.CommunityDescription)
dehydrateChannelsMembers(o.IDString(), clone)

if o.encryptor != nil && o.Encrypted() {
err := encryptDescription(o.encryptor, o, clone)
if err != nil {
return nil, err
}
}

return proto.Marshal(clone)
}

func (o *Community) MarshaledDescription() ([]byte, error) {
Expand Down Expand Up @@ -1412,28 +1421,28 @@ func (o *Community) ToProtocolMessageBytes() ([]byte, error) {
return o.toProtocolMessageBytes()
}

func (o *Community) DehydrateChannelsMembers() {
// To save space, we don't attach members for channels without permissions,
// otherwise the message will hit waku msg size limit.
for channelID, channel := range o.chats() {
if !o.ChannelHasTokenPermissions(o.ChatID(channelID)) {
channel.Members = map[string]*protobuf.CommunityMember{} // clean members
func channelHasTokenPermissions(communityID string, channelID string, permissions map[string]*protobuf.CommunityTokenPermission) bool {
for _, tokenPermission := range permissions {
if includes(tokenPermission.ChatIds, communityID+channelID) {
return true
}
}
return false
}

func HydrateChannelsMembers(communityID string, description *protobuf.CommunityDescription) {
channelHasTokenPermissions := func(channelID string) bool {
for _, tokenPermission := range description.TokenPermissions {
if includes(tokenPermission.ChatIds, communityID+channelID) {
return true
}
func dehydrateChannelsMembers(communityID string, description *protobuf.CommunityDescription) {
// To save space, we don't attach members for channels without permissions,
// otherwise the message will hit waku msg size limit.
for channelID, channel := range description.Chats {
if !channelHasTokenPermissions(communityID, channelID, description.TokenPermissions) {
channel.Members = map[string]*protobuf.CommunityMember{} // clean members
}
return false
}
}

func hydrateChannelsMembers(communityID string, description *protobuf.CommunityDescription) {
for channelID, channel := range description.Chats {
if !channelHasTokenPermissions(channelID) {
if !channelHasTokenPermissions(communityID, channelID, description.TokenPermissions) {
channel.Members = make(map[string]*protobuf.CommunityMember)
for pubKey, member := range description.Members {
channel.Members[pubKey] = member
Expand Down
63 changes: 63 additions & 0 deletions protocol/communities/community_description_encryption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package communities

import (
"go.uber.org/zap"

"github.com/status-im/status-go/protocol/protobuf"
)

type DescriptionEncryptor interface {
encryptCommunityDescription(community *Community, d *protobuf.CommunityDescription) (string, []byte, error)
decryptCommunityDescription(keyIDSeqNo string, d []byte) (*protobuf.CommunityDescription, error)
}

// Encrypts members and chats
func encryptDescription(encryptor DescriptionEncryptor, community *Community, description *protobuf.CommunityDescription) error {
descriptionToEncrypt := &protobuf.CommunityDescription{
Members: description.Members,
Chats: description.Chats,
}

keyIDSeqNo, encryptedDescription, err := encryptor.encryptCommunityDescription(community, descriptionToEncrypt)
if err != nil {
return err
}

// Set private data and cleanup unencrypted members and chats
if description.PrivateData == nil {
description.PrivateData = make(map[string][]byte)
}
description.PrivateData[keyIDSeqNo] = encryptedDescription
description.Members = make(map[string]*protobuf.CommunityMember)
description.Chats = make(map[string]*protobuf.CommunityChat)

return nil
}

// Decrypts members and chats
func decryptDescription(encryptor DescriptionEncryptor, description *protobuf.CommunityDescription, logger *zap.Logger) error {
for keyIDSeqNo, encryptedDescription := range description.PrivateData {
decryptedDescription, err := encryptor.decryptCommunityDescription(keyIDSeqNo, encryptedDescription)
if err != nil {
// ignore error, try to decrypt next data
logger.Debug("failed to decrypt community private data", zap.String("keyIDSeqNo", keyIDSeqNo), zap.Error(err))
continue
}

for pk, member := range decryptedDescription.Members {
if description.Members == nil {
description.Members = make(map[string]*protobuf.CommunityMember)
}
description.Members[pk] = member
}

for id, channel := range decryptedDescription.Chats {
if description.Chats == nil {
description.Chats = make(map[string]*protobuf.CommunityChat)
}
description.Chats[id] = channel
}
}

return nil
}
106 changes: 106 additions & 0 deletions protocol/communities/community_description_encryption_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package communities

import (
"errors"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/suite"
"go.uber.org/zap"

"github.com/status-im/status-go/protocol/protobuf"
)

func TestCommunityEncryptionDescriptionSuite(t *testing.T) {
suite.Run(t, new(CommunityEncryptionDescriptionSuite))
}

type CommunityEncryptionDescriptionSuite struct {
suite.Suite

descriptionEncryptor *DescriptionEncryptorMock
logger *zap.Logger
}

func (s *CommunityEncryptionDescriptionSuite) SetupTest() {
s.descriptionEncryptor = &DescriptionEncryptorMock{
descriptions: map[string]*protobuf.CommunityDescription{},
}
var err error
s.logger, err = zap.NewDevelopment()
s.Require().NoError(err)
}

type DescriptionEncryptorMock struct {
descriptions map[string]*protobuf.CommunityDescription
}

func (dem *DescriptionEncryptorMock) encryptCommunityDescription(community *Community, d *protobuf.CommunityDescription) (string, []byte, error) {
keyIDSeqNo := uuid.New().String()
dem.descriptions[keyIDSeqNo] = d
return keyIDSeqNo, []byte("encryptedDescription"), nil
}

func (dem *DescriptionEncryptorMock) decryptCommunityDescription(keyIDSeqNo string, d []byte) (*protobuf.CommunityDescription, error) {
description := dem.descriptions[keyIDSeqNo]
if description == nil {
return nil, errors.New("no key to decrypt private data")
}
return description, nil
}

func (dem *DescriptionEncryptorMock) forgetKeys() {
dem.descriptions = make(map[string]*protobuf.CommunityDescription)
}

func (s *CommunityEncryptionDescriptionSuite) description() *protobuf.CommunityDescription {
return &protobuf.CommunityDescription{
IntroMessage: "one of not encrypted fields",
Members: map[string]*protobuf.CommunityMember{
"a": &protobuf.CommunityMember{},
"b": &protobuf.CommunityMember{},
},
Chats: map[string]*protobuf.CommunityChat{
"c": &protobuf.CommunityChat{},
"d": &protobuf.CommunityChat{},
},
PrivateData: map[string][]byte{},
}
}

func (s *CommunityEncryptionDescriptionSuite) TestEncryptionDecryption() {
description := s.description()

err := encryptDescription(s.descriptionEncryptor, &Community{}, description)
s.Require().NoError(err)
s.Require().Len(description.PrivateData, 1)

// members and chats should become empty (encrypted)
s.Require().Empty(description.Members)
s.Require().Empty(description.Chats)
s.Require().Equal(description.IntroMessage, "one of not encrypted fields")

// members and chats should be brought back
err = decryptDescription(s.descriptionEncryptor, description, s.logger)
s.Require().NoError(err)
s.Require().Len(description.Members, 2)
s.Require().Len(description.Chats, 2)
s.Require().Equal(description.IntroMessage, "one of not encrypted fields")
}

func (s *CommunityEncryptionDescriptionSuite) TestDecryption_NoKeys() {
description := s.description()

err := encryptDescription(s.descriptionEncryptor, &Community{}, description)
s.Require().NoError(err)

// forget the keys, so description can't be decrypted
s.descriptionEncryptor.forgetKeys()

// members and chats should NOT be brought back
err = decryptDescription(s.descriptionEncryptor, description, s.logger)
s.Require().NoError(err)
s.Require().Empty(description.Members)
s.Require().Empty(description.Chats)
s.Require().Equal(description.IntroMessage, "one of not encrypted fields")
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func createTestCommunity(identity *ecdsa.PrivateKey) (*Community, error) {
MemberIdentity: &identity.PublicKey,
}

return New(config, &TimeSourceStub{})
return New(config, &TimeSourceStub{}, &DescriptionEncryptorMock{})
}

func TestCommunityEncryptionKeyActionSuite(t *testing.T) {
Expand Down
6 changes: 3 additions & 3 deletions protocol/communities/community_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ func (s *CommunitySuite) TestValidateRequestToJoin() {

for _, tc := range testCases {
s.Run(tc.name, func() {
org, err := New(tc.config, &TimeSourceStub{})
org, err := New(tc.config, &TimeSourceStub{}, &DescriptionEncryptorMock{})
s.Require().NoError(err)
err = org.ValidateRequestToJoin(tc.signer, tc.request)
s.Require().Equal(tc.err, err)
Expand Down Expand Up @@ -512,7 +512,7 @@ func (s *CommunitySuite) TestCanPost() {
s.Run(tc.name, func() {
var grant []byte
var err error
org, err := New(tc.config, &TimeSourceStub{})
org, err := New(tc.config, &TimeSourceStub{}, &DescriptionEncryptorMock{})
s.Require().NoError(err)

if tc.grant == validGrant {
Expand Down Expand Up @@ -882,7 +882,7 @@ func (s *CommunitySuite) buildCommunity(owner *ecdsa.PublicKey) *Community {
config.ID = owner
config.CommunityDescription = s.buildCommunityDescription()

org, err := New(config, &TimeSourceStub{})
org, err := New(config, &TimeSourceStub{}, &DescriptionEncryptorMock{})
s.Require().NoError(err)
return org
}
Expand Down
Loading

0 comments on commit b95e178

Please sign in to comment.