diff --git a/ROADMAP.md b/ROADMAP.md index 3899a4a2..90100339 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,10 +16,10 @@ * [x] Message edits * [x] Message reactions * [x] Message redactions - * [ ] Group info changes - * [ ] Name - * [ ] Avatar - * [ ] Topic + * [x] Group info changes + * [x] Name + * [x] Avatar + * [x] Topic * [ ] Membership actions * [ ] Join (accepting invites) * [ ] Invite diff --git a/pkg/libsignalgo/groupsecretparams.go b/pkg/libsignalgo/groupsecretparams.go index 48618688..c547ec0b 100644 --- a/pkg/libsignalgo/groupsecretparams.go +++ b/pkg/libsignalgo/groupsecretparams.go @@ -116,6 +116,26 @@ func (gsp *GroupSecretParams) DecryptBlobWithPadding(blob []byte) ([]byte, error return CopySignalOwnedBufferToBytes(plaintext), nil } +func (gsp *GroupSecretParams) EncryptBlobWithPaddingDeterministic(randomness Randomness, plaintext []byte, padding_len uint32) ([]byte, error) { + var ciphertext C.SignalOwnedBuffer = C.SignalOwnedBuffer{} + borrowedPlaintext := BytesToBuffer(plaintext) + signalFfiError := C.signal_group_secret_params_encrypt_blob_with_padding_deterministic( + &ciphertext, + (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)), + (*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness)), + borrowedPlaintext, + (C.uint32_t)(padding_len), + ) + runtime.KeepAlive(randomness) + runtime.KeepAlive(gsp) + runtime.KeepAlive(plaintext) + runtime.KeepAlive(padding_len) + if signalFfiError != nil { + return nil, wrapError(signalFfiError) + } + return CopySignalOwnedBufferToBytes(ciphertext), nil +} + func (gsp *GroupSecretParams) DecryptUUID(ciphertextUUID UUIDCiphertext) (uuid.UUID, error) { u := C.SignalServiceIdFixedWidthBinaryBytes{} signalFfiError := C.signal_group_secret_params_decrypt_service_id( diff --git a/pkg/libsignalgo/verifysignature.go b/pkg/libsignalgo/verifysignature.go index 69d354e9..08fbab7a 100644 --- a/pkg/libsignalgo/verifysignature.go +++ b/pkg/libsignalgo/verifysignature.go @@ -1,5 +1,5 @@ // mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber +// Copyright (C) 2024 Malte Eggers // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by diff --git a/pkg/signalmeow/attachments.go b/pkg/signalmeow/attachments.go index 2095d1ea..5fb06353 100644 --- a/pkg/signalmeow/attachments.go +++ b/pkg/signalmeow/attachments.go @@ -27,12 +27,17 @@ import ( "fmt" "io" "math" + "mime/multipart" "net/http" "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "go.mau.fi/util/random" + "google.golang.org/protobuf/proto" + "go.mau.fi/mautrix-signal/pkg/libsignalgo" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" + "go.mau.fi/mautrix-signal/pkg/signalmeow/types" "go.mau.fi/mautrix-signal/pkg/signalmeow/web" ) @@ -200,6 +205,80 @@ func (cli *Client) UploadAttachment(ctx context.Context, body []byte) (*signalpb return attachmentPointer, nil } +func (cli *Client) UploadGroupAvatar(ctx context.Context, avatarBytes []byte, gid types.GroupIdentifier) (*string, error) { + groupMasterKey, err := cli.Store.GroupStore.MasterKeyFromGroupIdentifier(ctx, gid) + if err != nil { + log.Err(err).Msg("Could not get master key from group id") + return nil, err + } + groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyToBytes(groupMasterKey)) + if err != nil { + log.Err(err).Msg("Failed to get Authorization for today") + return nil, err + } + groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyToBytes(groupMasterKey)) + if err != nil { + log.Err(err).Msg("Could not get groupSecretParams from master key") + return nil, err + } + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Avatar{Avatar: avatarBytes}} + encryptedAvatar, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not encrypt avatar into Group Property") + return nil, err + } + + // Get upload form from Signal server + formPath := "/v1/groups/avatar/form" + opts := &web.HTTPReqOpt{Username: &groupAuth.Username, Password: &groupAuth.Password, ContentType: web.ContentTypeProtobuf, Host: web.StorageHostname} + resp, err := web.SendHTTPRequest(ctx, http.MethodGet, formPath, opts) + if err != nil { + log.Err(err).Msg("Error sending request fetching avatar upload form") + return nil, err + } + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Err(err).Msg("Error decoding response body fetching upload attributes") + return nil, err + } + uploadForm := signalpb.AvatarUploadAttributes{} + err = proto.Unmarshal(body, &uploadForm) + if err != nil { + log.Err(err).Msg("failed to unmarshal group avatar upload form") + return nil, err + } + requestBody := &bytes.Buffer{} + w := multipart.NewWriter(requestBody) + w.WriteField("key", uploadForm.Key) + w.WriteField("x-amz-credential", uploadForm.Credential) + w.WriteField("acl", uploadForm.Acl) + w.WriteField("x-amz-algorithm", uploadForm.Algorithm) + w.WriteField("x-amz-date", uploadForm.Date) + w.WriteField("policy", uploadForm.Policy) + w.WriteField("x-amz-signature", uploadForm.Signature) + w.WriteField("Content-Type", "application/octet-stream") + filewriter, _ := w.CreateFormFile("file", "file") + filewriter.Write(*encryptedAvatar) + w.Close() + + // Upload avatar to CDN + resp, err = web.SendHTTPRequest(ctx, http.MethodPost, "", &web.HTTPReqOpt{ + Body: requestBody.Bytes(), + ContentType: web.ContentType(w.FormDataContentType()), + Host: web.CDN1Hostname, + }) + if err != nil { + log.Err(err).Msg("Error sending request uploading attachment") + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Error().Int("status_code", resp.StatusCode).Msg("Error uploading attachment") + return nil, fmt.Errorf("error uploading attachment: %s", resp.Status) + } + + return &uploadForm.Key, nil +} + func verifyMAC(key, body, mac []byte) bool { m := hmac.New(sha256.New, key) m.Write(body) diff --git a/pkg/signalmeow/groups.go b/pkg/signalmeow/groups.go index ff93789e..776327c1 100644 --- a/pkg/signalmeow/groups.go +++ b/pkg/signalmeow/groups.go @@ -1,5 +1,5 @@ // mautrix-signal - A Matrix-signal puppeting bridge. -// Copyright (C) 2023 Scott Weber +// Copyright (C) 2023 Scott Weber, Malte Eggers // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -21,6 +21,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -30,6 +31,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "google.golang.org/protobuf/proto" "go.mau.fi/mautrix-signal/pkg/libsignalgo" @@ -411,6 +413,18 @@ func decryptGroupPropertyIntoBlob(groupSecretParams libsignalgo.GroupSecretParam return &propertyBlob, nil } +func encryptBlobIntoGroupProperty(groupSecretParams libsignalgo.GroupSecretParams, attributeBlob *signalpb.GroupAttributeBlob) (*[]byte, error) { + decryptedProperty, err := proto.Marshal(attributeBlob) + if err != nil { + return nil, fmt.Errorf("error marshalling groupProperty: %w", err) + } + encryptedProperty, err := groupSecretParams.EncryptBlobWithPaddingDeterministic(libsignalgo.GenerateRandomness(), decryptedProperty, 0) + if err != nil { + return nil, fmt.Errorf("error encrypting blob with padding: %w", err) + } + return &encryptedProperty, nil +} + func cleanupStringProperty(property string) string { // strip non-printable characters from the string property = strings.Map(cleanupStringMapping, property) @@ -934,3 +948,153 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp return decryptedGroupChange, nil } + +func (cli *Client) ModifyGroupAttributes(ctx context.Context, newTitle *string, newDescription *string, newAvatarPath *string, gid types.GroupIdentifier, oldRevision uint32) (*GroupMessageSendResult, error) { + log := zerolog.Ctx(ctx).With().Str("action", "createModifyGroupTitle").Logger() + groupMasterKey, err := cli.Store.GroupStore.MasterKeyFromGroupIdentifier(ctx, gid) + if err != nil { + log.Err(err).Msg("Could not get master key from group id") + return nil, err + } + masterKeyBytes := masterKeyToBytes(groupMasterKey) + groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyBytes) + if err != nil { + log.Err(err).Msg("Could not get groupSecretParams from master key") + return nil, err + } + newRevision := oldRevision + 1 + groupChangeActions := &signalpb.GroupChange_Actions{Revision: newRevision} + if newTitle != nil { + modifyTitleAction, err := createModifyTitleAction(ctx, newTitle, gid, groupSecretParams) + if err != nil { + log.Err(err).Msg("couldn't build modifyTitle action") + return nil, err + } + groupChangeActions.ModifyTitle = modifyTitleAction + } + if newDescription != nil { + modifyDescription, err := createModifyDescriptionAction(ctx, newDescription, gid, groupSecretParams) + if err != nil { + log.Err(err).Msg("couldn't build modifyDescription action") + return nil, err + } + groupChangeActions.ModifyDescription = modifyDescription + } + if newAvatarPath != nil { + groupChangeActions.ModifyAvatar = &signalpb.GroupChange_Actions_ModifyAvatarAction{Avatar: *newAvatarPath} + } + signedGroupChange, err := cli.patchGroup(ctx, groupChangeActions, groupMasterKey, nil) + if err != nil { + log.Err(err).Msg("couldn't patch group on server") + } + groupChangeBytes, err := proto.Marshal(signedGroupChange) + masterKeyBytesBytes := [32]byte(masterKeyBytes) + if err != nil { + return nil, err + } + groupContext := &signalpb.GroupContextV2{Revision: &newRevision, GroupChange: groupChangeBytes, MasterKey: masterKeyBytesBytes[:]} + result, err := cli.SendGroupChange(ctx, gid, groupContext) + if err != nil { + log.Err(err).Msg("Error modifying group attributes") + } + _, ok := cli.GroupCache.groups[gid] + if ok { + if newTitle != nil { + cli.GroupCache.groups[gid].Title = *newTitle + } + if newDescription != nil { + cli.GroupCache.groups[gid].Description = *newDescription + } + if newAvatarPath != nil { + cli.GroupCache.groups[gid].Description = *newAvatarPath + } + cli.GroupCache.lastFetched[gid] = time.Now() + } + return result, err +} + +func createModifyTitleAction(ctx context.Context, title *string, gid types.GroupIdentifier, groupSecretParams libsignalgo.GroupSecretParams) (*signalpb.GroupChange_Actions_ModifyTitleAction, error) { + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Title{Title: *title}} + encryptedTitle, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not get encrypt Title") + return nil, err + } + return &signalpb.GroupChange_Actions_ModifyTitleAction{Title: *encryptedTitle}, nil +} + +func createModifyDescriptionAction(ctx context.Context, description *string, gid types.GroupIdentifier, groupSecretParams libsignalgo.GroupSecretParams) (*signalpb.GroupChange_Actions_ModifyDescriptionAction, error) { + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Description{Description: *description}} + encryptedDescription, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not get encrypt Title") + return nil, err + } + return &signalpb.GroupChange_Actions_ModifyDescriptionAction{Description: *encryptedDescription}, nil +} + +func (cli *Client) patchGroup(ctx context.Context, groupChange *signalpb.GroupChange_Actions, groupMasterKey types.SerializedGroupMasterKey, groupLinkPassword []byte) (*signalpb.GroupChange, error) { + log := zerolog.Ctx(ctx).With().Str("action", "patchGroup").Logger() + groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyToBytes(groupMasterKey)) + if err != nil { + log.Err(err).Msg("Failed to get Authorization for today") + return nil, err + } + var path string + if groupLinkPassword == nil { + path = "/v1/groups/" + } else { + path = fmt.Sprintf("/v1/groups/?inviteLinkPassword=%s", base64.StdEncoding.EncodeToString(groupLinkPassword)) + } + requestBody, err := proto.Marshal(groupChange) + if err != nil { + log.Err(err).Msg("Failed to marshal request") + return nil, err + } + opts := &web.HTTPReqOpt{ + Username: &groupAuth.Username, + Password: &groupAuth.Password, + ContentType: web.ContentTypeProtobuf, + Body: requestBody, + Host: web.StorageHostname, + } + resp, err := web.SendHTTPRequest(ctx, http.MethodPatch, path, opts) + if err != nil { + return nil, fmt.Errorf("SendRequest error: %w", err) + } + switch resp.StatusCode { + case http.StatusNoContent: + return nil, fmt.Errorf("no content: %d", resp.StatusCode) + case http.StatusBadRequest: + return nil, fmt.Errorf("group patch Not Accepted %d", resp.StatusCode) + case http.StatusForbidden: + return nil, fmt.Errorf("authorization failed %d", resp.StatusCode) + case http.StatusNotFound: + return nil, fmt.Errorf("not found %d", resp.StatusCode) + case http.StatusConflict: + if resp.Body != nil { + return nil, fmt.Errorf("contact manifest mismatch %d", resp.StatusCode) + } else { + return nil, fmt.Errorf("conflict %d", resp.StatusCode) + // TODO: conflict resolution, probably somewhere else + } + case http.StatusTooManyRequests: + return nil, errors.New("rate limit exceeded") + case 499: + return nil, errors.New("deprecated version") + } + // TODO: UnsuccessfulResponseCodeException + if resp.Body == nil { + return nil, errors.New("no response body") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read storage manifest response: %w", err) + } + signedGroupChange := signalpb.GroupChange{} + err = proto.Unmarshal(body, &signedGroupChange) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal signed groupChange: %w", err) + } + return &signedGroupChange, nil +} diff --git a/pkg/signalmeow/sending.go b/pkg/signalmeow/sending.go index 33cd93f3..aa0f016d 100644 --- a/pkg/signalmeow/sending.go +++ b/pkg/signalmeow/sending.go @@ -29,6 +29,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "go.mau.fi/util/exfmt" "google.golang.org/protobuf/proto" @@ -511,6 +512,25 @@ func wrapDataMessageInContent(dm *signalpb.DataMessage) *signalpb.Content { } } +func (cli *Client) SendGroupChange(ctx context.Context, gid types.GroupIdentifier, groupContext *signalpb.GroupContextV2) (*GroupMessageSendResult, error) { + log := zerolog.Ctx(ctx).With(). + Str("action", "send group change message"). + Stringer("group_id", gid). + Logger() + ctx = log.WithContext(ctx) + group, err := cli.RetrieveGroupByID(ctx, gid, 0) + if err != nil { + return nil, err + } + timestamp := currentMessageTimestamp() + dm := &signalpb.DataMessage{ + Timestamp: ×tamp, + GroupV2: groupContext, + } + content := wrapDataMessageInContent(dm) + return cli.sendToGroup(ctx, group.Members, content, timestamp) +} + func (cli *Client) SendGroupMessage(ctx context.Context, gid types.GroupIdentifier, content *signalpb.Content) (*GroupMessageSendResult, error) { log := zerolog.Ctx(ctx).With(). Str("action", "send group message"). @@ -530,13 +550,16 @@ func (cli *Client) SendGroupMessage(ctx context.Context, gid types.GroupIdentifi messageTimestamp = content.EditMessage.DataMessage.GetTimestamp() content.EditMessage.DataMessage.GroupV2 = groupMetadataForDataMessage(*group) } + return cli.sendToGroup(ctx, group.Members, content, messageTimestamp) +} +func (cli *Client) sendToGroup(ctx context.Context, recipients []*GroupMember, content *signalpb.Content, messageTimestamp uint64) (*GroupMessageSendResult, error) { // Send to each member of the group result := &GroupMessageSendResult{ SuccessfullySentTo: []SuccessfulSendResult{}, FailedToSendTo: []FailedSendResult{}, } - for _, member := range group.Members { + for _, member := range recipients { if member.UserID == cli.Store.ACI { // Don't send normal DataMessages to ourselves continue diff --git a/portal.go b/portal.go index 979fc348..48bd63ff 100644 --- a/portal.go +++ b/portal.go @@ -2184,3 +2184,71 @@ func (br *SignalBridge) CleanupRoom(ctx context.Context, log *zerolog.Logger, in log.Err(err).Msg("Failed to leave room while cleaning up portal") } } + +func (portal *Portal) HandleMatrixMeta(brSender bridge.User, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix event"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + sender := brSender.(*User) + if !sender.IsLoggedIn() { + log.Warn().Msg("Can't change title: user is not logged in") + return + } + + var err error + switch content := evt.Content.Parsed.(type) { + case *event.RoomNameEventContent: + if content.Name == portal.Name { + return + } + portal.Name = content.Name + _, err = sender.Client.ModifyGroupAttributes(ctx, &content.Name, nil, nil, portal.GroupID(), portal.Revision) + case *event.TopicEventContent: + if content.Topic == portal.Topic { + return + } + portal.Topic = content.Topic + _, err = sender.Client.ModifyGroupAttributes(ctx, nil, &content.Topic, nil, portal.GroupID(), portal.Revision) + case *event.RoomAvatarEventContent: + if content.URL == portal.AvatarURL { + return + } + var data []byte + if !content.URL.IsEmpty() { + data, err = portal.MainIntent().DownloadBytes(ctx, content.URL) + if err != nil { + log.Err(err).Stringer("Failed to download updated avatar %s", content.URL) + return + } + log.Debug().Stringers("%s set the group avatar to %s", []fmt.Stringer{sender.MXID, content.URL}) + } else { + log.Debug().Stringer("%s removed the group avatar", sender.MXID) + } + avatarPath, err := sender.Client.UploadGroupAvatar(ctx, data, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Failed to upload group avatar") + return + } + _, err = sender.Client.ModifyGroupAttributes(ctx, nil, nil, avatarPath, portal.GroupID(), portal.Revision) + if err == nil { + log.Debug().Msg("Successfully updated group avatar") + hash := sha256.Sum256(data) + newAvatarHash := hex.EncodeToString(hash[:]) + portal.AvatarSet = true + portal.AvatarPath = *avatarPath + portal.AvatarHash = newAvatarHash + portal.AvatarURL = content.URL + portal.UpdateBridgeInfo(ctx) + portal.Update(ctx) + } + } + if err != nil { + log.Err(err).Msg("Failed to update group") + return + } + portal.Revision = portal.Revision + 1 + log.Info().Msg("finished updating group") +}