Skip to content

Commit

Permalink
matrix -> signal group metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
maltee1 committed Feb 22, 2024
1 parent 9c201ca commit d5f52c9
Show file tree
Hide file tree
Showing 7 changed files with 361 additions and 7 deletions.
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions pkg/libsignalgo/groupsecretparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion pkg/libsignalgo/verifysignature.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
79 changes: 79 additions & 0 deletions pkg/signalmeow/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
166 changes: 165 additions & 1 deletion pkg/signalmeow/groups.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,6 +21,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
25 changes: 24 additions & 1 deletion pkg/signalmeow/sending.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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: &timestamp,
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").
Expand All @@ -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
Expand Down
Loading

0 comments on commit d5f52c9

Please sign in to comment.