package protocol import ( "bytes" "crypto/ecdsa" "fmt" "sort" "strings" "github.com/golang/protobuf/proto" "github.com/google/uuid" "github.com/pkg/errors" "github.com/status-im/status-go/eth-node/crypto" "github.com/status-im/status-go/eth-node/types" "github.com/status-im/status-go/protocol/protobuf" ) // MembershipUpdateMessage is a message used to propagate information // about group membership changes. // For more information, see https://github.com/status-im/specs/blob/master/status-group-chats-spec.md. type MembershipUpdateMessage struct { ChatID string `json:"chatId"` // UUID concatenated with hex-encoded public key of the creator for the chat Events []MembershipUpdateEvent `json:"events"` Message *protobuf.ChatMessage `json:"-"` EmojiReaction *protobuf.EmojiReaction `json:"-"` } const signatureLength = 65 func MembershipUpdateEventFromProtobuf(chatID string, raw []byte) (*MembershipUpdateEvent, error) { if len(raw) <= signatureLength { return nil, errors.New("invalid payload length") } decodedEvent := protobuf.MembershipUpdateEvent{} signature := raw[:signatureLength] encodedEvent := raw[signatureLength:] signatureMaterial := append([]byte(chatID), encodedEvent...) publicKey, err := crypto.ExtractSignature(signatureMaterial, signature) if err != nil { return nil, errors.Wrap(err, "failed to extract signature") } from := publicKeyToString(publicKey) err = proto.Unmarshal(encodedEvent, &decodedEvent) if err != nil { return nil, err } return &MembershipUpdateEvent{ ClockValue: decodedEvent.Clock, ChatID: chatID, Members: decodedEvent.Members, Name: decodedEvent.Name, Type: decodedEvent.Type, Color: decodedEvent.Color, Image: decodedEvent.Image, Signature: signature, RawPayload: encodedEvent, From: from, }, nil } func (m *MembershipUpdateMessage) ToProtobuf() (*protobuf.MembershipUpdateMessage, error) { var rawEvents [][]byte for _, e := range m.Events { var encodedEvent []byte encodedEvent = append(encodedEvent, e.Signature...) encodedEvent = append(encodedEvent, e.RawPayload...) rawEvents = append(rawEvents, encodedEvent) } mUM := &protobuf.MembershipUpdateMessage{ ChatId: m.ChatID, Events: rawEvents, } // If message is not piggybacking anything, that's a valid case and we just return switch { case m.Message != nil: mUM.ChatEntity = &protobuf.MembershipUpdateMessage_Message{Message: m.Message} case m.EmojiReaction != nil: mUM.ChatEntity = &protobuf.MembershipUpdateMessage_EmojiReaction{EmojiReaction: m.EmojiReaction} } return mUM, nil } func MembershipUpdateMessageFromProtobuf(raw *protobuf.MembershipUpdateMessage) (*MembershipUpdateMessage, error) { var events []MembershipUpdateEvent for _, e := range raw.Events { verifiedEvent, err := MembershipUpdateEventFromProtobuf(raw.ChatId, e) if err != nil { return nil, err } events = append(events, *verifiedEvent) } return &MembershipUpdateMessage{ ChatID: raw.ChatId, Events: events, Message: raw.GetMessage(), EmojiReaction: raw.GetEmojiReaction(), }, nil } // EncodeMembershipUpdateMessage encodes a MembershipUpdateMessage using protobuf serialization. func EncodeMembershipUpdateMessage(value MembershipUpdateMessage) ([]byte, error) { pb, err := value.ToProtobuf() if err != nil { return nil, err } return proto.Marshal(pb) } // MembershipUpdateEvent contains an event information. // Member and Members are hex-encoded values with 0x prefix. type MembershipUpdateEvent struct { Type protobuf.MembershipUpdateEvent_EventType `json:"type"` ClockValue uint64 `json:"clockValue"` Members []string `json:"members,omitempty"` // in "members-added" and "admins-added" events Name string `json:"name,omitempty"` // name of the group chat Color string `json:"color,omitempty"` // color of the group chat Image []byte `json:"image,omitempty"` // image of the group chat From string `json:"from,omitempty"` Signature []byte `json:"signature,omitempty"` ChatID string `json:"chatId"` RawPayload []byte `json:"rawPayload"` } func (u *MembershipUpdateEvent) Equal(update MembershipUpdateEvent) bool { return bytes.Equal(u.Signature, update.Signature) } func (u *MembershipUpdateEvent) Sign(key *ecdsa.PrivateKey) error { if len(u.ChatID) == 0 { return errors.New("can't sign with empty chatID") } encodedEvent, err := proto.Marshal(u.ToProtobuf()) if err != nil { return err } u.RawPayload = encodedEvent var signatureMaterial []byte signatureMaterial = append(signatureMaterial, []byte(u.ChatID)...) signatureMaterial = crypto.Keccak256(append(signatureMaterial, u.RawPayload...)) signature, err := crypto.Sign(signatureMaterial, key) if err != nil { return err } u.Signature = signature u.From = publicKeyToString(&key.PublicKey) return nil } func (u *MembershipUpdateEvent) ToProtobuf() *protobuf.MembershipUpdateEvent { return &protobuf.MembershipUpdateEvent{ Clock: u.ClockValue, Name: u.Name, Color: u.Color, Image: u.Image, Members: u.Members, Type: u.Type, } } func MergeMembershipUpdateEvents(dest []MembershipUpdateEvent, src []MembershipUpdateEvent) []MembershipUpdateEvent { 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, color string, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_CHAT_CREATED, Name: name, ClockValue: clock, Color: color, } } func NewNameChangedEvent(name string, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_NAME_CHANGED, Name: name, ClockValue: clock, } } func NewColorChangedEvent(color string, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_COLOR_CHANGED, Color: color, ClockValue: clock, } } func NewImageChangedEvent(image []byte, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_IMAGE_CHANGED, Image: image, ClockValue: clock, } } func NewMembersAddedEvent(members []string, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_MEMBERS_ADDED, Members: members, ClockValue: clock, } } func NewMemberJoinedEvent(clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_MEMBER_JOINED, ClockValue: clock, } } func NewAdminsAddedEvent(admins []string, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_ADMINS_ADDED, Members: admins, ClockValue: clock, } } func NewMemberRemovedEvent(member string, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_MEMBER_REMOVED, Members: []string{member}, ClockValue: clock, } } func NewAdminRemovedEvent(admin string, clock uint64) MembershipUpdateEvent { return MembershipUpdateEvent{ Type: protobuf.MembershipUpdateEvent_ADMIN_REMOVED, Members: []string{admin}, ClockValue: clock, } } type Group struct { chatID string name string color string image []byte events []MembershipUpdateEvent admins *stringSet members *stringSet } func groupChatID(creator *ecdsa.PublicKey) string { return uuid.New().String() + "-" + publicKeyToString(creator) } func NewGroupWithEvents(chatID string, events []MembershipUpdateEvent) (*Group, error) { return newGroup(chatID, events) } func NewGroupWithCreator(name string, color string, clock uint64, creator *ecdsa.PrivateKey) (*Group, error) { chatID := groupChatID(&creator.PublicKey) chatCreated := NewChatCreatedEvent(name, color, clock) chatCreated.ChatID = chatID err := chatCreated.Sign(creator) if err != nil { return nil, err } return newGroup(chatID, []MembershipUpdateEvent{chatCreated}) } func newGroup(chatID string, events []MembershipUpdateEvent) (*Group, error) { g := Group{ chatID: chatID, events: events, 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 _, event := range g.events { if chatID == "" { chatID = event.ChatID } else if event.ChatID != chatID { return errors.New("updates contain different chat IDs") } valid := g.validateEvent(event) if !valid { return fmt.Errorf("invalid event %#+v from %s", event, event.From) } g.processEvent(event) } 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) Name() string { return g.name } func (g Group) Color() string { return g.color } func (g Group) Image() []byte { return g.image } func (g Group) Events() []MembershipUpdateEvent { return g.events } // AbridgedEvents returns the minimum set of events for a user to publish a post // The events we want to keep: // 1) Chat created // 2) Latest color changed // 3) Latest image changed // 4) For each admin, the latest admins added event that contains them // 5) For each member, the latest members added event that contains them // 4 & 5, might bring removed admins or removed members, for those, we also need to // keep the event that removes them func (g Group) AbridgedEvents() []MembershipUpdateEvent { var events []MembershipUpdateEvent var nameChangedEventFound bool var colorChangedEventFound bool var imageChangedEventFound bool removedMembers := make(map[string]*MembershipUpdateEvent) addedMembers := make(map[string]bool) extraMembers := make(map[string]bool) admins := make(map[string]bool) // Iterate in reverse for i := len(g.events) - 1; i >= 0; i-- { event := g.events[i] switch event.Type { case protobuf.MembershipUpdateEvent_CHAT_CREATED: events = append(events, event) case protobuf.MembershipUpdateEvent_NAME_CHANGED: if nameChangedEventFound { continue } events = append(events, event) nameChangedEventFound = true case protobuf.MembershipUpdateEvent_COLOR_CHANGED: if colorChangedEventFound { continue } events = append(events, event) colorChangedEventFound = true case protobuf.MembershipUpdateEvent_IMAGE_CHANGED: if imageChangedEventFound { continue } events = append(events, event) imageChangedEventFound = true case protobuf.MembershipUpdateEvent_MEMBERS_ADDED: var shouldAddEvent bool for _, m := range event.Members { // If it's adding a current user, and we don't have a more // recent event // if it's an admin, we track it if admins[m] || (g.members.Has(m) && !addedMembers[m]) { addedMembers[m] = true shouldAddEvent = true } } if shouldAddEvent { // Append the event and check the not current members that are also // added for _, m := range event.Members { if !g.members.Has(m) && !admins[m] { extraMembers[m] = true } } events = append(events, event) } case protobuf.MembershipUpdateEvent_ADMIN_REMOVED: // We add it always for now events = append(events, event) case protobuf.MembershipUpdateEvent_ADMINS_ADDED: // We track admins in full admins[event.Members[0]] = true events = append(events, event) case protobuf.MembershipUpdateEvent_MEMBER_REMOVED: // Save member removed events, as we might need it // to remove members who have been added but subsequently left if removedMembers[event.Members[0]] == nil || removedMembers[event.Members[0]].ClockValue < event.ClockValue { removedMembers[event.Members[0]] = &event } case protobuf.MembershipUpdateEvent_MEMBER_JOINED: if g.members.Has(event.From) { events = append(events, event) } } } for m := range extraMembers { if removedMembers[m] != nil { events = append(events, *removedMembers[m]) } } sort.Slice(events, func(i, j int) bool { return events[i].ClockValue < events[j].ClockValue }) return events } func (g Group) Members() []string { return g.members.List() } func (g Group) Admins() []string { return g.admins.List() } func (g *Group) ProcessEvents(events []MembershipUpdateEvent) error { for _, event := range events { err := g.ProcessEvent(event) if err != nil { return err } } return nil } func (g *Group) ProcessEvent(event MembershipUpdateEvent) error { if !g.validateEvent(event) { return fmt.Errorf("invalid event %#+v", event) } // Check if exists g.events = append(g.events, event) g.processEvent(event) return nil } func (g Group) LastClockValue() uint64 { if len(g.events) == 0 { return 0 } return g.events[len(g.events)-1].ClockValue } func (g Group) Creator() (string, error) { if len(g.events) == 0 { return "", errors.New("no events in the group") } first := g.events[0] if first.Type != protobuf.MembershipUpdateEvent_CHAT_CREATED { 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 } func (g Group) IsMember(id string) bool { return g.members.Has(id) } // validateEvent returns true if a given event is valid. func (g Group) validateEvent(event MembershipUpdateEvent) bool { if len(event.From) == 0 { return false } switch event.Type { case protobuf.MembershipUpdateEvent_CHAT_CREATED: return g.admins.Empty() && g.members.Empty() case protobuf.MembershipUpdateEvent_NAME_CHANGED: return (g.admins.Has(event.From) || g.members.Has(event.From)) && len(event.Name) > 0 case protobuf.MembershipUpdateEvent_COLOR_CHANGED: return (g.admins.Has(event.From) || g.members.Has(event.From)) && len(event.Color) > 0 case protobuf.MembershipUpdateEvent_IMAGE_CHANGED: return (g.admins.Has(event.From) || g.members.Has(event.From)) && len(event.Image) > 0 case protobuf.MembershipUpdateEvent_MEMBERS_ADDED: return g.admins.Has(event.From) || g.members.Has(event.From) case protobuf.MembershipUpdateEvent_MEMBER_JOINED: return g.members.Has(event.From) case protobuf.MembershipUpdateEvent_MEMBER_REMOVED: // Member can remove themselves or admin can remove a member. return len(event.Members) == 1 && (event.From == event.Members[0] || (g.admins.Has(event.From) && !g.admins.Has(event.Members[0]))) case protobuf.MembershipUpdateEvent_ADMINS_ADDED: return g.admins.Has(event.From) && stringSliceSubset(event.Members, g.members.List()) case protobuf.MembershipUpdateEvent_ADMIN_REMOVED: return len(event.Members) == 1 && g.admins.Has(event.From) && event.From == event.Members[0] default: return false } } func (g *Group) processEvent(event MembershipUpdateEvent) { switch event.Type { case protobuf.MembershipUpdateEvent_CHAT_CREATED: g.name = event.Name g.color = event.Color g.members.Add(event.From) g.admins.Add(event.From) case protobuf.MembershipUpdateEvent_NAME_CHANGED: g.name = event.Name case protobuf.MembershipUpdateEvent_COLOR_CHANGED: g.color = event.Color case protobuf.MembershipUpdateEvent_IMAGE_CHANGED: g.image = event.Image case protobuf.MembershipUpdateEvent_ADMINS_ADDED: g.admins.Add(event.Members...) case protobuf.MembershipUpdateEvent_ADMIN_REMOVED: g.admins.Remove(event.Members[0]) case protobuf.MembershipUpdateEvent_MEMBERS_ADDED: g.members.Add(event.Members...) case protobuf.MembershipUpdateEvent_MEMBER_REMOVED: g.admins.Remove(event.Members[0]) g.members.Remove(event.Members[0]) } } func (g *Group) sortEvents() { sort.Slice(g.events, func(i, j int) bool { return g.events[i].ClockValue < g.events[j].ClockValue }) } func stringSliceSubset(subset []string, set []string) bool { for _, item1 := range set { var found bool for _, item2 := range subset { if item1 == item2 { found = true break } } if found { return true } } return false } func publicKeyToString(publicKey *ecdsa.PublicKey) string { return types.EncodeHex(crypto.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] }