From 1c6e908d2c5b10f002a422412b655ebac30d2c71 Mon Sep 17 00:00:00 2001 From: Kyle Truong Date: Tue, 29 Sep 2026 03:17:06 +0000 Subject: [PATCH] fix(chat): sign the timer and screen-capture setting in add-members xurl chat add-members failed with "Invalid action signature" on a group whose message timer (message_ttl_ms) or screen-capture blocking setting (screen_capture_blocking_enabled) had been set. The member-add action signature covers the group's current state. addGroupMembers already reads both settings from the conversation, but it never passed them to PrepareGroupMembersChange, so the signed state reported them as unset and didn't match the group. Pass CurrentTTLMsec and CurrentScreenCaptureBlockingEnabled from the conversation metadata. The new TestAddGroupMembersSignsGroupSettings checks that both values reach the signed member-add payload; it fails without the fix. --- CHANGELOG.md | 4 +++ cli/chat.go | 16 ++++++----- cli/chat_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8e541..5059f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All user-visible bugs and enhancements should be recorded here. ## Unreleased +### Fixed + +- `xurl chat add-members` no longer fails with "Invalid action signature" on a group whose message timer (`message_ttl_ms`) or screen-capture blocking setting (`screen_capture_blocking_enabled`) has been set. The signed group state left both out; it now carries the values from the conversation. + ## v1.3.2 - 2026-09-23 ### Fixed diff --git a/cli/chat.go b/cli/chat.go index d92f7d3..790964c 100644 --- a/cli/chat.go +++ b/cli/chat.go @@ -1636,13 +1636,15 @@ func (s *chatSession) addGroupMembers(conversationID string, newMembers []string return err } prepared, err := s.chat.PrepareGroupMembersChange(chatxdk.GroupMembersChangeParams{ - PublicKeys: inputs, - ConversationID: api.ChatConversationEventID(conversationID), - NewMemberIDs: newIDs, - CurrentMemberIDs: meta.MemberIDs, - CurrentAdminIDs: meta.AdminIDs, - CurrentTitle: meta.GroupName, - CurrentAvatarURL: meta.GroupAvatarURL, + PublicKeys: inputs, + ConversationID: api.ChatConversationEventID(conversationID), + NewMemberIDs: newIDs, + CurrentMemberIDs: meta.MemberIDs, + CurrentAdminIDs: meta.AdminIDs, + CurrentTitle: meta.GroupName, + CurrentAvatarURL: meta.GroupAvatarURL, + CurrentTTLMsec: meta.MessageTTLMs, + CurrentScreenCaptureBlockingEnabled: meta.ScreenCaptureBlockingEnabled, }) if err != nil { return fmt.Errorf("failed to prepare member change: %w", err) diff --git a/cli/chat_test.go b/cli/chat_test.go index 315c63b..42181ad 100644 --- a/cli/chat_test.go +++ b/cli/chat_test.go @@ -152,6 +152,75 @@ func TestPreparedChangeToRequestShape(t *testing.T) { assert.False(t, has) } +// addMembersStubClient serves the requests addGroupMembers makes and records +// the body it posts. +type addMembersStubClient struct { + api.Client + conversation string + publicKey string + posted string +} + +func (c *addMembersStubClient) SendRequest(opts api.RequestOptions) (json.RawMessage, error) { + switch { + case opts.Method == "GET" && opts.Endpoint == "/2/chat/conversations/g100": + return json.RawMessage(c.conversation), nil + case opts.Method == "GET" && strings.HasSuffix(opts.Endpoint, "/public_keys"): + return json.RawMessage(fmt.Sprintf(`{"data":[{"public_key_version":"1","public_key":%q}]}`, c.publicKey)), nil + case opts.Method == "POST" && opts.Endpoint == "/2/chat/conversations/g100/members": + c.posted = opts.Data + return json.RawMessage(`{"data":{}}`), nil + } + return nil, fmt.Errorf("unexpected request %s %s", opts.Method, opts.Endpoint) +} + +func TestAddGroupMembersSignsGroupSettings(t *testing.T) { + // The member-add signature covers the group's current settings. Its signed + // payload ends with {message_ttl_ms|null},{conversation key version}, + // {screen_capture_blocking_enabled|null}, and must match the conversation. + tests := []struct { + name string + settings string + want string + }{ + {"settings never set", ``, "null,%s,null"}, + {"timer on, screen capture off", `,"message_ttl_ms":86400000,"screen_capture_blocking_enabled":false`, "86400000,%s,false"}, + {"screen capture on", `,"screen_capture_blocking_enabled":true`, "null,%s,true"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chat := chatxdk.New() + defer chat.Close() + keys, err := chat.GenerateKeypairs() + require.NoError(t, err) + require.NoError(t, chat.SetIdentity("42", "1")) + + client := &addMembersStubClient{ + conversation: `{"data":{"id":"g100","type":"group","member_ids":["42","7"],"admin_ids":["42"]` + tt.settings + `}}`, + publicKey: keys.PublicKey.PublicKey, + } + s := &chatSession{chat: chat, client: client, userID: "42", usernames: map[string]string{}} + require.NoError(t, s.addGroupMembers("g100", []string{"300"}, true)) + + var body struct { + ConversationKeyVersion string `json:"conversation_key_version"` + ActionSignatures []struct { + SignaturePayload string `json:"signature_payload"` + } `json:"action_signatures"` + } + require.NoError(t, json.Unmarshal([]byte(client.posted), &body)) + var memberAdd string + for _, sig := range body.ActionSignatures { + if strings.HasPrefix(sig.SignaturePayload, "GroupChangeEvent.GroupMemberAddChange,") { + memberAdd = sig.SignaturePayload + } + } + require.NotEmpty(t, memberAdd, "no member-add signature in the request") + assert.True(t, strings.HasSuffix(memberAdd, ","+fmt.Sprintf(tt.want, body.ConversationKeyVersion)), memberAdd) + }) + } +} + func TestChatConversationPathHelpers(t *testing.T) { assert.Equal(t, "1-2", api.ChatConversationPathID("1:2")) assert.Equal(t, "1:2", api.ChatConversationEventID("1-2"))