From 1859f6c80c4e0c4d02124243d04ff4f5d1012327 Mon Sep 17 00:00:00 2001 From: Adam Babik Date: Sun, 13 Oct 2019 20:16:43 +0200 Subject: [PATCH] Implement group messages logic (#86) --- chat.go | 99 +++++- chat_group_proxy.go | 28 ++ crypto/crypto.go | 7 + message_handler.go | 68 ++++ message_processor.go | 106 ++++++- message_processor_test.go | 1 + messenger.go | 179 +++++++++-- messenger_test.go | 100 +++++- transport/whisper/whisper_service.go | 27 ++ v1/decoder.go | 4 - v1/encoder.go | 1 - v1/membership_update_message.go | 449 ++++++++++++++++++++++++--- v1/membership_update_message_test.go | 308 +++++++++++++----- 13 files changed, 1216 insertions(+), 161 deletions(-) create mode 100644 chat_group_proxy.go create mode 100644 message_handler.go diff --git a/chat.go b/chat.go index 70531a1..a892163 100644 --- a/chat.go +++ b/chat.go @@ -2,10 +2,13 @@ package statusproto import ( "crypto/ecdsa" + "crypto/sha1" + "encoding/hex" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" - "github.com/google/uuid" statusproto "github.com/status-im/status-protocol-go/types" + protocol "github.com/status-im/status-protocol-go/v1" ) type ChatType int @@ -52,6 +55,55 @@ type Chat struct { MembershipUpdates []ChatMembershipUpdate `json:"membershipUpdates"` } +func (c *Chat) MembersAsPublicKeys() ([]*ecdsa.PublicKey, error) { + publicKeys := make([]string, len(c.Members)) + for idx, item := range c.Members { + publicKeys[idx] = item.ID + } + return stringSliceToPublicKeys(publicKeys, true) +} + +func (c *Chat) updateChatFromProtocolGroup(g *protocol.Group) { + // ID + c.ID = g.ChatID() + + // Name + c.Name = g.Name() + + // Members + members := g.Members() + admins := g.Admins() + joined := g.Joined() + chatMembers := make([]ChatMember, 0, len(members)) + for _, m := range members { + chatMember := ChatMember{ + ID: m, + } + chatMember.Admin = stringSliceContains(admins, m) + chatMember.Joined = stringSliceContains(joined, m) + chatMembers = append(chatMembers, chatMember) + } + c.Members = chatMembers + + // MembershipUpdates + updates := g.Updates() + membershipUpdates := make([]ChatMembershipUpdate, 0, len(updates)) + for _, update := range updates { + membershipUpdate := ChatMembershipUpdate{ + Type: update.Type, + Name: update.Name, + ClockValue: uint64(update.ClockValue), // TODO: get rid of type casting + Signature: update.Signature, + From: update.From, + Member: update.Member, + Members: update.Members, + } + membershipUpdate.setID() + membershipUpdates = append(membershipUpdates, membershipUpdate) + } + c.MembershipUpdates = membershipUpdates +} + // ChatMembershipUpdate represent an event on membership of the chat type ChatMembershipUpdate struct { // Unique identifier for the event @@ -72,6 +124,11 @@ type ChatMembershipUpdate struct { Members []string `json:"members,omitempty"` } +func (u *ChatMembershipUpdate) setID() { + sum := sha1.Sum([]byte(u.Signature)) + u.ID = hex.EncodeToString(sum[:]) +} + // ChatMember represents a member who participates in a group chat type ChatMember struct { // ID is the hex encoded public key of the member @@ -113,14 +170,8 @@ func CreatePublicChat(name string) Chat { } } -func groupChatID(creator *ecdsa.PublicKey) string { - return uuid.New().String() + statusproto.EncodeHex(crypto.FromECDSAPub(creator)) -} - -func CreateGroupChat(name string, creator *ecdsa.PublicKey) Chat { +func createGroupChat() Chat { return Chat{ - ID: groupChatID(creator), - Name: name, Active: true, ChatType: ChatTypePrivateGroupChat, } @@ -134,3 +185,35 @@ func findChatByID(chatID string, chats []*Chat) *Chat { } return nil } + +func stringSliceToPublicKeys(slice []string, prefixed bool) ([]*ecdsa.PublicKey, error) { + result := make([]*ecdsa.PublicKey, len(slice)) + for idx, item := range slice { + var ( + b []byte + err error + ) + if prefixed { + b, err = hexutil.Decode(item) + } else { + b, err = hex.DecodeString(item) + } + if err != nil { + return nil, err + } + result[idx], err = crypto.UnmarshalPubkey(b) + if err != nil { + return nil, err + } + } + return result, nil +} + +func stringSliceContains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/chat_group_proxy.go b/chat_group_proxy.go new file mode 100644 index 0000000..19e4aee --- /dev/null +++ b/chat_group_proxy.go @@ -0,0 +1,28 @@ +package statusproto + +import ( + protocol "github.com/status-im/status-protocol-go/v1" +) + +func newProtocolGroupFromChat(chat *Chat) (*protocol.Group, error) { + return protocol.NewGroup(chat.ID, chatToFlattenMembershipUpdate(chat)) +} + +func chatToFlattenMembershipUpdate(chat *Chat) []protocol.MembershipUpdateFlat { + result := make([]protocol.MembershipUpdateFlat, len(chat.MembershipUpdates)) + for idx, update := range chat.MembershipUpdates { + result[idx] = protocol.MembershipUpdateFlat{ + ChatID: chat.ID, + From: update.From, + Signature: update.Signature, + MembershipUpdateEvent: protocol.MembershipUpdateEvent{ + Name: update.Name, + Type: update.Type, + ClockValue: int64(update.ClockValue), // TODO: remove type difference + Member: update.Member, + Members: update.Members, + }, + } + } + return result +} diff --git a/crypto/crypto.go b/crypto/crypto.go index f54f05b..d60e0c0 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -78,6 +78,7 @@ func VerifySignatures(signaturePairs [][3]string) error { } // ExtractSignatures extract from tuples of signatures content a public key +// DEPRECATED: use ExtractSignature func ExtractSignatures(signaturePairs [][2]string) ([]string, error) { response := make([]string, len(signaturePairs)) for i, signaturePair := range signaturePairs { @@ -102,6 +103,12 @@ func ExtractSignatures(signaturePairs [][2]string) ([]string, error) { return response, nil } +// ExtractSignature returns a public key for a given data and signature. +func ExtractSignature(data, signature []byte) (*ecdsa.PublicKey, error) { + dataHash := crypto.Keccak256(data) + return crypto.SigToPub(dataHash, signature) +} + func EncryptSymmetric(key, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { diff --git a/message_handler.go b/message_handler.go new file mode 100644 index 0000000..734f300 --- /dev/null +++ b/message_handler.go @@ -0,0 +1,68 @@ +package statusproto + +import ( + "github.com/pkg/errors" + + protocol "github.com/status-im/status-protocol-go/v1" +) + +type persistentMessageHandler struct { + persistence *sqlitePersistence +} + +func newPersistentMessageHandler(persistence *sqlitePersistence) *persistentMessageHandler { + return &persistentMessageHandler{persistence: persistence} +} + +// HandleMembershipUpdate updates a Chat instance according to the membership updates. +// It retrieves chat, if exists, and merges membership updates from the message. +// Finally, the Chat is updated with the new group events. +func (h *persistentMessageHandler) HandleMembershipUpdate(m protocol.MembershipUpdateMessage) error { + chat, err := h.chatID(m.ChatID) + switch err { + case errChatNotFound: + group, err := protocol.NewGroupWithMembershipUpdates(m.ChatID, m.Updates) + if err != nil { + return err + } + newChat := createGroupChat() + newChat.updateChatFromProtocolGroup(group) + chat = &newChat + case nil: + existingGroup, err := newProtocolGroupFromChat(chat) + if err != nil { + return errors.Wrap(err, "failed to create a Group from Chat") + } + updateGroup, err := protocol.NewGroupWithMembershipUpdates(m.ChatID, m.Updates) + if err != nil { + return errors.Wrap(err, "invalid membership update") + } + merged := protocol.MergeFlatMembershipUpdates(existingGroup.Updates(), updateGroup.Updates()) + newGroup, err := protocol.NewGroup(chat.ID, merged) + if err != nil { + return errors.Wrap(err, "failed to create a group with new membership updates") + } + chat.updateChatFromProtocolGroup(newGroup) + default: + return err + } + return h.persistence.SaveChat(*chat) +} + +func (h *persistentMessageHandler) chatID(chatID string) (*Chat, error) { + var chat *Chat + chats, err := h.persistence.Chats() + if err != nil { + return nil, err + } + for _, ch := range chats { + if chat.ID == chatID { + chat = ch + break + } + } + if chat == nil { + return nil, errChatNotFound + } + return chat, nil +} diff --git a/message_processor.go b/message_processor.go index bd3cca4..7c2e2b8 100644 --- a/message_processor.go +++ b/message_processor.go @@ -28,11 +28,16 @@ const ( whisperPoWTime = 5 ) +type messageHandler interface { + HandleMembershipUpdate(m protocol.MembershipUpdateMessage) error +} + type messageProcessor struct { identity *ecdsa.PrivateKey datasync *datasync.DataSync protocol *encryption.Protocol transport *transport.WhisperServiceTransport + handler messageHandler logger *zap.Logger featureFlags featureFlags @@ -43,6 +48,7 @@ func newMessageProcessor( database *sql.DB, enc *encryption.Protocol, transport *transport.WhisperServiceTransport, + handler messageHandler, logger *zap.Logger, features featureFlags, ) (*messageProcessor, error) { @@ -65,6 +71,7 @@ func newMessageProcessor( datasync: ds, protocol: enc, transport: transport, + handler: handler, logger: logger, featureFlags: features, } @@ -87,14 +94,14 @@ func (p *messageProcessor) Stop() { func (p *messageProcessor) SendPrivate( ctx context.Context, - publicKey *ecdsa.PublicKey, + recipient *ecdsa.PublicKey, chatID string, data []byte, clock int64, ) ([]byte, *protocol.Message, error) { p.logger.Debug( "sending a private message", - zap.Binary("public-key", crypto.FromECDSAPub(publicKey)), + zap.Binary("public-key", crypto.FromECDSAPub(recipient)), ) message := protocol.CreatePrivateTextMessage(data, clock, chatID) @@ -103,7 +110,7 @@ func (p *messageProcessor) SendPrivate( return nil, nil, errors.Wrap(err, "failed to encode message") } - messageID, err := p.sendPrivate(ctx, publicKey, encodedMessage) + messageID, err := p.sendPrivate(ctx, recipient, encodedMessage) if err != nil { return nil, nil, err } @@ -113,23 +120,25 @@ func (p *messageProcessor) SendPrivate( // SendPrivateRaw takes encoded data, encrypts it and sends through the wire. func (p *messageProcessor) SendPrivateRaw( ctx context.Context, - publicKey *ecdsa.PublicKey, + recipient *ecdsa.PublicKey, data []byte, ) ([]byte, error) { p.logger.Debug( "sending a private message", - zap.Binary("public-key", crypto.FromECDSAPub(publicKey)), + zap.Binary("public-key", crypto.FromECDSAPub(recipient)), zap.String("site", "SendPrivateRaw"), ) - return p.sendPrivate(ctx, publicKey, data) + return p.sendPrivate(ctx, recipient, data) } // sendPrivate sends data to the recipient identifying with a given public key. func (p *messageProcessor) sendPrivate( ctx context.Context, - publicKey *ecdsa.PublicKey, + recipient *ecdsa.PublicKey, data []byte, ) ([]byte, error) { + p.logger.Debug("sending private message", zap.Binary("recipient", crypto.FromECDSAPub(recipient))) + wrappedMessage, err := p.tryWrapMessageV1(data) if err != nil { return nil, errors.Wrap(err, "failed to wrap message") @@ -138,19 +147,19 @@ func (p *messageProcessor) sendPrivate( messageID := protocol.MessageID(&p.identity.PublicKey, wrappedMessage) if p.featureFlags.datasync { - if err := p.addToDataSync(publicKey, wrappedMessage); err != nil { + if err := p.addToDataSync(recipient, wrappedMessage); err != nil { return nil, errors.Wrap(err, "failed to send message with datasync") } // No need to call transport tracking. // It is done in a data sync dispatch step. } else { - messageSpec, err := p.protocol.BuildDirectMessage(p.identity, publicKey, wrappedMessage) + messageSpec, err := p.protocol.BuildDirectMessage(p.identity, recipient, wrappedMessage) if err != nil { return nil, errors.Wrap(err, "failed to encrypt message") } - hash, newMessage, err := p.sendMessageSpec(ctx, publicKey, messageSpec) + hash, newMessage, err := p.sendMessageSpec(ctx, recipient, messageSpec) if err != nil { return nil, errors.Wrap(err, "failed to send a message spec") } @@ -161,6 +170,61 @@ func (p *messageProcessor) sendPrivate( return messageID, nil } +func (p *messageProcessor) SendGroup( + ctx context.Context, + recipients []*ecdsa.PublicKey, + chatID string, + data []byte, + clock int64, +) ([][]byte, []*protocol.Message, error) { + p.logger.Debug("sending a group message", zap.Int("membersCount", len(recipients))) + + message := protocol.CreatePrivateGroupTextMessage(data, clock, chatID) + encodedMessage, err := p.encodeMessage(message) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to encode message") + } + + var resultIDs [][]byte + for _, recipient := range recipients { + messageID, err := p.sendPrivate(ctx, recipient, encodedMessage) + if err != nil { + return nil, nil, err + } + resultIDs = append(resultIDs, messageID) + } + return resultIDs, nil, nil +} + +func (p *messageProcessor) SendMembershipUpdate( + ctx context.Context, + recipients []*ecdsa.PublicKey, + chatID string, + updates []protocol.MembershipUpdate, + clock int64, +) ([][]byte, error) { + p.logger.Debug("sending a membership update", zap.Int("membersCount", len(recipients))) + + message := protocol.MembershipUpdateMessage{ + ChatID: chatID, + Updates: updates, + } + encodedMessage, err := protocol.EncodeMembershipUpdateMessage(message) + if err != nil { + return nil, errors.Wrap(err, "failed to encode membership update message") + } + + var resultIDs [][]byte + for _, recipient := range recipients { + messageID, err := p.sendPrivate(ctx, recipient, encodedMessage) + if err != nil { + return nil, err + } + resultIDs = append(resultIDs, messageID) + } + return resultIDs, nil +} + func (p *messageProcessor) SendPublic(ctx context.Context, chatID string, data []byte, clock int64) ([]byte, error) { logger := p.logger.With(zap.String("site", "SendPublic")) logger.Debug("sending a public message", zap.String("chatID", chatID)) @@ -249,6 +313,18 @@ func (p *messageProcessor) Process(shhMessage *whispertypes.Message) ([]*protoco m.ID = statusMessage.ID m.SigPubKey = statusMessage.SigPubKey() decodedMessages = append(decodedMessages, &m) + case protocol.MembershipUpdateMessage: + // Handle user message that can be attached to the membership update. + userMessage := m.Message + if userMessage != nil { + userMessage.ID = statusMessage.ID + userMessage.SigPubKey = statusMessage.SigPubKey() + decodedMessages = append(decodedMessages, userMessage) + } + + if err := p.processMembershipUpdate(m); err != nil { + hlogger.Error("failed to process MembershipUpdateMessage", zap.Error(err)) + } case protocol.PairMessage: fromOurDevice := isPubKeyEqual(statusMessage.SigPubKey(), &p.identity.PublicKey) if !fromOurDevice { @@ -267,6 +343,16 @@ func (p *messageProcessor) Process(shhMessage *whispertypes.Message) ([]*protoco return decodedMessages, nil } +func (p *messageProcessor) processMembershipUpdate(m protocol.MembershipUpdateMessage) error { + if err := m.Verify(); err != nil { + return err + } + if p.handler != nil { + return p.handler.HandleMembershipUpdate(m) + } + return errors.New("missing handler") +} + func (p *messageProcessor) processPairMessage(m protocol.PairMessage) error { metadata := &multidevice.InstallationMetadata{ Name: m.Name, diff --git a/message_processor_test.go b/message_processor_test.go index b3c4dfa..6cc3bb2 100644 --- a/message_processor_test.go +++ b/message_processor_test.go @@ -98,6 +98,7 @@ func (s *MessageProcessorSuite) SetupTest() { database, encryptionProtocol, whisperTransport, + nil, s.logger, featureFlags{}, ) diff --git a/messenger.go b/messenger.go index db8416c..3919266 100644 --- a/messenger.go +++ b/messenger.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" "github.com/pkg/errors" "go.uber.org/zap" @@ -284,6 +286,7 @@ func NewMessenger( database, encryptionProtocol, t, + newPersistentMessageHandler(&sqlitePersistence{db: database}), logger, c.featureFlags, ) @@ -454,12 +457,20 @@ func (m *Messenger) Mailservers() ([]string, error) { } func (m *Messenger) Join(chat Chat) error { - if chat.PublicKey != nil { + switch chat.ChatType { + case ChatTypeOneToOne: return m.transport.JoinPrivate(chat.PublicKey) - } else if chat.Name != "" { + case ChatTypePrivateGroupChat: + members, err := chat.MembersAsPublicKeys() + if err != nil { + return err + } + return m.transport.JoinGroup(members) + case ChatTypePublic: return m.transport.JoinPublic(chat.Name) + default: + return errors.New("chat is neither public nor private") } - return errors.New("chat is neither public nor private") } func (m *Messenger) Leave(chat Chat) error { @@ -471,6 +482,94 @@ func (m *Messenger) Leave(chat Chat) error { return errors.New("chat is neither public nor private") } +// TODO: consider moving to a ChatManager ??? +func (m *Messenger) CreateGroupChat(name string) (*Chat, error) { + chat := createGroupChat() + group, err := protocol.NewGroupWithCreator(name, m.identity) + if err != nil { + return nil, err + } + chat.updateChatFromProtocolGroup(group) + return &chat, nil +} + +func (m *Messenger) AddMembersToChat(ctx context.Context, chat *Chat, members []*ecdsa.PublicKey) error { + group, err := newProtocolGroupFromChat(chat) + if err != nil { + return err + } + encodedMembers := make([]string, len(members)) + for idx, member := range members { + encodedMembers[idx] = hexutil.Encode(crypto.FromECDSAPub(member)) + } + event := protocol.NewMembersAddedEvent(encodedMembers, group.NextClockValue()) + err = group.ProcessEvent(&m.identity.PublicKey, event) + if err != nil { + return err + } + if err := m.propagateMembershipUpdates(ctx, group); err != nil { + return err + } + chat.updateChatFromProtocolGroup(group) + return m.SaveChat(*chat) +} + +func (m *Messenger) ConfirmJoiningGroup(ctx context.Context, chat *Chat) error { + group, err := newProtocolGroupFromChat(chat) + if err != nil { + return err + } + event := protocol.NewMemberJoinedEvent( + statusproto.EncodeHex(crypto.FromECDSAPub(&m.identity.PublicKey)), + group.NextClockValue(), + ) + err = group.ProcessEvent(&m.identity.PublicKey, event) + if err != nil { + return err + } + if err := m.propagateMembershipUpdates(ctx, group); err != nil { + return err + } + chat.updateChatFromProtocolGroup(group) + return m.SaveChat(*chat) +} + +func (m *Messenger) propagateMembershipUpdates(ctx context.Context, group *protocol.Group) error { + events := make([]protocol.MembershipUpdateEvent, len(group.Updates())) + for idx, event := range group.Updates() { + events[idx] = event.MembershipUpdateEvent + } + update := protocol.MembershipUpdate{ + ChatID: group.ChatID(), + Events: events, + } + if err := update.Sign(m.identity); err != nil { + return err + } + recipients, err := stringSliceToPublicKeys(group.Members(), true) + if err != nil { + return err + } + // Filter out my key from the recipients + n := 0 + for _, recipient := range recipients { + if !isPubKeyEqual(recipient, &m.identity.PublicKey) { + recipients[n] = recipient + n++ + } + } + recipients = recipients[:n] + // Finally send membership updates to all recipients. + _, err = m.processor.SendMembershipUpdate( + ctx, + recipients, + group.ChatID(), + []protocol.MembershipUpdate{update}, + group.NextClockValue(), + ) + return err +} + func (m *Messenger) SaveChat(chat Chat) error { return m.persistence.SaveChat(chat) } @@ -522,7 +621,7 @@ func (m *Messenger) Contacts() ([]*Contact, error) { return m.persistence.Contacts() } -func (m *Messenger) Send(ctx context.Context, chatID string, data []byte) ([]byte, error) { +func (m *Messenger) Send(ctx context.Context, chatID string, data []byte) ([][]byte, error) { logger := m.logger.With(zap.String("site", "Send"), zap.String("chatID", chatID)) // A valid added chat is required. @@ -538,7 +637,8 @@ func (m *Messenger) Send(ctx context.Context, chatID string, data []byte) ([]byt logger.Debug("last message clock received", zap.Int64("clock", clock)) - if chat.PublicKey != nil { + switch chat.ChatType { + case ChatTypeOneToOne: logger.Debug("sending private message", zap.Binary("publicKey", crypto.FromECDSAPub(chat.PublicKey))) id, message, err := m.processor.SendPrivate(ctx, chat.PublicKey, chat.ID, data, clock) @@ -546,27 +646,64 @@ func (m *Messenger) Send(ctx context.Context, chatID string, data []byte) ([]byt return nil, err } - // Save our message because it won't be received from the transport layer. - message.ID = id // a Message need ID to be properly stored in the db - message.SigPubKey = &m.identity.PublicKey - message.ChatID = chatID + if err := m.cacheOwnMessage(chatID, id, message); err != nil { + return nil, err + } - if m.messagesPersistenceEnabled { - _, err = m.persistence.SaveMessages([]*protocol.Message{message}) - if err != nil { + return [][]byte{id}, nil + case ChatTypePublic: + logger.Debug("sending public message", zap.String("chatName", chat.Name)) + id, err := m.processor.SendPublic(ctx, chat.ID, data, clock) + if err != nil { + return nil, err + } + return [][]byte{id}, nil + case ChatTypePrivateGroupChat: + logger.Debug("sending group message", zap.String("chatName", chat.Name)) + recipients, err := chat.MembersAsPublicKeys() + if err != nil { + return nil, err + } + // Filter me out of recipients. + n := 0 + for _, item := range recipients { + if !isPubKeyEqual(item, &m.identity.PublicKey) { + recipients[n] = item + n++ + } + } + ids, messages, err := m.processor.SendGroup(ctx, recipients[:n], chat.ID, data, clock) + if err != nil { + return nil, err + } + for idx, message := range messages { + if err := m.cacheOwnMessage(chatID, ids[idx], message); err != nil { return nil, err } } - - // Cache it to be returned in Retrieve(). - m.ownMessages = append(m.ownMessages, message) - - return id, nil - } else if chat.Name != "" { - logger.Debug("sending public message", zap.String("chatName", chat.Name)) - return m.processor.SendPublic(ctx, chat.ID, data, clock) + return ids, nil + default: + return nil, errors.New("chat is neither public nor private") } - return nil, errors.New("chat is neither public nor private") +} + +func (m *Messenger) cacheOwnMessage(chatID string, id []byte, message *protocol.Message) error { + // Save our message because it won't be received from the transport layer. + message.ID = id // a Message need ID to be properly stored in the db + message.SigPubKey = &m.identity.PublicKey + message.ChatID = chatID + + if m.messagesPersistenceEnabled { + _, err := m.persistence.SaveMessages([]*protocol.Message{message}) + if err != nil { + return err + } + } + + // Cache it to be returned in Retrieve(). + m.ownMessages = append(m.ownMessages, message) + + return nil } // SendRaw takes encoded data, encrypts it and sends through the wire. diff --git a/messenger_test.go b/messenger_test.go index a81addf..64d903b 100644 --- a/messenger_test.go +++ b/messenger_test.go @@ -309,7 +309,7 @@ func (s *MessengerSuite) TestRetrieveOwnPrivate() { // Verify message fields. message := messages[0] - s.Equal(messageID, message.ID) + s.Equal(messageID[0], message.ID) s.Equal(&s.privateKey.PublicKey, message.SigPubKey) // this is OUR message } @@ -337,7 +337,7 @@ func (s *MessengerSuite) TestRetrieveTheirPrivate() { // Validate received message. s.Require().Len(messages, 1) message := messages[0] - s.Equal(messageID, message.ID) + s.Equal(messageID[0], message.ID) s.Equal(&theirMessenger.identity.PublicKey, message.SigPubKey) } @@ -793,6 +793,102 @@ func (s *MessengerSuite) TestSharedSecretHandler() { s.NoError(err) } +func (s *MessengerSuite) TestCreateGroupChat() { + chat, err := s.m.CreateGroupChat("test") + s.NoError(err) + s.Equal("test", chat.Name) + publicKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSAPub(&s.m.identity.PublicKey)) + s.Contains(chat.ID, publicKeyHex) + s.EqualValues([]string{publicKeyHex}, []string{chat.Members[0].ID}) +} + +func (s *MessengerSuite) TestAddMembersToChat() { + chat, err := s.m.CreateGroupChat("test") + s.NoError(err) + key, err := crypto.GenerateKey() + s.NoError(err) + err = s.m.AddMembersToChat(context.Background(), chat, []*ecdsa.PublicKey{&key.PublicKey}) + s.NoError(err) + publicKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSAPub(&s.m.identity.PublicKey)) + keyHex := "0x" + hex.EncodeToString(crypto.FromECDSAPub(&key.PublicKey)) + s.EqualValues([]string{publicKeyHex, keyHex}, []string{chat.Members[0].ID, chat.Members[1].ID}) +} + +// TestGroupChatAutocreate verifies that after receiving a membership update message +// for non-existing group chat, a new one is created. +func (s *MessengerSuite) TestGroupChatAutocreate() { + theirMessenger := s.newMessenger() + chat, err := theirMessenger.CreateGroupChat("test-group") + s.NoError(err) + err = theirMessenger.SaveChat(*chat) + s.NoError(err) + err = theirMessenger.AddMembersToChat( + context.Background(), + chat, + []*ecdsa.PublicKey{&s.privateKey.PublicKey}, + ) + s.NoError(err) + s.Equal(2, len(chat.Members)) + + var chats []*Chat + + err = tt.RetryWithBackOff(func() error { + _, err := s.m.RetrieveAll(context.Background(), RetrieveLatest) + if err != nil { + return err + } + chats, err = s.m.Chats() + if err != nil { + return err + } + if len(chats) == 0 { + return errors.New("expected a group chat to be created") + } + return nil + }) + s.NoError(err) + s.Equal(chat.ID, chats[0].ID) + s.Equal("test-group", chats[0].Name) + s.Equal(2, len(chats[0].Members)) + + // Send confirmation. + err = s.m.ConfirmJoiningGroup(context.Background(), chats[0]) + s.Require().NoError(err) +} + +func (s *MessengerSuite) TestGroupChatMessages() { + theirMessenger := s.newMessenger() + chat, err := theirMessenger.CreateGroupChat("test-group") + s.NoError(err) + err = theirMessenger.SaveChat(*chat) + s.NoError(err) + err = theirMessenger.AddMembersToChat( + context.Background(), + chat, + []*ecdsa.PublicKey{&s.privateKey.PublicKey}, + ) + s.NoError(err) + _, err = theirMessenger.Send(context.Background(), chat.ID, []byte("hello!")) + s.NoError(err) + + var messages []*protocol.Message + + err = tt.RetryWithBackOff(func() error { + var err error + messages, err = s.m.RetrieveAll(context.Background(), RetrieveLatest) + if err == nil && len(messages) == 0 { + err = errors.New("no messages") + } + return err + }) + s.NoError(err) + + // Validate received message. + s.Require().Len(messages, 1) + message := messages[0] + s.Equal(&theirMessenger.identity.PublicKey, message.SigPubKey) +} + type PostProcessorSuite struct { suite.Suite diff --git a/transport/whisper/whisper_service.go b/transport/whisper/whisper_service.go index 7214c67..ef7a0f8 100644 --- a/transport/whisper/whisper_service.go +++ b/transport/whisper/whisper_service.go @@ -81,6 +81,9 @@ type WhisperServiceTransport struct { } // NewWhisperServiceTransport returns a new WhisperServiceTransport. +// TODO: leaving a chat should verify that for a given public key +// there are no other chats. It may happen that we leave a private chat +// but still have a public chat for a given public key. func NewWhisperServiceTransport( shh whispertypes.Whisper, privateKey *ecdsa.PrivateKey, @@ -185,6 +188,30 @@ func (a *WhisperServiceTransport) LeavePrivate(publicKey *ecdsa.PublicKey) error return a.filters.Remove(filters...) } +func (a *WhisperServiceTransport) JoinGroup(publicKeys []*ecdsa.PublicKey) error { + _, err := a.filters.LoadDiscovery() + if err != nil { + return err + } + for _, pk := range publicKeys { + _, err = a.filters.LoadContactCode(pk) + if err != nil { + return err + } + } + return nil +} + +func (a *WhisperServiceTransport) LeaveGroup(publicKeys []*ecdsa.PublicKey) error { + for _, publicKey := range publicKeys { + filters := a.filters.FiltersByPublicKey(publicKey) + if err := a.filters.Remove(filters...); err != nil { + return err + } + } + return nil +} + type Message struct { Message *whispertypes.Message Public bool diff --git a/v1/decoder.go b/v1/decoder.go index ea17a4f..1ee9d85 100644 --- a/v1/decoder.go +++ b/v1/decoder.go @@ -163,10 +163,6 @@ func membershipUpdateMessageHandler(d transit.Decoder, value interface{}) (inter if !ok { break } - update.From, ok = value[transit.Keyword("from")].(string) - if !ok { - break - } update.Signature, ok = value[transit.Keyword("signature")].(string) if !ok { break diff --git a/v1/encoder.go b/v1/encoder.go index b0cc549..a9754c4 100644 --- a/v1/encoder.go +++ b/v1/encoder.go @@ -77,7 +77,6 @@ func (messageValueEncoder) Encode(e transit.Encoder, value reflect.Value, asStri element := map[interface{}]interface{}{ transit.Keyword("chat-id"): update.ChatID, - transit.Keyword("from"): update.From, transit.Keyword("events"): events, transit.Keyword("signature"): update.Signature, } diff --git a/v1/membership_update_message.go b/v1/membership_update_message.go index ac8e2ed..e0e73a5 100644 --- a/v1/membership_update_message.go +++ b/v1/membership_update_message.go @@ -3,10 +3,18 @@ package statusproto import ( "bytes" "crypto/ecdsa" + "encoding/hex" "encoding/json" + "fmt" "reflect" "sort" + "strings" + "time" + "github.com/ethereum/go-ethereum/common/hexutil" + gethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/google/uuid" + "github.com/pkg/errors" "github.com/status-im/status-protocol-go/crypto" ) @@ -29,11 +37,35 @@ type MembershipUpdateMessage struct { Message *Message `json:"message"` // optional message } +// Verify makes sure that the received update message has a valid signature. +// It also extracts public key from the signature available as From field. +// It does not verify the updates and their events. This should be done +// separately using Group struct. +func (m *MembershipUpdateMessage) Verify() error { + for idx, update := range m.Updates { + if err := update.extractFrom(); err != nil { + return errors.Wrapf(err, "failed to extract an author of %d update", idx) + } + m.Updates[idx] = update + } + return nil +} + +// EncodeMembershipUpdateMessage encodes a MembershipUpdateMessage using Transit serialization. +func EncodeMembershipUpdateMessage(value MembershipUpdateMessage) ([]byte, error) { + var buf bytes.Buffer + encoder := NewMessageEncoder(&buf) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + type MembershipUpdate struct { ChatID string `json:"chatId"` - From string `json:"from"` - Signature string `json:"signature"` + Signature string `json:"signature"` // hex-encoded without 0x prefix Events []MembershipUpdateEvent `json:"events"` + From string `json:"from"` // hex-encoded with 0x prefix } // Sign creates a signature from MembershipUpdateEvents @@ -41,22 +73,7 @@ type MembershipUpdate struct { // It follows the algorithm describe in the spec: // https://github.com/status-im/specs/blob/master/status-group-chats-spec.md#signature. func (u *MembershipUpdate) Sign(identity *ecdsa.PrivateKey) error { - sort.Slice(u.Events, func(i, j int) bool { - return u.Events[i].ClockValue < u.Events[j].ClockValue - }) - tuples := make([]interface{}, len(u.Events)) - for idx, event := range u.Events { - tuples[idx] = tupleMembershipUpdateEvent(event) - } - structureToSign := []interface{}{ - tuples, - u.ChatID, - } - data, err := json.Marshal(structureToSign) - if err != nil { - return err - } - signature, err := crypto.SignBytesAsHex(data, identity) + signature, err := createMembershipUpdateSignature(u.ChatID, u.Events, identity) if err != nil { return err } @@ -64,6 +81,38 @@ func (u *MembershipUpdate) Sign(identity *ecdsa.PrivateKey) error { return nil } +func (u *MembershipUpdate) extractFrom() error { + content, err := stringifyMembershipUpdateEvents(u.ChatID, u.Events) + if err != nil { + return errors.Wrap(err, "failed to stringify events") + } + signatureBytes, err := hex.DecodeString(u.Signature) + if err != nil { + return errors.Wrap(err, "failed to decode signature") + } + publicKey, err := crypto.ExtractSignature(content, signatureBytes) + if err != nil { + return errors.Wrap(err, "failed to extract signature") + } + u.From = hexutil.Encode(gethcrypto.FromECDSAPub(publicKey)) + return nil +} + +func (u *MembershipUpdate) Flat() []MembershipUpdateFlat { + result := make([]MembershipUpdateFlat, 0, len(u.Events)) + for _, event := range u.Events { + result = append(result, MembershipUpdateFlat{ + MembershipUpdateEvent: event, + ChatID: u.ChatID, + Signature: u.Signature, + From: u.From, + }) + } + return result +} + +// MembershipUpdateEvent contains an event information. +// Member and Members are hex-encoded values with 0x prefix. type MembershipUpdateEvent struct { Type string `json:"type"` ClockValue int64 `json:"clockValue"` @@ -72,10 +121,49 @@ type MembershipUpdateEvent struct { Name string `json:"name,omitempty"` // name of the group chat } -func NewChatCreatedEvent(name string, clock int64) MembershipUpdateEvent { +func (u MembershipUpdateEvent) Equal(update MembershipUpdateEvent) bool { + return u.Type == update.Type && + u.ClockValue == update.ClockValue && + u.Member == update.Member && + stringSliceEquals(u.Members, update.Members) && + u.Name == update.Name +} + +type MembershipUpdateFlat struct { + MembershipUpdateEvent + ChatID string `json:"chatId"` + Signature string `json:"signature"` + From string `json:"from"` +} + +func (u MembershipUpdateFlat) Equal(update MembershipUpdateFlat) bool { + return u.ChatID == update.ChatID && + u.Signature == update.Signature && + u.From == update.From && + u.MembershipUpdateEvent.Equal(update.MembershipUpdateEvent) +} + +func MergeFlatMembershipUpdates(dest []MembershipUpdateFlat, src []MembershipUpdateFlat) []MembershipUpdateFlat { + for _, update := range src { + var exists bool + for _, existing := range dest { + if existing.Equal(update) { + exists = true + break + } + } + if !exists { + dest = append(dest, update) + } + } + return dest +} + +func NewChatCreatedEvent(name string, admin string, clock int64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: MembershipUpdateChatCreated, Name: name, + Member: admin, ClockValue: clock, } } @@ -128,14 +216,27 @@ func NewAdminRemovedEvent(admin string, clock int64) MembershipUpdateEvent { } } -// EncodeMembershipUpdateMessage encodes a MembershipUpdateMessage using Transit serialization. -func EncodeMembershipUpdateMessage(value MembershipUpdateMessage) ([]byte, error) { - var buf bytes.Buffer - encoder := NewMessageEncoder(&buf) - if err := encoder.Encode(value); err != nil { - return nil, err +func stringifyMembershipUpdateEvents(chatID string, events []MembershipUpdateEvent) ([]byte, error) { + sort.Slice(events, func(i, j int) bool { + return events[i].ClockValue < events[j].ClockValue + }) + tuples := make([]interface{}, len(events)) + for idx, event := range events { + tuples[idx] = tupleMembershipUpdateEvent(event) } - return buf.Bytes(), nil + structureToSign := []interface{}{ + tuples, + chatID, + } + return json.Marshal(structureToSign) +} + +func createMembershipUpdateSignature(chatID string, events []MembershipUpdateEvent, identity *ecdsa.PrivateKey) (string, error) { + data, err := stringifyMembershipUpdateEvents(chatID, events) + if err != nil { + return "", err + } + return crypto.SignBytesAsHex(data, identity) } var membershipUpdateEventFieldNamesCompat = map[string]string{ @@ -173,41 +274,222 @@ func tupleMembershipUpdateEvent(update MembershipUpdateEvent) [][]interface{} { } type Group struct { - ChatID string - Admins []string - Contacts []string + chatID string + name string + updates []MembershipUpdateFlat + admins *stringSet + members *stringSet } -// ValidateEvent returns true if a given event is valid. -func (g *Group) ValidateEvent(from string, event MembershipUpdateEvent) bool { +func groupChatID(creator *ecdsa.PublicKey) string { + return uuid.New().String() + "-" + hexutil.Encode(gethcrypto.FromECDSAPub(creator)) +} + +func NewGroupWithMembershipUpdates(chatID string, updates []MembershipUpdate) (*Group, error) { + flatten := make([]MembershipUpdateFlat, 0, len(updates)) + for _, update := range updates { + flatten = append(flatten, update.Flat()...) + } + return newGroup(chatID, flatten) +} + +func NewGroupWithCreator(name string, creator *ecdsa.PrivateKey) (*Group, error) { + chatID := groupChatID(&creator.PublicKey) + creatorHex := publicKeyToString(&creator.PublicKey) + clock := TimestampInMsFromTime(time.Now()) + chatCreated := NewChatCreatedEvent(name, creatorHex, int64(clock)) + update := MembershipUpdate{ + ChatID: chatID, + From: creatorHex, + Events: []MembershipUpdateEvent{chatCreated}, + } + if err := update.Sign(creator); err != nil { + return nil, err + } + return newGroup(chatID, update.Flat()) +} + +func NewGroup(chatID string, updates []MembershipUpdateFlat) (*Group, error) { + return newGroup(chatID, updates) +} + +func newGroup(chatID string, updates []MembershipUpdateFlat) (*Group, error) { + g := Group{ + chatID: chatID, + updates: updates, + admins: newStringSet(), + members: newStringSet(), + } + if err := g.init(); err != nil { + return nil, err + } + return &g, nil +} + +func (g *Group) init() error { + g.sortEvents() + + var chatID string + + for _, update := range g.updates { + if chatID == "" { + chatID = update.ChatID + } else if update.ChatID != chatID { + return errors.New("updates contain different chat IDs") + } + valid := g.validateEvent(update.From, update.MembershipUpdateEvent) + if !valid { + return fmt.Errorf("invalid event %#+v from %s", update.MembershipUpdateEvent, update.From) + } + g.processEvent(update.From, update.MembershipUpdateEvent) + } + + valid := g.validateChatID(g.chatID) + if !valid { + return fmt.Errorf("invalid chat ID: %s", g.chatID) + } + if chatID != g.chatID { + return fmt.Errorf("expected chat ID equal %s, got %s", g.chatID, chatID) + } + + return nil +} + +func (g Group) ChatID() string { + return g.chatID +} + +func (g Group) Updates() []MembershipUpdateFlat { + return g.updates +} + +func (g Group) Name() string { + return g.name +} + +func (g Group) Members() []string { + return g.members.List() +} + +func (g Group) Admins() []string { + return g.admins.List() +} + +func (g Group) Joined() []string { + var result []string + for _, update := range g.updates { + if update.Type == MembershipUpdateMemberJoined { + result = append(result, update.Member) + } + } + return result +} + +func (g *Group) ProcessEvents(from *ecdsa.PublicKey, events []MembershipUpdateEvent) error { + for _, event := range events { + err := g.ProcessEvent(from, event) + if err != nil { + return err + } + } + return nil +} + +func (g *Group) ProcessEvent(from *ecdsa.PublicKey, event MembershipUpdateEvent) error { + fromHex := hexutil.Encode(gethcrypto.FromECDSAPub(from)) + if !g.validateEvent(fromHex, event) { + return fmt.Errorf("invalid event %#+v from %s", event, from) + } + update := MembershipUpdate{ + ChatID: g.chatID, + From: fromHex, + Events: []MembershipUpdateEvent{event}, + } + g.updates = append(g.updates, update.Flat()...) + g.processEvent(fromHex, event) + return nil +} + +func (g Group) LastClockValue() int64 { + if len(g.updates) == 0 { + return 0 + } + return g.updates[len(g.updates)-1].ClockValue +} + +func (g Group) NextClockValue() int64 { + return g.LastClockValue() + 1 +} + +func (g Group) creator() (string, error) { + if len(g.updates) == 0 { + return "", errors.New("no events in the group") + } + first := g.updates[0] + if first.Type != MembershipUpdateChatCreated { + return "", fmt.Errorf("expected first event to be 'chat-created', got %s", first.Type) + } + return first.From, nil +} + +func (g Group) validateChatID(chatID string) bool { + creator, err := g.creator() + if err != nil || creator == "" { + return false + } + // TODO: It does not verify that the prefix is a valid UUID. + // Improve it so that the prefix follows UUIDv4 spec. + return strings.HasSuffix(chatID, creator) && chatID != creator +} + +// validateEvent returns true if a given event is valid. +func (g Group) validateEvent(from string, event MembershipUpdateEvent) bool { switch event.Type { case MembershipUpdateChatCreated: - return len(g.Admins) == 0 && len(g.Contacts) == 0 + return g.admins.Empty() && g.members.Empty() case MembershipUpdateNameChanged: - return stringSliceContains(g.Admins, from) && len(event.Name) > 0 + return g.admins.Has(from) && len(event.Name) > 0 case MembershipUpdateMembersAdded: - return stringSliceContains(g.Admins, from) + return g.admins.Has(from) case MembershipUpdateMemberJoined: - return stringSliceContains(g.Contacts, from) && from == event.Member + return g.members.Has(from) && from == event.Member case MembershipUpdateMemberRemoved: // Member can remove themselves or admin can remove a member. - return from == event.Member || (stringSliceContains(g.Admins, from) && !stringSliceContains(g.Admins, event.Member)) + return from == event.Member || (g.admins.Has(from) && !g.admins.Has(event.Member)) case MembershipUpdateAdminsAdded: - return stringSliceContains(g.Admins, from) && stringSliceSubset(event.Members, g.Contacts) + return g.admins.Has(from) && stringSliceSubset(event.Members, g.members.List()) case MembershipUpdateAdminRemoved: - return stringSliceContains(g.Admins, from) && from == event.Member + return g.admins.Has(from) && from == event.Member default: return false } } -func stringSliceContains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } +func (g *Group) processEvent(from string, event MembershipUpdateEvent) { + switch event.Type { + case MembershipUpdateChatCreated: + g.name = event.Name + g.members.Add(event.Member) + g.admins.Add(event.Member) + case MembershipUpdateNameChanged: + g.name = event.Name + case MembershipUpdateAdminsAdded: + g.admins.Add(event.Members...) + case MembershipUpdateAdminRemoved: + g.admins.Remove(event.Member) + case MembershipUpdateMembersAdded: + g.members.Add(event.Members...) + case MembershipUpdateMemberRemoved: + g.members.Remove(event.Member) + case MembershipUpdateMemberJoined: + g.members.Add(event.Member) } - return false +} + +func (g *Group) sortEvents() { + sort.Slice(g.updates, func(i, j int) bool { + return g.updates[i].ClockValue < g.updates[j].ClockValue + }) } func stringSliceSubset(subset []string, set []string) bool { @@ -225,3 +507,82 @@ func stringSliceSubset(subset []string, set []string) bool { } return false } + +func stringSliceEquals(slice1, slice2 []string) bool { + set := map[string]struct{}{} + for _, s := range slice1 { + set[s] = struct{}{} + } + for _, s := range slice2 { + _, ok := set[s] + if !ok { + return false + } + } + return true +} + +func publicKeyToString(publicKey *ecdsa.PublicKey) string { + return hexutil.Encode(gethcrypto.FromECDSAPub(publicKey)) +} + +type stringSet struct { + m map[string]struct{} + items []string +} + +func newStringSet() *stringSet { + return &stringSet{ + m: make(map[string]struct{}), + } +} + +func newStringSetFromSlice(s []string) *stringSet { + set := newStringSet() + if len(s) > 0 { + set.Add(s...) + } + return set +} + +func (s *stringSet) Add(items ...string) { + for _, item := range items { + if _, ok := s.m[item]; !ok { + s.m[item] = struct{}{} + s.items = append(s.items, item) + } + } +} + +func (s *stringSet) Remove(items ...string) { + for _, item := range items { + if _, ok := s.m[item]; ok { + delete(s.m, item) + s.removeFromItems(item) + } + } +} + +func (s *stringSet) Has(item string) bool { + _, ok := s.m[item] + return ok +} + +func (s *stringSet) Empty() bool { + return len(s.items) == 0 +} + +func (s *stringSet) List() []string { + return s.items +} + +func (s *stringSet) removeFromItems(dropped string) { + n := 0 + for _, item := range s.items { + if item != dropped { + s.items[n] = item + n++ + } + } + s.items = s.items[:n] +} diff --git a/v1/membership_update_message_test.go b/v1/membership_update_message_test.go index 846bd0b..760ca6c 100644 --- a/v1/membership_update_message_test.go +++ b/v1/membership_update_message_test.go @@ -1,6 +1,7 @@ package statusproto import ( + "encoding/hex" "strings" "testing" "unicode" @@ -12,22 +13,21 @@ import ( ) var ( - testMembershipUpdateMessageBytes = []byte(`["~#g5",["072ea460-84d3-53c5-9979-1ca36fb5d1020x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1",["~#list",[["^ ","~:chat-id","072ea460-84d3-53c5-9979-1ca36fb5d1020x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1","~:from","0x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1","~:events",[["^ ","~:type","chat-created","~:name","thathata","~:clock-value",156897373998501],["^ ","^5","members-added","^7",156897373998502,"~:members",["~#set",["0x04aebe2bb01a988abe7d978662f21de7760486119876c680e5a559e38e086a2df6dad41c4e4d9079c03db3bced6cb70fca76afc5650e50ea19b81572046a813534"]]]],"~:signature","7fca3d614cf55bc6cdf9c17fd1e65d1688673322bf1f004c58c78e0927edefea3d1053bf6a9d2e058ae88079f588105dccf2a2f9f330f6035cd47c715ee5950601"]]],null]]`) + testMembershipUpdateMessageBytes = []byte(`["~#g5",["072ea460-84d3-53c5-9979-1ca36fb5d1020x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1",["~#list",[["^ ","~:chat-id","072ea460-84d3-53c5-9979-1ca36fb5d1020x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1","~:events",[["^ ","~:type","chat-created","~:name","thathata","~:clock-value",156897373998501],["^ ","^4","members-added","^6",156897373998502,"~:members",["~#set",["0x04aebe2bb01a988abe7d978662f21de7760486119876c680e5a559e38e086a2df6dad41c4e4d9079c03db3bced6cb70fca76afc5650e50ea19b81572046a813534"]]]],"~:signature","7fca3d614cf55bc6cdf9c17fd1e65d1688673322bf1f004c58c78e0927edefea3d1053bf6a9d2e058ae88079f588105dccf2a2f9f330f6035cd47c715ee5950601"]]],null]]`) testMembershipUpdateMessageStruct = MembershipUpdateMessage{ ChatID: "072ea460-84d3-53c5-9979-1ca36fb5d1020x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1", Updates: []MembershipUpdate{ { ChatID: "072ea460-84d3-53c5-9979-1ca36fb5d1020x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1", Signature: "7fca3d614cf55bc6cdf9c17fd1e65d1688673322bf1f004c58c78e0927edefea3d1053bf6a9d2e058ae88079f588105dccf2a2f9f330f6035cd47c715ee5950601", - From: "0x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1", Events: []MembershipUpdateEvent{ { - Type: "chat-created", + Type: MembershipUpdateChatCreated, Name: "thathata", ClockValue: 156897373998501, }, { - Type: "members-added", + Type: MembershipUpdateMembersAdded, Members: []string{"0x04aebe2bb01a988abe7d978662f21de7760486119876c680e5a559e38e086a2df6dad41c4e4d9079c03db3bced6cb70fca76afc5650e50ea19b81572046a813534"}, ClockValue: 156897373998502, }, @@ -107,7 +107,100 @@ func TestSignMembershipUpdate(t *testing.T) { require.Equal(t, expected, update.Signature) } +func TestGroupCreator(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + g, err := NewGroupWithCreator("abc", key) + require.NoError(t, err) + creator, err := g.creator() + require.NoError(t, err) + require.Equal(t, publicKeyToString(&key.PublicKey), creator) +} + +func TestGroupProcessEvent(t *testing.T) { + createGroup := func(admins, members []string, name string) Group { + return Group{ + name: name, + admins: newStringSetFromSlice(admins), + members: newStringSetFromSlice(members), + } + } + + testCases := []struct { + Name string + Group Group + Result Group + From string + Event MembershipUpdateEvent + }{ + { + Name: "chat-created event", + Group: createGroup(nil, nil, ""), + Result: createGroup([]string{"0xabc"}, []string{"0xabc"}, "some-name"), + From: "0xabc", + Event: NewChatCreatedEvent("some-name", "0xabc", 0), + }, + { + Name: "name-changed event", + Group: createGroup(nil, nil, ""), + Result: createGroup(nil, nil, "some-name"), + From: "0xabc", + Event: NewNameChangedEvent("some-name", 0), + }, + { + Name: "admins-added event", + Group: createGroup(nil, nil, ""), + Result: createGroup([]string{"0xabc", "0x123"}, nil, ""), + From: "0xabc", + Event: NewAdminsAddedEvent([]string{"0xabc", "0x123"}, 0), + }, + { + Name: "admin-removed event", + Group: createGroup([]string{"0xabc", "0xdef"}, nil, ""), + Result: createGroup([]string{"0xdef"}, nil, ""), + From: "0xabc", + Event: NewAdminRemovedEvent("0xabc", 0), + }, + { + Name: "members-added event", + Group: createGroup(nil, nil, ""), + Result: createGroup(nil, []string{"0xabc", "0xdef"}, ""), + From: "0xabc", + Event: NewMembersAddedEvent([]string{"0xabc", "0xdef"}, 0), + }, + { + Name: "member-removed event", + Group: createGroup(nil, []string{"0xabc", "0xdef"}, ""), + Result: createGroup(nil, []string{"0xdef"}, ""), + From: "0xabc", + Event: NewMemberRemovedEvent("0xabc", 0), + }, + { + Name: "member-joined event", + Group: createGroup(nil, []string{"0xabc"}, ""), + Result: createGroup(nil, []string{"0xabc", "0xdef"}, ""), + From: "0xabc", + Event: NewMemberJoinedEvent("0xdef", 0), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + g := tc.Group + g.processEvent(tc.From, tc.Event) + require.EqualValues(t, tc.Result, g) + }) + } +} + func TestGroupValidateEvent(t *testing.T) { + createGroup := func(admins, members []string) Group { + return Group{ + admins: newStringSetFromSlice(admins), + members: newStringSetFromSlice(members), + } + } + testCases := []struct { Name string From string @@ -116,152 +209,132 @@ func TestGroupValidateEvent(t *testing.T) { Result bool }{ { - Name: "chat-created with empty admins and contacts", - Event: NewChatCreatedEvent("test", 0), + Name: "chat-created with empty admins and members", + Group: createGroup(nil, nil), + Event: NewChatCreatedEvent("test", "0xabc", 0), Result: true, }, { - Name: "chat-created with existing admins", - Group: Group{ - Admins: []string{"0xabc"}, - }, - Event: NewChatCreatedEvent("test", 0), + Name: "chat-created with existing admins", + Group: createGroup([]string{"0xabc"}, nil), + Event: NewChatCreatedEvent("test", "0xabc", 0), Result: false, }, { - Name: "chat-created with existing contacts", - Group: Group{ - Contacts: []string{"0xabc"}, - }, - Event: NewChatCreatedEvent("test", 0), + Name: "chat-created with existing members", + Group: createGroup(nil, []string{"0xabc"}), + Event: NewChatCreatedEvent("test", "0xabc", 0), Result: false, }, { - Name: "name-changed allowed because from is admin", - From: "0xabc", - Group: Group{ - Admins: []string{"0xabc"}, - }, + Name: "name-changed allowed because from is admin", + From: "0xabc", + Group: createGroup([]string{"0xabc"}, nil), Event: NewNameChangedEvent("new-name", 0), Result: true, }, { Name: "name-changed not allowed for non-admins", From: "0xabc", + Group: createGroup(nil, nil), Event: NewNameChangedEvent("new-name", 0), Result: false, }, { - Name: "members-added allowed because from is admin", - From: "0xabc", - Group: Group{ - Admins: []string{"0xabc"}, - }, + Name: "members-added allowed because from is admin", + From: "0xabc", + Group: createGroup([]string{"0xabc"}, nil), Event: NewMembersAddedEvent([]string{"0x123"}, 0), Result: true, }, { Name: "members-added not allowed for non-admins", From: "0xabc", + Group: createGroup(nil, nil), Event: NewMembersAddedEvent([]string{"0x123"}, 0), Result: false, }, { Name: "member-removed allowed because removing themselves", From: "0xabc", + Group: createGroup(nil, nil), Event: NewMemberRemovedEvent("0xabc", 0), Result: true, }, { - Name: "member-removed allowed because from is admin", - From: "0xabc", - Group: Group{ - Admins: []string{"0xabc"}, - }, + Name: "member-removed allowed because from is admin", + From: "0xabc", + Group: createGroup([]string{"0xabc"}, nil), Event: NewMemberRemovedEvent("0x123", 0), Result: true, }, { Name: "member-removed not allowed for non-admins", From: "0xabc", + Group: createGroup(nil, nil), Event: NewMemberRemovedEvent("0x123", 0), Result: false, }, { - Name: "member-joined must be in contacts", - From: "0xabc", - Group: Group{ - Contacts: []string{"0xabc"}, - }, + Name: "member-joined must be in members", + From: "0xabc", + Group: createGroup(nil, []string{"0xabc"}), Event: NewMemberJoinedEvent("0xabc", 0), Result: true, }, { - Name: "member-joined not valid because not in contacts", + Name: "member-joined not valid because not in members", From: "0xabc", + Group: createGroup(nil, nil), Event: NewMemberJoinedEvent("0xabc", 0), Result: false, }, { Name: "member-joined not valid because from differs from the event", From: "0xdef", + Group: createGroup(nil, nil), Event: NewMemberJoinedEvent("0xabc", 0), Result: false, }, { - Name: "admins-added allowed because originating from other admin", - From: "0xabc", - Group: Group{ - Admins: []string{"0xabc", "0x123"}, - Contacts: []string{"0xdef", "0xghi"}, - }, + Name: "admins-added allowed because originating from other admin", + From: "0xabc", + Group: createGroup([]string{"0xabc", "0x123"}, []string{"0xdef", "0xghi"}), Event: NewAdminsAddedEvent([]string{"0xdef"}, 0), Result: true, }, { - Name: "admins-added not allowed because not from admin", - From: "0xabc", - Group: Group{ - Admins: []string{"0x123"}, - Contacts: []string{"0xdef", "0xghi"}, - }, + Name: "admins-added not allowed because not from admin", + From: "0xabc", + Group: createGroup([]string{"0x123"}, []string{"0xdef", "0xghi"}), Event: NewAdminsAddedEvent([]string{"0xdef"}, 0), Result: false, }, { - Name: "admins-added not allowed because not in contacts", - From: "0xabc", - Group: Group{ - Admins: []string{"0xabc", "0x123"}, - Contacts: []string{"0xghi"}, - }, + Name: "admins-added not allowed because not in members", + From: "0xabc", + Group: createGroup([]string{"0xabc", "0x123"}, []string{"0xghi"}), Event: NewAdminsAddedEvent([]string{"0xdef"}, 0), Result: false, }, { - Name: "admin-removed allowed because is admin and removes themselves", - From: "0xabc", - Group: Group{ - Admins: []string{"0xabc"}, - }, + Name: "admin-removed allowed because is admin and removes themselves", + From: "0xabc", + Group: createGroup([]string{"0xabc"}, nil), Event: NewAdminRemovedEvent("0xabc", 0), Result: true, }, { - Name: "admin-removed not allowed because not themselves", - From: "0xabc", - Group: Group{ - Admins: []string{"0xabc", "0xdef"}, - }, + Name: "admin-removed not allowed because not themselves", + From: "0xabc", + Group: createGroup([]string{"0xabc", "0xdef"}, nil), Event: NewAdminRemovedEvent("0xdef", 0), Result: false, }, { - Name: "admin-removed not allowed because not admin", - From: "0xdef", - Group: Group{ - Admins: []string{"0xabc"}, - }, + Name: "admin-removed not allowed because not admin", + From: "0xdef", + Group: createGroup([]string{"0xabc"}, nil), Event: NewAdminRemovedEvent("0xabc", 0), Result: false, }, @@ -269,8 +342,101 @@ func TestGroupValidateEvent(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { - result := tc.Group.ValidateEvent(tc.From, tc.Event) + result := tc.Group.validateEvent(tc.From, tc.Event) assert.Equal(t, tc.Result, result) }) } } + +func TestMembershipUpdateMessageProcess(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + updates := []MembershipUpdate{ + { + ChatID: "some-chat", + Events: []MembershipUpdateEvent{ + NewChatCreatedEvent("some-name", "0xabc", 0), + }, + }, + } + err = updates[0].Sign(key) + require.NoError(t, err) + require.NotEmpty(t, updates[0].Signature) + + message := MembershipUpdateMessage{ + ChatID: "some-chat", + Updates: updates, + } + err = message.Verify() + require.NoError(t, err) + require.EqualValues(t, "0x"+hex.EncodeToString(crypto.FromECDSAPub(&key.PublicKey)), updates[0].From) +} + +func TestMembershipUpdateEventEqual(t *testing.T) { + u1 := MembershipUpdateEvent{ + Type: MembershipUpdateChatCreated, + ClockValue: 1, + Member: "0xabc", + Members: []string{"0xabc"}, + Name: "abc", + } + require.True(t, u1.Equal(u1)) + + // Verify equality breaking. + u2 := u1 + u2.Members = append(u2.Members, "0xdef") + require.False(t, u1.Equal(u2)) + u2 = u1 + u2.Type = MembershipUpdateMembersAdded + require.False(t, u1.Equal(u2)) + u2 = u1 + u2.ClockValue = 2 + require.False(t, u1.Equal(u2)) + u2 = u1 + u2.Member = "0xdef" + require.False(t, u1.Equal(u2)) + u2 = u1 + u2.Name = "def" + require.False(t, u1.Equal(u2)) +} + +func TestMembershipUpdateFlatEqual(t *testing.T) { + u1 := MembershipUpdateFlat{ + ChatID: "abc", + Signature: "abc", + From: "0xabc", + } + require.True(t, u1.Equal(u1)) + + // Verify equality breaking. + u2 := u1 + u2.ChatID = "def" + require.False(t, u1.Equal(u2)) + u2 = u1 + u2.Signature = "def" + require.False(t, u1.Equal(u2)) + u2 = u1 + u2.From = "0xdef" + require.False(t, u1.Equal(u2)) +} + +func TestMergeFlatMembershipUpdates(t *testing.T) { + u1 := []MembershipUpdateFlat{ + { + ChatID: "abc", + Signature: "abc", + From: "0xabc", + }, + } + u2 := []MembershipUpdateFlat{ + { + ChatID: "abc", + Signature: "def", + From: "0xdef", + }, + } + result := MergeFlatMembershipUpdates(u1, u1) + require.EqualValues(t, u1, result) + result = MergeFlatMembershipUpdates(u1, u2) + require.EqualValues(t, append(u1, u2...), result) +}