diff --git a/ROADMAP.md b/ROADMAP.md index a060c0de..85c7494f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,15 +16,16 @@ * [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 - * [ ] Leave - * [ ] Kick/Ban/Unban + * [x] Invite + * [x] Leave + * [x] Kick/Ban/Unban + * [x] Group permissions * [x] Typing notifications * [x] Read receipts * [x] Delivery receipts (sent after message is bridged) diff --git a/commands.go b/commands.go index b21304ee..6d925911 100644 --- a/commands.go +++ b/commands.go @@ -18,11 +18,16 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" + "errors" "fmt" "strconv" "strings" "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "github.com/skip2/go-qrcode" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridge/commands" @@ -31,6 +36,7 @@ import ( "go.mau.fi/mautrix-signal/pkg/libsignalgo" "go.mau.fi/mautrix-signal/pkg/signalmeow" + "go.mau.fi/mautrix-signal/pkg/signalmeow/types" ) var ( @@ -63,6 +69,9 @@ func (br *SignalBridge) RegisterCommands() { cmdDeletePortal, cmdDeleteAllPortals, cmdCleanupLostPortals, + cmdInviteLink, + cmdResetInviteLink, + cmdCreate, ) } @@ -650,3 +659,264 @@ func fnCleanupLostPortals(ce *WrappedCommandEvent) { } ce.Reply("Finished cleaning up portals") } + +var cmdInviteLink = &commands.FullHandler{ + Func: wrapCommand(fnInviteLink), + Name: "invite-link", + Help: commands.HelpMeta{ + Section: HelpSectionPortalManagement, + Description: "Get the invite link for the corresponding Signal Group", + }, + RequiresLogin: true, +} + +func fnInviteLink(ce *WrappedCommandEvent) { + if ce.Portal == nil { + ce.Reply("This is not a portal room") + return + } + if ce.Portal.IsPrivateChat() { + ce.Reply("Invite Links are not available for private chats") + return + } + inviteLinkPassword, err := ce.Portal.GetInviteLink(ce.Ctx, ce.User) + if err != nil { + ce.Reply("Error getting invite link %w", err) + return + } + ce.Reply(inviteLinkPassword) +} + +var cmdResetInviteLink = &commands.FullHandler{ + Func: wrapCommand(fnResetInviteLink), + Name: "reset-invite-link", + Help: commands.HelpMeta{ + Section: HelpSectionPortalManagement, + Description: "Generate a new invite link password", + }, + RequiresLogin: true, +} + +func fnResetInviteLink(ce *WrappedCommandEvent) { + if ce.Portal == nil { + ce.Reply("This is not a portal room") + return + } + if ce.Portal.IsPrivateChat() { + ce.Reply("Invite Links are not available for private chats") + return + } + err := ce.Portal.ResetInviteLink(ce.Ctx, ce.User) + if err != nil { + ce.Reply("Error setting new invite link %w", err) + } + inviteLink, err := ce.Portal.GetInviteLink(ce.Ctx, ce.User) + if err != nil { + ce.Reply("Error getting new invite link %w", err) + return + } + ce.Reply(inviteLink) +} + +var cmdCreate = &commands.FullHandler{ + Func: wrapCommand(fnCreate), + Name: "create", + Help: commands.HelpMeta{ + Section: HelpSectionCreatingPortals, + Description: "Create a Signal group chat for the current Matrix room.", + }, + RequiresLogin: true, +} + +func fnCreate(ce *WrappedCommandEvent) { + if ce.Portal != nil { + ce.Reply("This is already a portal room") + return + } + + roomState, err := ce.Bot.State(ce.Ctx, ce.RoomID) + if err != nil { + ce.Reply("Failed to get room state: %w", err) + return + } + members := roomState[event.StateMember] + powerLevelsRaw, ok := roomState[event.StatePowerLevels][""] + if !ok { + ce.Reply("Failed to get room power levels") + return + } + powerLevelsRaw.Content.ParseRaw(event.StatePowerLevels) + powerLevels := powerLevelsRaw.Content.AsPowerLevels() + joinRulesRaw, ok := roomState[event.StateJoinRules][""] + if !ok { + ce.Reply("Failed to get join rules") + return + } + joinRulesRaw.Content.ParseRaw(event.StateJoinRules) + joinRule := joinRulesRaw.Content.AsJoinRules().JoinRule + roomNameEventRaw, ok := roomState[event.StateRoomName][""] + if !ok { + ce.Reply("Failed to get room name") + return + } + roomNameEventRaw.Content.ParseRaw(event.StateRoomName) + roomName := roomNameEventRaw.Content.AsRoomName().Name + if len(roomName) == 0 { + ce.Reply("Please set a name for the room first") + return + } + roomTopic := "" + roomTopicEvent, ok := roomState[event.StateTopic][""] + if ok { + roomTopicEvent.Content.ParseRaw(event.StateTopic) + roomTopic = roomTopicEvent.Content.AsTopic().Topic + } + roomAvatarEvent, ok := roomState[event.StateRoomAvatar][""] + var avatarHash string + var avatarURL id.ContentURI + var avatarBytes []byte + if ok { + roomAvatarEvent.Content.ParseRaw(event.StateRoomAvatar) + avatarURL = roomAvatarEvent.Content.AsRoomAvatar().URL + if !avatarURL.IsEmpty() { + avatarBytes, err = ce.Bot.DownloadBytes(ce.Ctx, avatarURL) + if err != nil { + ce.ZLog.Err(err).Stringer("Failed to download updated avatar %s", avatarURL) + return + } + hash := sha256.Sum256(avatarBytes) + avatarHash = hex.EncodeToString(hash[:]) + log.Debug().Stringers("%s set the group avatar to %s", []fmt.Stringer{ce.User.MXID, avatarURL}) + } + } + var encryptionEvent *event.EncryptionEventContent + encryptionEventContent, ok := roomState[event.StateEncryption][""] + if ok { + encryptionEventContent.Content.ParseRaw(event.StateEncryption) + encryptionEvent = encryptionEventContent.Content.AsEncryption() + } + var participants []*signalmeow.GroupMember + var bannedMembers []*signalmeow.BannedMember + participantDedup := make(map[uuid.UUID]bool) + participantDedup[uuid.Nil] = true + for key, member := range members { + mxid := id.UserID(key) + member.Content.ParseRaw(event.StateMember) + content := member.Content.AsMember() + membership := content.Membership + var uuid uuid.UUID + puppet := ce.Bridge.GetPuppetByMXID(mxid) + if puppet != nil { + uuid = puppet.SignalID + } else { + user := ce.Bridge.GetUserByMXID(mxid) + if user != nil && user.IsLoggedIn() { + uuid = user.SignalID + } + } + role := signalmeow.GroupMember_DEFAULT + if powerLevels.GetUserLevel(mxid) >= 50 { + role = signalmeow.GroupMember_ADMINISTRATOR + } + if !participantDedup[uuid] { + participantDedup[uuid] = true + // invites should be added on signal and then auto-joined + // joined members that need to be pending-Members should have their signal invite auto-accepted + if membership == event.MembershipJoin || membership == event.MembershipInvite { + participants = append(participants, &signalmeow.GroupMember{ + UserID: uuid, + Role: role, + }) + } else if membership == event.MembershipBan { + bannedMembers = append(bannedMembers, &signalmeow.BannedMember{ + UserID: uuid, + }) + } + } + } + addFromInviteLinkAccess := signalmeow.AccessControl_UNSATISFIABLE + if joinRule == event.JoinRulePublic { + addFromInviteLinkAccess = signalmeow.AccessControl_ANY + } else if joinRule == event.JoinRuleKnock { + addFromInviteLinkAccess = signalmeow.AccessControl_ADMINISTRATOR + } + var inviteLinkPassword types.SerializedInviteLinkPassword + if addFromInviteLinkAccess != signalmeow.AccessControl_UNSATISFIABLE { + inviteLinkPassword = signalmeow.GenerateInviteLinkPassword() + } + membersAccess := signalmeow.AccessControl_MEMBER + if powerLevels.Invite() >= 50 { + membersAccess = signalmeow.AccessControl_ADMINISTRATOR + } + attributesAccess := signalmeow.AccessControl_MEMBER + if powerLevels.StateDefault() >= 50 { + attributesAccess = signalmeow.AccessControl_ADMINISTRATOR + } + announcementsOnly := false + if powerLevels.EventsDefault >= 50 { + announcementsOnly = true + } + ce.ZLog.Info(). + Str("room_name", roomName). + Any("participants", participants). + Msg("Creating Signal group for Matrix room") + group, err := ce.User.Client.CreateGroupOnServer(ce.Ctx, &signalmeow.Group{ + Title: roomName, + Description: roomTopic, + Members: participants, + AccessControl: &signalmeow.GroupAccessControl{ + Members: membersAccess, + Attributes: attributesAccess, + AddFromInviteLink: addFromInviteLinkAccess, + }, + InviteLinkPassword: &inviteLinkPassword, + BannedMembers: bannedMembers, + AnnouncementsOnly: announcementsOnly, + }, avatarBytes) + if err != nil { + ce.Reply("Failed to create group: %v", err) + return + } + gid := group.GroupIdentifier + ce.ZLog.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("group_id", gid) + }) + portal := ce.User.GetPortalByChatID(gid.String()) + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + if len(portal.MXID) != 0 { + ce.ZLog.Warn().Msg("Detected race condition in room creation") + // TODO race condition, clean up the old room + } + portal.MXID = ce.RoomID + portal.Name = roomName + portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1 + if !portal.Encrypted && ce.Bridge.Config.Bridge.Encryption.Default { + _, err = portal.MainIntent().SendStateEvent(ce.Ctx, portal.MXID, event.StateEncryption, "", portal.GetEncryptionEventContent()) + if err != nil { + ce.ZLog.Err(err).Msg("Failed to enable encryption in room") + if errors.Is(err, mautrix.MForbidden) { + ce.Reply("I don't seem to have permission to enable encryption in this room.") + } else { + ce.Reply("Failed to enable encryption in room: %v", err) + } + } + portal.Encrypted = true + } + revision, err := ce.User.Client.UpdateGroup(ce.Ctx, &signalmeow.GroupChange{}, gid) + if err != nil { + ce.Reply("Failed to update Group") + return + } + portal.Revision = revision + portal.AvatarHash = avatarHash + portal.AvatarURL = avatarURL + portal.AvatarPath = group.AvatarPath + portal.AvatarSet = true + err = portal.Update(ce.Ctx) + if err != nil { + ce.ZLog.Err(err).Msg("Failed to save portal after creating group") + } + portal.UpdateBridgeInfo(ce.Ctx) + ce.Reply("Successfully created Signal group %s", gid.String()) +} diff --git a/config/bridge.go b/config/bridge.go index 088d2869..3b7967cd 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -52,6 +52,7 @@ type BridgeConfig struct { PublicPortals bool `yaml:"public_portals"` CaptionInMessage bool `yaml:"caption_in_message"` FederateRooms bool `yaml:"federate_rooms"` + BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"` diff --git a/config/upgrade.go b/config/upgrade.go index cb0ab06c..0d943d67 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -125,6 +125,7 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds") helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages") helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation") + helper.Copy(up.Bool, "bridge", "bridge_matrix_leave") helper.Copy(up.Str, "bridge", "provisioning", "prefix") if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" { diff --git a/example-config.yaml b/example-config.yaml index a509a0c4..b61c355f 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -255,7 +255,8 @@ bridge: # Disable rotating keys when a user's devices change? # You should not enable this option unless you understand all the implications. disable_device_change_key_rotation: false - + # Should leaving the room on Matrix make the user leave on Signal? + bridge_matrix_leave: true # Settings for provisioning API provisioning: # Prefix for the provisioning API paths. diff --git a/go.mod b/go.mod index 7359b165..949f87e8 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f golang.org/x/net v0.22.0 google.golang.org/protobuf v1.33.0 - maunium.net/go/mautrix v0.18.0 + maunium.net/go/mautrix v0.18.1-0.20240322180408-ade00e8603f9 nhooyr.io/websocket v1.8.10 ) diff --git a/go.sum b/go.sum index 3bb4666a..3916c4d4 100644 --- a/go.sum +++ b/go.sum @@ -95,7 +95,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.18.0 h1:sNsApeSWB8x0hLjGcdmi5JqO6Tvp2PVkiSStz+Yas6k= -maunium.net/go/mautrix v0.18.0/go.mod h1:STwJZ+6CAeiEQs7fYCkd5aC12XR5DXANE6Swy/PBKGo= +maunium.net/go/mautrix v0.18.1-0.20240322180408-ade00e8603f9 h1:Xl741d8hAFdBPJPT/ydc6zTQM4R4L+5/d1X+AevLdXY= +maunium.net/go/mautrix v0.18.1-0.20240322180408-ade00e8603f9/go.mod h1:STwJZ+6CAeiEQs7fYCkd5aC12XR5DXANE6Swy/PBKGo= nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/pkg/libsignalgo/groupsecretparams.go b/pkg/libsignalgo/groupsecretparams.go index 6e1fc9fc..cd79cd9c 100644 --- a/pkg/libsignalgo/groupsecretparams.go +++ b/pkg/libsignalgo/groupsecretparams.go @@ -117,6 +117,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( @@ -137,6 +157,22 @@ func (gsp *GroupSecretParams) DecryptUUID(ciphertextUUID UUIDCiphertext) (uuid.U return serviceID.UUID, nil } +func (gsp *GroupSecretParams) EncryptUUID(uuid uuid.UUID) (*UUIDCiphertext, error) { + var cipherTextUUID [C.SignalUUID_CIPHERTEXT_LEN]C.uchar + signalFfiError := C.signal_group_secret_params_encrypt_service_id( + &cipherTextUUID, + (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)), + NewACIServiceID(uuid).CFixedBytes(), + ) + runtime.KeepAlive(gsp) + if signalFfiError != nil { + return nil, wrapError(signalFfiError) + } + var result UUIDCiphertext + copy(result[:], C.GoBytes(unsafe.Pointer(&cipherTextUUID), C.int(C.SignalUUID_CIPHERTEXT_LEN))) + return &result, nil +} + func (gsp *GroupSecretParams) DecryptProfileKey(ciphertextProfileKey ProfileKeyCiphertext, u uuid.UUID) (*ProfileKey, error) { profileKey := [C.SignalPROFILE_KEY_LEN]C.uchar{} signalFfiError := C.signal_group_secret_params_decrypt_profile_key( @@ -155,3 +191,58 @@ func (gsp *GroupSecretParams) DecryptProfileKey(ciphertextProfileKey ProfileKeyC copy(result[:], C.GoBytes(unsafe.Pointer(&profileKey), C.int(C.SignalPROFILE_KEY_LEN))) return &result, nil } + +func (gsp *GroupSecretParams) EncryptProfileKey(profileKey ProfileKey, u uuid.UUID) (*ProfileKeyCiphertext, error) { + ciphertextProfileKey := [C.SignalPROFILE_KEY_CIPHERTEXT_LEN]C.uchar{} + signalFfiError := C.signal_group_secret_params_encrypt_profile_key( + &ciphertextProfileKey, + (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)), + (*[C.SignalPROFILE_KEY_LEN]C.uint8_t)(unsafe.Pointer(&profileKey)), + NewACIServiceID(u).CFixedBytes(), + ) + runtime.KeepAlive(gsp) + runtime.KeepAlive(profileKey) + if signalFfiError != nil { + return nil, wrapError(signalFfiError) + } + var result ProfileKeyCiphertext + copy(result[:], C.GoBytes(unsafe.Pointer(&ciphertextProfileKey), C.int(C.SignalPROFILE_KEY_CIPHERTEXT_LEN))) + return &result, nil +} + +func (gsp *GroupSecretParams) CreateExpiringProfileKeyCredentialPresentation(spp ServerPublicParams, credential ExpiringProfileKeyCredential) (*ProfileKeyCredentialPresentation, error) { + var out C.SignalOwnedBuffer = C.SignalOwnedBuffer{} + randomness := GenerateRandomness() + signalFfiError := C.signal_server_public_params_create_expiring_profile_key_credential_presentation_deterministic( + &out, + (*[C.SignalSERVER_PUBLIC_PARAMS_LEN]C.uchar)(unsafe.Pointer(&spp)), + (*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness)), + (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(gsp)), + (*[C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN]C.uchar)(unsafe.Pointer(&credential)), + ) + runtime.KeepAlive(gsp) + runtime.KeepAlive(spp) + runtime.KeepAlive(credential) + runtime.KeepAlive(randomness) + if signalFfiError != nil { + return nil, wrapError(signalFfiError) + } + presentationBytes := CopySignalOwnedBufferToBytes(out) + presentation := ProfileKeyCredentialPresentation(presentationBytes) + return &presentation, nil +} + +func (gsp *GroupSecretParams) GetMasterKey() (*GroupMasterKey, error) { + masterKeyBytes := [C.SignalGROUP_MASTER_KEY_LEN]C.uchar{} + signalFfiError := C.signal_group_secret_params_get_master_key( + &masterKeyBytes, + (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(gsp)), + ) + runtime.KeepAlive(gsp) + if signalFfiError != nil { + return nil, wrapError(signalFfiError) + } + var groupMasterKey GroupMasterKey + copy(groupMasterKey[:], C.GoBytes(unsafe.Pointer(&masterKeyBytes), C.int(C.SignalGROUP_MASTER_KEY_LEN))) + return &groupMasterKey, nil +} diff --git a/pkg/libsignalgo/profilekey.go b/pkg/libsignalgo/profilekey.go index bcf9d491..aadecf99 100644 --- a/pkg/libsignalgo/profilekey.go +++ b/pkg/libsignalgo/profilekey.go @@ -124,6 +124,8 @@ type ProfileKeyCredentialRequest [C.SignalPROFILE_KEY_CREDENTIAL_REQUEST_LEN]byt type ProfileKeyCredentialResponse []byte type ProfileKeyCredentialPresentation []byte type ServerPublicParams [C.SignalSERVER_PUBLIC_PARAMS_LEN]byte +type ExpiringProfileKeyCredential [C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN]byte +type ExpiringProfileKeyCredentialResponse [C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_RESPONSE_LEN]byte func CreateProfileKeyCredentialRequestContext(serverPublicParams ServerPublicParams, u uuid.UUID, profileKey ProfileKey) (*ProfileKeyCredentialRequestContext, error) { c_result := [C.SignalPROFILE_KEY_CREDENTIAL_REQUEST_CONTEXT_LEN]C.uchar{} @@ -167,14 +169,36 @@ func (p *ProfileKeyCredentialRequestContext) ProfileKeyCredentialRequestContextG return &result, nil } -func NewProfileKeyCredentialResponse(b []byte) (ProfileKeyCredentialResponse, error) { +func NewExpiringProfileKeyCredentialResponse(b []byte) (*ExpiringProfileKeyCredentialResponse, error) { borrowedBuffer := BytesToBuffer(b) signalFfiError := C.signal_expiring_profile_key_credential_response_check_valid_contents(borrowedBuffer) runtime.KeepAlive(b) if signalFfiError != nil { return nil, wrapError(signalFfiError) } - return ProfileKeyCredentialResponse(b), nil + response := ExpiringProfileKeyCredentialResponse(b) + return &response, nil +} + +func ReceiveExpiringProfileKeyCredential(spp ServerPublicParams, requestContext *ProfileKeyCredentialRequestContext, response *ExpiringProfileKeyCredentialResponse, currentTimeInSeconds uint64) (*ExpiringProfileKeyCredential, error) { + c_credential := [C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN]C.uchar{} + signalFfiError := C.signal_server_public_params_receive_expiring_profile_key_credential( + &c_credential, + (*[C.SignalSERVER_PUBLIC_PARAMS_LEN]C.uchar)(unsafe.Pointer(&spp[0])), + (*[C.SignalPROFILE_KEY_CREDENTIAL_REQUEST_CONTEXT_LEN]C.uchar)(unsafe.Pointer(requestContext)), + (*[C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_RESPONSE_LEN]C.uchar)(unsafe.Pointer(response)), + (C.uint64_t)(currentTimeInSeconds), + ) + runtime.KeepAlive(spp) + runtime.KeepAlive(requestContext) + runtime.KeepAlive(response) + runtime.KeepAlive(currentTimeInSeconds) + if signalFfiError != nil { + return nil, wrapError(signalFfiError) + } + credential := ExpiringProfileKeyCredential{} + copy(credential[:], C.GoBytes(unsafe.Pointer(&c_credential), C.int(C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN))) + return &credential, nil } //func NewProfileKeyCredentialPresentation(b []byte) (ProfileKeyCredentialPresentation, error) { 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 8978bbd3..0d71b07d 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 @@ -18,9 +18,11 @@ package signalmeow import ( "context" + "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -30,6 +32,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" @@ -89,8 +92,32 @@ type Group struct { PendingMembers []*PendingMember RequestingMembers []*RequestingMember BannedMembers []*BannedMember + InviteLinkPassword *types.SerializedInviteLinkPassword //PublicKey *libsignalgo.PublicKey - //InviteLinkPassword []byte +} + +func (group *Group) GetInviteLink() (string, error) { + if group.InviteLinkPassword == nil { + return "", fmt.Errorf("no invite link password set") + } + masterKeyBytes := masterKeyToBytes(group.groupMasterKey) + inviteLinkPasswordBytes, err := inviteLinkPasswordToBytes(*group.InviteLinkPassword) + if err != nil { + return "", fmt.Errorf("couldn't decode invite link password") + } + inviteLinkContents := signalpb.GroupInviteLink_V1Contents{ + V1Contents: &signalpb.GroupInviteLink_GroupInviteLinkContentsV1{ + GroupMasterKey: masterKeyBytes[:], + InviteLinkPassword: inviteLinkPasswordBytes, + }, + } + inviteLink := signalpb.GroupInviteLink{Contents: &inviteLinkContents} + inviteLinkEncoded, err := proto.Marshal(&inviteLink) + if err != nil { + return "", fmt.Errorf("failed to marshal invite link") + } + inviteLinkPath := base64.URLEncoding.EncodeToString(inviteLinkEncoded) + return "https://signal.group/#" + inviteLinkPath, nil } type GroupAccessControl struct { @@ -172,7 +199,113 @@ type GroupChange struct { AddBannedMembers []*BannedMember DeleteBannedMembers []*uuid.UUID PromotePendingPniAciMembers []*ProfileKeyMember - // ModifyInviteLinkPassword []byte + ModifyInviteLinkPassword *types.SerializedInviteLinkPassword +} + +func (groupChange *GroupChange) isEmptpy() bool { + return len(groupChange.AddMembers) == 0 && + len(groupChange.DeleteMembers) == 0 && + len(groupChange.ModifyMemberRoles) == 0 && + len(groupChange.ModifyMemberProfileKeys) == 0 && + len(groupChange.AddPendingMembers) == 0 && + len(groupChange.PromotePendingMembers) == 0 && + groupChange.ModifyTitle == nil && + groupChange.ModifyAvatar == nil && + groupChange.ModifyDisappearingMessagesDuration == nil && + groupChange.ModifyAttributesAccess == nil && + groupChange.ModifyMemberAccess == nil && + groupChange.ModifyAddFromInviteLinkAccess == nil && + len(groupChange.AddRequestingMembers) == 0 && + len(groupChange.DeleteRequestingMembers) == 0 && + len(groupChange.PromoteRequestingMembers) == 0 && + groupChange.ModifyDescription == nil && + groupChange.ModifyAnnouncementsOnly == nil && + len(groupChange.AddBannedMembers) == 0 && + len(groupChange.DeleteMembers) == 0 +} + +func (groupChange *GroupChange) resolveConflict(group *Group) { + if *groupChange.ModifyTitle == group.Title { + groupChange.ModifyTitle = nil + } + if *groupChange.ModifyDescription == group.Description { + groupChange.ModifyDescription = nil + } + if *groupChange.ModifyAvatar == group.AvatarPath { + groupChange.ModifyAvatar = nil + } + if *groupChange.ModifyDisappearingMessagesDuration == group.DisappearingMessagesDuration { + groupChange.ModifyDisappearingMessagesDuration = nil + } + if *groupChange.ModifyAttributesAccess == group.AccessControl.Attributes { + groupChange.ModifyAttributesAccess = nil + } + if *groupChange.ModifyMemberAccess == group.AccessControl.Members { + groupChange.ModifyAttributesAccess = nil + } + if *groupChange.ModifyAddFromInviteLinkAccess == group.AccessControl.AddFromInviteLink { + groupChange.ModifyAddFromInviteLinkAccess = nil + } + if *groupChange.ModifyAnnouncementsOnly == group.AnnouncementsOnly { + groupChange.ModifyAnnouncementsOnly = nil + } + members := make(map[uuid.UUID]GroupMemberRole) + for _, member := range group.Members { + members[member.UserID] = member.Role + } + pendingMembers := make(map[uuid.UUID]bool) + for _, pendingMember := range group.PendingMembers { + pendingMembers[pendingMember.UserID] = true + } + requestingMembers := make(map[uuid.UUID]bool) + for _, requestingMember := range group.RequestingMembers { + requestingMembers[requestingMember.UserID] = true + } + for i, member := range groupChange.AddMembers { + if _, ok := members[member.GroupMember.UserID]; ok { + groupChange.AddMembers = append(groupChange.AddMembers[:i], groupChange.AddMembers[i+1:]...) + } + } + for i, promotePendingMember := range groupChange.PromotePendingMembers { + if _, ok := members[promotePendingMember.UserID]; ok { + groupChange.PromotePendingMembers = append(groupChange.PromotePendingMembers[:i], groupChange.PromotePendingMembers[i+1:]...) + } + } + for i, promoteRequestingMember := range groupChange.PromotePendingMembers { + if _, ok := members[promoteRequestingMember.UserID]; ok { + groupChange.PromoteRequestingMembers = append(groupChange.PromoteRequestingMembers[:i], groupChange.PromoteRequestingMembers[i+1:]...) + } + } + for i, pendingMember := range groupChange.AddPendingMembers { + if pendingMembers[pendingMember.GroupMember.UserID] { + groupChange.AddPendingMembers = append(groupChange.AddPendingMembers[:i], groupChange.AddPendingMembers[i+1:]...) + } + } + for i, requestingMember := range groupChange.AddRequestingMembers { + if pendingMembers[requestingMember.UserID] { + groupChange.AddRequestingMembers = append(groupChange.AddRequestingMembers[:i], groupChange.AddRequestingMembers[i+1:]...) + } + } + for i, deletePendingMember := range groupChange.DeletePendingMembers { + if !pendingMembers[*deletePendingMember] { + groupChange.DeletePendingMembers = append(groupChange.DeletePendingMembers[:i], groupChange.DeletePendingMembers[i+1:]...) + } + } + for i, deleteRequestingMember := range groupChange.DeleteRequestingMembers { + if !pendingMembers[*deleteRequestingMember] { + groupChange.DeleteRequestingMembers = append(groupChange.DeleteRequestingMembers[:i], groupChange.DeleteRequestingMembers[i+1:]...) + } + } + for i, deleteMember := range groupChange.DeleteMembers { + if _, ok := members[*deleteMember]; !ok { + groupChange.DeleteMembers = append(groupChange.DeleteMembers[:i], groupChange.DeleteMembers[i+1:]...) + } + } + for i, modifyMemberRole := range groupChange.ModifyMemberRoles { + if members[modifyMemberRole.UserID] == modifyMemberRole.Role { + groupChange.ModifyMemberRoles = append(groupChange.ModifyMemberRoles[:i], groupChange.ModifyMemberRoles[i+1:]...) + } + } } func (groupChange *GroupChange) getGroupMasterKey() types.SerializedGroupMasterKey { @@ -316,6 +449,18 @@ func masterKeyFromBytes(masterKey libsignalgo.GroupMasterKey) types.SerializedGr return types.SerializedGroupMasterKey(base64.StdEncoding.EncodeToString(masterKey[:])) } +func inviteLinkPasswordToBytes(inviteLinkPassword types.SerializedInviteLinkPassword) ([]byte, error) { + inviteLinkPasswordBytes, err := base64.StdEncoding.DecodeString((string(inviteLinkPassword))) + if err != nil { + return nil, err + } + return inviteLinkPasswordBytes, nil +} + +func InviteLinkPasswordFromBytes(inviteLinkPassword []byte) types.SerializedInviteLinkPassword { + return types.SerializedInviteLinkPassword(base64.StdEncoding.EncodeToString(inviteLinkPassword)) +} + func groupIdentifierFromMasterKey(masterKey types.SerializedGroupMasterKey) (types.GroupIdentifier, error) { groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyToBytes(masterKey)) if err != nil { @@ -437,6 +582,10 @@ func decryptGroup(ctx context.Context, encryptedGroup *signalpb.Group, groupMast AddFromInviteLink: (AccessControl)(encryptedGroup.AccessControl.AddFromInviteLink), } } + if len(encryptedGroup.InviteLinkPassword) > 0 { + inviteLinkPassword := InviteLinkPasswordFromBytes(encryptedGroup.InviteLinkPassword) + decryptedGroup.InviteLinkPassword = &inviteLinkPassword + } return decryptedGroup, nil } @@ -453,6 +602,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) @@ -499,6 +660,9 @@ func (cli *Client) fetchGroupByID(ctx context.Context, gid types.GroupIdentifier if groupMasterKey == "" { return nil, fmt.Errorf("No group master key found for group identifier %s", gid) } + return cli.fetchGroupWithMasterKey(ctx, groupMasterKey) +} +func (cli *Client) fetchGroupWithMasterKey(ctx context.Context, groupMasterKey types.SerializedGroupMasterKey) (*Group, error) { masterKeyBytes := masterKeyToBytes(groupMasterKey) groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyBytes) if err != nil { @@ -960,6 +1124,10 @@ func (cli *Client) DecryptGroupChange(ctx context.Context, groupContext *signalp newDisappaeringMessagesDuration := timerBlob.GetDisappearingMessagesDuration() decryptedGroupChange.ModifyDisappearingMessagesDuration = &newDisappaeringMessagesDuration } + if encryptedActions.ModifyInviteLinkPassword != nil { + inviteLinkPassword := InviteLinkPasswordFromBytes(encryptedActions.ModifyInviteLinkPassword.InviteLinkPassword) + decryptedGroupChange.ModifyInviteLinkPassword = &inviteLinkPassword + } return decryptedGroupChange, nil } @@ -983,7 +1151,7 @@ func decryptMember(ctx context.Context, member *signalpb.Member, groupSecretPara ProfileKey: *profileKey, Role: GroupMemberRole(member.Role), JoinedAtRevision: member.JoinedAtRevision, - }, err + }, nil } func decryptPendingMember(ctx context.Context, pendingMember *signalpb.PendingMember, groupSecretParams libsignalgo.GroupSecretParams) (*PendingMember, error) { @@ -1032,3 +1200,511 @@ func decryptRequestingMember(ctx context.Context, requestingMember *signalpb.Req Timestamp: requestingMember.Timestamp, }, nil } + +func (cli *Client) EncryptAndSignGroupChange(ctx context.Context, decryptedGroupChange *GroupChange, gid types.GroupIdentifier) (*signalpb.GroupChange, error) { + log := zerolog.Ctx(ctx).With().Str("action", "EncryptGroupChange").Logger() + groupMasterKey := decryptedGroupChange.groupMasterKey + 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 + } + groupChangeActions := &signalpb.GroupChange_Actions{Revision: decryptedGroupChange.Revision} + if decryptedGroupChange.ModifyTitle != nil { + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Title{Title: *decryptedGroupChange.ModifyTitle}} + encryptedTitle, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not get encrypt Title") + return nil, err + } + groupChangeActions.ModifyTitle = &signalpb.GroupChange_Actions_ModifyTitleAction{Title: *encryptedTitle} + } + if decryptedGroupChange.ModifyDescription != nil { + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Description{Description: *decryptedGroupChange.ModifyDescription}} + encryptedDescription, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not get encrypt description") + return nil, err + } + groupChangeActions.ModifyDescription = &signalpb.GroupChange_Actions_ModifyDescriptionAction{Description: *encryptedDescription} + } + if decryptedGroupChange.ModifyAvatar != nil { + groupChangeActions.ModifyAvatar = &signalpb.GroupChange_Actions_ModifyAvatarAction{Avatar: *decryptedGroupChange.ModifyAvatar} + } + for _, addMember := range decryptedGroupChange.AddMembers { + encryptedMember, err := cli.encryptMember(ctx, &addMember.GroupMember, &groupSecretParams) + if err != nil { + log.Err(err).Msg("Failed to encrypt GroupMember") + } + groupChangeActions.AddMembers = append(groupChangeActions.AddMembers, &signalpb.GroupChange_Actions_AddMemberAction{ + Added: encryptedMember, + JoinFromInviteLink: addMember.JoinFromInviteLink, + }) + } + for _, deleteMember := range decryptedGroupChange.DeleteMembers { + encryptedUserID, err := groupSecretParams.EncryptUUID(*deleteMember) + if err != nil { + log.Err(err).Msg("Encrypt UserId error for deleteMember") + return nil, err + } + groupChangeActions.DeleteMembers = append(groupChangeActions.DeleteMembers, &signalpb.GroupChange_Actions_DeleteMemberAction{ + DeletedUserId: encryptedUserID[:], + }) + } + for _, modifyMemberRoles := range decryptedGroupChange.ModifyMemberRoles { + encryptedUserID, err := groupSecretParams.EncryptUUID(modifyMemberRoles.UserID) + if err != nil { + log.Err(err).Msg("Encrypt UserId error for modifyMemberRoles") + return nil, err + } + groupChangeActions.ModifyMemberRoles = append(groupChangeActions.ModifyMemberRoles, &signalpb.GroupChange_Actions_ModifyMemberRoleAction{ + UserId: encryptedUserID[:], + Role: signalpb.Member_Role(modifyMemberRoles.Role), + }) + } + // for _, addPendingMember := range decryptedGroupChange.AddPendingMembers { + // } + for _, deletePendingMember := range decryptedGroupChange.DeletePendingMembers { + encryptedUserID, err := groupSecretParams.EncryptUUID(*deletePendingMember) + if err != nil { + log.Err(err).Msg("Encrypt UserId error for deletePendingMember") + return nil, err + } + groupChangeActions.DeletePendingMembers = append(groupChangeActions.DeletePendingMembers, &signalpb.GroupChange_Actions_DeletePendingMemberAction{ + DeletedUserId: encryptedUserID[:], + }) + } + for _, promotePendingMember := range decryptedGroupChange.PromotePendingMembers { + expiringProfileKeyCredential, err := cli.FetchExpiringProfileKeyCredentialById(ctx, promotePendingMember.UserID) + if err != nil { + log.Err(err).Msg("failed getting expiring profile key credential for addMember") + return nil, err + } + presentation, err := groupSecretParams.CreateExpiringProfileKeyCredentialPresentation( + prodServerPublicParams, + *expiringProfileKeyCredential, + ) + if err != nil { + log.Err(err).Msg("failed creating expiring profile key credential presentation for addMember") + return nil, err + } + groupChangeActions.PromotePendingMembers = append(groupChangeActions.PromotePendingMembers, &signalpb.GroupChange_Actions_PromotePendingMemberAction{ + Presentation: *presentation, + }) + } + for _, addRequestingMember := range decryptedGroupChange.AddRequestingMembers { + expiringProfileKeyCredential, err := cli.FetchExpiringProfileKeyCredentialById(ctx, addRequestingMember.UserID) + if err != nil { + log.Err(err).Msg("failed getting expiring profile key credential for addMember") + return nil, err + } + presentation, err := groupSecretParams.CreateExpiringProfileKeyCredentialPresentation( + prodServerPublicParams, + *expiringProfileKeyCredential, + ) + if err != nil { + log.Err(err).Msg("failed creating expiring profile key credential presentation for addMember") + return nil, err + } + groupChangeActions.AddRequestingMembers = append(groupChangeActions.AddRequestingMembers, &signalpb.GroupChange_Actions_AddRequestingMemberAction{ + Added: &signalpb.RequestingMember{ + Presentation: *presentation, + }, + }) + } + for _, deleteRequestingMember := range decryptedGroupChange.DeleteRequestingMembers { + encryptedUserID, err := groupSecretParams.EncryptUUID(*deleteRequestingMember) + if err != nil { + log.Err(err).Msg("Encrypt UserId error for promotePendingMember") + return nil, err + } + groupChangeActions.DeleteRequestingMembers = append(groupChangeActions.DeleteRequestingMembers, &signalpb.GroupChange_Actions_DeleteRequestingMemberAction{ + DeletedUserId: encryptedUserID[:], + }) + } + for _, promoteRequestingMember := range decryptedGroupChange.PromoteRequestingMembers { + encryptedUserID, err := groupSecretParams.EncryptUUID(promoteRequestingMember.UserID) + if err != nil { + log.Err(err).Msg("Encrypt UserId error for promoteRequestingMember") + return nil, err + } + + groupChangeActions.PromoteRequestingMembers = append(groupChangeActions.PromoteRequestingMembers, &signalpb.GroupChange_Actions_PromoteRequestingMemberAction{ + UserId: encryptedUserID[:], + Role: signalpb.Member_Role(promoteRequestingMember.Role), + }) + } + for _, addBannedMember := range decryptedGroupChange.AddBannedMembers { + encryptedUserID, err := groupSecretParams.EncryptUUID(addBannedMember.UserID) + if err != nil { + log.Err(err).Msg("Encrypt UserId error for promoteRequestingMember") + return nil, err + } + groupChangeActions.AddBannedMembers = append(groupChangeActions.AddBannedMembers, &signalpb.GroupChange_Actions_AddBannedMemberAction{ + Added: &signalpb.BannedMember{ + UserId: encryptedUserID[:], + Timestamp: addBannedMember.Timestamp, + }, + }) + } + for _, deleteBannedMember := range decryptedGroupChange.DeleteBannedMembers { + encryptedUserID, err := groupSecretParams.EncryptUUID(*deleteBannedMember) + if err != nil { + log.Err(err).Msg("Encrypt UserId error for promoteRequestingMember") + return nil, err + } + groupChangeActions.DeleteBannedMembers = append(groupChangeActions.DeleteBannedMembers, &signalpb.GroupChange_Actions_DeleteBannedMemberAction{ + DeletedUserId: encryptedUserID[:], + }) + } + if decryptedGroupChange.ModifyAnnouncementsOnly != nil { + groupChangeActions.ModifyAnnouncementsOnly = &signalpb.GroupChange_Actions_ModifyAnnouncementsOnlyAction{ + AnnouncementsOnly: *decryptedGroupChange.ModifyAnnouncementsOnly, + } + } + if decryptedGroupChange.ModifyAttributesAccess != nil { + groupChangeActions.ModifyAttributesAccess = &signalpb.GroupChange_Actions_ModifyAttributesAccessControlAction{ + AttributesAccess: signalpb.AccessControl_AccessRequired(*decryptedGroupChange.ModifyAttributesAccess), + } + } + if decryptedGroupChange.ModifyMemberAccess != nil { + groupChangeActions.ModifyMemberAccess = &signalpb.GroupChange_Actions_ModifyMembersAccessControlAction{ + MembersAccess: signalpb.AccessControl_AccessRequired(*decryptedGroupChange.ModifyMemberAccess), + } + } + if decryptedGroupChange.ModifyAddFromInviteLinkAccess != nil { + groupChangeActions.ModifyAddFromInviteLinkAccess = &signalpb.GroupChange_Actions_ModifyAddFromInviteLinkAccessControlAction{ + AddFromInviteLinkAccess: signalpb.AccessControl_AccessRequired(*decryptedGroupChange.ModifyAddFromInviteLinkAccess), + } + } + if decryptedGroupChange.ModifyDisappearingMessagesDuration != nil { + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_DisappearingMessagesDuration{DisappearingMessagesDuration: *decryptedGroupChange.ModifyDisappearingMessagesDuration}} + encryptedTimer, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not get encrypt Title") + return nil, err + } + groupChangeActions.ModifyDisappearingMessagesTimer = &signalpb.GroupChange_Actions_ModifyDisappearingMessagesTimerAction{Timer: *encryptedTimer} + } + if decryptedGroupChange.ModifyInviteLinkPassword != nil { + inviteLinkPasswordBytes, err := inviteLinkPasswordToBytes(*decryptedGroupChange.ModifyInviteLinkPassword) + if err != nil { + log.Err(err).Msg("Failed to decode invite link password") + } + groupChangeActions.ModifyInviteLinkPassword = &signalpb.GroupChange_Actions_ModifyInviteLinkPasswordAction{ + InviteLinkPassword: inviteLinkPasswordBytes, + } + } + + return cli.patchGroup(ctx, groupChangeActions, groupMasterKey, nil) +} + +func (cli *Client) encryptMember(ctx context.Context, member *GroupMember, groupSecretParams *libsignalgo.GroupSecretParams) (*signalpb.Member, error) { + log := zerolog.Ctx(ctx) + expiringProfileKeyCredential, err := cli.FetchExpiringProfileKeyCredentialById(ctx, member.UserID) + if err != nil { + log.Err(err).Msg("failed getting expiring profile key credential for addMember") + return nil, err + } + presentation, err := groupSecretParams.CreateExpiringProfileKeyCredentialPresentation( + prodServerPublicParams, + *expiringProfileKeyCredential, + ) + if err != nil { + log.Err(err).Msg("failed creating expiring profile key credential presentation for addMember") + return nil, err + } + encryptedMember := signalpb.Member{ + Presentation: *presentation, + Role: signalpb.Member_Role(member.Role), + } + return &encryptedMember, nil +} + +var ( + NoContentError = RespError{Err: "NoContentError"} + GroupPatchNotAcceptedError = RespError{Err: "GroupPatchNotAcceptedError"} + ConflictError = RespError{Err: "ConflictError"} + AuthorizationFailedError = RespError{Err: "AuthorizationFailedError"} + NotFoundError = RespError{Err: "NotFoundError"} + ContactManifestMismatchError = RespError{Err: "ContactManifestMismatchError"} + RateLimitError = RespError{Err: "RateLimitError"} + DeprecatedVersionError = RespError{Err: "DeprecatedVersionError"} + GroupExistsError = RespError{Err: "GroupExistsError"} +) + +type RespError struct { + Err string +} + +func (e RespError) Error() string { + return e.Err +} + +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, NoContentError + case http.StatusBadRequest: + return nil, GroupPatchNotAcceptedError + case http.StatusForbidden: + return nil, AuthorizationFailedError + case http.StatusNotFound: + return nil, NotFoundError + case http.StatusConflict: + if resp.Body != nil { + return nil, ContactManifestMismatchError + } else { + return nil, ConflictError + } + case http.StatusTooManyRequests: + return nil, RateLimitError + case 499: + return nil, DeprecatedVersionError + } + 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 +} + +func (cli *Client) UpdateGroup(ctx context.Context, groupChange *GroupChange, gid types.GroupIdentifier) (uint32, error) { + log := zerolog.Ctx(ctx).With().Str("action", "UpdateGroup").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 0, err + } + groupChange.groupMasterKey = groupMasterKey + masterKeyBytes := masterKeyToBytes(groupMasterKey) + var refetchedAddMemberCredentials bool + var signedGroupChange *signalpb.GroupChange + group, err := cli.RetrieveGroupByID(ctx, gid, 0) + if err != nil { + log.Err(err).Msg("Failed to retrieve Group") + } + if group.InviteLinkPassword == nil && groupChange.ModifyAddFromInviteLinkAccess != nil && groupChange.ModifyInviteLinkPassword != nil { + inviteLinkPasswordBytes := make([]byte, 16) + rand.Read(inviteLinkPasswordBytes) + inviteLinkPassword := InviteLinkPasswordFromBytes(inviteLinkPasswordBytes) + groupChange.ModifyInviteLinkPassword = &inviteLinkPassword + } + groupChange.Revision = group.Revision + 1 + for attempt := 0; attempt < 5; attempt++ { + signedGroupChange, err = cli.EncryptAndSignGroupChange(ctx, groupChange, gid) + if errors.Is(err, GroupPatchNotAcceptedError) { + log.Warn().Str("Error applying GroupChange, retrying...", err.Error()) + if len(groupChange.AddMembers) > 0 && !refetchedAddMemberCredentials { + refetchedAddMemberCredentials = true + // change = refetchAddMemberCredentials(change); TODO + } else { + return 0, fmt.Errorf("Group Change Failed: %w", err) + } + } else if errors.Is(err, ConflictError) { + delete(cli.GroupCache.groups, gid) + delete(cli.GroupCache.lastFetched, gid) + delete(cli.GroupCache.activeCalls, gid) + group, err = cli.RetrieveGroupByID(ctx, gid, 0) + groupChange.resolveConflict(group) + if groupChange.isEmptpy() { + log.Debug().Msg("Change is empty after conflict resolution") + } + groupChange.Revision = group.Revision + 1 + } else { + break + } + } + delete(cli.GroupCache.groups, gid) + delete(cli.GroupCache.lastFetched, gid) + delete(cli.GroupCache.activeCalls, gid) + if err != nil { + log.Err(err).Msg("couldn't patch group on server") + return 0, err + } + groupChangeBytes, err := proto.Marshal(signedGroupChange) + if err != nil { + log.Err(err).Msg("Error marshalling signed GroupChange") + return 0, err + } + groupContext := &signalpb.GroupContextV2{Revision: &groupChange.Revision, GroupChange: groupChangeBytes, MasterKey: masterKeyBytes[:]} + _, err = cli.SendGroupChange(ctx, group, groupContext, groupChange) + if err != nil { + log.Err(err).Msg("Error sending GroupChange to group members") + } + return groupChange.Revision, nil +} + +func (cli *Client) EncryptGroup(ctx context.Context, decryptedGroup *Group, groupSecretParams libsignalgo.GroupSecretParams) (*signalpb.Group, error) { + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Title{Title: decryptedGroup.Title}} + encryptedTitle, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not get encrypt Title") + return nil, err + } + groupPublicParams, err := groupSecretParams.GetPublicParams() + if err != nil { + log.Err(err).Msg("Couldn't get public params from GroupSecretParams") + return nil, err + } + encryptedGroup := &signalpb.Group{ + PublicKey: groupPublicParams[:], + Title: *encryptedTitle, + Avatar: decryptedGroup.AvatarPath, + AnnouncementsOnly: decryptedGroup.AnnouncementsOnly, + Revision: 0, + } + if decryptedGroup.Description != "" { + attributeBlob := signalpb.GroupAttributeBlob{Content: &signalpb.GroupAttributeBlob_Description{Description: decryptedGroup.Description}} + encryptedDescription, err := encryptBlobIntoGroupProperty(groupSecretParams, &attributeBlob) + if err != nil { + log.Err(err).Msg("Could not get encrypt Description") + return nil, err + } + encryptedGroup.Description = *encryptedDescription + } + if decryptedGroup.AccessControl != nil { + encryptedGroup.AccessControl = &signalpb.AccessControl{ + Members: signalpb.AccessControl_AccessRequired(decryptedGroup.AccessControl.Members), + Attributes: signalpb.AccessControl_AccessRequired(decryptedGroup.AccessControl.Attributes), + AddFromInviteLink: signalpb.AccessControl_AccessRequired(decryptedGroup.AccessControl.AddFromInviteLink), + } + if decryptedGroup.AccessControl.AddFromInviteLink != AccessControl_UNSATISFIABLE { + inviteLinkPasswordBytes := make([]byte, 16) + rand.Read(inviteLinkPasswordBytes) + encryptedGroup.InviteLinkPassword = inviteLinkPasswordBytes + } + } + for _, member := range decryptedGroup.Members { + encryptedMember, err := cli.encryptMember(ctx, member, &groupSecretParams) + if err != nil { + log.Err(err).Msg("Failed to encrypt GroupMember") + } + encryptedGroup.Members = append(encryptedGroup.Members, encryptedMember) + } + return encryptedGroup, nil +} + +func (cli *Client) CreateGroupOnServer(ctx context.Context, decryptedGroup *Group, avatarBytes []byte) (*Group, error) { + log := zerolog.Ctx(ctx).With().Str("action", "CreateGroupOnServer").Logger() + masterKeyByteArray := make([]byte, 32) + rand.Read(masterKeyByteArray) + masterKeyBytes := libsignalgo.GroupMasterKey(masterKeyByteArray) + groupMasterKey := masterKeyFromBytes(masterKeyBytes) + groupId, err := groupIdentifierFromMasterKey(groupMasterKey) + if err != nil { + log.Err(err).Msg("Couldn't get gid from masterkey") + return nil, err + } + err = cli.Store.GroupStore.StoreMasterKey(ctx, groupId, groupMasterKey) + if err != nil { + return nil, fmt.Errorf("StoreMasterKey error: %w", err) + } + log.Debug().Msg(string(groupMasterKey)) + groupSecretParams, err := libsignalgo.DeriveGroupSecretParamsFromMasterKey(masterKeyBytes) + if err != nil { + log.Err(err).Msg("DeriveGroupSecretParamsFromMasterKey error") + return nil, err + } + if len(avatarBytes) > 0 { + avatarPath, err := cli.UploadGroupAvatar(ctx, avatarBytes, groupId) + if err != nil { + log.Err(err).Msg("Failed to upload group avatar") + return nil, err + } + decryptedGroup.AvatarPath = *avatarPath + } + encryptedGroup, err := cli.EncryptGroup(ctx, decryptedGroup, groupSecretParams) + if err != nil { + log.Err(err).Msg("Failed to encrypt group") + return nil, err + } + log.Debug().Stringer("groupID", groupId) + groupAuth, err := cli.GetAuthorizationForToday(ctx, masterKeyBytes) + if err != nil { + log.Err(err).Msg("Failed to get Authorization for today") + return nil, err + } + path := "/v1/groups/" + requestBody, err := proto.Marshal(encryptedGroup) + 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.MethodPut, path, opts) + if err != nil { + return nil, fmt.Errorf("SendRequest error: %w", err) + } + switch resp.StatusCode { + case http.StatusNoContent: + return nil, NoContentError + case http.StatusForbidden: + return nil, AuthorizationFailedError + case http.StatusNotFound: + return nil, NotFoundError + case http.StatusConflict: + return nil, GroupExistsError + case http.StatusTooManyRequests: + return nil, RateLimitError + case 499: + return nil, DeprecatedVersionError + case http.StatusBadRequest: + return nil, fmt.Errorf("failed to put new group: bad request") + } + group, err := cli.fetchGroupWithMasterKey(ctx, groupMasterKey) + if err != nil { + return nil, fmt.Errorf("failed to get new group: %w", err) + } + log.Debug().Stringer("group id", group.GroupIdentifier).Msg("new group created") + return group, nil +} + +func GenerateInviteLinkPassword() types.SerializedInviteLinkPassword { + inviteLinkPasswordBytes := make([]byte, 16) + rand.Read(inviteLinkPasswordBytes) + return InviteLinkPasswordFromBytes(inviteLinkPasswordBytes) +} diff --git a/pkg/signalmeow/profile.go b/pkg/signalmeow/profile.go index f27db40e..ba160c7b 100644 --- a/pkg/signalmeow/profile.go +++ b/pkg/signalmeow/profile.go @@ -154,7 +154,6 @@ func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID) } func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*types.Profile, error) { - log := zerolog.Ctx(ctx) profileKey, err := cli.ProfileKeyForSignalID(ctx, signalID) if err != nil { return nil, fmt.Errorf("error getting profile key: %w", err) @@ -162,6 +161,15 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*t return nil, errProfileKeyNotFound } + credentialRequest, err := cli.ProfileKeyCredentialRequest(ctx, signalID) + if err != nil { + return nil, fmt.Errorf("error getting profile key credential request: %w", err) + } + return cli.fetchProfileWithRequestAndKey(ctx, signalID, credentialRequest, profileKey) +} + +func (cli *Client) fetchProfileWithRequestAndKey(ctx context.Context, signalID uuid.UUID, credentialRequest []byte, profileKey *libsignalgo.ProfileKey) (*types.Profile, error) { + log := zerolog.Ctx(ctx) profileKeyVersion, err := profileKey.GetProfileKeyVersion(signalID) if err != nil { return nil, fmt.Errorf("error getting profile key version: %w", err) @@ -172,12 +180,6 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*t return nil, fmt.Errorf("error deriving access key: %w", err) } base64AccessKey := base64.StdEncoding.EncodeToString(accessKey[:]) - - credentialRequest, err := cli.ProfileKeyCredentialRequest(ctx, signalID) - if err != nil { - return nil, fmt.Errorf("error getting profile key credential request: %w", err) - } - path := "/v1/profile/" + signalID.String() useUnidentified := profileKeyVersion != nil && accessKey != nil if useUnidentified { @@ -241,6 +243,7 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*t } // TODO store other metadata fields? profile.AvatarPath = profileResponse.Avatar + profile.Credential = profileResponse.Credential profile.Key = *profileKey return &profile, nil @@ -344,3 +347,41 @@ func AesgcmEncrypt(key, nonce, plaintext []byte) ([]byte, error) { } return aesgcm.Seal(nil, nonce, plaintext, nil), nil } + +func (cli *Client) FetchExpiringProfileKeyCredentialById(ctx context.Context, signalACI uuid.UUID) (*libsignalgo.ExpiringProfileKeyCredential, error) { + profileKey, err := cli.ProfileKeyForSignalID(ctx, signalACI) + if err != nil { + return nil, fmt.Errorf("error getting profile key for ACI: %w", err) + } + requestContext, err := libsignalgo.CreateProfileKeyCredentialRequestContext( + prodServerPublicParams, + signalACI, + *profileKey, + ) + if err != nil { + return nil, fmt.Errorf("error creating profile key credential request context: %w", err) + } + + request, err := requestContext.ProfileKeyCredentialRequestContextGetRequest() + if err != nil { + return nil, fmt.Errorf("error getting profile key credential request: %w", err) + } + + // convert request bytes to hexidecimal representation + hexRequest := hex.EncodeToString(request[:]) + credentialRequest := []byte(hexRequest) + + profile, err := cli.fetchProfileWithRequestAndKey(ctx, signalACI, credentialRequest, profileKey) + if err != nil { + return nil, fmt.Errorf("failed to fetch profile: %w", err) + } + response, err := libsignalgo.NewExpiringProfileKeyCredentialResponse(profile.Credential) + if err != nil { + return nil, fmt.Errorf("failed to get expiring profile key credential response: %w", err) + } + epkc, err := libsignalgo.ReceiveExpiringProfileKeyCredential(prodServerPublicParams, requestContext, response, uint64(time.Now().Unix())) + if err != nil { + return nil, fmt.Errorf("failed to receive expiring profile key credential: %w", err) + } + return epkc, nil +} diff --git a/pkg/signalmeow/sending.go b/pkg/signalmeow/sending.go index 5bc5161b..1822660a 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" @@ -516,6 +517,31 @@ func wrapDataMessageInContent(dm *signalpb.DataMessage) *signalpb.Content { } } +func (cli *Client) SendGroupChange(ctx context.Context, group *Group, groupContext *signalpb.GroupContextV2, groupChange *GroupChange) (*GroupMessageSendResult, error) { + log := zerolog.Ctx(ctx).With(). + Str("action", "send group change message"). + Stringer("group_id", group.GroupIdentifier). + Logger() + ctx = log.WithContext(ctx) + timestamp := currentMessageTimestamp() + dm := &signalpb.DataMessage{ + Timestamp: ×tamp, + GroupV2: groupContext, + } + content := wrapDataMessageInContent(dm) + recipients := group.Members + for _, member := range group.PendingMembers { + recipients = append(recipients, &member.GroupMember) + } + for _, member := range groupChange.AddPendingMembers { + recipients = append(recipients, &member.GroupMember) + } + for _, member := range groupChange.AddMembers { + recipients = append(recipients, &member.GroupMember) + } + return cli.sendToGroup(ctx, recipients, 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"). @@ -535,13 +561,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/pkg/signalmeow/types/contact.go b/pkg/signalmeow/types/contact.go index f00c89b8..a9fdbd32 100644 --- a/pkg/signalmeow/types/contact.go +++ b/pkg/signalmeow/types/contact.go @@ -31,6 +31,7 @@ type Profile struct { AvatarPath string Key libsignalgo.ProfileKey FetchedAt time.Time + Credential []byte } func (p *Profile) Equals(other *Profile) bool { diff --git a/pkg/signalmeow/types/identifer.go b/pkg/signalmeow/types/identifer.go index 334e9bba..c11238c7 100644 --- a/pkg/signalmeow/types/identifer.go +++ b/pkg/signalmeow/types/identifer.go @@ -44,3 +44,4 @@ func (gid GroupIdentifier) Bytes() (raw libsignalgo.GroupIdentifier, err error) // This is just base64 encoded group master key type SerializedGroupMasterKey string +type SerializedInviteLinkPassword string diff --git a/portal.go b/portal.go index 9c2049e1..13a23e96 100644 --- a/portal.go +++ b/portal.go @@ -31,6 +31,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "go.mau.fi/util/exfmt" "go.mau.fi/util/jsontime" "go.mau.fi/util/variationselector" "google.golang.org/protobuf/proto" @@ -1090,9 +1091,7 @@ func (portal *Portal) handleSignalGroupChange(source *User, sender *Puppet, grou if *groupChange.ModifyAttributesAccess == signalmeow.AccessControl_ADMINISTRATOR { level = 50 } - levels.EnsureEventLevel(event.StateRoomName, level) - levels.EnsureEventLevel(event.StateTopic, level) - levels.EnsureEventLevel(event.StateRoomAvatar, level) + levels.StateDefaultPtr = &level } if groupChange.ModifyMemberAccess != nil { level := 0 @@ -1130,6 +1129,7 @@ func (portal *Portal) handleSignalGroupChange(source *User, sender *Puppet, grou log.Err(err).Msg("Failed to save portal in database after processing group change") } portal.UpdateBridgeInfo(ctx) + portal.CleanupIfEmpty(ctx) } func (portal *Portal) sendMembershipForPuppetAndUser(ctx context.Context, sender *Puppet, target uuid.UUID, membership event.Membership, action string) (puppet *Puppet, err error) { @@ -1328,6 +1328,14 @@ func (portal *Portal) handleSignalNormalDataMessage(source *User, sender *Puppet portal.storeMessageInDB(ctx, resp.EventID, sender.SignalID, converted.Timestamp, i) if converted.DisappearIn != 0 { portal.addDisappearingMessage(ctx, resp.EventID, converted.DisappearIn, sender.SignalID == source.SignalID) + // Ensure portal expiration timer is correct in DMs + if portal.implicitlyUpdateExpirationTimer(ctx, converted.DisappearIn) { + log.Info().Uint32("new_time", converted.DisappearIn).Msg("Implicitly updated expiration timer") + err := portal.Update(ctx) + if err != nil { + log.Err(err).Msg("Failed to save portal in database after implicitly updating group info") + } + } } } } @@ -2018,6 +2026,22 @@ func (portal *Portal) updateExpirationTimer(ctx context.Context, newExpirationTi return true } +func (portal *Portal) implicitlyUpdateExpirationTimer(ctx context.Context, newExpirationTimer uint32) bool { + if portal.ExpirationTime == newExpirationTimer { + return false + } + portal.ExpirationTime = newExpirationTimer + if portal.MXID != "" { + msg := portal.MsgConv.ConvertDisappearingTimerChangeToMatrix(ctx, newExpirationTimer, false) + msg.Content.Body = fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", exfmt.Duration(time.Duration(newExpirationTimer)*time.Second)) + _, err := portal.sendMainIntentMessage(ctx, msg.Content) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to send notice about disappearing message timer changing implicitly") + } + } + return true +} + func (portal *Portal) updateName(ctx context.Context, newName string, sender *Puppet) bool { if portal.Name == newName && (portal.NameSet || portal.MXID == "") { return false @@ -2323,6 +2347,7 @@ func (portal *Portal) SyncParticipants(ctx context.Context, source *User, info * } } } + portal.CleanupIfEmpty(ctx) return userIDs } @@ -2452,3 +2477,428 @@ 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) HandleMatrixLeave(brSender bridge.User, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix leave"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + sender := brSender.(*User) + if portal.IsPrivateChat() { + log.Info().Msg("User left private chat portal, cleaning up and deleting...") + portal.Delete() + portal.Cleanup(ctx, false) + return + } else if portal.bridge.Config.Bridge.BridgeMatrixLeave { + portal.deleteMember(sender, sender.SignalID, evt) + } + portal.CleanupIfEmpty(ctx) +} +func (portal *Portal) HandleMatrixKick(brSender bridge.User, ghost bridge.Ghost, evt *event.Event) { + portal.deleteMember(brSender.(*User), ghost.(*Puppet).SignalID, evt) +} +func (portal *Portal) deleteMember(sender *User, target uuid.UUID, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix kick/leave"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + groupChange := &signalmeow.GroupChange{DeleteMembers: []*uuid.UUID{&target}} + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error deleting Member from Signal") + return + } + portal.Revision = revision + portal.Update(ctx) +} +func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix invite"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + sender := brSender.(*User) + puppet := brGhost.(*Puppet) + role := signalmeow.GroupMember_DEFAULT + levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) + if err != nil { + log.Err(err).Msg("Couldn't get power levels") + if levels.GetUserLevel(puppet.IntentFor(portal).UserID) >= 50 { + role = signalmeow.GroupMember_ADMINISTRATOR + } + } + groupChange := &signalmeow.GroupChange{AddMembers: []*signalmeow.AddMember{{ + GroupMember: signalmeow.GroupMember{ + UserID: puppet.SignalID, + Role: role, + }, + }}} + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error inviting user on Signal") + } + puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) + portal.Revision = revision + portal.Update(ctx) +} + +func (portal *Portal) HandleMatrixAcceptKnock(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix accept knock"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + sender := brSender.(*User) + puppet := brGhost.(*Puppet) + role := signalmeow.GroupMember_DEFAULT + levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID) + if err != nil { + log.Err(err).Msg("Couldn't get power levels") + if levels.GetUserLevel(puppet.IntentFor(portal).UserID) >= 50 { + role = signalmeow.GroupMember_ADMINISTRATOR + } + } + groupChange := &signalmeow.GroupChange{PromoteRequestingMembers: []*signalmeow.RoleMember{{ + UserID: puppet.SignalID, + Role: role, + }}} + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error accepting join request on Signal") + } + portal.Revision = revision + portal.Update(ctx) +} + +func (portal *Portal) HandleMatrixRejectKnock(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { + portal.removeRequestingMember(brSender.(*User), brGhost.(*Puppet).SignalID, evt) +} + +func (portal *Portal) HandleMatrixRetractKnock(brSender bridge.User, evt *event.Event) { + portal.removeRequestingMember(brSender.(*User), brSender.(*User).SignalID, evt) +} + +func (portal *Portal) removeRequestingMember(sender *User, target uuid.UUID, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix knock -> leave"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + groupChange := &signalmeow.GroupChange{DeleteRequestingMembers: []*uuid.UUID{&target}} + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error removing requesting member") + } + portal.Revision = revision + portal.Update(ctx) +} + +func (portal *Portal) HandleMatrixKnock(brSender bridge.User, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix knock"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + log.Debug().Msg("Knocks aren't implemented yet :(") +} + +func (portal *Portal) HandleMatrixBan(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix ban"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + sender := brSender.(*User) + puppet := brGhost.(*Puppet) + groupChange := &signalmeow.GroupChange{AddBannedMembers: []*signalmeow.BannedMember{{ + UserID: puppet.SignalID, + Timestamp: uint64(time.Now().UnixMilli()), + }}} + switch prevMembership := evt.Unsigned.PrevContent.AsMember().Membership; prevMembership { + case event.MembershipJoin: + groupChange.DeleteMembers = []*uuid.UUID{&puppet.SignalID} + case event.MembershipKnock: + groupChange.DeleteRequestingMembers = []*uuid.UUID{&puppet.SignalID} + case event.MembershipInvite: + groupChange.DeletePendingMembers = []*uuid.UUID{&puppet.SignalID} + } + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error banning on Signal") + } + portal.Revision = revision + portal.Update(ctx) +} + +func (portal *Portal) HandleMatrixUnban(brSender bridge.User, brGhost bridge.Ghost, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix unban"). + Stringer("event_id", evt.ID). + Str("event_type", evt.Type.String()). + Logger() + ctx := log.WithContext(context.TODO()) + sender := brSender.(*User) + puppet := brGhost.(*Puppet) + groupChange := &signalmeow.GroupChange{DeleteBannedMembers: []*uuid.UUID{&puppet.SignalID}} + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error unbanning on Signal") + } + portal.Revision = revision + portal.Update(ctx) +} + +func (portal *Portal) HandleMatrixPowerLevels(brSender bridge.User, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix power levels"). + 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 power levels: user is not logged in") + return + } + evt.Content.ParseRaw(event.StatePowerLevels) + levels := evt.Content.AsPowerLevels() + var prevLevels *event.PowerLevelsEventContent + if evt.Unsigned.PrevContent != nil { + evt.Unsigned.PrevContent.ParseRaw(event.StatePowerLevels) + prevLevels = evt.Unsigned.PrevContent.AsPowerLevels() + } else { + prevLevels = &event.PowerLevelsEventContent{} + } + groupChange := &signalmeow.GroupChange{} + var role signalmeow.GroupMemberRole + for user, level := range levels.Users { + prevLevel := prevLevels.GetUserLevel(user) + if (level >= 50 && prevLevel < 50) || (level < 50 && prevLevel >= 50) { + puppet := portal.bridge.GetPuppetByMXID(user) + if puppet == nil { + log.Warn().Stringer("mxid", user).Msg("Couldn't get puppet for power level change") + continue + } + role = signalmeow.GroupMember_DEFAULT + if level >= 50 { + role = signalmeow.GroupMember_ADMINISTRATOR + } + groupChange.ModifyMemberRoles = append(groupChange.ModifyMemberRoles, &signalmeow.RoleMember{ + UserID: puppet.SignalID, + Role: role, + }) + } + } + if levels.EventsDefault >= 50 && prevLevels.EventsDefault < 50 { + announcementsOnly := true + groupChange.ModifyAnnouncementsOnly = &announcementsOnly + } else if levels.EventsDefault < 50 && prevLevels.EventsDefault >= 50 { + announcementsOnly := false + groupChange.ModifyAnnouncementsOnly = &announcementsOnly + } + if levels.StateDefault() >= 50 && prevLevels.StateDefault() < 50 { + attributesAccess := signalmeow.AccessControl_ADMINISTRATOR + groupChange.ModifyAttributesAccess = &attributesAccess + } else if levels.StateDefault() < 50 && prevLevels.StateDefault() >= 50 { + attributesAccess := signalmeow.AccessControl_MEMBER + groupChange.ModifyAttributesAccess = &attributesAccess + } + if levels.Invite() >= 50 && prevLevels.Invite() < 50 { + memberAccess := signalmeow.AccessControl_ADMINISTRATOR + groupChange.ModifyMemberAccess = &memberAccess + } else if levels.Invite() < 50 && prevLevels.Invite() >= 50 { + memberAccess := signalmeow.AccessControl_MEMBER + groupChange.ModifyMemberAccess = &memberAccess + } + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error changing group access control") + return + } + portal.Revision = revision + portal.Update(ctx) +} + +func (portal *Portal) HandleMatrixJoinRule(brSender bridge.User, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix join rule"). + 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 join rule: user is not logged in") + return + } + evt.Content.ParseRaw(event.StateJoinRules) + joinRule := evt.Content.AsJoinRules().JoinRule + groupChange := &signalmeow.GroupChange{} + addFromInviteLinkAccess := signalmeow.AccessControl_UNSATISFIABLE + if joinRule == event.JoinRuleKnock { + addFromInviteLinkAccess = signalmeow.AccessControl_ADMINISTRATOR + } else if joinRule == event.JoinRulePublic { + addFromInviteLinkAccess = signalmeow.AccessControl_ANY + } + groupChange.ModifyAddFromInviteLinkAccess = &addFromInviteLinkAccess + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error updating group access control") + return + } + portal.Revision = revision + portal.Update(ctx) +} + +func (portal *Portal) HandleMatrixMeta(brSender bridge.User, evt *event.Event) { + log := portal.log.With(). + Str("action", "handle matrix meta"). + 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 room info: user is not logged in") + return + } + + var err error + groupChange := &signalmeow.GroupChange{Revision: portal.Revision + 1} + var avatarPath *string + var avatarHash string + var avatarURL id.ContentURI + var avatarChanged bool + switch content := evt.Content.Parsed.(type) { + case *event.RoomNameEventContent: + if content.Name == portal.Name { + return + } + portal.Name = content.Name + groupChange.ModifyTitle = &content.Name + case *event.TopicEventContent: + if content.Topic == portal.Topic { + return + } + portal.Topic = content.Topic + groupChange.ModifyDescription = &content.Topic + 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 + } + groupChange.ModifyAvatar = avatarPath + hash := sha256.Sum256(data) + avatarHash = hex.EncodeToString(hash[:]) + avatarChanged = true + avatarURL = content.URL + } + revision, err := sender.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error updating group attributes") + return + } + if avatarChanged { + log.Debug().Msg("Successfully updated group avatar") + portal.AvatarSet = true + portal.AvatarPath = *avatarPath + portal.AvatarHash = avatarHash + portal.AvatarURL = avatarURL + portal.UpdateBridgeInfo(ctx) + } + portal.Revision = revision + portal.Update(ctx) + log.Info().Msg("finished updating group") +} + +func (portal *Portal) CleanupIfEmpty(ctx context.Context) { + log := portal.log.With(). + Str("action", "Clean up if empty"). + Logger() + users, err := portal.GetMatrixUsers(ctx) + if err != nil { + log.Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up") + return + } + + if len(users) == 0 { + log.Info().Msg("Room seems to be empty, cleaning up...") + portal.Delete() + portal.Cleanup(ctx, false) + } +} + +func (portal *Portal) GetMatrixUsers(ctx context.Context) ([]id.UserID, error) { + members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID) + if err != nil { + return nil, fmt.Errorf("failed to get member list: %w", err) + } + var users []id.UserID + for userID := range members.Joined { + _, isPuppet := portal.bridge.ParsePuppetMXID(userID) + if !isPuppet && userID != portal.bridge.Bot.UserID { + users = append(users, userID) + } + } + return users, nil +} + +func (portal *Portal) GetInviteLink(ctx context.Context, source *User) (string, error) { + info, err := source.Client.RetrieveGroupByID(ctx, portal.GroupID(), portal.Revision) + if err != nil { + log.Err(err). + Stringer("source_user_id", source.MXID). + Msg("Failed to fetch group info") + return "", err + } + inviteLinkPassword, err := info.GetInviteLink() + if err != nil { + log.Err(err).Msg("Failed to get invite link") + } + return inviteLinkPassword, nil +} + +func (portal *Portal) ResetInviteLink(ctx context.Context, source *User) error { + inviteLinkPassword := signalmeow.GenerateInviteLinkPassword() + groupChange := &signalmeow.GroupChange{ModifyInviteLinkPassword: &inviteLinkPassword} + revision, err := source.Client.UpdateGroup(ctx, groupChange, portal.GroupID()) + if err != nil { + log.Err(err).Msg("Error setting invite link password") + return err + } + portal.Revision = revision + portal.Update(ctx) + return nil +} + +func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) { + evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} + if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom { + evt.RotationPeriodMillis = rot.Milliseconds + evt.RotationPeriodMessages = rot.Messages + } + return +}