From 7a02cc815d428c6190a30f17f9c4687fbca1d3b2 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 8 Sep 2020 19:25:05 -0700 Subject: [PATCH] Support for new GroupV2 groups --- .gitignore | 1 + .yarnclean | 3 + _locales/en/messages.json | 741 +++++- js/background.js | 426 ++- js/delivery_receipts.js | 2 +- js/models/blockedNumbers.js | 2 +- js/models/conversations.js | 557 ++-- js/models/messages.js | 317 ++- js/modules/privacy.js | 21 +- js/modules/signal.js | 8 + js/modules/types/conversation.js | 8 +- js/read_receipts.js | 2 +- js/views/contact_list_view.js | 2 +- js/views/conversation_view.js | 48 +- libtextsecure/protobufs.js | 5 +- preload.js | 8 +- protos/Groups.proto | 152 ++ stylesheets/_modules.scss | 58 +- test/models/messages_test.js | 2 +- test/modules/privacy_test.js | 12 + ts/ConversationController.ts | 6 +- ts/Crypto.ts | 5 + ts/RemoteConfig.ts | 3 +- ts/components/ContactListItem.stories.tsx | 27 + ts/components/ContactListItem.tsx | 59 +- .../ConversationHeader.stories.tsx | 2 +- .../conversation/ConversationHeader.tsx | 8 +- .../conversation/GroupV2Change.stories.tsx | 866 +++++++ ts/components/conversation/GroupV2Change.tsx | 60 + .../conversation/Timeline.stories.tsx | 1 + .../conversation/TimelineItem.stories.tsx | 6 + ts/components/conversation/TimelineItem.tsx | 27 +- .../conversation/TimerNotification.tsx | 6 +- ts/groupChange.ts | 536 ++++ ts/groups.ts | 2296 +++++++++++++++++ ts/model-types.d.ts | 88 +- ts/services/groupCredentialFetcher.ts | 212 ++ ts/services/storage.ts | 20 +- ts/services/storageRecordOps.ts | 105 +- ts/sql/Server.ts | 21 +- ts/state/ducks/conversations.ts | 19 +- ts/state/smart/ContactName.tsx | 32 + ts/state/smart/TimelineItem.tsx | 12 + ts/textsecure.d.ts | 307 ++- ts/textsecure/AccountManager.ts | 5 + ts/textsecure/MessageReceiver.ts | 145 +- ts/textsecure/OutgoingMessage.ts | 53 +- ts/textsecure/SendMessage.ts | 275 +- ts/textsecure/WebAPI.ts | 348 ++- ts/util/deleteForEveryone.ts | 2 +- ts/util/lint/exceptions.json | 38 +- ts/util/zkgroup.ts | 181 +- ts/window.d.ts | 19 + 53 files changed, 7326 insertions(+), 839 deletions(-) create mode 100644 protos/Groups.proto create mode 100644 ts/components/conversation/GroupV2Change.stories.tsx create mode 100644 ts/components/conversation/GroupV2Change.tsx create mode 100644 ts/groupChange.ts create mode 100644 ts/groups.ts create mode 100644 ts/services/groupCredentialFetcher.ts create mode 100644 ts/state/smart/ContactName.tsx diff --git a/.gitignore b/.gitignore index 1f5db62a9..7a0ffc6d2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ release/ .nyc_output/ *.sublime* /sql/ +/start.sh # generated files js/components.js diff --git a/.yarnclean b/.yarnclean index 73649a14a..c725e7590 100644 --- a/.yarnclean +++ b/.yarnclean @@ -18,6 +18,9 @@ example coverage .nyc_output +# unneeded files +*.js.map + # build scripts Makefile Gulpfile.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 181fe1fba..9de50fac5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -272,7 +272,7 @@ "description": "Used as a label on a button allowing user to see more information" }, "youLeftTheGroup": { - "message": "You left the group.", + "message": "You are no longer a member of the group.", "description": "Displayed when a user can't send a message because they have left the group" }, "scrollDown": { @@ -1381,6 +1381,16 @@ } } }, + "timerSetByMember": { + "message": "A member set the disappearing message time to $time$.", + "description": "Message displayed when timer is by an unknown group member.", + "placeholders": { + "time": { + "content": "$1", + "example": "10m" + } + } + }, "theyChangedTheTimer": { "message": "$name$ set the disappearing message time to $time$.", "description": "Message displayed when someone else changes the message expiration timer in a conversation.", @@ -1499,6 +1509,10 @@ "message": "Disappearing messages disabled", "description": "Displayed in the left pane when the timer is turned off" }, + "disappearingMessagesDisabledByMember": { + "message": "A member disabled disappearing messages.", + "description": "Displayed in the left pane when the timer is turned off" + }, "disabledDisappearingMessages": { "message": "$name$ disabled disappearing messages.", "description": "Displayed in the conversation list when the timer is turned off", @@ -2829,5 +2843,730 @@ "EmojiButton__label": { "message": "Emoji", "description": "Label for emoji button" + }, + "GroupV2--admin": { + "message": "Admin", + "description": "Shown next to the set of administrators in a group" + }, + "GroupV2--timerConflict": { + "message": "Failed to update disappearing message timer. Please try again later.", + "description": "Shown if the user runs into a group update conflict attempting to update a GroupV2 message timer" + }, + "GroupV2--title--change--other": { + "message": "$memberName$ changed the group name to \"$newTitle$\".", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + }, + "newTitle": { + "content": "$2", + "example": "Saturday Hiking" + } + } + }, + "GroupV2--title--change--you": { + "message": "You changed the group name to \"$newTitle$\".", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "newTitle": { + "content": "$1", + "example": "Saturday Hiking" + } + } + }, + "GroupV2--title--change--unknown": { + "message": "A member changed the group name to \"$newTitle$\".", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "newTitle": { + "content": "$1", + "example": "Saturday Hiking" + } + } + }, + + "GroupV2--title--remove--other": { + "message": "$memberName$ removed the group name.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--title--remove--you": { + "message": "You removed the group name.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--title--remove--unknown": { + "message": "A member removed the group name.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + + "GroupV2--avatar--change--other": { + "message": "$memberName$ changed the group avatar.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--avatar--change--you": { + "message": "You changed the group avatar.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--avatar--change--unknown": { + "message": "A member changed the group avatar.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--avatar--remove--other": { + "message": "$memberName$ removed the group avatar.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--avatar--remove--you": { + "message": "You removed the group avatar.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--avatar--remove--unknown": { + "message": "A member removed the group avatar.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + + "GroupV2--access-attributes--admins--other": { + "message": "$adminName$ changed who can edit group info to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--access-attributes--admins--you": { + "message": "You changed who can edit group info to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-attributes--admins--unknown": { + "message": "An admin changed who can edit group info to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-attributes--all--other": { + "message": "$adminName$ changed who can edit group info to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--access-attributes--all--you": { + "message": "You changed who can edit group info to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-attributes--all--unknown": { + "message": "An admin changed who can edit group info to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-members--admins--other": { + "message": "$adminName$ changed who can edit group membership to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--access-members--admins--you": { + "message": "You changed who can edit group membership to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-members--admins--unknown": { + "message": "An admin changed who can edit group membership to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-members--all--other": { + "message": "$adminName$ changed who can edit group membership to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--access-members--all--you": { + "message": "You changed who can edit group membership to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-members--all--unknown": { + "message": "An admin changed who can edit group membership to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + + "GroupV2--member-add--from-invite--other": { + "message": "$inviteeName$ accepted an invitation to the group from $inviterName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Alice" + }, + "inviterName": { + "content": "$2", + "example": "Bob" + } + } + }, + "GroupV2--member-add--from-invite--you": { + "message": "You accepted an invitation to the group from $inviterName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviterName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-add--from-invite--from-you": { + "message": "$inviteeName$ accepted your invitation to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-add--other--other": { + "message": "$adderName$ added $addeeName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adderName": { + "content": "$1", + "example": "Bob" + }, + "addeeName": { + "content": "$2", + "example": "Alice" + } + } + }, + "GroupV2--member-add--other--you": { + "message": "You added $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-add--other--unknown": { + "message": "A member added $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-add--you--other": { + "message": "$memberName$ added you to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-add--you--you": { + "message": "You joined the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--member-add--you--unknown": { + "message": "A member added you to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--member-remove--other--other": { + "message": "$adminName$ removed $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "memberName": { + "content": "$2", + "example": "Alice" + } + } + }, + "GroupV2--member-remove--other--self": { + "message": "$memberName$ left.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-remove--other--you": { + "message": "You removed $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-remove--other--unknown": { + "message": "A member removed $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-remove--you--other": { + "message": "$adminName$ removed you.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-remove--you--you": { + "message": "You left.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--member-remove--you--unknown": { + "message": "A member removed you.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + + "GroupV2--member-privilege--promote--other--other": { + "message": "$adminName$ made $memberName$ an admin.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "memberName": { + "content": "$2", + "example": "Alice" + } + } + }, + "GroupV2--member-privilege--promote--other--you": { + "message": "You made $memberName$ an admin.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-privilege--promote--other--unknown": { + "message": "An admin made $memberName$ an admin.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-privilege--promote--you--other": { + "message": "$adminName$ made you an admin.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-privilege--promote--you--unknown": { + "message": "An admin made you an admin.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--member-privilege--demote--other--other": { + "message": "$adminName$ revoked admin privileges from $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "memberName": { + "content": "$2", + "example": "Alice" + } + } + }, + "GroupV2--member-privilege--demote--other--you": { + "message": "You revoked admin privileges from $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-privilege--demote--other--unknown": { + "message": "An admin revoked admin privileges from $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "memberName": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-privilege--demote--you--other": { + "message": "$adminName$ revoked your admin privileges.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--member-privilege--demote--you--unknown": { + "message": "An admin revoked your admin privileges.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + + "GroupV2--pending-add--one--other--other": { + "message": "$memberName$ invited 1 person to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-add--one--other--you": { + "message": "You invited $inviteeName$ to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-add--one--other--unknown": { + "message": "A member invited 1 person to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-add--one--you--other": { + "message": "$memberName$ invited you to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-add--one--you--unknown": { + "message": "A member invited you to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--pending-add--many--other": { + "message": "$memberName$ invited $count$ people to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + }, + "count": { + "content": "$2", + "example": "5" + } + } + }, + "GroupV2--pending-add--many--you": { + "message": "You invited $count$ people to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "GroupV2--pending-add--many--unknown": { + "message": "A member invited $count$ people to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + + "GroupV2--pending-remove--decline--other": { + "message": "1 person invited by $memberName$ declined the invitation to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--decline--you": { + "message": "$inviteeName$ declined your invitation to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--decline--unknown": { + "message": "1 person declined their invitation to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--pending-remove--revoke--one--other": { + "message": "$memberName$ revoked an invitation to the group for 1 person.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke--one--you": { + "message": "You revoked an invitation to the group for 1 person.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke--one--unknown": { + "message": "An admin revoked an invitation to the group for 1 person.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke--many--other": { + "message": "$memberName$ revoked invitations to the group for $count$ people.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + }, + "count": { + "content": "$2", + "example": "5" + } + } + }, + "GroupV2--pending-remove--revoke--many--you": { + "message": "You revoked invitations to the group for $count$ people.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "GroupV2--pending-remove--revoke--many--unknown": { + "message": "An admin revoked invitations to the group for $count$ people.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from--one--other": { + "message": "$adminName$ revoked an invitation to the group for 1 person invited by $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "memberName": { + "content": "$2", + "example": "Alice" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from--one--you": { + "message": "You revoked an invitation to the group for 1 person invited by $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from--one--unknown": { + "message": "An admin revoked an invitation to the group for 1 person invited by $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from-you--one--other": { + "message": "$adminName$ revoked the invitation to the group you sent to $inviteeName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from-you--one--you": { + "message": "You rescinded your invitation to $inviteeName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from-you--one--unknown": { + "message": "An admin revoked the invitation to the group you sent to $inviteeName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviteeName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from--many--other": { + "message": "$adminName$ revoked invitations to the group for $count$ people invited by $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "memberName": { + "content": "$2", + "example": "Alice" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from--many--you": { + "message": "You revoked invitations to the group for $count$ people invited by $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + }, + "memberName": { + "content": "$2", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from--many--unknown": { + "message": "An admin revoked invitations to the group for $count$ people invited by $memberName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + }, + "memberName": { + "content": "$2", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from-you--many--other": { + "message": "$adminName$ revoked the invitations to the group you sent to $count$ people.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "count": { + "content": "$2", + "example": "5" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from-you--many--you": { + "message": "You rescinded your invitation to $count$ people.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "GroupV2--pending-remove--revoke-invite-from-you--many--unknown": { + "message": "An admin revoked the invitations to the group you sent to $count$ people.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } } } diff --git a/js/background.js b/js/background.js index 844f172b6..d10aa2b91 100644 --- a/js/background.js +++ b/js/background.js @@ -546,7 +546,7 @@ } if ( - window.isBeforeVersion(lastVersion, 'v1.35.0-beta.11') && + window.isBeforeVersion(lastVersion, 'v1.36.0-beta.1') && window.isAfterVersion(lastVersion, 'v1.35.0-beta.1') ) { await window.Signal.Services.eraseAllStorageServiceState(); @@ -606,6 +606,17 @@ // flags are represented in the cached props we generate on load of each convo. window.Signal.RemoteConfig.initRemoteConfig(); + // On startup, we don't want to wait for the remote config fetch if we've already + // learned that this instance supports GroupsV2. + // This is how we keep it sticky. Once it is enabled, we never disable it. + if ( + window.Signal.RemoteConfig.isEnabled('desktop.gv2') || + window.storage.get('gv2-enabled') + ) { + window.GV2 = true; + window.storage.put('gv2-enabled', true); + } + try { await Promise.all([ ConversationController.load(), @@ -1552,6 +1563,33 @@ removeMessageRequestListener(); } ); + + // Listen for changes to the `desktop.gv2` remote configuration flag + const removeGv2Listener = window.Signal.RemoteConfig.onChange( + 'desktop.gv2', + async ({ enabled }) => { + if (!enabled) { + return; + } + + window.GV2 = true; + + await window.storage.put('gv2-enabled', true); + + window.Signal.Services.handleUnknownRecords( + window.textsecure.protobuf.ManifestRecord.Identifier.Type.GROUPV2 + ); + + // Erase current manifest version so we re-process storage service data + await window.storage.remove('manifestVersion'); + + // Kick off storage service fetch to grab GroupV2 information + await window.Signal.Services.runStorageServiceSyncJob(); + + // This is a one-time thing + removeGv2Listener(); + } + ); } window.getSyncRequest = () => @@ -1657,45 +1695,58 @@ PASSWORD ); - try { - if (connectCount === 0) { - const lonelyE164s = window - .getConversations() - .filter( - c => - c.isPrivate() && - c.get('e164') && - !c.get('uuid') && - !c.isEverUnregistered() - ) - .map(c => c.get('e164')); - - if (lonelyE164s.length > 0) { - const lookup = await textsecure.messaging.getUuidsForE164s( - lonelyE164s - ); - const e164s = Object.keys(lookup); - e164s.forEach(e164 => { - const uuid = lookup[e164]; - if (!uuid) { - const byE164 = window.ConversationController.get(e164); - if (byE164) { - byE164.setUnregistered(); - } - } - window.ConversationController.ensureContactIds({ - e164, - uuid, - highTrust: true, - }); - }); - } + if (connectCount === 0) { + try { + // Force a re-fetch before we process our queue. We may want to turn on something + // which changes how we process incoming messages! + await window.Signal.RemoteConfig.refreshRemoteConfig(); + } catch (error) { + window.log.error( + 'connect: Error refreshing remote config:', + error && error.stack ? error.stack : error + ); + } + + try { + if (window.Signal.RemoteConfig.isEnabled('desktop.cds')) { + const lonelyE164s = window + .getConversations() + .filter( + c => + c.isPrivate() && + c.get('e164') && + !c.get('uuid') && + !c.isEverUnregistered() + ) + .map(c => c.get('e164')); + + if (lonelyE164s.length > 0) { + const lookup = await textsecure.messaging.getUuidsForE164s( + lonelyE164s + ); + const e164s = Object.keys(lookup); + e164s.forEach(e164 => { + const uuid = lookup[e164]; + if (!uuid) { + const byE164 = window.ConversationController.get(e164); + if (byE164) { + byE164.setUnregistered(); + } + } + window.ConversationController.ensureContactIds({ + e164, + uuid, + highTrust: true, + }); + }); + } + } + } catch (error) { + window.log.error( + 'connect: Error fetching UUIDs for lonely e164s:', + error && error.stack ? error.stack : error + ); } - } catch (error) { - window.log.error( - 'Error fetching UUIDs for lonely e164s:', - error && error.stack ? error.stack : error - ); } connectCount += 1; @@ -1718,6 +1769,8 @@ ); window.textsecure.messageReceiver = messageReceiver; + window.Signal.Services.initializeGroupCredentialFetcher(); + preMessageReceiverStatus = null; function addQueuedEventListener(name, handler) { @@ -1810,26 +1863,25 @@ } } - // TODO: uncomment this once we want to start registering UUID support - // const hasRegisteredUuidSupportKey = 'hasRegisteredUuidSupport'; - // if ( - // !storage.get(hasRegisteredUuidSupportKey) && - // textsecure.storage.user.getUuid() - // ) { - // const server = WebAPI.connect({ - // username: USERNAME || OLD_USERNAME, - // password: PASSWORD, - // }); - // try { - // await server.registerCapabilities({ uuid: true }); - // storage.put(hasRegisteredUuidSupportKey, true); - // } catch (error) { - // window.log.error( - // 'Error: Unable to register support for UUID messages.', - // error && error.stack ? error.stack : error - // ); - // } - // } + const hasRegisteredGroupV2SupportKey = 'hasRegisteredGroupV2Support'; + if ( + !storage.get(hasRegisteredGroupV2SupportKey) && + textsecure.storage.user.getUuid() + ) { + const server = WebAPI.connect({ + username: USERNAME || OLD_USERNAME, + password: PASSWORD, + }); + try { + await server.registerCapabilities({ gv2: true }); + storage.put(hasRegisteredGroupV2SupportKey, true); + } catch (error) { + window.log.error( + 'Error: Unable to register support for GroupV2.', + error && error.stack ? error.stack : error + ); + } + } const deviceId = textsecure.storage.user.getDeviceId(); @@ -1918,6 +1970,49 @@ view.applyTheme(); } } + + const FIVE_MINUTES = 5 * 60 * 1000; + + // Note: once this function returns, there still might be messages being processed on + // a given conversation's queue. But we have processed all events from the websocket. + async function waitForEmptyEventQueue() { + if (!messageReceiver) { + window.log.info( + 'waitForEmptyEventQueue: No messageReceiver available, returning early' + ); + return; + } + + if (!messageReceiver.hasEmptied()) { + window.log.info( + 'waitForEmptyEventQueue: Waiting for MessageReceiver empty event...' + ); + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + + const timeout = setTimeout(reject, FIVE_MINUTES); + const onEmptyOnce = () => { + messageReceiver.removeEventListener('empty', onEmptyOnce); + clearTimeout(timeout); + resolve(); + }; + messageReceiver.addEventListener('empty', onEmptyOnce); + + await promise; + } + + window.log.info( + 'waitForEmptyEventQueue: Waiting for event handler queue idle...' + ); + await eventHandlerQueue.onIdle(); + } + + window.waitForEmptyEventQueue = waitForEmptyEventQueue; + async function onEmpty() { await Promise.all([ window.waitForAllBatchers(), @@ -1937,11 +2032,6 @@ logger: window.log, }); - // Force a re-fetch here when we've processed our queue. Without this, we won't try - // again for two hours after our first attempt. Which might have been while we were - // offline or didn't have credentials. - window.Signal.RemoteConfig.refreshRemoteConfig(); - let interval = setInterval(() => { const view = window.owsDesktopApp.appView; if (view) { @@ -2024,7 +2114,7 @@ // Note: this type of message is automatically removed from cache in MessageReceiver const { typing, sender, senderUuid, senderDevice } = ev; - const { groupId, started } = typing || {}; + const { groupId, groupV2Id, started } = typing || {}; // We don't do anything with incoming typing messages if the setting is disabled if (!storage.get('typingIndicators')) { @@ -2036,27 +2126,34 @@ uuid: senderUuid, highTrust: true, }); - const conversation = ConversationController.get(groupId || senderId); + const conversation = ConversationController.get( + groupV2Id || groupId || senderId + ); const ourId = ConversationController.getOurConversationId(); - if (conversation) { - // We drop typing notifications in groups we're not a part of - if (!conversation.isPrivate() && !conversation.hasMember(ourId)) { - window.log.warn( - `Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` - ); - return; - } - - conversation.notifyTyping({ - isTyping: started, - isMe: ourId === senderId, - sender, - senderUuid, - senderId, - senderDevice, - }); + if (!conversation) { + window.log.warn( + `onTyping: Did not find conversation for typing indicator (groupv2(${groupV2Id}), group(${groupId}), ${sender}, ${senderUuid})` + ); + return; } + + // We drop typing notifications in groups we're not a part of + if (!conversation.isPrivate() && !conversation.hasMember(ourId)) { + window.log.warn( + `Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` + ); + return; + } + + conversation.notifyTyping({ + isTyping: started, + isMe: ourId === senderId, + sender, + senderUuid, + senderId, + senderDevice, + }); } async function onStickerPack(ev) { @@ -2227,6 +2324,7 @@ } } + // Note: this handler is only for v1 groups received via 'group sync' messages async function onGroupReceived(ev) { const details = ev.groupDetails; const { id } = details; @@ -2244,6 +2342,13 @@ id, 'group' ); + if (conversation.get('groupVersion') > 1) { + window.log.warn( + 'Got group sync for v2 group: ', + conversation.idForLoggoing() + ); + return; + } const memberConversations = details.membersE164.map(e164 => ConversationController.getOrCreate(e164, 'private') @@ -2321,37 +2426,6 @@ ); } - // Descriptors - const getGroupDescriptor = group => ({ - type: Message.GROUP, - id: group.id, - }); - - // Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`: - const getDescriptorForSent = ({ message, destination, destinationUuid }) => - message.group - ? getGroupDescriptor(message.group) - : { - type: Message.PRIVATE, - id: ConversationController.ensureContactIds({ - e164: destination, - uuid: destinationUuid, - }), - }; - - // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: - const getDescriptorForReceived = ({ message, source, sourceUuid }) => - message.group - ? getGroupDescriptor(message.group) - : { - type: Message.PRIVATE, - id: ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - highTrust: true, - }), - }; - // Received: async function handleMessageReceivedProfileUpdate({ data, @@ -2369,6 +2443,50 @@ return confirm(); } + // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: + const getDescriptorForReceived = ({ message, source, sourceUuid }) => { + if (message.groupV2) { + const { id } = message.groupV2; + const conversationId = ConversationController.ensureGroup(id, { + groupVersion: 2, + masterKey: message.groupV2.masterKey, + secretParams: message.groupV2.secretParams, + publicParams: message.groupV2.publicParams, + }); + + return { + type: Message.GROUP, + id: conversationId, + }; + } + if (message.group) { + const { id } = message.group; + const fromContactId = ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + highTrust: true, + }); + + const conversationId = ConversationController.ensureGroup(id, { + addedBy: fromContactId, + }); + + return { + type: Message.GROUP, + id: conversationId, + }; + } + + return { + type: Message.PRIVATE, + id: ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + highTrust: true, + }), + }; + }; + // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). @@ -2392,13 +2510,16 @@ if (data.message.reaction) { const { reaction } = data.message; - window.log.info('Queuing reaction for', reaction.targetTimestamp); + window.log.info( + 'Queuing incoming reaction for', + reaction.targetTimestamp + ); const reactionModel = Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, targetAuthorE164: reaction.targetAuthorE164, targetAuthorUuid: reaction.targetAuthorUuid, - targetTimestamp: reaction.targetTimestamp.toNumber(), + targetTimestamp: reaction.targetTimestamp, timestamp: Date.now(), fromId: ConversationController.ensureContactIds({ e164: data.source, @@ -2413,7 +2534,7 @@ if (data.message.delete) { const { delete: del } = data.message; - window.log.info('Queuing DOE for', del.targetSentTimestamp); + window.log.info('Queuing incoming DOE for', del.targetSentTimestamp); const deleteModel = Whisper.Deletes.add({ targetSentTimestamp: del.targetSentTimestamp, serverTimestamp: data.serverTimestamp, @@ -2508,11 +2629,6 @@ data.unidentifiedDeliveries = unidentified.map(item => item.destination); } - const isGroup = descriptor.type === Message.GROUP; - const conversationId = isGroup - ? ConversationController.ensureGroup(descriptor.id) - : descriptor.id; - return new Whisper.Message({ source: textsecure.storage.user.getNumber(), sourceUuid: textsecure.storage.user.getUuid(), @@ -2521,7 +2637,7 @@ serverTimestamp: data.serverTimestamp, sent_to: sentTo, received_at: now, - conversationId, + conversationId: descriptor.id, type: 'outgoing', sent: true, unidentifiedDeliveries: data.unidentifiedDeliveries || [], @@ -2532,6 +2648,42 @@ }); } + // Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`: + const getDescriptorForSent = ({ message, destination, destinationUuid }) => { + if (message.groupV2) { + const { id } = message.groupV2; + const conversationId = ConversationController.ensureGroup(id, { + groupVersion: 2, + masterKey: message.groupV2.masterKey, + secretParams: message.groupV2.secretParams, + publicParams: message.groupV2.publicParams, + }); + + return { + type: Message.GROUP, + id: conversationId, + }; + } + if (message.group) { + const { id } = message.group; + const conversationId = ConversationController.ensureGroup(id); + + return { + type: Message.GROUP, + id: conversationId, + }; + } + + return { + type: Message.PRIVATE, + id: ConversationController.ensureContactIds({ + e164: destination, + uuid: destinationUuid, + highTrust: true, + }), + }; + }; + // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). @@ -2555,12 +2707,13 @@ if (data.message.reaction) { const { reaction } = data.message; + window.log.info('Queuing sent reaction for', reaction.targetTimestamp); const reactionModel = Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, targetAuthorE164: reaction.targetAuthorE164, targetAuthorUuid: reaction.targetAuthorUuid, - targetTimestamp: reaction.targetTimestamp.toNumber(), + targetTimestamp: reaction.targetTimestamp, timestamp: Date.now(), fromId: ConversationController.getOurConversationId(), fromSync: true, @@ -2574,6 +2727,7 @@ if (data.message.delete) { const { delete: del } = data.message; + window.log.info('Queuing sent DOE for', del.targetSentTimestamp); const deleteModel = Whisper.Deletes.add({ targetSentTimestamp: del.targetSentTimestamp, serverTimestamp: del.serverTimestamp, @@ -2594,20 +2748,6 @@ } function initIncomingMessage(data, descriptor) { - // Ensure that we have an accurate record for who this message is from - const fromContactId = ConversationController.ensureContactIds({ - e164: data.source, - uuid: data.sourceUuid, - highTrust: true, - }); - - const isGroup = descriptor.type === Message.GROUP; - const conversationId = isGroup - ? ConversationController.ensureGroup(descriptor.id, { - addedBy: fromContactId, - }) - : fromContactId; - return new Whisper.Message({ source: data.source, sourceUuid: data.sourceUuid, @@ -2615,7 +2755,7 @@ sent_at: data.timestamp, serverTimestamp: data.serverTimestamp, received_at: Date.now(), - conversationId, + conversationId: descriptor.id, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', unread: 1, @@ -2729,6 +2869,14 @@ const conversationId = message.get('conversationId'); const conversation = ConversationController.get(conversationId); + if (!conversation) { + window.log.warn( + 'onError: No conversation id, cannot save error bubble' + ); + ev.confirm(); + return Promise.resolve(); + } + // This matches the queueing behavior used in Message.handleDataMessage conversation.queueJob(async () => { const existingMessage = await window.Signal.Data.getMessageBySender( diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index e38281eaf..f1ded1ac8 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -18,7 +18,7 @@ if (conversation.isPrivate()) { recipients = [conversation.id]; } else { - recipients = conversation.get('members') || []; + recipients = conversation.getMemberIds(); } const receipts = this.filter( receipt => diff --git a/js/models/blockedNumbers.js b/js/models/blockedNumbers.js index 03bbc55c5..b84b896a2 100644 --- a/js/models/blockedNumbers.js +++ b/js/models/blockedNumbers.js @@ -65,7 +65,7 @@ return; } - window.log.info(`adding groupId(${groupId}) to blocked list`); + window.log.info(`adding group(${groupId}) to blocked list`); storage.put(BLOCKED_GROUPS_ID, groupIds.concat(groupId)); }; storage.removeBlockedGroup = groupId => { diff --git a/js/models/conversations.js b/js/models/conversations.js index c01f232e6..c510044a9 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -78,6 +78,9 @@ const e164 = this.get('e164'); return `${uuid || e164} (${this.id})`; } + if (this.get('groupVersion') > 1) { + return `groupv2(${this.get('groupId')})`; + } const groupId = this.get('groupId'); return `group(${groupId})`; @@ -403,24 +406,83 @@ } }, + async fetchLatestGroupV2Data() { + if (this.get('groupVersion') !== 2) { + return; + } + + await window.Signal.Groups.waitThenMaybeUpdateGroup({ + conversation: this, + }); + }, + maybeRepairGroupV2(data) { + if ( + this.get('groupVersion') && + this.get('masterKey') && + this.get('secretParams') && + this.get('publicParams') + ) { + return; + } + + window.log.info(`Repairing GroupV2 conversation ${this.idForLogging()}`); + const { masterKey, secretParams, publicParams } = data; + + this.set({ masterKey, secretParams, publicParams, groupVersion: 2 }); + + window.Signal.Data.updateConversation(this.attributes); + }, + getGroupV2Info(groupChange) { + if (this.isPrivate() || this.get('groupVersion') !== 2) { + return null; + } + return { + masterKey: window.Signal.Crypto.base64ToArrayBuffer( + this.get('masterKey') + ), + revision: this.get('revision'), + members: this.getRecipients(), + groupChange, + }; + }, + getGroupV1Info() { + if (this.isPrivate() || this.get('groupVersion') > 0) { + return null; + } + + return { + id: this.get('groupId'), + members: this.getRecipients(), + }; + }, + sendTypingMessage(isTyping) { if (!textsecure.messaging) { return; } - const groupId = !this.isPrivate() ? this.get('groupId') : null; - const groupNumbers = this.getRecipients(); + // We don't send typing messages to our other devices + if (this.isMe()) { + return; + } + const recipientId = this.isPrivate() ? this.getSendTarget() : null; + const groupId = !this.isPrivate() ? this.get('groupId') : null; + const groupMembers = this.getRecipients(); + + // We don't send typing messages if our recipients list is empty + if (!this.isPrivate() && !groupMembers.length) { + return; + } const sendOptions = this.getSendOptions(); - this.wrapSend( textsecure.messaging.sendTypingMessage( { isTyping, recipientId, groupId, - groupNumbers, + groupMembers, }, sendOptions ) @@ -581,7 +643,7 @@ lastUpdated: this.get('timestamp'), membersCount: this.isPrivate() ? undefined - : (this.get('members') || []).length, + : (this.get('membersV2') || this.get('members') || []).length, messageRequestsEnabled, muteExpiresAt: this.get('muteExpiresAt'), name: this.get('name'), @@ -793,7 +855,8 @@ return; } - await this.fetchContacts(); + this.fetchContacts(); + await Promise.all( this.contactCollection.map(async contact => { if (!contact.isMe()) { @@ -1324,26 +1387,59 @@ return this.jobQueue.add(taskWithTimeout); }, - getRecipients() { + getMembers() { if (this.isPrivate()) { - return [this.getSendTarget()]; + return [this]; } - const me = ConversationController.getOurConversationId(); - // The list of members might not always be conversationIds for old groups. + if (this.get('membersV2')) { + return _.compact( + this.get('membersV2').map(member => { + const c = ConversationController.get(member.conversationId); + + // In groups we won't sent to contacts we believe are unregistered + if (c && c.isUnregistered()) { + return null; + } + + return c; + }) + ); + } + + if (this.get('members')) { + return _.compact( + this.get('members').map(id => { + const c = ConversationController.get(id); + + // In groups we won't sent to contacts we believe are unregistered + if (c && c.isUnregistered()) { + return null; + } + + return c; + }) + ); + } + + window.log.warn( + 'getMembers: Group conversation had neither membersV2 nor members' + ); + + return []; + }, + + getMemberIds() { + const members = this.getMembers(); + return members.map(member => member.id); + }, + + getRecipients() { + const members = this.getMembers(); + + // Eliminate our return _.compact( - this.get('members').map(memberId => { - const c = ConversationController.get(memberId); - if (c.id === me) { - return null; - } - // We don't want to even attempt a send if we have recently discovered that they - // are unregistered. - if (c.isUnregistered()) { - return null; - } - return c.getSendTarget(); - }) + members.map(member => (member.isMe() ? null : member.getSendTarget())) ); }, @@ -1549,11 +1645,11 @@ if (this.isMe()) { const dataMessage = await textsecure.messaging.getMessageProto( destination, - null, - null, - null, - null, - null, + null, // body + null, // attachments + null, // quote + null, // preview + null, // sticker outgoingReaction, timestamp, expireTimer, @@ -1568,11 +1664,11 @@ if (this.isPrivate()) { return textsecure.messaging.sendMessageToIdentifier( destination, - null, - null, - null, - null, - null, + null, // body + null, // attachments + null, // quote + null, // preview + null, // sticker outgoingReaction, timestamp, expireTimer, @@ -1582,17 +1678,14 @@ } return textsecure.messaging.sendMessageToGroup( - this.get('groupId'), - this.getRecipients(), - null, - null, - null, - null, - null, - outgoingReaction, - timestamp, - expireTimer, - profileKey, + { + groupV1: this.getGroupV1Info(), + groupV2: this.getGroupV2Info(), + reaction: outgoingReaction, + timestamp, + expireTimer, + profileKey, + }, options ); })(); @@ -1741,7 +1834,7 @@ quote, preview, sticker, - null, + null, // reaction now, expireTimer, profileKey @@ -1752,43 +1845,38 @@ const conversationType = this.get('type'); const options = this.getSendOptions(); - const promise = (() => { - switch (conversationType) { - case Message.PRIVATE: - return textsecure.messaging.sendMessageToIdentifier( - destination, - messageBody, - finalAttachments, - quote, - preview, - sticker, - null, - now, - expireTimer, - profileKey, - options - ); - case Message.GROUP: - return textsecure.messaging.sendMessageToGroup( - this.get('groupId'), - this.getRecipients(), - messageBody, - finalAttachments, - quote, - preview, - sticker, - null, - now, - expireTimer, - profileKey, - options - ); - default: - throw new TypeError( - `Invalid conversation type: '${conversationType}'` - ); - } - })(); + let promise; + if (conversationType === Message.GROUP) { + promise = textsecure.messaging.sendMessageToGroup( + { + attachments: finalAttachments, + expireTimer, + groupV1: this.getGroupV1Info(), + groupV2: this.getGroupV2Info(), + messageText: messageBody, + preview, + profileKey, + quote, + sticker, + timestamp: now, + }, + options + ); + } else { + promise = textsecure.messaging.sendMessageToIdentifier( + destination, + messageBody, + finalAttachments, + quote, + preview, + sticker, + null, // reaction + now, + expireTimer, + profileKey, + options + ); + } return message.send(this.wrapSend(promise)); }); @@ -2012,7 +2100,9 @@ const currentTimestamp = this.get('timestamp') || null; const timestamp = activityMessage - ? activityMessage.get('sent_at') || currentTimestamp + ? activityMessage.get('sent_at') || + activityMessage.get('received_at') || + currentTimestamp : currentTimestamp; this.set({ @@ -2043,12 +2133,90 @@ } }, + async updateExpirationTimerInGroupV2(seconds) { + // Make change on the server + const actions = window.Signal.Groups.buildDisappearingMessagesTimerChange( + { + expireTimer: seconds || 0, + group: this.attributes, + } + ); + let signedGroupChange; + try { + signedGroupChange = await window.Signal.Groups.uploadGroupChange({ + actions, + group: this.attributes, + serverPublicParamsBase64: window.getServerPublicParams(), + }); + } catch (error) { + // Get latest GroupV2 data, since we ran into trouble updating it + this.fetchLatestGroupV2Data(); + throw error; + } + + // Update local conversation + this.set({ + expireTimer: seconds || 0, + revision: actions.version, + }); + window.Signal.Data.updateConversation(this.attributes); + + // Create local notification + const timestamp = Date.now(); + const id = window.getGuid(); + const message = MessageController.register( + id, + new Whisper.Message({ + id, + conversationId: this.id, + sent_at: timestamp, + received_at: timestamp, + flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expirationTimerUpdate: { + expireTimer: seconds, + sourceUuid: this.ourUuid, + }, + }) + ); + await window.Signal.Data.saveMessage(message.attributes, { + Message: Whisper.Message, + forceSave: true, + }); + this.trigger('newmessage', message); + + // Send message to all group members + const profileKey = this.get('profileSharing') + ? storage.get('profileKey') + : undefined; + const sendOptions = this.getSendOptions(); + const promise = textsecure.messaging.sendMessageToGroup( + { + groupV2: this.getGroupV2Info(signedGroupChange.toArrayBuffer()), + timestamp, + profileKey, + }, + sendOptions + ); + + message.send(promise); + }, + async updateExpirationTimer( providedExpireTimer, providedSource, receivedAt, options = {} ) { + if (this.get('groupVersion') === 2) { + if (providedSource || receivedAt) { + throw new Error( + 'updateExpirationTimer: GroupV2 timers are not updated this way' + ); + } + await this.updateExpirationTimerInGroupV2(providedExpireTimer); + return false; + } + let expireTimer = providedExpireTimer; let source = providedSource; if (this.get('left')) { @@ -2131,12 +2299,12 @@ textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; const dataMessage = await textsecure.messaging.getMessageProto( this.getSendTarget(), - null, - [], - null, - [], - null, - null, + null, // body + [], // attachments + null, // quote + [], // preview + null, // sticker + null, // reaction message.get('sent_at'), expireTimer, profileKey, @@ -2250,79 +2418,6 @@ } }, - async updateGroup(providedGroupUpdate) { - let groupUpdate = providedGroupUpdate; - - if (this.isPrivate()) { - throw new Error('Called update group on private conversation'); - } - if (groupUpdate === undefined) { - groupUpdate = this.pick(['name', 'avatar', 'members']); - } - const now = Date.now(); - const model = new Whisper.Message({ - conversationId: this.id, - type: 'outgoing', - sent_at: now, - received_at: now, - group_update: groupUpdate, - }); - - const id = await window.Signal.Data.saveMessage(model.attributes, { - Message: Whisper.Message, - }); - - model.set({ id }); - - const message = MessageController.register(model.id, model); - this.addSingleMessage(message); - - const options = this.getSendOptions(); - message.send( - this.wrapSend( - textsecure.messaging.updateGroup( - this.id, - this.get('name'), - this.get('avatar'), - this.get('members'), - options - ) - ) - ); - }, - - async leaveGroup() { - const now = Date.now(); - if (this.get('type') === 'group') { - const groupIdentifiers = this.getRecipients(); - this.set({ left: true }); - window.Signal.Data.updateConversation(this.attributes); - - const model = new Whisper.Message({ - group_update: { left: 'You' }, - conversationId: this.id, - type: 'outgoing', - sent_at: now, - received_at: now, - }); - - const id = await window.Signal.Data.saveMessage(model.attributes, { - Message: Whisper.Message, - }); - model.set({ id }); - - const message = MessageController.register(model.id, model); - this.addSingleMessage(message); - - const options = this.getSendOptions(); - message.send( - this.wrapSend( - textsecure.messaging.leaveGroup(this.id, groupIdentifiers, options) - ) - ); - } - }, - async markRead(newestUnreadDate, providedOptions) { const options = providedOptions || {}; _.defaults(options, { sendReadReceipts: true }); @@ -2444,14 +2539,7 @@ getProfiles() { // request all conversation members' keys - let conversations = []; - if (this.isPrivate()) { - conversations = [this]; - } else { - conversations = this.get('members') - .map(id => ConversationController.get(id)) - .filter(Boolean); - } + const conversations = this.getMembers(); return Promise.all( _.map(conversations, conversation => { this.getProfile(conversation.get('uuid'), conversation.get('e164')); @@ -2822,30 +2910,21 @@ }, hasMember(identifier) { - const cid = ConversationController.getConversationId(identifier); - return cid && _.contains(this.get('members'), cid); + const id = ConversationController.getConversationId(identifier); + const memberIds = this.getMemberIds(); + + return _.contains(memberIds, id); }, fetchContacts() { if (this.isPrivate()) { this.contactCollection.reset([this]); - return Promise.resolve(); } - const members = this.get('members') || []; - const promises = members.map(identifier => - ConversationController.getOrCreateAndWait(identifier, 'private') - ); - - return Promise.all(promises).then(contacts => { - _.forEach(contacts, contact => { - this.listenTo( - contact, - 'change:verified', - this.onMemberVerifiedChange - ); - }); - - this.contactCollection.reset(contacts); + const members = this.getMembers(); + _.forEach(members, member => { + this.listenTo(member, 'change:verified', this.onMemberVerifiedChange); }); + + this.contactCollection.reset(members); }, async destroyMessages() { @@ -2946,6 +3025,43 @@ return null; }, + canChangeTimer() { + if (this.isPrivate()) { + return true; + } + + if (this.get('groupVersion') !== 2) { + return true; + } + + const accessControlEnum = + textsecure.protobuf.AccessControl.AccessRequired; + const accessControl = this.get('accessControl'); + const canAnyoneChangeTimer = + accessControl && + (accessControl.attributes === accessControlEnum.ANY || + accessControl.attributes === accessControlEnum.MEMBER); + if (canAnyoneChangeTimer) { + return true; + } + + const memberEnum = textsecure.protobuf.Member.Role; + const members = this.get('membersV2') || []; + const myId = ConversationController.getConversationId( + textsecure.storage.user.getUuid() || textsecure.storage.user.getNumber() + ); + const me = members.find(item => item.conversationId === myId); + if (!me) { + return false; + } + + const isAdministrator = me.role === memberEnum.ADMINISTRATOR; + if (isAdministrator) { + return true; + } + + return false; + }, // Set of items to captureChanges on: // [-] uuid @@ -3184,4 +3300,71 @@ }); Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' '); + + // This is a wrapper model used to display group members in the member list view, within + // the world of backbone, but layering another bit of group-specific data top of base + // conversation data. + Whisper.GroupMemberConversation = Backbone.Model.extend({ + initialize(attributes) { + const { conversation, isAdmin } = attributes; + + if (!conversation) { + throw new Error( + 'GroupMemberConversation.initialze: conversation required!' + ); + } + if (!_.isBoolean(isAdmin)) { + throw new Error('GroupMemberConversation.initialze: isAdmin required!'); + } + + // If our underlying conversation changes, we change too + this.listenTo(conversation, 'change', () => { + this.trigger('change', this); + }); + + this.conversation = conversation; + this.isAdmin = isAdmin; + }, + + format() { + return { + ...this.conversation.format(), + isAdmin: this.isAdmin, + }; + }, + + get(...params) { + return this.conversation.get(...params); + }, + + getTitle() { + return this.conversation.getTitle(); + }, + + isMe() { + return this.conversation.isMe(); + }, + }); + + // We need a custom collection here to get the sorting we need + Whisper.GroupConversationCollection = Backbone.Collection.extend({ + model: Whisper.GroupMemberConversation, + + initialize() { + this.collator = new Intl.Collator(); + }, + + comparator(left, right) { + if (left.isAdmin && !right.isAdmin) { + return -1; + } + if (!left.isAdmin && right.isAdmin) { + return 1; + } + + const leftLower = left.getTitle().toLowerCase(); + const rightLower = right.getTitle().toLowerCase(); + return this.collator.compare(leftLower, rightLower); + }, + }); })(); diff --git a/js/models/messages.js b/js/models/messages.js index bda36f368..59a437daf 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -45,6 +45,9 @@ const { addStickerPackReference, getMessageBySender } = window.Signal.Data; const { bytesFromString } = window.Signal.Crypto; + const PLACEHOLDER_CONTACT = { + title: i18n('unknownContact'), + }; window.AccountCache = Object.create(null); window.AccountJobs = Object.create(null); @@ -140,6 +143,7 @@ !this.isEndSession() && !this.isExpirationTimerUpdate() && !this.isGroupUpdate() && + !this.isGroupV2Change() && !this.isKeyChange() && !this.isMessageHistoryUnsynced() && !this.isProfileChange() && @@ -156,6 +160,12 @@ data: this.getPropsForUnsupportedMessage(), }; } + if (this.isGroupV2Change()) { + return { + type: 'groupV2Change', + data: this.getPropsForGroupV2Change(), + }; + } if (this.isMessageHistoryUnsynced()) { return { type: 'linkNotification', @@ -213,24 +223,13 @@ // Other top-level prop-generation getPropsForSearchResult() { - const ourId = ConversationController.getOurConversationId(); const sourceId = this.getContactId(); - const fromContact = this.findAndFormatContact(sourceId); - - if (ourId === sourceId) { - fromContact.isMe = true; - } - + const from = this.findAndFormatContact(sourceId); const convo = this.getConversation(); - - const to = convo ? this.findAndFormatContact(convo.get('id')) : {}; - - if (to && convo && convo.isMe()) { - to.isMe = true; - } + const to = this.findAndFormatContact(convo.get('id')); return { - from: fromContact || {}, + from, to, isSelected: this.isSelected, @@ -358,6 +357,9 @@ versionAtReceive < requiredVersion ); }, + isGroupV2Change() { + return Boolean(this.get('groupV2Change')); + }, isExpirationTimerUpdate() { const flag = textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; @@ -399,6 +401,16 @@ contact: this.findAndFormatContact(sourceId), }; }, + getPropsForGroupV2Change() { + const { protobuf } = window.textsecure; + + return { + AccessControlEnum: protobuf.AccessControl.AccessRequired, + RoleEnum: protobuf.Member.Role, + ourConversationId: window.ConversationController.getOurConversationId(), + change: this.get('groupV2Change'), + }; + }, getPropsForTimerNotification() { const timerUpdate = this.get('expirationTimerUpdate'); if (!timerUpdate) { @@ -414,9 +426,10 @@ uuid: sourceUuid, }); const ourId = ConversationController.getOurConversationId(); + const formattedContact = this.findAndFormatContact(sourceId); const basicProps = { - ...this.findAndFormatContact(sourceId), + ...formattedContact, type: 'fromOther', timespan, disabled, @@ -434,6 +447,12 @@ type: 'fromMe', }; } + if (!sourceId) { + return { + ...basicProps, + type: 'fromMember', + }; + } return basicProps; }, @@ -473,10 +492,6 @@ }); } - const placeholderContact = { - title: i18n('unknownContact'), - }; - if (groupUpdate.joined) { changes.push({ type: 'add', @@ -484,8 +499,7 @@ Array.isArray(groupUpdate.joined) ? groupUpdate.joined : [groupUpdate.joined], - identifier => - this.findAndFormatContact(identifier) || placeholderContact + identifier => this.findAndFormatContact(identifier) ), }); } @@ -502,8 +516,7 @@ Array.isArray(groupUpdate.left) ? groupUpdate.left : [groupUpdate.left], - identifier => - this.findAndFormatContact(identifier) || placeholderContact + identifier => this.findAndFormatContact(identifier) ), }); } @@ -600,15 +613,6 @@ const reactions = (this.get('reactions') || []).map(re => { const c = this.findAndFormatContact(re.fromId); - if (!c) { - return { - emoji: re.emoji, - from: { - id: re.fromId, - }, - }; - } - return { emoji: re.emoji, timestamp: re.timestamp, @@ -661,17 +665,29 @@ // Dependencies of prop-generation functions findAndFormatContact(identifier) { + if (!identifier) { + return PLACEHOLDER_CONTACT; + } + const contactModel = this.findContact(identifier); if (contactModel) { return contactModel.format(); } - const { format } = PhoneNumber; + const { format, isValidNumber } = PhoneNumber; const regionCode = storage.get('regionCode'); + + if (!isValidNumber(identifier, { regionCode })) { + return PLACEHOLDER_CONTACT; + } + + const phoneNumber = format(identifier, { + ourRegionCode: regionCode, + }); + return { - phoneNumber: format(identifier, { - ourRegionCode: regionCode, - }), + title: phoneNumber, + phoneNumber, }; }, findContact(identifier) { @@ -910,6 +926,29 @@ }; } + if (this.isGroupV2Change()) { + const { protobuf } = window.textsecure; + const change = this.get('groupV2Change'); + + const lines = window.Signal.GroupChange.renderChange(change, { + AccessControlEnum: protobuf.AccessControl.AccessRequired, + i18n: window.i18n, + ourConversationId: window.ConversationController.getOurConversationId(), + renderContact: conversationId => { + const conversation = window.ConversationController.get( + conversationId + ); + return conversation + ? conversation.getTitle() + : window.i18n('unknownUser'); + }, + renderString: (key, i18n, placeholders) => i18n(key, placeholders), + RoleEnum: protobuf.Member.Role, + }); + + return { text: lines.join(' ') }; + } + const attachments = this.get('attachments') || []; if (this.isTapToView()) { @@ -1315,6 +1354,7 @@ // Rendered sync messages const isCallHistory = this.isCallHistory(); const isGroupUpdate = this.isGroupUpdate(); + const isGroupV2Change = this.isGroupV2Change(); const isEndSession = this.isEndSession(); const isExpirationTimerUpdate = this.isExpirationTimerUpdate(); const isVerifiedChange = this.isVerifiedChange(); @@ -1342,6 +1382,7 @@ // Rendered sync messages isCallHistory || isGroupUpdate || + isGroupV2Change || isEndSession || isExpirationTimerUpdate || isVerifiedChange || @@ -1634,6 +1675,8 @@ // Because this is a partial group send, we manually construct the request like // sendMessageToGroup does. + const groupV2 = conversation.getGroupV2Info(); + promise = textsecure.messaging.sendMessage( { recipients, @@ -1645,10 +1688,13 @@ sticker: stickerWithData, expireTimer: this.get('expireTimer'), profileKey, - group: { - id: this.getConversation().get('groupId'), - type: textsecure.protobuf.GroupContext.Type.DELIVER, - }, + groupV2, + group: groupV2 + ? null + : { + id: this.getConversation().get('groupId'), + type: textsecure.protobuf.GroupContext.Type.DELIVER, + }, }, options ); @@ -2392,19 +2438,79 @@ } } - // We drop incoming messages for groups we already know about, which we're not a - // part of, except for group updates. - const ourUuid = textsecure.storage.user.getUuid(); - const ourNumber = textsecure.storage.user.getNumber(); - const isGroupUpdate = + const existingRevision = conversation.get('revision'); + const isGroupV2 = Boolean(initialMessage.groupV2); + const isV2GroupUpdate = + initialMessage.groupV2 && + (!existingRevision || + initialMessage.groupV2.revision > existingRevision); + + // GroupV2 + if (isGroupV2) { + conversation.maybeRepairGroupV2( + _.pick(initialMessage.groupV2, [ + 'masterKey', + 'secretParams', + 'publicParams', + ]) + ); + } + + if (isV2GroupUpdate) { + const { revision, groupChange } = initialMessage.groupV2; + try { + await window.Signal.Groups.maybeUpdateGroup({ + conversation, + groupChangeBase64: groupChange, + newRevision: revision, + timestamp: message.get('received_at'), + }); + } catch (error) { + const errorText = error && error.stack ? error.stack : error; + window.log.error( + `handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}` + ); + throw error; + } + } + + const ourConversationId = ConversationController.getOurConversationId(); + const senderId = ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + }); + const isV1GroupUpdate = initialMessage.group && initialMessage.group.type !== textsecure.protobuf.GroupContext.Type.DELIVER; + + // Drop an incoming GroupV2 message if we or the sender are not part of the group + // after applying the message's associated group chnages. if ( type === 'incoming' && !conversation.isPrivate() && - !conversation.hasMember(ourNumber || ourUuid) && - !isGroupUpdate + isGroupV2 && + (conversation.get('left') || + !conversation.hasMember(ourConversationId) || + !conversation.hasMember(senderId)) + ) { + window.log.warn( + `Received message destined for group ${conversation.idForLogging()}, which we or the sender are not a part of. Dropping.` + ); + confirm(); + return; + } + + // We drop incoming messages for v1 groups we already know about, which we're not + // a part of, except for group updates. Because group v1 updates haven't been + // applied by this point. + if ( + type === 'incoming' && + !conversation.isPrivate() && + !isGroupV2 && + !isV1GroupUpdate && + (conversation.get('left') || + !conversation.hasMember(ourConversationId)) ) { window.log.warn( `Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` @@ -2488,7 +2594,9 @@ let attributes = { ...conversation.attributes, }; - if (dataMessage.group) { + + // GroupV1 + if (!isGroupV2 && dataMessage.group) { const pendingGroupUpdate = []; const memberConversations = await Promise.all( dataMessage.group.membersE164.map(e164 => @@ -2597,10 +2705,6 @@ conversation.set({ addedBy: message.getContactId() }); } } else if (dataMessage.group.type === GROUP_TYPES.QUIT) { - const senderId = ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - }); const sender = ConversationController.get(senderId); const inGroup = Boolean( sender && @@ -2638,6 +2742,17 @@ } } + // Drop empty messages after. This needs to happen after the initial + // message.set call and after GroupV1 processing to make sure all possible + // properties are set before we determine that a message is empty. + if (message.isEmpty()) { + window.log.info( + `handleDataMessage: Dropping empty message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` + ); + confirm(); + return; + } + if (type === 'outgoing') { const receipts = Whisper.DeliveryReceipts.forMessage( conversation, @@ -2652,61 +2767,66 @@ }) ); } + attributes.active_at = now; conversation.set(attributes); - if (message.isExpirationTimerUpdate()) { - message.set({ - expirationTimerUpdate: { - source, - sourceUuid, - expireTimer: dataMessage.expireTimer, - }, - }); - conversation.set({ expireTimer: dataMessage.expireTimer }); - } else if (dataMessage.expireTimer) { + if (dataMessage.expireTimer) { message.set({ expireTimer: dataMessage.expireTimer }); } - // NOTE: Remove once the above uses - // `Conversation::updateExpirationTimer`: - const { expireTimer } = dataMessage; - const shouldLogExpireTimerChange = - message.isExpirationTimerUpdate() || expireTimer; - if (shouldLogExpireTimerChange) { - window.log.info("Update conversation 'expireTimer'", { - id: conversation.idForLogging(), - expireTimer, - source: 'handleDataMessage', - }); - } + if (!isGroupV2) { + if (message.isExpirationTimerUpdate()) { + message.set({ + expirationTimerUpdate: { + source, + sourceUuid, + expireTimer: dataMessage.expireTimer, + }, + }); + conversation.set({ expireTimer: dataMessage.expireTimer }); + } - if (!message.isEndSession()) { - if (dataMessage.expireTimer) { - if ( - dataMessage.expireTimer !== conversation.get('expireTimer') + // NOTE: Remove once the above calls this.model.updateExpirationTimer() + const { expireTimer } = dataMessage; + const shouldLogExpireTimerChange = + message.isExpirationTimerUpdate() || expireTimer; + if (shouldLogExpireTimerChange) { + window.log.info("Update conversation 'expireTimer'", { + id: conversation.idForLogging(), + expireTimer, + source: 'handleDataMessage', + }); + } + + if (!message.isEndSession()) { + if (dataMessage.expireTimer) { + if ( + dataMessage.expireTimer !== conversation.get('expireTimer') + ) { + conversation.updateExpirationTimer( + dataMessage.expireTimer, + source, + message.get('received_at'), + { + fromGroupUpdate: message.isGroupUpdate(), + } + ); + } + } else if ( + conversation.get('expireTimer') && + // We only turn off timers if it's not a group update + !message.isGroupUpdate() ) { conversation.updateExpirationTimer( - dataMessage.expireTimer, + null, source, - message.get('received_at'), - { - fromGroupUpdate: message.isGroupUpdate(), - } + message.get('received_at') ); } - } else if ( - conversation.get('expireTimer') && - // We only turn off timers if it's not a group update - !message.isGroupUpdate() - ) { - conversation.updateExpirationTimer( - null, - source, - message.get('received_at') - ); } } + if (type === 'incoming') { const readSync = Whisper.ReadSyncs.forMessage(message); if (readSync) { @@ -2804,17 +2924,6 @@ } } - // Drop empty messages. This needs to happen after the initial - // message.set call to make sure all possible properties are set - // before we determine that a message is empty. - if (message.isEmpty()) { - window.log.info( - `Dropping empty datamessage ${message.idForLogging()} in conversation ${conversation.idForLogging()}` - ); - confirm(); - return; - } - const conversationTimestamp = conversation.get('timestamp'); if ( !conversationTimestamp || diff --git a/js/modules/privacy.js b/js/modules/privacy.js index abcd8b388..fde0953f6 100644 --- a/js/modules/privacy.js +++ b/js/modules/privacy.js @@ -10,6 +10,7 @@ const APP_ROOT_PATH = path.join(__dirname, '..', '..', '..'); const PHONE_NUMBER_PATTERN = /\+\d{7,12}(\d{3})/g; const UUID_PATTERN = /[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{9}([0-9A-F]{3})/gi; const GROUP_ID_PATTERN = /(group\()([^)]+)(\))/g; +const GROUP_V2_ID_PATTERN = /(groupv2\()([^=)]+)(=?=?\))/g; const REDACTION_PLACEHOLDER = '[REDACTED]'; // _redactPath :: Path -> String -> String @@ -80,11 +81,21 @@ exports.redactGroupIds = text => { throw new TypeError("'text' must be a string"); } - return text.replace( - GROUP_ID_PATTERN, - (match, before, id, after) => - `${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice(-3)}${after}` - ); + return text + .replace( + GROUP_ID_PATTERN, + (match, before, id, after) => + `${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice( + -3 + )}${after}` + ) + .replace( + GROUP_V2_ID_PATTERN, + (match, before, id, after) => + `${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice( + -3 + )}${after}` + ); }; // redactSensitivePaths :: String -> String diff --git a/js/modules/signal.js b/js/modules/signal.js index 87985922c..39db40082 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -9,6 +9,8 @@ const { const Data = require('../../ts/sql/Client').default; const Emojis = require('./emojis'); const EmojiLib = require('../../ts/components/emoji/lib'); +const Groups = require('../../ts/groups'); +const GroupChange = require('../../ts/groupChange'); const IndexedDB = require('./indexeddb'); const Notifications = require('../../ts/notifications'); const OS = require('../../ts/OS'); @@ -108,6 +110,9 @@ const { IdleDetector } = require('./idle_detector'); const MessageDataMigrator = require('./messages_data_migrator'); // Processes / Services +const { + initializeGroupCredentialFetcher, +} = require('../../ts/services/groupCredentialFetcher'); const { initializeNetworkObserver, } = require('../../ts/services/networkObserver'); @@ -333,6 +338,7 @@ exports.setup = (options = {}) => { calling, eraseAllStorageServiceState, handleUnknownRecords, + initializeGroupCredentialFetcher, initializeNetworkObserver, initializeUpdateListener, notify, @@ -378,6 +384,8 @@ exports.setup = (options = {}) => { Data, Emojis, EmojiLib, + Groups, + GroupChange, IndexedDB, LinkPreviews, Metadata, diff --git a/js/modules/types/conversation.js b/js/modules/types/conversation.js index c48a7c6c0..34c7abfd0 100644 --- a/js/modules/types/conversation.js +++ b/js/modules/types/conversation.js @@ -1,16 +1,12 @@ -/* global crypto, window */ +/* global window */ const { isFunction, isNumber } = require('lodash'); const { arrayBufferToBase64, base64ToArrayBuffer, + computeHash, } = require('../../../ts/Crypto'); -async function computeHash(arraybuffer) { - const hash = await crypto.subtle.digest({ name: 'SHA-512' }, arraybuffer); - return arrayBufferToBase64(hash); -} - function buildAvatarUpdater({ field }) { return async (conversation, data, options = {}) => { if (!conversation) { diff --git a/js/read_receipts.js b/js/read_receipts.js index c0149aabd..fe16af2f2 100644 --- a/js/read_receipts.js +++ b/js/read_receipts.js @@ -20,7 +20,7 @@ if (conversation.isPrivate()) { ids = [conversation.id]; } else { - ids = conversation.get('members'); + ids = conversation.getMemberIds(); } const receipts = this.filter( receipt => diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index aa4028143..3ead9ce47 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -28,7 +28,7 @@ className: 'contact-wrapper', Component: window.Signal.Components.ContactListItem, props: { - ...this.model.cachedProps, + ...this.model.format(), onClick: this.showIdentity.bind(this), }, }); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index e9855402c..3fc37689c 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -215,6 +215,9 @@ Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({ template: i18n('maximumAttachments'), }); + Whisper.TimerConflictToast = Whisper.ToastView.extend({ + template: i18n('GroupV2--timerConflict'), + }); Whisper.ConversationLoadingScreen = Whisper.View.extend({ templateName: 'conversation-loading-screen', @@ -311,6 +314,13 @@ this.model.updateSharedGroups.bind(this.model), FIVE_MINUTES ); + this.model.throttledFetchLatestGroupV2Data = + this.model.throttledFetchLatestGroupV2Data || + _.throttle( + this.model.fetchLatestGroupV2Data.bind(this.model), + FIVE_MINUTES + ); + this.debouncedMaybeGrabLinkPreview = _.debounce( this.maybeGrabLinkPreview.bind(this), 200 @@ -385,8 +395,13 @@ leftGroup: this.model.get('left'), - expirationSettingName, + disableTimerChanges: + this.model.get('left') || + !this.model.getAccepted() || + !this.model.canChangeTimer(), showBackButton: Boolean(this.panels && this.panels.length), + + expirationSettingName, timerOptions: Whisper.ExpirationTimerOptions.map(item => ({ name: item.getName(), value: item.get('seconds'), @@ -1826,6 +1841,8 @@ this.setQuoteMessage(quotedMessageId); } + this.model.throttledFetchLatestGroupV2Data(); + const statusPromise = this.model.throttledGetProfiles(); // eslint-disable-next-line more/no-then this.statusFetch = statusPromise.then(() => @@ -2044,7 +2061,18 @@ async showMembers(e, providedMembers, options = {}) { _.defaults(options, { needVerify: false }); - const model = providedMembers || this.model.contactCollection; + let model = providedMembers || this.model.contactCollection; + + if (!providedMembers && this.model.get('groupVersion') === 2) { + model = new Whisper.GroupConversationCollection( + this.model.get('membersV2').map(({ conversationId, role }) => ({ + conversation: ConversationController.get(conversationId), + isAdmin: + role === window.textsecure.protobuf.Member.Role.ADMINISTRATOR, + })) + ); + } + const view = new Whisper.GroupMemberList({ model, // we pass this in to allow nested panels @@ -2496,11 +2524,17 @@ this.model.endSession(); }, - setDisappearingMessages(seconds) { - if (seconds > 0) { - this.model.updateExpirationTimer(seconds); - } else { - this.model.updateExpirationTimer(null); + async setDisappearingMessages(seconds) { + try { + if (seconds > 0) { + await this.model.updateExpirationTimer(seconds); + } else { + await this.model.updateExpirationTimer(null); + } + } catch (error) { + if (error.code === 409) { + this.showToast(Whisper.TimerConflictToast); + } } }, diff --git a/libtextsecure/protobufs.js b/libtextsecure/protobufs.js index e67df0961..b09b24a24 100644 --- a/libtextsecure/protobufs.js +++ b/libtextsecure/protobufs.js @@ -18,7 +18,7 @@ } const protos = result.build('signalservice'); if (!protos) { - const text = `Error loading protos from ${filename} (root: ${window.PROTO_ROOT})`; + const text = `Error loading protos from ${filename} - no exported types! (root: ${window.PROTO_ROOT})`; window.log.error(text); throw new Error(text); } @@ -41,4 +41,7 @@ // Metadata-specific protos loadProtoBufs('UnidentifiedDelivery.proto'); + + // Groups + loadProtoBufs('Groups.proto'); })(); diff --git a/preload.js b/preload.js index 86ddd8b1e..9ef4e7ebc 100644 --- a/preload.js +++ b/preload.js @@ -19,6 +19,8 @@ try { window.PROTO_ROOT = 'protos'; const config = require('url').parse(window.location.toString(), true).query; + window.GV2 = false; + let title = config.name; if (config.environment !== 'production') { title += ` - ${config.environment}`; @@ -375,12 +377,14 @@ try { paths.forEach(path => { const val = _.get(obj, path); if (val) { - if (!window.isValidGuid(val)) { + if (!val || !window.isValidGuid(val)) { window.log.warn( `Normalizing invalid uuid: ${val} at path ${path} in context "${context}"` ); } - _.set(obj, path, val.toLowerCase()); + if (val && val.toLowerCase) { + _.set(obj, path, val.toLowerCase()); + } } }); }; diff --git a/protos/Groups.proto b/protos/Groups.proto new file mode 100644 index 000000000..b05def303 --- /dev/null +++ b/protos/Groups.proto @@ -0,0 +1,152 @@ +syntax = "proto3"; + +package signalservice; + +option java_package = "org.whispersystems.signalservice.protos.groups"; +option java_multiple_files = true; + +message AvatarUploadAttributes { + string key = 1; + string credential = 2; + string acl = 3; + string algorithm = 4; + string date = 5; + string policy = 6; + string signature = 7; +} + +message Member { + enum Role { + UNKNOWN = 0; + DEFAULT = 1; // Normal member + ADMINISTRATOR = 2; // Group admin + } + + bytes userId = 1; // The UuidCiphertext + Role role = 2; + bytes profileKey = 3; // The ProfileKeyCiphertext + bytes presentation = 4; // ProfileKeyCredentialPresentation + uint32 joinedAtVersion = 5; // The Group.version this member joined at +} + +message PendingMember { + Member member = 1; // The “invited” member + bytes addedByUserId = 2; // The UID who invited this member + uint64 timestamp = 3; // The time the invitation occurred +} + +message AccessControl { + enum AccessRequired { + UNKNOWN = 0; + MEMBER = 2; // Any group member can make the modification + ADMINISTRATOR = 3; // Only administrators can make the modification + } + + AccessRequired attributes = 1; // Who can modify the group title, avatar, disappearing messages timer + AccessRequired members = 2; // Who can add people to the group +} + +message Group { + bytes publicKey = 1; // GroupPublicParams + bytes title = 2; // Encrypted title + string avatar = 3; // Pointer to encrypted avatar (‘key’ from AvatarUploadAttributes) + bytes disappearingMessagesTimer = 4; // Encrypted timer + AccessControl accessControl = 5; + uint32 version = 6; // Current group version number + repeated Member members = 7; + repeated PendingMember pendingMembers = 8; +} + +message GroupChange { + + message Actions { + + message AddMemberAction { + Member added = 1; + } + + message DeleteMemberAction { + bytes deletedUserId = 1; + } + + message ModifyMemberRoleAction { + bytes userId = 1; + Member.Role role = 2; + } + + message ModifyMemberProfileKeyAction { + bytes presentation = 1; + } + + message AddPendingMemberAction { + PendingMember added = 1; + } + + message DeletePendingMemberAction { + bytes deletedUserId = 1; + } + + message PromotePendingMemberAction { + bytes presentation = 1; + } + + message ModifyTitleAction { + bytes title = 1; + } + + message ModifyAvatarAction { + string avatar = 1; + } + + message ModifyDisappearingMessagesTimerAction { + bytes timer = 1; + } + + message ModifyAttributesAccessControlAction { + AccessControl.AccessRequired attributesAccess = 1; + } + + message ModifyAvatarAccessControlAction { + AccessControl.AccessRequired avatarAccess = 1; + } + + message ModifyMembersAccessControlAction { + AccessControl.AccessRequired membersAccess = 1; + } + + bytes sourceUuid = 1; // Who made the change + uint32 version = 2; // The change version number + repeated AddMemberAction addMembers = 3; // Members added + repeated DeleteMemberAction deleteMembers = 4; // Members deleted + repeated ModifyMemberRoleAction modifyMemberRoles = 5; // Modified member roles + repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; // Modified member profile keys + repeated AddPendingMemberAction addPendingMembers = 7; // Pending members added + repeated DeletePendingMemberAction deletePendingMembers = 8; // Pending members deleted + repeated PromotePendingMemberAction promotePendingMembers = 9; // Pending invitations accepted + ModifyTitleAction modifyTitle = 10; // Changed title + ModifyAvatarAction modifyAvatar = 11; // Changed avatar + ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer + ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control + ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control + } + + bytes actions = 1; // The serialized actions + bytes serverSignature = 2; // Server’s signature over serialized actions +} + +message GroupChanges { + message GroupChangeState { + GroupChange groupChange = 1; + Group groupState = 2; + } + + repeated GroupChangeState groupChanges = 1; +} + +message GroupAttributeBlob { + oneof content { + string title = 1; + bytes avatar = 2; + uint32 disappearingMessagesDuration = 3; + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 26516e4c1..61191dcfb 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2581,7 +2581,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', color: $color-gray-60; } @include dark-theme { - color: $color-gray-25; + color: $color-gray-05; } } @@ -2603,7 +2603,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', @include color-svg('../images/icons/v2/timer-24.svg', $color-gray-60); } @include dark-theme { - @include color-svg('../images/icons/v2/timer-24.svg', $color-gray-25); + @include color-svg('../images/icons/v2/timer-24.svg', $color-gray-05); } } @@ -2617,7 +2617,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', @include dark-theme { @include color-svg( '../images/icons/v2/timer-disabled-24.svg', - $color-gray-25 + $color-gray-05 ); } } @@ -2689,6 +2689,11 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', .module-contact-list-item__text { margin-left: 8px; + + display: flex; + flex-direction: row; + + flex-grow: 1; } .module-contact-list-item__text__name { @@ -2722,6 +2727,14 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } +.module-contact-list-item__admin { + flex-grow: 1; + text-align: right; + height: 100%; + + @include font-body-2-bold; +} + // Module: In Contacts Icon .module-in-contacts-icon__icon { @@ -9001,6 +9014,45 @@ button.module-image__border-overlay:focus { } } +// Module: GroupV2 Change + +.module-group-v2-change { + @include font-body-1; + + margin-left: 2em; + margin-right: 2em; + + text-align: center; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-05; + } +} + +.module-group-v2-change--icon { + @include light-theme { + @include color-svg( + '../images/icons/v2/group-outline-20.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/group-outline-20.svg', + $color-gray-05 + ); + } + + height: 20px; + width: 20px; + + margin-left: auto; + margin-right: auto; +} + /* Third-party module: react-tooltip-lite */ .react-tooltip-lite { diff --git a/test/models/messages_test.js b/test/models/messages_test.js index 78faf35be..a02752b72 100644 --- a/test/models/messages_test.js +++ b/test/models/messages_test.js @@ -161,7 +161,7 @@ describe('Message', () => { left: 'You', }, }).getNotificationData(), - { text: 'You left the group.' } + { text: 'You are no longer a member of the group.' } ); }); diff --git a/test/modules/privacy_test.js b/test/modules/privacy_test.js index bb764779d..55d046502 100644 --- a/test/modules/privacy_test.js +++ b/test/modules/privacy_test.js @@ -59,6 +59,18 @@ describe('Privacy', () => { 'and group([REDACTED]hij)'; assert.equal(actual, expected); }); + + it('should remove newlines from redacted group V2 IDs', () => { + const text = + 'This is a log line with three group IDs: groupv2(abcd32341a==)\n' + + 'and groupv2(abcd32341ad=) and and groupv2(abcd32341ade)'; + + const actual = Privacy.redactGroupIds(text); + const expected = + 'This is a log line with three group IDs: groupv2([REDACTED]41a==)\n' + + 'and groupv2([REDACTED]1ad=) and and groupv2([REDACTED]ade)'; + assert.equal(actual, expected); + }); }); describe('redactAll', () => { diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 5b263932d..cd27b717f 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -217,7 +217,7 @@ export class ConversationController { return null; } - getOurConversationId() { + getOurConversationId(): string | undefined { const e164 = window.textsecure.storage.user.getNumber(); const uuid = window.textsecure.storage.user.getUuid(); return this.ensureContactIds({ e164, uuid, highTrust: true }); @@ -238,7 +238,7 @@ export class ConversationController { e164?: string; uuid?: string; highTrust?: boolean; - }) { + }): string | undefined { // Check for at least one parameter being provided. This is necessary // because this path can be called on startup to resolve our own ID before // our phone number or UUID are known. The existing behavior in these @@ -546,7 +546,7 @@ export class ConversationController { * ensures the existence of a group conversation and returns a string * representing the local database ID of the group conversation. */ - ensureGroup(groupId: string, additionalInitProps = {}) { + ensureGroup(groupId: string, additionalInitProps = {}): string { return this.getOrCreate(groupId, 'group', additionalInitProps).get('id'); } /** diff --git a/ts/Crypto.ts b/ts/Crypto.ts index e015bb3b7..ce4578ad8 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -62,6 +62,11 @@ export async function deriveStickerPackKey(packKey: ArrayBuffer) { return concatenateBytes(part1, part2); } +export async function computeHash(data: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest({ name: 'SHA-512' }, data); + return arrayBufferToBase64(hash); +} + // High-level Operations export async function encryptDeviceName( diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 8414a3b9a..74aa0c3c2 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -1,7 +1,7 @@ import { get, throttle } from 'lodash'; import { WebAPIType } from './textsecure/WebAPI'; -type ConfigKeyType = 'desktop.messageRequests'; +type ConfigKeyType = 'desktop.messageRequests' | 'desktop.gv2' | 'desktop.cds'; type ConfigValueType = { name: ConfigKeyType; enabled: boolean; @@ -66,6 +66,7 @@ export const refreshRemoteConfig = async () => { // If enablement changes at all, notify listeners const currentListeners = listeners[name] || []; if (previouslyEnabled !== enabled) { + window.log.info(`Remote Config: Flag ${name} has been enabled`); currentListeners.forEach(listener => { listener(value); }); diff --git a/ts/components/ContactListItem.stories.tsx b/ts/components/ContactListItem.stories.tsx index f13a81bab..f503fec77 100644 --- a/ts/components/ContactListItem.stories.tsx +++ b/ts/components/ContactListItem.stories.tsx @@ -69,6 +69,33 @@ storiesOf('Components/ContactListItem', module) /> ); }) + .add('With name and profile, admin', () => { + return ( + + ); + }) + .add('With just number, admin', () => { + return ( + + ); + }) .add('With name and profile, no avatar', () => { return ( void; + phoneNumber?: string; + profileName?: string; + title: string; } export class ContactListItem extends React.Component { @@ -51,13 +52,14 @@ export class ContactListItem extends React.Component { public render() { const { i18n, + isAdmin, + isMe, + isVerified, name, onClick, - isMe, phoneNumber, profileName, title, - isVerified, } = this.props; const displayName = isMe ? i18n('you') : title; @@ -76,23 +78,30 @@ export class ContactListItem extends React.Component { > {this.renderAvatar()}
-
- - {shouldShowIcon ? ( - - {' '} - - - ) : null} -
-
- {showVerified ? ( -
- ) : null} - {showVerified ? ` ${i18n('verified')}` : null} - {showVerified && showNumber ? ' ∙ ' : null} - {showNumber ? phoneNumber : null} +
+
+ + {shouldShowIcon ? ( + + {' '} + + + ) : null} +
+
+ {showVerified ? ( +
+ ) : null} + {showVerified ? ` ${i18n('verified')}` : null} + {showVerified && showNumber ? ' ∙ ' : null} + {showNumber ? phoneNumber : null} +
+ {isAdmin ? ( +
+ {i18n('GroupV2--admin')} +
+ ) : null}
); diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index cf4e1af3b..8cd71c51b 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -228,7 +228,7 @@ const stories: Array = [ phoneNumber: '', id: '2', type: 'group', - leftGroup: true, + disableTimerChanges: true, expirationSettingName: '10 seconds', timerOptions: [ { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index b1a9d1e73..128c787c6 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -35,8 +35,8 @@ export interface PropsDataType { isVerified?: boolean; isMe?: boolean; isArchived?: boolean; - leftGroup?: boolean; + disableTimerChanges?: boolean; expirationSettingName?: string; muteExpirationLabel?: string; showBackButton?: boolean; @@ -286,12 +286,12 @@ export class ConversationHeader extends React.Component { public renderMenu(triggerId: string) { const { + disableTimerChanges, i18n, isAccepted, isMe, type, isArchived, - leftGroup, muteExpirationLabel, onDeleteMessages, onResetSession, @@ -329,7 +329,7 @@ export class ConversationHeader extends React.Component { return ( - {!leftGroup && isAccepted ? ( + {disableTimerChanges ? null : ( {(timerOptions || []).map(item => ( { ))} - ) : null} + )} {muteOptions.map(item => ( ( + + {`Conversation(${conversationId})`} + +); + +const renderChange = (change: GroupV2ChangeType) => ( + +); + +storiesOf('Components/Conversation/GroupV2Change', module) + .add('Multiple', () => { + return ( + <> + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + { + type: 'avatar', + removed: false, + }, + { + type: 'member-add', + conversationId: OUR_ID, + }, + { + type: 'member-privilege', + conversationId: OUR_ID, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + })} + + ); + }) + .add('Title', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + ], + })} + {renderChange({ + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'title', + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'title', + }, + ], + })} + {renderChange({ + details: [ + { + type: 'title', + }, + ], + })} + + ); + }) + .add('Avatar', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'avatar', + removed: false, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'avatar', + removed: false, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'avatar', + removed: false, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'avatar', + removed: true, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'avatar', + removed: true, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'avatar', + removed: true, + }, + ], + })} + + ); + }) + .add('Access (Attributes)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + })} + + ); + }) + .add('Access (Members)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + })} + + ); + }) + .add('Member Add', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-add', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'member-add', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-add', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-add', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + from: CONTACT_B, + details: [ + { + type: 'member-add', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-add', + conversationId: CONTACT_A, + }, + ], + })} + + ); + }) + .add('Member Add - from invite', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-add-from-invite', + conversationId: OUR_ID, + inviter: CONTACT_A, + }, + ], + })} + {renderChange({ + from: CONTACT_B, + details: [ + { + type: 'member-add-from-invite', + conversationId: CONTACT_A, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-add-from-invite', + conversationId: CONTACT_A, + inviter: CONTACT_B, + }, + ], + })} + + ); + }) + .add('Member Remove', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-remove', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'member-remove', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-remove', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-remove', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'member-remove', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + from: CONTACT_B, + details: [ + { + type: 'member-remove', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-remove', + conversationId: CONTACT_A, + }, + ], + })} + + ); + }) + // tslint:disable-next-line max-func-body-length + .add('Member Privilege', () => { + return ( + <> + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'member-privilege', + conversationId: OUR_ID, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-privilege', + conversationId: OUR_ID, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-privilege', + conversationId: CONTACT_A, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'member-privilege', + conversationId: CONTACT_A, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-privilege', + conversationId: CONTACT_A, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'member-privilege', + conversationId: OUR_ID, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-privilege', + conversationId: OUR_ID, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-privilege', + conversationId: CONTACT_A, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'member-privilege', + conversationId: CONTACT_A, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-privilege', + conversationId: CONTACT_A, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + })} + + ); + }) + .add('Pending Add - one', () => { + return ( + <> + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'pending-add-one', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-add-one', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-add-one', + conversationId: INVITEE_A, + }, + ], + })} + {renderChange({ + from: CONTACT_B, + details: [ + { + type: 'pending-add-one', + conversationId: INVITEE_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-add-one', + conversationId: INVITEE_A, + }, + ], + })} + + ); + }) + .add('Pending Add - many', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-add-many', + count: 5, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'pending-add-many', + count: 5, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-add-many', + count: 5, + }, + ], + })} + + ); + }) + // tslint:disable-next-line max-func-body-length + .add('Pending Remove - one', () => { + return ( + <> + {renderChange({ + from: INVITEE_A, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + from: INVITEE_A, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + }, + ], + })} + {renderChange({ + from: INVITEE_A, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: CONTACT_B, + }, + ], + })} + {renderChange({ + from: CONTACT_C, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: CONTACT_B, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: CONTACT_B, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + inviter: CONTACT_B, + }, + ], + })} + + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + }, + ], + })} + {renderChange({ + from: CONTACT_B, + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-remove-one', + conversationId: INVITEE_A, + }, + ], + })} + + ); + }) + .add('Pending Remove - many', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-remove-many', + count: 5, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 5, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-remove-many', + count: 5, + inviter: OUR_ID, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-remove-many', + count: 5, + inviter: CONTACT_A, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 5, + inviter: CONTACT_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-remove-many', + count: 5, + inviter: CONTACT_A, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'pending-remove-many', + count: 5, + }, + ], + })} + + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'pending-remove-many', + count: 5, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-remove-many', + count: 5, + }, + ], + })} + + ); + }); diff --git a/ts/components/conversation/GroupV2Change.tsx b/ts/components/conversation/GroupV2Change.tsx new file mode 100644 index 000000000..aab0ed267 --- /dev/null +++ b/ts/components/conversation/GroupV2Change.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; + +import { ReplacementValuesType } from '../../types/I18N'; +import { FullJSXType, Intl } from '../Intl'; +import { LocalizerType } from '../../types/Util'; + +import { GroupV2ChangeType } from '../../groups'; + +import { renderChange, SmartContactRendererType } from '../../groupChange'; + +import { AccessControlClass, MemberClass } from '../../textsecure.d'; + +export type PropsDataType = { + ourConversationId: string; + change: GroupV2ChangeType; + AccessControlEnum: typeof AccessControlClass.AccessRequired; + RoleEnum: typeof MemberClass.Role; +}; + +export type PropsHousekeepingType = { + i18n: LocalizerType; + renderContact: SmartContactRendererType; +}; + +export type PropsType = PropsDataType & PropsHousekeepingType; + +function renderStringToIntl( + id: string, + i18n: LocalizerType, + components?: Array | ReplacementValuesType +): FullJSXType { + return ; +} + +export function GroupV2Change(props: PropsType): React.ReactElement { + const { + AccessControlEnum, + change, + i18n, + ourConversationId, + renderContact, + RoleEnum, + } = props; + + return ( +
+
+ {renderChange(change, { + AccessControlEnum, + i18n, + ourConversationId, + renderContact, + renderString: renderStringToIntl, + RoleEnum, + }).map((item: FullJSXType, index: number) => ( +
{item}
+ ))} +
+ ); +} diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 7fd10941a..49da99b29 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -256,6 +256,7 @@ const renderItem = (id: string) => ( i18n={i18n} conversationId="" conversationAccepted + renderContact={() => '*ContactName*'} {...actions()} /> ); diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 4bdd39c23..a6f10e88d 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -28,6 +28,10 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({ /> ); +const renderContact = (conversationId: string) => ( + {conversationId} +); + const getDefaultProps = () => ({ conversationId: 'conversation-id', conversationAccepted: true, @@ -55,6 +59,8 @@ const getDefaultProps = () => ({ scrollToQuotedMessage: action('scrollToQuotedMessage'), downloadNewVersion: action('downloadNewVersion'), showIdentity: action('showIdentity'), + + renderContact, renderEmojiPicker, }); diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 26fc4d39b..a74b88e0c 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -35,6 +35,11 @@ import { GroupNotification, PropsData as GroupNotificationProps, } from './GroupNotification'; +import { + GroupV2Change, + PropsDataType as GroupV2ChangeProps, +} from './GroupV2Change'; +import { SmartContactRendererType } from '../../groupChange'; import { ResetSessionNotification } from './ResetSessionNotification'; import { ProfileChangeNotification, @@ -73,6 +78,10 @@ type GroupNotificationType = { type: 'groupNotification'; data: GroupNotificationProps; }; +type GroupV2ChangeType = { + type: 'groupV2Change'; + data: GroupV2ChangeProps; +}; type ResetSessionNotificationType = { type: 'resetSessionNotification'; data: null; @@ -85,6 +94,7 @@ type ProfileChangeNotificationType = { export type TimelineItemType = | CallHistoryType | GroupNotificationType + | GroupV2ChangeType | LinkNotificationType | MessageType | ProfileChangeNotificationType @@ -101,6 +111,7 @@ type PropsLocalType = { id: string; isSelected: boolean; selectMessage: (messageId: string, conversationId: string) => unknown; + renderContact: SmartContactRendererType; i18n: LocalizerType; }; @@ -120,6 +131,7 @@ export class TimelineItem extends React.PureComponent { isSelected, item, i18n, + renderContact, selectMessage, } = this.props; @@ -165,6 +177,14 @@ export class TimelineItem extends React.PureComponent { notification = ( ); + } else if (item.type === 'groupV2Change') { + notification = ( + + ); } else if (item.type === 'resetSessionNotification') { notification = ( @@ -174,7 +194,12 @@ export class TimelineItem extends React.PureComponent { ); } else { - throw new Error('TimelineItem: Unknown type!'); + // Weird, yes, but the idea is to get a compile error when we aren't comprehensive + // with our if/else checks above, but also log out the type we don't understand if + // we encounter it at runtime. + const unknownItem: never = item; + const asItem = unknownItem as TimelineItemType; + throw new Error(`TimelineItem: Unknown type: ${asItem.type}`); } return ( diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index b889cef29..adf5ac3c9 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -6,7 +6,7 @@ import { Intl } from '../Intl'; import { LocalizerType } from '../../types/Util'; export type PropsData = { - type: 'fromOther' | 'fromMe' | 'fromSync'; + type: 'fromOther' | 'fromMe' | 'fromSync' | 'fromMember'; phoneNumber?: string; profileName?: string; title: string; @@ -66,6 +66,10 @@ export class TimerNotification extends React.Component { return disabled ? i18n('disappearingMessagesDisabled') : i18n('timerSetOnSync', [timespan]); + case 'fromMember': + return disabled + ? i18n('disappearingMessagesDisabledByMember') + : i18n('timerSetByMember', [timespan]); default: console.warn('TimerNotification: unsupported type provided:', type); diff --git a/ts/groupChange.ts b/ts/groupChange.ts new file mode 100644 index 000000000..606169e87 --- /dev/null +++ b/ts/groupChange.ts @@ -0,0 +1,536 @@ +import { FullJSXType } from './components/Intl'; +import { LocalizerType } from './types/Util'; +import { ReplacementValuesType } from './types/I18N'; +import { missingCaseError } from './util/missingCaseError'; + +import { AccessControlClass, MemberClass } from './textsecure.d'; +import { GroupV2ChangeDetailType, GroupV2ChangeType } from './groups'; + +export type SmartContactRendererType = (conversationId: string) => FullJSXType; +export type StringRendererType = ( + id: string, + i18n: LocalizerType, + components?: Array | ReplacementValuesType +) => FullJSXType; + +export type RenderOptionsType = { + AccessControlEnum: typeof AccessControlClass.AccessRequired; + from?: string; + i18n: LocalizerType; + ourConversationId: string; + renderContact: SmartContactRendererType; + renderString: StringRendererType; + RoleEnum: typeof MemberClass.Role; +}; + +export function renderChange( + change: GroupV2ChangeType, + options: RenderOptionsType +) { + const { details, from } = change; + + return details.map((detail: GroupV2ChangeDetailType) => + renderChangeDetail(detail, { + ...options, + from, + }) + ); +} + +// tslint:disable-next-line cyclomatic-complexity max-func-body-length +export function renderChangeDetail( + detail: GroupV2ChangeDetailType, + options: RenderOptionsType +): FullJSXType { + const { + AccessControlEnum, + from, + i18n, + ourConversationId, + renderContact, + renderString, + RoleEnum, + } = options; + const fromYou = Boolean(from && from === ourConversationId); + + if (detail.type === 'title') { + const { newTitle } = detail; + + if (newTitle) { + if (fromYou) { + return renderString('GroupV2--title--change--you', i18n, [newTitle]); + } else if (from) { + return renderString('GroupV2--title--change--other', i18n, { + memberName: renderContact(from), + newTitle, + }); + } else { + return renderString('GroupV2--title--change--unknown', i18n, [ + newTitle, + ]); + } + } else { + if (fromYou) { + return renderString('GroupV2--title--remove--you', i18n); + } else if (from) { + return renderString('GroupV2--title--remove--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--title--remove--unknown', i18n); + } + } + } else if (detail.type === 'avatar') { + if (detail.removed) { + if (fromYou) { + return renderString('GroupV2--avatar--remove--you', i18n); + } else if (from) { + return renderString('GroupV2--avatar--remove--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--avatar--remove--unknown', i18n); + } + } else { + if (fromYou) { + return renderString('GroupV2--avatar--change--you', i18n); + } else if (from) { + return renderString('GroupV2--avatar--change--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--avatar--change--unknown', i18n); + } + } + } else if (detail.type === 'access-attributes') { + const { newPrivilege } = detail; + + if (newPrivilege === AccessControlEnum.ADMINISTRATOR) { + if (fromYou) { + return renderString('GroupV2--access-attributes--admins--you', i18n); + } else if (from) { + return renderString('GroupV2--access-attributes--admins--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString( + 'GroupV2--access-attributes--admins--unknown', + i18n + ); + } + } else if (newPrivilege === AccessControlEnum.MEMBER) { + if (fromYou) { + return renderString('GroupV2--access-attributes--all--you', i18n); + } else if (from) { + return renderString('GroupV2--access-attributes--all--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--access-attributes--all--unknown', i18n); + } + } else { + throw new Error( + `access-attributes change type, privilege ${newPrivilege} is unknown` + ); + } + } else if (detail.type === 'access-members') { + const { newPrivilege } = detail; + + if (newPrivilege === AccessControlEnum.ADMINISTRATOR) { + if (fromYou) { + return renderString('GroupV2--access-members--admins--you', i18n); + } else if (from) { + return renderString('GroupV2--access-members--admins--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--access-members--admins--unknown', i18n); + } + } else if (newPrivilege === AccessControlEnum.MEMBER) { + if (fromYou) { + return renderString('GroupV2--access-members--all--you', i18n); + } else if (from) { + return renderString('GroupV2--access-members--all--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--access-members--all--unknown', i18n); + } + } else { + throw new Error( + `access-members change type, privilege ${newPrivilege} is unknown` + ); + } + } else if (detail.type === 'member-add') { + const { conversationId } = detail; + const weAreJoiner = conversationId === ourConversationId; + + if (weAreJoiner) { + if (fromYou) { + return renderString('GroupV2--member-add--you--you', i18n); + } else if (from) { + return renderString('GroupV2--member-add--you--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--member-add--you--unknown', i18n); + } + } else { + if (fromYou) { + return renderString('GroupV2--member-add--other--you', i18n, [ + renderContact(conversationId), + ]); + } else if (from) { + return renderString('GroupV2--member-add--other--other', i18n, { + adderName: renderContact(from), + addeeName: renderContact(conversationId), + }); + } else { + return renderString('GroupV2--member-add--other--unknown', i18n, [ + renderContact(conversationId), + ]); + } + } + } else if (detail.type === 'member-add-from-invite') { + const { conversationId, inviter } = detail; + const weAreJoiner = conversationId === ourConversationId; + const weAreInviter = Boolean(inviter && inviter === ourConversationId); + + if (weAreJoiner) { + return renderString('GroupV2--member-add--from-invite--you', i18n, [ + renderContact(inviter), + ]); + } else if (weAreInviter) { + return renderString('GroupV2--member-add--from-invite--from-you', i18n, [ + renderContact(conversationId), + ]); + } else { + return renderString('GroupV2--member-add--from-invite--other', i18n, { + inviteeName: renderContact(conversationId), + inviterName: renderContact(inviter), + }); + } + } else if (detail.type === 'member-remove') { + const { conversationId } = detail; + const weAreLeaver = conversationId === ourConversationId; + + if (weAreLeaver) { + if (fromYou) { + return renderString('GroupV2--member-remove--you--you', i18n); + } else if (from) { + return renderString('GroupV2--member-remove--you--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--member-remove--you--unknown', i18n); + } + } else { + if (fromYou) { + return renderString('GroupV2--member-remove--other--you', i18n, [ + renderContact(conversationId), + ]); + } else if (from && from === conversationId) { + return renderString('GroupV2--member-remove--other--self', i18n, [ + renderContact(from), + ]); + } else if (from) { + return renderString('GroupV2--member-remove--other--other', i18n, { + adminName: renderContact(from), + memberName: renderContact(conversationId), + }); + } else { + return renderString('GroupV2--member-remove--other--unknown', i18n, [ + renderContact(conversationId), + ]); + } + } + } else if (detail.type === 'member-privilege') { + const { conversationId, newPrivilege } = detail; + const weAreMember = conversationId === ourConversationId; + + if (newPrivilege === RoleEnum.ADMINISTRATOR) { + if (weAreMember) { + if (from) { + return renderString( + 'GroupV2--member-privilege--promote--you--other', + i18n, + [renderContact(from)] + ); + } else { + return renderString( + 'GroupV2--member-privilege--promote--you--unknown', + i18n + ); + } + } else { + if (fromYou) { + return renderString( + 'GroupV2--member-privilege--promote--other--you', + i18n, + [renderContact(conversationId)] + ); + } else if (from) { + return renderString( + 'GroupV2--member-privilege--promote--other--other', + i18n, + { + adminName: renderContact(from), + memberName: renderContact(conversationId), + } + ); + } else { + return renderString( + 'GroupV2--member-privilege--promote--other--unknown', + i18n, + [renderContact(conversationId)] + ); + } + } + } else if (newPrivilege === RoleEnum.DEFAULT) { + if (weAreMember) { + if (from) { + return renderString( + 'GroupV2--member-privilege--demote--you--other', + i18n, + [renderContact(from)] + ); + } else { + return renderString( + 'GroupV2--member-privilege--demote--you--unknown', + i18n + ); + } + } else { + if (fromYou) { + return renderString( + 'GroupV2--member-privilege--demote--other--you', + i18n, + [renderContact(conversationId)] + ); + } else if (from) { + return renderString( + 'GroupV2--member-privilege--demote--other--other', + i18n, + { + adminName: renderContact(from), + memberName: renderContact(conversationId), + } + ); + } else { + return renderString( + 'GroupV2--member-privilege--demote--other--unknown', + i18n, + [renderContact(conversationId)] + ); + } + } + } else { + throw new Error( + `member-privilege change type, privilege ${newPrivilege} is unknown` + ); + } + } else if (detail.type === 'pending-add-one') { + const { conversationId } = detail; + const weAreInvited = conversationId === ourConversationId; + if (weAreInvited) { + if (from) { + return renderString('GroupV2--pending-add--one--you--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--pending-add--one--you--unknown', i18n); + } + } else { + if (fromYou) { + return renderString('GroupV2--pending-add--one--other--you', i18n, [ + renderContact(conversationId), + ]); + } else if (from) { + return renderString('GroupV2--pending-add--one--other--other', i18n, [ + renderContact(from), + ]); + } else { + return renderString('GroupV2--pending-add--one--other--unknown', i18n); + } + } + } else if (detail.type === 'pending-add-many') { + const { count } = detail; + + if (fromYou) { + return renderString('GroupV2--pending-add--many--you', i18n, [ + count.toString(), + ]); + } else if (from) { + return renderString('GroupV2--pending-add--many--other', i18n, { + memberName: renderContact(from), + count: count.toString(), + }); + } else { + return renderString('GroupV2--pending-add--many--unknown', i18n, [ + count.toString(), + ]); + } + } else if (detail.type === 'pending-remove-one') { + const { inviter, conversationId } = detail; + const weAreInviter = Boolean(inviter && inviter === ourConversationId); + const sentByInvited = Boolean(from && from === conversationId); + + if (weAreInviter) { + if (inviter && sentByInvited) { + return renderString('GroupV2--pending-remove--decline--you', i18n, [ + renderContact(conversationId), + ]); + } else if (fromYou) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from-you--one--you', + i18n, + [renderContact(conversationId)] + ); + } else if (from) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from-you--one--other', + i18n, + { + adminName: renderContact(from), + inviteeName: renderContact(conversationId), + } + ); + } else { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from-you--one--unknown', + i18n, + [renderContact(conversationId)] + ); + } + } else if (sentByInvited) { + if (inviter) { + return renderString('GroupV2--pending-remove--decline--other', i18n, [ + renderContact(inviter), + ]); + } else { + return renderString('GroupV2--pending-remove--decline--unknown', i18n); + } + } else if (inviter) { + if (fromYou) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from--one--you', + i18n, + [renderContact(inviter)] + ); + } else if (from) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from--one--other', + i18n, + { + adminName: renderContact(from), + memberName: renderContact(inviter), + } + ); + } else { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from--one--unknown', + i18n, + [renderContact(inviter)] + ); + } + } else { + if (fromYou) { + return renderString('GroupV2--pending-remove--revoke--one--you', i18n); + } else if (from) { + return renderString( + 'GroupV2--pending-remove--revoke--one--other', + i18n, + [renderContact(from)] + ); + } else { + return renderString( + 'GroupV2--pending-remove--revoke--one--unknown', + i18n + ); + } + } + } else if (detail.type === 'pending-remove-many') { + const { count, inviter } = detail; + const weAreInviter = Boolean(inviter && inviter === ourConversationId); + + if (weAreInviter) { + if (fromYou) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from-you--many--you', + i18n, + [count.toString()] + ); + } else if (from) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from-you--many--other', + i18n, + { + adminName: renderContact(from), + count: count.toString(), + } + ); + } else { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from-you--many--unknown', + i18n, + [count.toString()] + ); + } + } else if (inviter) { + if (fromYou) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from--many--you', + i18n, + { + count: count.toString(), + memberName: renderContact(inviter), + } + ); + } else if (from) { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from--many--other', + i18n, + { + adminName: renderContact(from), + count: count.toString(), + memberName: renderContact(inviter), + } + ); + } else { + return renderString( + 'GroupV2--pending-remove--revoke-invite-from--many--unknown', + i18n, + { + count: count.toString(), + memberName: renderContact(inviter), + } + ); + } + } else { + if (fromYou) { + return renderString( + 'GroupV2--pending-remove--revoke--many--you', + i18n, + [count.toString()] + ); + } else if (from) { + return renderString( + 'GroupV2--pending-remove--revoke--many--other', + i18n, + { + memberName: renderContact(from), + count: count.toString(), + } + ); + } else { + return renderString( + 'GroupV2--pending-remove--revoke--many--unknown', + i18n, + [count.toString()] + ); + } + } + } else { + throw missingCaseError(detail); + } +} diff --git a/ts/groups.ts b/ts/groups.ts new file mode 100644 index 000000000..b6ff1fca1 --- /dev/null +++ b/ts/groups.ts @@ -0,0 +1,2296 @@ +/* tslint:disable no-dynamic-delete no-unnecessary-local-variable */ + +import { + compact, + Dictionary, + flatten, + fromPairs, + isNumber, + values, +} from 'lodash'; +import { + getCredentialsForToday, + GROUP_CREDENTIALS_KEY, + maybeFetchNewCredentials, +} from './services/groupCredentialFetcher'; +import { + ConversationAttributesType, + ConversationModelType, + GroupV2MemberType, + GroupV2PendingMemberType, + MessageAttributesType, +} from './model-types.d'; +import { + decryptGroupBlob, + decryptProfileKey, + decryptProfileKeyCredentialPresentation, + decryptUuid, + deriveGroupID, + deriveGroupPublicParams, + deriveGroupSecretParams, + encryptGroupBlob, + getAuthCredentialPresentation, + getClientZkAuthOperations, + getClientZkGroupCipher, +} from './util/zkgroup'; +import { ClientZkGroupCipher } from 'zkgroup'; +import { + arrayBufferToBase64, + arrayBufferToHex, + base64ToArrayBuffer, + computeHash, +} from './Crypto'; +import { + GroupAttributeBlobClass, + GroupChangeClass, + GroupChangesClass, + GroupClass, + MemberClass, + PendingMemberClass, + ProtoBinaryType, +} from './textsecure.d'; +import { GroupCredentialsType } from './textsecure/WebAPI'; +import { v4 as getGuid } from 'uuid'; +import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message'; + +export type GroupV2AccessAttributesChangeType = { + type: 'access-attributes'; + newPrivilege: number; +}; +export type GroupV2AccessMembersChangeType = { + type: 'access-members'; + newPrivilege: number; +}; +export type GroupV2AvatarChangeType = { + type: 'avatar'; + removed: boolean; +}; +export type GroupV2TitleChangeType = { + type: 'title'; + // Allow for null, because the title could be removed entirely + newTitle?: string; +}; + +// No disappearing messages timer change type - message.expirationTimerUpdate used instead + +export type GroupV2MemberAddChangeType = { + type: 'member-add'; + conversationId: string; +}; +export type GroupV2MemberAddFromInviteChangeType = { + type: 'member-add-from-invite'; + conversationId: string; + inviter: string; +}; +export type GroupV2MemberPrivilegeChangeType = { + type: 'member-privilege'; + conversationId: string; + newPrivilege: number; +}; +export type GroupV2MemberRemoveChangeType = { + type: 'member-remove'; + conversationId: string; +}; + +export type GroupV2PendingAddOneChangeType = { + type: 'pending-add-one'; + conversationId: string; +}; +export type GroupV2PendingAddManyChangeType = { + type: 'pending-add-many'; + count: number; +}; +// Note: pending-remove is only used if user didn't also join the group at the same time +export type GroupV2PendingRemoveOneChangeType = { + type: 'pending-remove-one'; + conversationId: string; + inviter?: string; +}; +// Note: pending-remove is only used if user didn't also join the group at the same time +export type GroupV2PendingRemoveManyChangeType = { + type: 'pending-remove-many'; + count: number; + inviter?: string; +}; + +export type GroupV2ChangeDetailType = + | GroupV2TitleChangeType + | GroupV2AvatarChangeType + | GroupV2AccessAttributesChangeType + | GroupV2AccessMembersChangeType + | GroupV2MemberAddChangeType + | GroupV2MemberAddFromInviteChangeType + | GroupV2MemberRemoveChangeType + | GroupV2MemberPrivilegeChangeType + | GroupV2PendingAddOneChangeType + | GroupV2PendingAddManyChangeType + | GroupV2PendingRemoveOneChangeType + | GroupV2PendingRemoveManyChangeType; + +export type GroupV2ChangeType = { + from?: string; + details: Array; +}; + +if (!isNumber(MAX_MESSAGE_SCHEMA)) { + throw new Error( + 'groups.ts: Unable to capture max message schema from js/modules/types/message' + ); +} + +type MemberType = { + profileKey: string; + uuid: string; +}; +type UpdatesResultType = { + // The array of new messages to be added into the message timeline + groupChangeMessages: Array; + // The set of members in the group, and we largely just pull profile keys for each, + // because the group membership is updated in newAttributes + members: Array; + // To be merged into the conversation model + newAttributes: ConversationAttributesType; +}; + +// Constants + +export const MASTER_KEY_LENGTH = 32; +const TEMPORAL_AUTH_REJECTED_CODE = 401; +const GROUP_ACCESS_DENIED_CODE = 403; + +// Group Changes + +export function buildDisappearingMessagesTimerChange({ + expireTimer, + group, +}: { + expireTimer?: number; + group: ConversationAttributesType; +}): GroupChangeClass.Actions { + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + const blob = new window.textsecure.protobuf.GroupAttributeBlob(); + blob.disappearingMessagesDuration = expireTimer; + + if (!group.secretParams) { + throw new Error( + 'buildDisappearingMessagesTimerChange: group was missing secretParams!' + ); + } + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + + const blobPlaintext = blob.toArrayBuffer(); + const blobCipherText = encryptGroupBlob(clientZkGroupCipher, blobPlaintext); + + const timerAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyDisappearingMessagesTimerAction(); + timerAction.timer = blobCipherText; + + actions.version = (group.revision || 0) + 1; + actions.modifyDisappearingMessagesTimer = timerAction; + + return actions; +} + +export async function uploadGroupChange({ + actions, + group, + serverPublicParamsBase64, +}: { + actions: GroupChangeClass.Actions; + group: ConversationAttributesType; + serverPublicParamsBase64: string; +}): Promise { + const logId = idForLogging(group); + const sender = window.textsecure.messaging; + if (!sender) { + throw new Error('textsecure.messaging is not available!'); + } + + // Ensure we have the credentials we need before attempting GroupsV2 operations + await maybeFetchNewCredentials(); + + if (!group.secretParams) { + throw new Error('uploadGroupChange: group was missing secretParams!'); + } + if (!group.publicParams) { + throw new Error('uploadGroupChange: group was missing publicParams!'); + } + + const groupCredentials = getCredentialsForToday( + window.storage.get(GROUP_CREDENTIALS_KEY) + ); + + const options = { + authCredentialBase64: groupCredentials.today.credential, + serverPublicParamsBase64, + groupPublicParamsBase64: group.publicParams, + groupSecretParamsBase64: group.secretParams, + }; + + try { + const optionsForToday = getGroupCredentials(options); + const result = await sender.modifyGroup(actions, optionsForToday); + + return result; + } catch (error) { + if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { + window.log.info( + `uploadGroupChange/${logId}: Credential for today failed, failing over to tomorrow...` + ); + const optionsForTomorrow = getGroupCredentials({ + ...options, + authCredentialBase64: groupCredentials.tomorrow.credential, + }); + return sender.modifyGroup(actions, optionsForTomorrow); + } + + throw error; + } +} + +// Utility + +export function deriveGroupFields(masterKey: ArrayBuffer) { + const secretParams = deriveGroupSecretParams(masterKey); + const publicParams = deriveGroupPublicParams(secretParams); + const id = deriveGroupID(secretParams); + + return { + id, + secretParams, + publicParams, + }; +} + +// Fetching and applying group changes + +type MaybeUpdatePropsType = { + conversation: ConversationModelType; + groupChangeBase64?: string; + newRevision?: number; + timestamp?: number; + dropInitialJoinMessage?: boolean; +}; + +export async function waitThenMaybeUpdateGroup(options: MaybeUpdatePropsType) { + // First wait to process all incoming messages on the websocket + await window.waitForEmptyEventQueue(); + + // Then wait to process all outstanding messages for this conversation + const { conversation } = options; + + await conversation.queueJob(async () => { + try { + // And finally try to update the group + await maybeUpdateGroup(options); + } catch (error) { + window.log.error( + `waitThenMaybeUpdateGroup/${conversation.idForLogging()}: maybeUpdateGroup failure:`, + error && error.stack ? error.stack : error + ); + } + }); +} + +export async function maybeUpdateGroup({ + conversation, + groupChangeBase64, + newRevision, + timestamp, + dropInitialJoinMessage, +}: MaybeUpdatePropsType) { + const logId = conversation.idForLogging(); + + try { + // Ensure we have the credentials we need before attempting GroupsV2 operations + await maybeFetchNewCredentials(); + + const { + newAttributes, + groupChangeMessages, + members, + } = await getGroupUpdates({ + group: conversation.attributes, + serverPublicParamsBase64: window.getServerPublicParams(), + newRevision, + groupChangeBase64, + dropInitialJoinMessage, + }); + + conversation.set(newAttributes); + + // Ensure that all generated message are ordered properly. Before the provided timestamp + // so update messages appear before the initiating message, or after now(). + let syntheticTimestamp = timestamp + ? timestamp - (groupChangeMessages.length + 1) + : Date.now(); + // Save all synthetic messages describing group changes + const changeMessagesToSave = groupChangeMessages.map(changeMessage => { + // We do this to preserve the order of the timeline + syntheticTimestamp += 1; + + return { + ...changeMessage, + conversationId: conversation.id, + received_at: syntheticTimestamp, + }; + }); + + if (changeMessagesToSave.length > 0) { + await window.Signal.Data.saveMessages(changeMessagesToSave, { + forceSave: true, + }); + changeMessagesToSave.forEach(changeMessage => { + const model = new window.Whisper.Message(changeMessage); + window.MessageController.register(model.id, model); + conversation.trigger('newmessage', model); + }); + } + + // Capture profile key for each member in the group, if we don't have it yet + members.forEach(member => { + const contact = window.ConversationController.get(member.uuid); + + if (member.profileKey && contact && !contact.get('profileKey')) { + // tslint:disable-next-line no-floating-promises + contact.setProfileKey(member.profileKey); + } + }); + + await conversation.updateLastMessage(); + } catch (error) { + window.log.error( + `maybeUpdateGroup/${logId}: Failed to update group:`, + error && error.stack ? error.stack : error + ); + throw error; + } +} + +function idForLogging(group: ConversationAttributesType) { + return `groupv2(${group.groupId})`; +} + +async function getGroupUpdates({ + dropInitialJoinMessage, + group, + serverPublicParamsBase64, + newRevision, + groupChangeBase64, +}: { + dropInitialJoinMessage?: boolean; + group: ConversationAttributesType; + groupChangeBase64?: string; + newRevision?: number; + serverPublicParamsBase64: string; +}): Promise { + const logId = idForLogging(group); + + window.log.info(`getGroupUpdates/${logId}: Starting...`); + + const currentRevision = group.revision; + const isFirstFetch = !isNumber(group.revision); + + if ( + groupChangeBase64 && + ((isFirstFetch && newRevision === 0) || + (isNumber(newRevision) && + isNumber(currentRevision) && + newRevision === currentRevision + 1)) + ) { + window.log.info(`getGroupUpdates/${logId}: Processing just one change`); + const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64); + const groupChange = window.textsecure.protobuf.GroupChange.decode( + groupChangeBuffer + ); + return integrateGroupChange({ group, newRevision, groupChange }); + } + + if (isNumber(newRevision)) { + try { + const result = await updateGroupViaLogs({ + group, + serverPublicParamsBase64, + newRevision, + }); + + return result; + } catch (error) { + if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { + // We will fail over to the updateGroupViaState call below + window.log.info( + `getGroupUpdates/${logId}: Temporal credential failure, now fetching full group state` + ); + } else if (error.code === GROUP_ACCESS_DENIED_CODE) { + // We will fail over to the updateGroupViaState call below + window.log.info( + `getGroupUpdates/${logId}: Log access denied, now fetching full group state` + ); + } else { + throw error; + } + } + } + + return updateGroupViaState({ + dropInitialJoinMessage, + group, + serverPublicParamsBase64, + }); +} + +async function updateGroupViaState({ + dropInitialJoinMessage, + group, + serverPublicParamsBase64, +}: { + dropInitialJoinMessage?: boolean; + group: ConversationAttributesType; + serverPublicParamsBase64: string; +}): Promise { + const logId = idForLogging(group); + const data = window.storage.get(GROUP_CREDENTIALS_KEY); + if (!data) { + throw new Error('updateGroupViaState: No group credentials!'); + } + + const groupCredentials = getCredentialsForToday(data); + + const stateOptions = { + dropInitialJoinMessage, + group, + serverPublicParamsBase64, + authCredentialBase64: groupCredentials.today.credential, + }; + try { + window.log.info( + `updateGroupViaState/${logId}: Getting full group state...` + ); + // We await this here so our try/catch below takes effect + const result = await getCurrentGroupState(stateOptions); + + return result; + } catch (error) { + if (error.code === GROUP_ACCESS_DENIED_CODE) { + return generateLeftGroupChanges(group); + } + if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { + window.log.info( + `updateGroupViaState/${logId}: Credential for today failed, failing over to tomorrow...` + ); + try { + const result = await getCurrentGroupState({ + ...stateOptions, + authCredentialBase64: groupCredentials.tomorrow.credential, + }); + return result; + } catch (error) { + if (error.code === GROUP_ACCESS_DENIED_CODE) { + return generateLeftGroupChanges(group); + } + } + } + + throw error; + } +} + +async function updateGroupViaLogs({ + group, + serverPublicParamsBase64, + newRevision, +}: { + group: ConversationAttributesType; + newRevision: number; + serverPublicParamsBase64: string; +}): Promise { + const logId = idForLogging(group); + const data = window.storage.get(GROUP_CREDENTIALS_KEY); + if (!data) { + throw new Error('getGroupUpdates: No group credentials!'); + } + + const groupCredentials = getCredentialsForToday(data); + const deltaOptions = { + group, + newRevision, + serverPublicParamsBase64, + authCredentialBase64: groupCredentials.today.credential, + }; + try { + window.log.info( + `updateGroupViaLogs/${logId}: Getting group delta from ${group.revision} to ${newRevision} for group groupv2(${group.groupId})...` + ); + const result = await getGroupDelta(deltaOptions); + + return result; + } catch (error) { + if (error.code === TEMPORAL_AUTH_REJECTED_CODE) { + window.log.info( + `updateGroupViaLogs/${logId}: Credential for today failed, failing over to tomorrow...` + ); + + return getGroupDelta({ + ...deltaOptions, + authCredentialBase64: groupCredentials.tomorrow.credential, + }); + } else { + throw error; + } + } +} + +function generateBasicMessage() { + return { + id: getGuid(), + schemaVersion: MAX_MESSAGE_SCHEMA, + }; +} + +function generateLeftGroupChanges( + group: ConversationAttributesType +): UpdatesResultType { + const idLog = idForLogging(group); + window.log.info(`generateLeftGroupChanges/${idLog}: Starting...`); + const ourConversationId = window.ConversationController.getOurConversationId(); + if (!ourConversationId) { + throw new Error( + 'generateLeftGroupChanges: We do not have a conversationId!' + ); + } + const existingMembers = group.membersV2 || []; + const newAttributes: ConversationAttributesType = { + ...group, + membersV2: existingMembers.filter( + member => member.conversationId !== ourConversationId + ), + left: true, + }; + const isNewlyRemoved = + existingMembers.length > (newAttributes.membersV2 || []).length; + + const youWereRemovedMessage = { + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + details: [ + { + type: 'member-remove' as const, + conversationId: ourConversationId, + }, + ], + }, + }; + + return { + newAttributes, + groupChangeMessages: isNewlyRemoved ? [youWereRemovedMessage] : [], + members: [], + }; +} + +function getGroupCredentials({ + authCredentialBase64, + groupPublicParamsBase64, + groupSecretParamsBase64, + serverPublicParamsBase64, +}: { + authCredentialBase64: string; + groupPublicParamsBase64: string; + groupSecretParamsBase64: string; + serverPublicParamsBase64: string; +}): GroupCredentialsType { + const authOperations = getClientZkAuthOperations(serverPublicParamsBase64); + + const presentation = getAuthCredentialPresentation( + authOperations, + authCredentialBase64, + groupSecretParamsBase64 + ); + + return { + groupPublicParamsHex: arrayBufferToHex( + base64ToArrayBuffer(groupPublicParamsBase64) + ), + authCredentialPresentationHex: arrayBufferToHex(presentation), + }; +} + +async function getGroupDelta({ + group, + newRevision, + serverPublicParamsBase64, + authCredentialBase64, +}: { + group: ConversationAttributesType; + newRevision: number; + serverPublicParamsBase64: string; + authCredentialBase64: string; +}): Promise { + const sender = window.textsecure.messaging; + if (!sender) { + throw new Error('getGroupDelta: textsecure.messaging is not available!'); + } + if (!group.publicParams) { + throw new Error('getGroupDelta: group was missing publicParams!'); + } + if (!group.secretParams) { + throw new Error('getGroupDelta: group was missing secretParams!'); + } + + const options = getGroupCredentials({ + authCredentialBase64, + groupPublicParamsBase64: group.publicParams, + groupSecretParamsBase64: group.secretParams, + serverPublicParamsBase64, + }); + + const currentRevision = group.revision; + let revisionToFetch = isNumber(currentRevision) ? currentRevision + 1 : 0; + + let response; + const changes: Array = []; + do { + response = await sender.getGroupLog(revisionToFetch, options); + changes.push(response.changes); + if (response.end) { + revisionToFetch = response.end + 1; + } + } while (response.end && response.end < newRevision); + + // Would be nice to cache the unused groupChanges here, to reduce server roundtrips + + return integrateGroupChanges({ + changes, + group, + newRevision, + }); +} + +async function integrateGroupChanges({ + group, + newRevision, + changes, +}: { + group: ConversationAttributesType; + newRevision: number; + changes: Array; +}): Promise { + const idLog = idForLogging(group); + let attributes = group; + const finalMessages: Array> = []; + const finalMembers: Array> = []; + + const imax = changes.length; + for (let i = 0; i < imax; i += 1) { + const { groupChanges } = changes[i]; + + if (!groupChanges) { + continue; + } + + const jmax = groupChanges.length; + for (let j = 0; j < jmax; j += 1) { + const changeState = groupChanges[j]; + + const { groupChange } = changeState; + + if (!groupChange) { + continue; + } + + try { + const { + newAttributes, + groupChangeMessages, + members, + } = await integrateGroupChange({ + group: attributes, + newRevision, + groupChange, + }); + + attributes = newAttributes; + finalMessages.push(groupChangeMessages); + finalMembers.push(members); + } catch (error) { + window.log.error( + `integrateGroupChanges/${idLog}: Failed to apply change log, continuing to apply remaining change logs.`, + error && error.stack ? error.stack : error + ); + } + } + } + + // If this is our first fetch, we will collapse this down to one set of messages + const isFirstFetch = !isNumber(group.revision); + if (isFirstFetch) { + // The first array in finalMessages is from the first revision we could process. It + // should contain a message about how we joined the group. + const joinMessages = finalMessages[0]; + const alreadyHaveJoinMessage = joinMessages && joinMessages.length > 0; + + // There have been other changes since that first revision, so we generate diffs for + // the whole of the change since then, likely without the initial join message. + const otherMessages = extractDiffs({ + old: group, + current: attributes, + dropInitialJoinMessage: alreadyHaveJoinMessage, + }); + + const groupChangeMessages = alreadyHaveJoinMessage + ? [joinMessages[0], ...otherMessages] + : otherMessages; + + return { + newAttributes: attributes, + groupChangeMessages, + members: flatten(finalMembers), + }; + } + + return { + newAttributes: attributes, + groupChangeMessages: flatten(finalMessages), + members: flatten(finalMembers), + }; +} + +async function integrateGroupChange({ + group, + groupChange, + newRevision, +}: { + group: ConversationAttributesType; + groupChange: GroupChangeClass; + newRevision: number; +}): Promise { + const logId = idForLogging(group); + if (!group.secretParams) { + throw new Error('integrateGroupChange: Group was missing secretParams!'); + } + + const groupChangeActions = window.textsecure.protobuf.GroupChange.Actions.decode( + groupChange.actions.toArrayBuffer() + ); + + if (groupChangeActions.version && groupChangeActions.version > newRevision) { + return { + newAttributes: group, + groupChangeMessages: [], + members: [], + }; + } + + const decryptedChangeActions = decryptGroupChange( + groupChangeActions, + group.secretParams, + logId + ); + + const { sourceUuid } = decryptedChangeActions; + const sourceConversation = window.ConversationController.getOrCreate( + sourceUuid, + 'private' + ); + const sourceConversationId = sourceConversation.id; + + const { newAttributes, newProfileKeys } = await applyGroupChange({ + group, + actions: decryptedChangeActions, + }); + const groupChangeMessages = extractDiffs({ + old: group, + current: newAttributes, + sourceConversationId, + }); + + return { + newAttributes, + groupChangeMessages, + members: newProfileKeys.map(item => ({ + ...item, + profileKey: arrayBufferToBase64(item.profileKey), + })), + }; +} + +export async function getCurrentGroupState({ + authCredentialBase64, + dropInitialJoinMessage, + group, + serverPublicParamsBase64, +}: { + authCredentialBase64: string; + dropInitialJoinMessage?: boolean; + group: ConversationAttributesType; + serverPublicParamsBase64: string; +}): Promise { + const logId = idForLogging(group); + const sender = window.textsecure.messaging; + if (!sender) { + throw new Error('textsecure.messaging is not available!'); + } + if (!group.secretParams) { + throw new Error('getCurrentGroupState: group was missing secretParams!'); + } + if (!group.publicParams) { + throw new Error('getCurrentGroupState: group was missing publicParams!'); + } + + const options = getGroupCredentials({ + authCredentialBase64, + groupPublicParamsBase64: group.publicParams, + groupSecretParamsBase64: group.secretParams, + serverPublicParamsBase64, + }); + + const groupState = await sender.getGroup(options); + const decryptedGroupState = decryptGroupState( + groupState, + group.secretParams, + logId + ); + + const newAttributes = await applyGroupState(group, decryptedGroupState); + + return { + newAttributes, + groupChangeMessages: extractDiffs({ + old: group, + current: newAttributes, + dropInitialJoinMessage, + }), + members: getMembers(decryptedGroupState), + }; +} + +// tslint:disable-next-line max-func-body-length cyclomatic-complexity +function extractDiffs({ + current, + dropInitialJoinMessage, + old, + sourceConversationId, +}: { + current: ConversationAttributesType; + dropInitialJoinMessage?: boolean; + old: ConversationAttributesType; + sourceConversationId?: string; +}): Array { + const logId = idForLogging(old); + const details: Array = []; + const ourConversationId = window.ConversationController.getOurConversationId(); + let areWeInGroup = false; + + if ( + current.accessControl && + (!old.accessControl || + old.accessControl.attributes !== current.accessControl.attributes) + ) { + details.push({ + type: 'access-attributes', + newPrivilege: current.accessControl.attributes, + }); + } + if ( + current.accessControl && + (!old.accessControl || + old.accessControl.members !== current.accessControl.members) + ) { + details.push({ + type: 'access-members', + newPrivilege: current.accessControl.members, + }); + } + if ( + Boolean(old.avatar) !== Boolean(current.avatar) || + old.avatar?.hash !== current.avatar?.hash + ) { + details.push({ + type: 'avatar', + removed: !current.avatar, + }); + } + if (old.name !== current.name) { + details.push({ + type: 'title', + newTitle: current.name, + }); + } + + // No disappearing message timer check here - see below + + const oldMemberLookup: Dictionary = fromPairs( + (old.membersV2 || []).map(member => [member.conversationId, member]) + ); + const oldPendingMemberLookup: Dictionary = fromPairs( + (old.pendingMembersV2 || []).map(member => [member.conversationId, member]) + ); + + (current.membersV2 || []).forEach(currentMember => { + const { conversationId } = currentMember; + + if (ourConversationId && conversationId === ourConversationId) { + areWeInGroup = true; + } + + const oldMember = oldMemberLookup[conversationId]; + if (!oldMember) { + const pendingMember = oldPendingMemberLookup[conversationId]; + + if (pendingMember && pendingMember.addedByUserId) { + details.push({ + type: 'member-add-from-invite', + conversationId, + inviter: pendingMember.addedByUserId, + }); + } else { + details.push({ + type: 'member-add', + conversationId, + }); + } + + // If we capture a pending remove here, it's an 'accept invitation', and we don't + // want to generate a generic pending-remove event for it + delete oldPendingMemberLookup[conversationId]; + } else if (oldMember.role !== currentMember.role) { + details.push({ + type: 'member-privilege', + conversationId, + newPrivilege: currentMember.role, + }); + } + + // This deletion makes it easier to capture removals + delete oldMemberLookup[conversationId]; + }); + + const removedMemberIds = Object.keys(oldMemberLookup); + removedMemberIds.forEach(conversationId => { + details.push({ + type: 'member-remove', + conversationId, + }); + }); + + let lastPendingConversationId: string | undefined; + let count = 0; + (current.pendingMembersV2 || []).forEach(currentPendingMember => { + const { conversationId } = currentPendingMember; + const oldPendingMember = oldPendingMemberLookup[conversationId]; + + if (!oldPendingMember) { + lastPendingConversationId = conversationId; + count += 1; + } + + // This deletion makes it easier to capture removals + delete oldPendingMemberLookup[conversationId]; + }); + + if (count > 1) { + details.push({ + type: 'pending-add-many', + count, + }); + } else if (count === 1) { + if (lastPendingConversationId) { + details.push({ + type: 'pending-add-one', + conversationId: lastPendingConversationId, + }); + } else { + window.log.warn( + `extractDiffs/${logId}: pending-add count was 1, no last conversationId available` + ); + } + } + + // Note: The only members left over here should be people who were moved from the + // pending list but also not added to the group at the same time. + const removedPendingMemberIds = Object.keys(oldPendingMemberLookup); + if (removedPendingMemberIds.length > 1) { + const firstConversationId = removedPendingMemberIds[0]; + const firstRemovedMember = oldPendingMemberLookup[firstConversationId]; + const inviter = firstRemovedMember.addedByUserId; + const allSameInviter = removedPendingMemberIds.every( + id => oldPendingMemberLookup[id].addedByUserId === inviter + ); + details.push({ + type: 'pending-remove-many', + count: removedPendingMemberIds.length, + inviter: allSameInviter ? inviter : undefined, + }); + } else if (removedPendingMemberIds.length === 1) { + const conversationId = removedPendingMemberIds[0]; + const removedMember = oldPendingMemberLookup[conversationId]; + + details.push({ + type: 'pending-remove-one', + conversationId, + inviter: removedMember.addedByUserId, + }); + } + + let message: MessageAttributesType | undefined; + let timerNotification: MessageAttributesType | undefined; + const conversation = sourceConversationId + ? window.ConversationController.get(sourceConversationId) + : null; + const sourceUuid = conversation ? conversation.get('uuid') : undefined; + + const firstUpdate = !isNumber(old.revision); + const firstEventSourceId = sourceConversationId || ourConversationId; + + if (firstUpdate && dropInitialJoinMessage) { + message = undefined; + } else if (firstUpdate && ourConversationId && areWeInGroup) { + message = { + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + from: firstEventSourceId, + details: [ + { + type: 'member-add', + conversationId: ourConversationId, + }, + ], + }, + }; + } else if (details.length > 0) { + message = { + ...generateBasicMessage(), + type: 'group-v2-change', + sourceUuid, + groupV2Change: { + from: sourceConversationId, + details, + }, + }; + } + + // This is checked differently, because it needs to be its own entry in the timeline, + // with its own icon, etc. + if ( + // Turn on or turned off + Boolean(old.expireTimer) !== Boolean(current.expireTimer) || + // Still on, but changed value + (Boolean(old.expireTimer) && + Boolean(current.expireTimer) && + old.expireTimer !== current.expireTimer) + ) { + timerNotification = { + ...generateBasicMessage(), + type: 'timer-notification', + sourceUuid, + flags: + window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expirationTimerUpdate: { + expireTimer: current.expireTimer || 0, + sourceUuid, + }, + }; + } + + const result = compact([message, timerNotification]); + + window.log.info( + `extractDiffs/${logId} complete, generated ${result.length} change messages` + ); + + return result; +} + +function getMembers(groupState: GroupClass) { + if (!groupState.members || !groupState.members.length) { + return []; + } + + return groupState.members.map((member: MemberClass) => ({ + profileKey: member.profileKey, + uuid: member.userId, + })); +} + +type GroupChangeMemberType = { + profileKey: ArrayBuffer; + uuid: string; +}; +type GroupChangeResultType = { + newAttributes: ConversationAttributesType; + newProfileKeys: Array; +}; + +// tslint:disable-next-line cyclomatic-complexity max-func-body-length +async function applyGroupChange({ + group, + actions, +}: { + sourceConversationId?: string; + group: ConversationAttributesType; + actions: GroupChangeClass.Actions; +}): Promise { + const logId = idForLogging(group); + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + const version = actions.version || 0; + const result = { + ...group, + }; + const newProfileKeys: Array = []; + + const members: Dictionary = fromPairs( + (result.membersV2 || []).map(member => [member.conversationId, member]) + ); + const pendingMembers: Dictionary = fromPairs( + (result.pendingMembersV2 || []).map(member => [ + member.conversationId, + member, + ]) + ); + + // version?: number; + result.revision = version; + + // addMembers?: Array; + (actions.addMembers || []).map(addMember => { + const { added } = addMember; + if (!added) { + throw new Error('applyGroupChange: addMember.added is missing'); + } + + const conversation = window.ConversationController.getOrCreate( + added.userId, + 'private', + { + profileKey: added.profileKey + ? arrayBufferToBase64(added.profileKey) + : undefined, + } + ); + + if (members[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to add member failed; already in members.` + ); + return; + } + + members[conversation.id] = { + conversationId: conversation.id, + role: added.role || MEMBER_ROLE_ENUM.DEFAULT, + joinedAtVersion: version, + }; + + if (pendingMembers[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Removing newly-added member from pendingMembers.` + ); + delete pendingMembers[conversation.id]; + } + + if (added.profileKey) { + newProfileKeys.push({ + profileKey: added.profileKey, + uuid: added.userId, + }); + } + }); + + // deleteMembers?: Array; + (actions.deleteMembers || []).forEach(deleteMember => { + const { deletedUserId } = deleteMember; + if (!deletedUserId) { + throw new Error( + 'applyGroupChange: deleteMember.deletedUserId is missing' + ); + } + + const conversation = window.ConversationController.getOrCreate( + deletedUserId, + 'private' + ); + + if (members[conversation.id]) { + delete members[conversation.id]; + } else { + window.log.warn( + `applyGroupChange/${logId}: Attempt to remove member failed; was not in members.` + ); + } + }); + + // modifyMemberRoles?: Array; + (actions.modifyMemberRoles || []).forEach(modifyMemberRole => { + const { role, userId } = modifyMemberRole; + if (!role || !userId) { + throw new Error('applyGroupChange: modifyMemberRole had a missing value'); + } + + const conversation = window.ConversationController.getOrCreate( + userId, + 'private' + ); + + if (members[conversation.id]) { + members[conversation.id] = { + ...members[conversation.id], + role, + }; + } else { + throw new Error( + 'applyGroupChange: modifyMemberRole tried to modify nonexistent member' + ); + } + }); + + // modifyMemberProfileKeys?: Array; + (actions.modifyMemberProfileKeys || []).forEach(modifyMemberProfileKey => { + const { profileKey, uuid } = modifyMemberProfileKey; + if (!profileKey || !uuid) { + throw new Error( + 'applyGroupChange: modifyMemberProfileKey had a missing value' + ); + } + + newProfileKeys.push({ + profileKey, + uuid, + }); + }); + + // addPendingMembers?: Array; + (actions.addPendingMembers || []).forEach(addPendingMember => { + const { added } = addPendingMember; + if (!added || !added.member) { + throw new Error( + 'applyGroupChange: modifyMemberProfileKey had a missing value' + ); + } + + const conversation = window.ConversationController.getOrCreate( + added.member.userId, + 'private' + ); + + if (members[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to add pendingMember failed; was already in members.` + ); + return; + } + if (pendingMembers[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to add pendingMember failed; was already in pendingMembers.` + ); + return; + } + + pendingMembers[conversation.id] = { + conversationId: conversation.id, + addedByUserId: added.addedByUserId, + timestamp: added.timestamp, + }; + + if (added.member && added.member.profileKey) { + newProfileKeys.push({ + profileKey: added.member.profileKey, + uuid: added.member.userId, + }); + } + }); + + // deletePendingMembers?: Array; + (actions.deletePendingMembers || []).forEach(deletePendingMember => { + const { deletedUserId } = deletePendingMember; + if (!deletedUserId) { + throw new Error( + 'applyGroupChange: deletePendingMember.deletedUserId is null!' + ); + } + + const conversation = window.ConversationController.getOrCreate( + deletedUserId, + 'private' + ); + + if (pendingMembers[conversation.id]) { + delete pendingMembers[conversation.id]; + } else { + window.log.warn( + `applyGroupChange/${logId}: Attempt to remove pendingMember failed; was not in pendingMembers.` + ); + } + }); + + // promotePendingMembers?: Array; + (actions.promotePendingMembers || []).forEach(promotePendingMember => { + const { profileKey, uuid } = promotePendingMember; + if (!profileKey || !uuid) { + throw new Error( + 'applyGroupChange: promotePendingMember had a missing value' + ); + } + + const conversation = window.ConversationController.getOrCreate( + uuid, + 'private', + { + profileKey: profileKey ? arrayBufferToBase64(profileKey) : undefined, + } + ); + + if (pendingMembers[conversation.id]) { + delete pendingMembers[conversation.id]; + } else { + window.log.warn( + `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was not in pendingMembers.` + ); + } + + if (members[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.` + ); + return; + } + + members[conversation.id] = { + conversationId: conversation.id, + joinedAtVersion: version, + role: MEMBER_ROLE_ENUM.DEFAULT, + }; + + newProfileKeys.push({ + profileKey, + uuid, + }); + }); + + // modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction; + if (actions.modifyTitle) { + const title: GroupAttributeBlobClass | undefined = + actions.modifyTitle.title; + if (title && title.content === 'title') { + result.name = title.title; + } else { + window.log.warn( + `applyGroupChange/${logId}: Clearing group title due to missing data.` + ); + result.name = undefined; + } + } + + // modifyAvatar?: GroupChangeClass.Actions.ModifyAvatarAction; + if (actions.modifyAvatar) { + const avatar = actions.modifyAvatar.avatar; + await applyNewAvatar(avatar, result, logId); + } + + // modifyDisappearingMessagesTimer?: GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction; + if (actions.modifyDisappearingMessagesTimer) { + const disappearingMessagesTimer: GroupAttributeBlobClass | undefined = + actions.modifyDisappearingMessagesTimer.timer; + if ( + disappearingMessagesTimer && + disappearingMessagesTimer.content === 'disappearingMessagesDuration' + ) { + result.expireTimer = + disappearingMessagesTimer.disappearingMessagesDuration; + } else { + window.log.warn( + `applyGroupChange/${logId}: Clearing group expireTimer due to missing data.` + ); + result.expireTimer = undefined; + } + } + + result.accessControl = result.accessControl || { + members: ACCESS_ENUM.MEMBER, + attributes: ACCESS_ENUM.MEMBER, + }; + + // modifyAttributesAccess?: GroupChangeClass.Actions.ModifyAttributesAccessControlAction; + if (actions.modifyAttributesAccess) { + result.accessControl = { + ...result.accessControl, + attributes: + actions.modifyAttributesAccess.attributesAccess || ACCESS_ENUM.MEMBER, + }; + } + + // modifyMemberAccess?: GroupChangeClass.Actions.ModifyMembersAccessControlAction; + if (actions.modifyMemberAccess) { + result.accessControl = { + ...result.accessControl, + attributes: + actions.modifyMemberAccess.membersAccess || ACCESS_ENUM.MEMBER, + }; + } + + const ourConversationId = window.ConversationController.getOurConversationId(); + if (ourConversationId) { + result.left = !members[ourConversationId]; + } + + // Go from lookups back to arrays + result.membersV2 = values(members); + result.pendingMembersV2 = values(pendingMembers); + + return { + newAttributes: result, + newProfileKeys, + }; +} + +async function applyNewAvatar( + newAvatar: string | undefined, + result: ConversationAttributesType, + logId: string +) { + try { + // Avatar has been dropped + if (!newAvatar && result.avatar) { + await window.Signal.Migrations.deleteAttachmentData(result.avatar.path); + result.avatar = undefined; + } + + // Group has avatar; has it changed? + if (newAvatar && (!result.avatar || result.avatar.url !== newAvatar)) { + const sender = window.textsecure.messaging; + if (!sender) { + throw new Error( + 'applyNewAvatar: textsecure.messaging is not available!' + ); + } + + if (!result.secretParams) { + throw new Error('applyNewAvatar: group was missing secretParams!'); + } + + const ciphertext = await sender.getGroupAvatar(newAvatar); + const clientZkGroupCipher = getClientZkGroupCipher(result.secretParams); + const plaintext = decryptGroupBlob(clientZkGroupCipher, ciphertext); + const blob = window.textsecure.protobuf.GroupAttributeBlob.decode( + plaintext + ); + if (blob.content !== 'avatar') { + throw new Error( + `applyNewAvatar: Returned blob had incorrect content: ${blob.content}` + ); + } + + const data = blob.avatar.toArrayBuffer(); + const hash = await computeHash(data); + + if (result.avatar && result.avatar.path && result.avatar.hash !== hash) { + await window.Signal.Migrations.deleteAttachmentData(result.avatar.path); + result.avatar = undefined; + } + + if (!result.avatar) { + const path = await window.Signal.Migrations.writeNewAttachmentData( + data + ); + result.avatar = { + url: newAvatar, + path, + hash, + }; + } + } + } catch (error) { + window.log.warn( + `applyNewAvatar/${logId} Failed to handle avatar, clearing it`, + error.stack + ); + if (result.avatar && result.avatar.path) { + await window.Signal.Migrations.deleteAttachmentData(result.avatar.path); + } + result.avatar = undefined; + } +} + +// tslint:disable-next-line cyclomatic-complexity max-func-body-length +async function applyGroupState( + group: ConversationAttributesType, + groupState: GroupClass +): Promise { + const logId = idForLogging(group); + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + const version = groupState.version || 0; + const result = { + ...group, + }; + + // version + result.revision = version; + + // title + // Note: During decryption, title becomes a GroupAttributeBlob + const title: GroupAttributeBlobClass | undefined = groupState.title; + if (title && title.content === 'title') { + result.name = title.title; + } else { + result.name = undefined; + } + + // avatar + await applyNewAvatar(groupState.avatar, result, logId); + + // disappearingMessagesTimer + // Note: during decryption, disappearingMessageTimer becomes a GroupAttributeBlob + const disappearingMessagesTimer: GroupAttributeBlobClass | undefined = + groupState.disappearingMessagesTimer; + if ( + disappearingMessagesTimer && + disappearingMessagesTimer.content === 'disappearingMessagesDuration' + ) { + result.expireTimer = disappearingMessagesTimer.disappearingMessagesDuration; + } else { + result.expireTimer = undefined; + } + + // accessControl + const { accessControl } = groupState; + result.accessControl = { + attributes: + (accessControl && accessControl.attributes) || ACCESS_ENUM.MEMBER, + members: (accessControl && accessControl.members) || ACCESS_ENUM.MEMBER, + }; + + // Optimization: we assume we have left the group unless we are found in members + result.left = true; + const ourConversationId = window.ConversationController.getOurConversationId(); + + // members + if (groupState.members) { + result.membersV2 = groupState.members.map((member: MemberClass) => { + const conversation = window.ConversationController.getOrCreate( + member.userId, + 'private', + { + profileKey: member.profileKey + ? arrayBufferToBase64(member.profileKey) + : undefined, + } + ); + + if (ourConversationId && conversation.id === ourConversationId) { + result.left = false; + } + + if ( + !member.role || + member.role === window.textsecure.protobuf.Member.Role.UNKNOWN + ) { + throw new Error( + 'applyGroupState: Received false or UNKNOWN member.role' + ); + } + + return { + role: member.role, + joinedAtVersion: member.joinedAtVersion || version, + conversationId: conversation.id, + }; + }); + } + + // pendingMembers + if (groupState.pendingMembers) { + result.pendingMembersV2 = groupState.pendingMembers.map( + (member: PendingMemberClass) => { + let pending; + let invitedBy; + + if (member.member && member.member.userId) { + pending = window.ConversationController.getOrCreate( + member.member.userId, + 'private', + { + profileKey: member.member.profileKey + ? arrayBufferToBase64(member.member.profileKey) + : undefined, + } + ); + } else { + throw new Error('Pending member did not have an associated userId'); + } + + if (member.addedByUserId) { + invitedBy = window.ConversationController.getOrCreate( + member.addedByUserId, + 'private' + ); + } else { + throw new Error('Pending member did not have an addedByUserID'); + } + + return { + addedByUserId: invitedBy.id, + conversationId: pending.id, + timestamp: member.timestamp, + }; + } + ); + } + + return result; +} + +function isValidRole(role?: number): boolean { + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + + return ( + role === MEMBER_ROLE_ENUM.ADMINISTRATOR || role === MEMBER_ROLE_ENUM.DEFAULT + ); +} + +function isValidAccess(access?: number): boolean { + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + + return access === ACCESS_ENUM.ADMINISTRATOR || access === ACCESS_ENUM.MEMBER; +} + +function isValidProfileKey(buffer?: ArrayBuffer): boolean { + return Boolean(buffer && buffer.byteLength === 32); +} + +function hasData(data: ProtoBinaryType): boolean { + return data && data.limit > 0; +} + +// tslint:disable-next-line max-func-body-length cyclomatic-complexity +function decryptGroupChange( + actions: GroupChangeClass.Actions, + groupSecretParams: string, + logId: string +): GroupChangeClass.Actions { + const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams); + + if (hasData(actions.sourceUuid)) { + try { + actions.sourceUuid = decryptUuid( + clientZkGroupCipher, + actions.sourceUuid.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt sourceUuid. Clearing sourceUuid.`, + error && error.stack ? error.stack : error + ); + actions.sourceUuid = undefined; + } + + window.normalizeUuids(actions, ['sourceUuid'], 'groups.decryptGroupChange'); + + if (!window.isValidGuid(actions.sourceUuid)) { + window.log.warn( + `decryptGroupChange/${logId}: Invalid sourceUuid. Clearing sourceUuid.` + ); + actions.sourceUuid = undefined; + } + } else { + throw new Error('decryptGroupChange: Missing sourceUuid'); + } + + // addMembers?: Array; + actions.addMembers = compact( + (actions.addMembers || []).map(addMember => { + if (addMember.added) { + const decrypted = decryptMember( + clientZkGroupCipher, + addMember.added, + logId + ); + if (!decrypted) { + return null; + } + + addMember.added = decrypted; + return addMember; + } else { + throw new Error( + 'decryptGroupChange: AddMember was missing added field!' + ); + } + }) + ); + + // deleteMembers?: Array; + actions.deleteMembers = compact( + (actions.deleteMembers || []).map(deleteMember => { + if (hasData(deleteMember.deletedUserId)) { + try { + deleteMember.deletedUserId = decryptUuid( + clientZkGroupCipher, + deleteMember.deletedUserId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt deleteMembers.deletedUserId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + } else { + throw new Error( + 'decryptGroupChange: deleteMember.deletedUserId was missing' + ); + } + + window.normalizeUuids( + deleteMember, + ['deletedUserId'], + 'groups.decryptGroupChange' + ); + + if (!window.isValidGuid(deleteMember.deletedUserId)) { + window.log.warn( + `decryptGroupChange/${logId}: Dropping deleteMember due to invalid userId` + ); + + return null; + } + + return deleteMember; + }) + ); + + // modifyMemberRoles?: Array; + actions.modifyMemberRoles = compact( + (actions.modifyMemberRoles || []).map(modifyMember => { + if (hasData(modifyMember.userId)) { + try { + modifyMember.userId = decryptUuid( + clientZkGroupCipher, + modifyMember.userId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt modifyMemberRole.userId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + } else { + throw new Error( + 'decryptGroupChange: modifyMemberRole.userId was missing' + ); + } + + window.normalizeUuids( + modifyMember, + ['userId'], + 'groups.decryptGroupChange' + ); + + if (!window.isValidGuid(modifyMember.userId)) { + window.log.warn( + `decryptGroupChange/${logId}: Dropping modifyMemberRole due to invalid userId` + ); + + return null; + } + + if (!isValidRole(modifyMember.role)) { + throw new Error( + 'decryptGroupChange: modifyMemberRole had invalid role' + ); + } + + return modifyMember; + }) + ); + + // modifyMemberProfileKeys?: Array; + actions.modifyMemberProfileKeys = compact( + (actions.modifyMemberProfileKeys || []).map(modifyMemberProfileKey => { + if (hasData(modifyMemberProfileKey.presentation)) { + const { profileKey, uuid } = decryptProfileKeyCredentialPresentation( + clientZkGroupCipher, + modifyMemberProfileKey.presentation.toArrayBuffer() + ); + + modifyMemberProfileKey.profileKey = profileKey; + modifyMemberProfileKey.uuid = uuid; + + if ( + !modifyMemberProfileKey.uuid || + !modifyMemberProfileKey.profileKey + ) { + throw new Error( + 'decryptGroupChange: uuid or profileKey missing after modifyMemberProfileKey decryption!' + ); + } + + if (!window.isValidGuid(modifyMemberProfileKey.uuid)) { + window.log.warn( + `decryptGroupChange/${logId}: Dropping modifyMemberProfileKey due to invalid userId` + ); + + return null; + } + + if (!isValidProfileKey(modifyMemberProfileKey.profileKey)) { + throw new Error( + 'decryptGroupChange: modifyMemberProfileKey had invalid profileKey' + ); + } + } else { + throw new Error( + 'decryptGroupChange: modifyMemberProfileKey.presentation was missing' + ); + } + + return modifyMemberProfileKey; + }) + ); + + // addPendingMembers?: Array; + actions.addPendingMembers = compact( + (actions.addPendingMembers || []).map(addPendingMember => { + if (addPendingMember.added) { + const decrypted = decryptPendingMember( + clientZkGroupCipher, + addPendingMember.added, + logId + ); + if (!decrypted) { + return null; + } + + addPendingMember.added = decrypted; + return addPendingMember; + } else { + throw new Error( + 'decryptGroupChange: addPendingMember was missing added field!' + ); + } + }) + ); + + // deletePendingMembers?: Array; + actions.deletePendingMembers = compact( + (actions.deletePendingMembers || []).map(deletePendingMember => { + if (hasData(deletePendingMember.deletedUserId)) { + try { + deletePendingMember.deletedUserId = decryptUuid( + clientZkGroupCipher, + deletePendingMember.deletedUserId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt deletePendingMembers.deletedUserId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + } else { + throw new Error( + 'decryptGroupChange: deletePendingMembers.deletedUserId was missing' + ); + } + + window.normalizeUuids( + deletePendingMember, + ['deletedUserId'], + 'groups.decryptGroupChange' + ); + + if (!window.isValidGuid(deletePendingMember.deletedUserId)) { + window.log.warn( + `decryptGroupChange/${logId}: Dropping deletePendingMember due to invalid deletedUserId` + ); + + return null; + } + + return deletePendingMember; + }) + ); + + // promotePendingMembers?: Array; + actions.promotePendingMembers = compact( + (actions.promotePendingMembers || []).map(promotePendingMember => { + if (hasData(promotePendingMember.presentation)) { + const { profileKey, uuid } = decryptProfileKeyCredentialPresentation( + clientZkGroupCipher, + promotePendingMember.presentation.toArrayBuffer() + ); + + promotePendingMember.profileKey = profileKey; + promotePendingMember.uuid = uuid; + + if (!promotePendingMember.uuid || !promotePendingMember.profileKey) { + throw new Error( + 'decryptGroupChange: uuid or profileKey missing after promotePendingMember decryption!' + ); + } + + if (!window.isValidGuid(promotePendingMember.uuid)) { + window.log.warn( + `decryptGroupChange/${logId}: Dropping modifyMemberProfileKey due to invalid userId` + ); + + return null; + } + + if (!isValidProfileKey(promotePendingMember.profileKey)) { + throw new Error( + 'decryptGroupChange: modifyMemberProfileKey had invalid profileKey' + ); + } + } else { + throw new Error( + 'decryptGroupChange: promotePendingMember.presentation was missing' + ); + } + + return promotePendingMember; + }) + ); + + // modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction; + if (actions.modifyTitle && hasData(actions.modifyTitle.title)) { + try { + actions.modifyTitle.title = window.textsecure.protobuf.GroupAttributeBlob.decode( + decryptGroupBlob( + clientZkGroupCipher, + actions.modifyTitle.title.toArrayBuffer() + ) + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt modifyTitle.title`, + error && error.stack ? error.stack : error + ); + actions.modifyTitle.title = undefined; + } + } else if (actions.modifyTitle) { + actions.modifyTitle.title = undefined; + } + + // modifyAvatar?: GroupChangeClass.Actions.ModifyAvatarAction; + // Note: decryption happens during application of the change, on download of the avatar + + // modifyDisappearingMessagesTimer?: GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction; + if ( + actions.modifyDisappearingMessagesTimer && + hasData(actions.modifyDisappearingMessagesTimer.timer) + ) { + try { + actions.modifyDisappearingMessagesTimer.timer = window.textsecure.protobuf.GroupAttributeBlob.decode( + decryptGroupBlob( + clientZkGroupCipher, + actions.modifyDisappearingMessagesTimer.timer.toArrayBuffer() + ) + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt modifyDisappearingMessagesTimer.timer`, + error && error.stack ? error.stack : error + ); + actions.modifyDisappearingMessagesTimer.timer = undefined; + } + } else if (actions.modifyDisappearingMessagesTimer) { + actions.modifyDisappearingMessagesTimer.timer = undefined; + } + + // modifyAttributesAccess?: GroupChangeClass.Actions.ModifyAttributesAccessControlAction; + if ( + actions.modifyAttributesAccess && + !isValidAccess(actions.modifyAttributesAccess.attributesAccess) + ) { + throw new Error( + 'decryptGroupChange: modifyAttributesAccess.attributesAccess was not a valid role' + ); + } + + // modifyMemberAccess?: GroupChangeClass.Actions.ModifyMembersAccessControlAction; + if ( + actions.modifyMemberAccess && + !isValidAccess(actions.modifyMemberAccess.membersAccess) + ) { + throw new Error( + 'decryptGroupChange: modifyMemberAccess.membersAccess was not a valid role' + ); + } + + return actions; +} + +// tslint:disable-next-line max-func-body-length +function decryptGroupState( + groupState: GroupClass, + groupSecretParams: string, + logId: string +): GroupClass { + const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams); + + // title + if (hasData(groupState.title)) { + try { + groupState.title = window.textsecure.protobuf.GroupAttributeBlob.decode( + decryptGroupBlob(clientZkGroupCipher, groupState.title.toArrayBuffer()) + ); + } catch (error) { + window.log.warn( + `decryptGroupState/${logId}: Unable to decrypt title. Clearing it.`, + error && error.stack ? error.stack : error + ); + groupState.title = undefined; + } + } else { + groupState.title = undefined; + } + + // avatar + // Note: decryption happens during application of the change, on download of the avatar + + // disappearing message timer + if (hasData(groupState.disappearingMessagesTimer)) { + try { + groupState.disappearingMessagesTimer = window.textsecure.protobuf.GroupAttributeBlob.decode( + decryptGroupBlob( + clientZkGroupCipher, + groupState.disappearingMessagesTimer.toArrayBuffer() + ) + ); + } catch (error) { + window.log.warn( + `decryptGroupState/${logId}: Unable to decrypt disappearing message timer. Clearing it.`, + error && error.stack ? error.stack : error + ); + groupState.disappearingMessagesTimer = undefined; + } + } else { + groupState.disappearingMessagesTimer = undefined; + } + + // accessControl + if ( + !groupState.accessControl || + !isValidAccess(groupState.accessControl.attributes) + ) { + throw new Error( + 'decryptGroupState: Access control for attributes is missing or invalid' + ); + } + if ( + !groupState.accessControl || + !isValidAccess(groupState.accessControl.members) + ) { + throw new Error( + 'decryptGroupState: Access control for members is missing or invalid' + ); + } + + // version + if (!isNumber(groupState.version)) { + throw new Error( + `decryptGroupState: Expected version to be a number; it was ${groupState.version}` + ); + } + + // members + if (groupState.members) { + groupState.members = compact( + groupState.members.map((member: MemberClass) => + decryptMember(clientZkGroupCipher, member, logId) + ) + ); + } + + // pending members + if (groupState.pendingMembers) { + groupState.pendingMembers = compact( + groupState.pendingMembers.map((member: PendingMemberClass) => + decryptPendingMember(clientZkGroupCipher, member, logId) + ) + ); + } + + return groupState; +} + +function decryptMember( + clientZkGroupCipher: ClientZkGroupCipher, + member: MemberClass, + logId: string +) { + // userId + if (hasData(member.userId)) { + try { + member.userId = decryptUuid( + clientZkGroupCipher, + member.userId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptMember/${logId}: Unable to decrypt member userid. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + + window.normalizeUuids(member, ['userId'], 'groups.decryptMember'); + + if (!window.isValidGuid(member.userId)) { + window.log.warn( + `decryptMember/${logId}: Dropping member due to invalid userId` + ); + + return null; + } + } else { + throw new Error('decryptMember: Member had missing userId'); + } + + // profileKey + if (hasData(member.profileKey)) { + member.profileKey = decryptProfileKey( + clientZkGroupCipher, + member.profileKey.toArrayBuffer(), + member.userId + ); + + if (!isValidProfileKey(member.profileKey)) { + throw new Error('decryptMember: Member had invalid profileKey'); + } + } else { + throw new Error('decryptMember: Member had missing profileKey'); + } + + // role + if (!isValidRole(member.role)) { + throw new Error('decryptMember: Member had invalid role'); + } + + return member; +} + +// tslint:disable-next-line max-func-body-length cyclomatic-complexity +function decryptPendingMember( + clientZkGroupCipher: ClientZkGroupCipher, + member: PendingMemberClass, + logId: string +) { + // addedByUserId + if (hasData(member.addedByUserId)) { + try { + member.addedByUserId = decryptUuid( + clientZkGroupCipher, + member.addedByUserId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptPendingMember/${logId}: Unable to decrypt pending member addedByUserId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + + window.normalizeUuids( + member, + ['addedByUserId'], + 'groups.decryptPendingMember' + ); + + if (!window.isValidGuid(member.addedByUserId)) { + window.log.warn( + `decryptPendingMember/${logId}: Dropping pending member due to invalid addedByUserId` + ); + return null; + } + } else { + throw new Error('decryptPendingMember: Member had missing addedByUserId'); + } + + // timestamp + if (member.timestamp) { + member.timestamp = member.timestamp.toNumber(); + + const now = Date.now(); + if (!member.timestamp || member.timestamp > now) { + member.timestamp = now; + } + } + + if (!member.member) { + window.log.warn( + `decryptPendingMember/${logId}: Dropping pending member due to missing member details` + ); + + return null; + } + + const { userId, profileKey, role } = member.member; + + // userId + if (hasData(userId)) { + try { + member.member.userId = decryptUuid( + clientZkGroupCipher, + userId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptPendingMember/${logId}: Unable to decrypt pending member userId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + + window.normalizeUuids( + member.member, + ['userId'], + 'groups.decryptPendingMember' + ); + + if (!window.isValidGuid(member.member.userId)) { + window.log.warn( + `decryptPendingMember/${logId}: Dropping pending member due to invalid member.userId` + ); + + return null; + } + } else { + throw new Error('decryptPendingMember: Member had missing member.userId'); + } + + // profileKey + if (hasData(profileKey)) { + try { + member.member.profileKey = decryptProfileKey( + clientZkGroupCipher, + profileKey.toArrayBuffer(), + userId + ); + } catch (error) { + window.log.warn( + `decryptPendingMember/${logId}: Unable to decrypt pending member profileKey. Dropping profileKey.`, + error && error.stack ? error.stack : error + ); + member.member.profileKey = null; + } + + if (!isValidProfileKey(member.member.profileKey)) { + window.log.warn( + `decryptPendingMember/${logId}: Dropping profileKey, since it was invalid` + ); + + member.member.profileKey = null; + } + } + + // role + if (!isValidRole(role)) { + throw new Error('decryptPendingMember: Member had invalid role'); + } + + return member; +} diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 807c6b4d6..23d84c94c 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -1,5 +1,6 @@ import * as Backbone from 'backbone'; +import { GroupV2ChangeType } from './groups'; import { LocalizerType } from './types/Util'; import { CallHistoryDetailsType } from './types/Calling'; import { ColorType } from './types/Colors'; @@ -26,7 +27,24 @@ type TaskResultType = any; type MessageAttributesType = { id: string; - serverTimestamp: number; + type?: string; + + expirationTimerUpdate?: { + expireTimer: number; + source?: string; + sourceUuid?: string; + }; + // Legacy fields for timer update notification only + flags?: number; + groupV2Change?: GroupV2ChangeType; + // Required. Used to sort messages in the database for the conversation timeline. + received_at?: number; + // More of a legacy feature, needed as we were updating the schema of messages in the + // background, when we were still in IndexedDB, before attachments had gone to disk + // We set this so that the idle message upgrade process doesn't pick this message up + schemaVersion: number; + serverTimestamp?: number; + sourceUuid?: string; }; declare class MessageModelType extends Backbone.Model { @@ -49,27 +67,71 @@ type ConversationTypeType = 'private' | 'group'; type ConversationAttributesType = { id: string; - uuid?: string; - e164?: string; + type: ConversationTypeType; + timestamp: number; + // Shared fields active_at?: number | null; draft?: string; - groupId?: string; isArchived?: boolean; lastMessage?: string; - members?: Array; + name?: string; needsStorageServiceSync?: boolean; needsVerification?: boolean; - profileFamilyName?: string | null; - profileKey?: string | null; - profileName?: string | null; profileSharing: boolean; storageID?: string; storageUnknownFields: string; - type: ConversationTypeType; unreadCount?: number; - verified?: number; version: number; + + // Private core info + uuid?: string; + e164?: string; + + // Private other fields + profileFamilyName?: string | null; + profileKey?: string | null; + profileName?: string | null; + verified?: number; + + // Group-only + groupId?: string; + left: boolean; + groupVersion?: number; + + // GroupV1 only + members?: Array; + + // GroupV2 core info + masterKey?: string; + secretParams?: string; + publicParams?: string; + revision?: number; + + // GroupV2 other fields + accessControl?: { + attributes: number; + members: number; + }; + avatar?: { + url: string; + path: string; + hash: string; + }; + expireTimer?: number; + membersV2?: Array; + pendingMembersV2?: Array; +}; + +export type GroupV2MemberType = { + conversationId: string; + role: number; + joinedAtVersion: number; +}; +export type GroupV2PendingMemberType = { + addedByUserId: string; + conversationId: string; + timestamp: number; }; type VerificationOptions = { @@ -113,6 +175,12 @@ export declare class ConversationModelType extends Backbone.Model< isMe(): boolean; isPrivate(): boolean; isVerified(): boolean; + maybeRepairGroupV2(data: { + masterKey: string; + secretParams: string; + publicParams: string; + }): void; + queueJob(job: () => Promise): Promise; safeGetVerified(): Promise; setArchived(isArchived: boolean): void; setProfileKey( diff --git a/ts/services/groupCredentialFetcher.ts b/ts/services/groupCredentialFetcher.ts new file mode 100644 index 000000000..c74b6f3dd --- /dev/null +++ b/ts/services/groupCredentialFetcher.ts @@ -0,0 +1,212 @@ +import { last, sortBy } from 'lodash'; +import { AuthCredentialResponse } from 'zkgroup'; + +import { + base64ToCompatArray, + compatArrayToBase64, + getClientZkAuthOperations, +} from '../util/zkgroup'; + +import { GroupCredentialType } from '../textsecure/WebAPI'; + +export const GROUP_CREDENTIALS_KEY = 'groupCredentials'; + +type CredentialsDataType = Array; +type RequestDatesType = { + startDay: number; + endDay: number; +}; +type NextCredentialsType = { + today: GroupCredentialType; + tomorrow: GroupCredentialType; +}; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function getTodayInEpoch() { + return Math.floor(Date.now() / DAY); +} + +async function sleep(ms: number) { + // tslint:disable-next-line no-string-based-set-timeout + return new Promise(resolve => setTimeout(resolve, ms)); +} + +let started = false; + +export async function initializeGroupCredentialFetcher(): Promise { + if (started) { + return; + } + + window.log.info('initializeGroupCredentialFetcher: starting...'); + started = true; + + // Because we fetch eight days of credentials at a time, we really only need to run + // this about once a week. But there's no problem running it more often; it will do + // nothing if no new credentials are needed, and will only request needed credentials. + await runWithRetry(maybeFetchNewCredentials, { scheduleAnother: 4 * HOUR }); +} + +type BackoffType = { + [key: number]: number | undefined; + max: number; +}; +const BACKOFF: BackoffType = { + 0: SECOND, + 1: 5 * SECOND, + 2: 30 * SECOND, + 3: 2 * MINUTE, + max: 5 * MINUTE, +}; + +export async function runWithRetry( + fn: () => Promise, + options: { scheduleAnother?: number } = {} +): Promise { + let count = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + count += 1; + // eslint-disable-next-line no-await-in-loop + await fn(); + return; + } catch (error) { + const wait = BACKOFF[count] || BACKOFF.max; + window.log.info( + `runWithRetry: ${fn.name} failed. Waiting ${wait}ms for retry. Error: ${error.stack}` + ); + // eslint-disable-next-line no-await-in-loop + await sleep(wait); + } + } + + // It's important to schedule our next run here instead of the level above; otherwise we + // could end up with multiple endlessly-retrying runs. + const duration = options.scheduleAnother; + if (duration) { + window.log.info( + `runWithRetry: scheduling another run with a setTimeout duration of ${duration}ms` + ); + setTimeout(async () => runWithRetry(fn, options), duration); + } +} + +// In cases where we are at a day boundary, we might need to use tomorrow in a retry +export function getCredentialsForToday( + data: CredentialsDataType | undefined +): NextCredentialsType { + if (!data) { + throw new Error('getCredentialsForToday: No credentials fetched!'); + } + + const todayInEpoch = getTodayInEpoch(); + const todayIndex = data.findIndex( + (item: GroupCredentialType) => item.redemptionTime === todayInEpoch + ); + if (todayIndex < 0) { + throw new Error( + 'getCredentialsForToday: Cannot find credentials for today' + ); + } + + return { + today: data[todayIndex], + tomorrow: data[todayIndex + 1], + }; +} + +export async function maybeFetchNewCredentials(): Promise { + const uuid = window.textsecure.storage.user.getUuid(); + if (!uuid) { + window.log.info('maybeFetchCredentials: no UUID, returning early'); + return; + } + const previous: CredentialsDataType | undefined = window.storage.get( + GROUP_CREDENTIALS_KEY + ); + const requestDates = getDatesForRequest(previous); + if (!requestDates) { + window.log.info('maybeFetchCredentials: no new credentials needed'); + return; + } + + const accountManager = window.getAccountManager(); + if (!accountManager) { + window.log.info('maybeFetchCredentials: unable to get AccountManager'); + return; + } + + const { startDay, endDay } = requestDates; + window.log.info( + `maybeFetchCredentials: fetching credentials for ${startDay} through ${endDay}` + ); + + const serverPublicParamsBase64 = window.getServerPublicParams(); + const clientZKAuthOperations = getClientZkAuthOperations( + serverPublicParamsBase64 + ); + const newCredentials = sortCredentials( + await accountManager.getGroupCredentials(startDay, endDay) + ).map((item: GroupCredentialType) => { + const authCredential = clientZKAuthOperations.receiveAuthCredential( + uuid, + item.redemptionTime, + new AuthCredentialResponse(base64ToCompatArray(item.credential)) + ); + const credential = compatArrayToBase64(authCredential.serialize()); + + return { + redemptionTime: item.redemptionTime, + credential, + }; + }); + + const todayInEpoch = getTodayInEpoch(); + const previousCleaned = previous + ? previous.filter( + (item: GroupCredentialType) => item.redemptionTime >= todayInEpoch + ) + : []; + const finalCredentials = [...previousCleaned, ...newCredentials]; + + window.log.info('maybeFetchCredentials: Saving new credentials...'); + // Note: we don't wait for this to finish + window.storage.put(GROUP_CREDENTIALS_KEY, finalCredentials); + window.log.info('maybeFetchCredentials: Save complete.'); +} + +export function getDatesForRequest( + data?: CredentialsDataType +): RequestDatesType | undefined { + const todayInEpoch = getTodayInEpoch(); + const oneWeekOut = todayInEpoch + 7; + + const lastCredential = last(data); + if (!lastCredential || lastCredential.redemptionTime < todayInEpoch) { + return { + startDay: todayInEpoch, + endDay: oneWeekOut, + }; + } + + if (lastCredential.redemptionTime >= oneWeekOut) { + return undefined; + } + + return { + startDay: lastCredential.redemptionTime + 1, + endDay: oneWeekOut, + }; +} + +export function sortCredentials( + data: CredentialsDataType +): CredentialsDataType { + return sortBy(data, (item: GroupCredentialType) => item.redemptionTime); +} diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 8f2592bb1..b3427677b 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -22,9 +22,11 @@ import { mergeAccountRecord, mergeContactRecord, mergeGroupV1Record, + mergeGroupV2Record, toAccountRecord, toContactRecord, toGroupV1Record, + toGroupV2Record, } from './storageRecordOps'; const { @@ -128,6 +130,11 @@ async function generateManifest( // eslint-disable-next-line no-await-in-loop storageRecord.contact = await toContactRecord(conversation); identifier.type = ITEM_TYPE.CONTACT; + } else if ((conversation.get('groupVersion') || 0) > 1) { + storageRecord = new window.textsecure.protobuf.StorageRecord(); + // eslint-disable-next-line no-await-in-loop + storageRecord.groupV1 = await toGroupV2Record(conversation); + identifier.type = ITEM_TYPE.GROUPV2; } else { storageRecord = new window.textsecure.protobuf.StorageRecord(); // eslint-disable-next-line no-await-in-loop @@ -389,7 +396,8 @@ async function fetchManifest( if (err.code === 404) { await createNewManifest(); return; - } else if (err.code === 204) { + } + if (err.code === 204) { // noNewerManifest we're ok return; } @@ -429,6 +437,12 @@ async function mergeRecord( hasConflict = await mergeContactRecord(storageID, storageRecord.contact); } else if (itemType === ITEM_TYPE.GROUPV1 && storageRecord.groupV1) { hasConflict = await mergeGroupV1Record(storageID, storageRecord.groupV1); + } else if ( + window.GV2 && + itemType === ITEM_TYPE.GROUPV2 && + storageRecord.groupV2 + ) { + hasConflict = await mergeGroupV2Record(storageID, storageRecord.groupV2); } else if (itemType === ITEM_TYPE.ACCOUNT && storageRecord.account) { hasConflict = await mergeAccountRecord(storageID, storageRecord.account); } else { @@ -592,9 +606,9 @@ async function processManifest( ); return true; - } else { - consecutiveConflicts = 0; } + + consecutiveConflicts = 0; } catch (err) { window.log.error( `storageService.processManifest: failed! ${ diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index f5b7ee13c..6c0f6ba1a 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -1,5 +1,5 @@ /* tslint:disable no-backbone-get-set-outside-model */ -import _ from 'lodash'; +import { isEqual, isNumber } from 'lodash'; import { arrayBufferToBase64, @@ -11,12 +11,18 @@ import { AccountRecordClass, ContactRecordClass, GroupV1RecordClass, + GroupV2RecordClass, } from '../textsecure.d'; +import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups'; import { ConversationModelType } from '../model-types.d'; const { updateConversation } = dataInterface; -type RecordClass = AccountRecordClass | ContactRecordClass | GroupV1RecordClass; +type RecordClass = + | AccountRecordClass + | ContactRecordClass + | GroupV1RecordClass + | GroupV2RecordClass; function toRecordVerified(verified: number): number { const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus; @@ -147,6 +153,24 @@ export async function toGroupV1Record( return groupV1Record; } +export async function toGroupV2Record( + conversation: ConversationModelType +): Promise { + const groupV2Record = new window.textsecure.protobuf.GroupV2Record(); + + const masterKey = conversation.get('masterKey'); + if (masterKey !== undefined) { + groupV2Record.masterKey = base64ToArrayBuffer(masterKey); + } + groupV2Record.blocked = conversation.isBlocked(); + groupV2Record.whitelisted = Boolean(conversation.get('profileSharing')); + groupV2Record.archived = Boolean(conversation.get('isArchived')); + + applyUnknownFields(groupV2Record, conversation); + + return groupV2Record; +} + type MessageRequestCapableRecord = ContactRecordClass | GroupV1RecordClass; function applyMessageRequestState( @@ -183,7 +207,7 @@ function doesRecordHavePendingChanges( ): boolean { const shouldSync = Boolean(conversation.get('needsStorageServiceSync')); - const hasConflict = !_.isEqual(mergedRecord, serviceRecord); + const hasConflict = !isEqual(mergedRecord, serviceRecord); if (shouldSync && !hasConflict) { conversation.set({ needsStorageServiceSync: false }); @@ -240,6 +264,81 @@ export async function mergeGroupV1Record( return hasPendingChanges; } +export async function mergeGroupV2Record( + storageID: string, + groupV2Record: GroupV2RecordClass +): Promise { + window.log.info(`storageService.mergeGroupV2Record: merging ${storageID}`); + + if (!groupV2Record.masterKey) { + window.log.info( + `storageService.mergeGroupV2Record: no master key for ${storageID}` + ); + return false; + } + + const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer(); + const groupFields = deriveGroupFields(masterKeyBuffer); + + const groupId = arrayBufferToBase64(groupFields.id); + const masterKey = arrayBufferToBase64(masterKeyBuffer); + const secretParams = arrayBufferToBase64(groupFields.secretParams); + const publicParams = arrayBufferToBase64(groupFields.publicParams); + + const now = Date.now(); + const conversationId = window.ConversationController.ensureGroup(groupId, { + // We want this conversation to show in the left pane when we first learn about it + active_at: now, + timestamp: now, + // Basic GroupV2 data + groupVersion: 2, + masterKey, + secretParams, + publicParams, + }); + const conversation = window.ConversationController.get(conversationId); + + if (!conversation) { + throw new Error( + `storageService.mergeGroupV2Record: No conversation for groupv2(${groupId})` + ); + } + + conversation.maybeRepairGroupV2({ + masterKey, + secretParams, + publicParams, + }); + + conversation.set({ + isArchived: Boolean(groupV2Record.archived), + storageID, + }); + + applyMessageRequestState(groupV2Record, conversation); + + addUnknownFields(groupV2Record, conversation); + + const hasPendingChanges = doesRecordHavePendingChanges( + await toGroupV2Record(conversation), + groupV2Record, + conversation + ); + + updateConversation(conversation.attributes); + + const isFirstSync = !isNumber(window.storage.get('manifestVersion')); + const dropInitialJoinMessage = isFirstSync; + // tslint:disable-next-line no-floating-promises + waitThenMaybeUpdateGroup({ + conversation, + dropInitialJoinMessage, + }); + window.log.info(`storageService.mergeGroupV2Record: merged ${storageID}`); + + return hasPendingChanges; +} + export async function mergeContactRecord( storageID: string, contactRecord: ContactRecordClass diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 89149e164..efbe06977 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -10,6 +10,7 @@ import { redactAll } from '../../js/modules/privacy'; import { remove as removeUserConfig } from '../../app/user_config'; import { combineNames } from '../util/combineNames'; +import { GroupV2MemberType } from '../model-types.d'; import { LocaleMessagesType } from '../types/I18N'; import pify from 'pify'; @@ -2070,6 +2071,7 @@ async function saveConversation( groupId, id, members, + membersV2, name, profileFamilyName, profileName, @@ -2077,6 +2079,13 @@ async function saveConversation( uuid, } = data; + // prettier-ignore + const membersList = membersV2 + ? membersV2.map((item: GroupV2MemberType) => item.conversationId).join(' ') + : members + ? members.join(' ') + : null; + await instance.run( `INSERT INTO conversations ( id, @@ -2119,7 +2128,7 @@ async function saveConversation( $active_at: active_at, $type: type, - $members: members ? members.join(' ') : null, + $members: membersList, $name: name, $profileName: profileName, $profileFamilyName: profileFamilyName, @@ -2156,6 +2165,7 @@ async function updateConversation(data: ConversationType) { active_at, type, members, + membersV2, name, profileName, profileFamilyName, @@ -2163,6 +2173,13 @@ async function updateConversation(data: ConversationType) { uuid, } = data; + // prettier-ignore + const membersList = membersV2 + ? membersV2.map((item: GroupV2MemberType) => item.conversationId).join(' ') + : members + ? members.join(' ') + : null; + await db.run( `UPDATE conversations SET json = $json, @@ -2187,7 +2204,7 @@ async function updateConversation(data: ConversationType) { $active_at: active_at, $type: type, - $members: members ? members.join(' ') : null, + $members: membersList, $name: name, $profileName: profileName, $profileFamilyName: profileFamilyName, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 8f9a2ad97..c11771284 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -1009,6 +1009,19 @@ export function reducer( id ); + let metrics; + if (messageIds.length === 0) { + metrics = { + totalUnread: 0, + }; + } else { + metrics = { + ...existingConversation.metrics, + oldest, + newest, + }; + } + return { ...state, messagesLookup: omit(messagesLookup, id), @@ -1017,11 +1030,7 @@ export function reducer( ...existingConversation, messageIds, heightChangeMessageIds, - metrics: { - ...existingConversation.metrics, - oldest, - newest, - }, + metrics, }, }, }; diff --git a/ts/state/smart/ContactName.tsx b/ts/state/smart/ContactName.tsx new file mode 100644 index 000000000..67f4b85a3 --- /dev/null +++ b/ts/state/smart/ContactName.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { useSelector } from 'react-redux'; +import { StateType } from '../reducer'; + +import { ContactName } from '../../components/conversation/ContactName'; + +import { getIntl } from '../selectors/user'; +import { + GetConversationByIdType, + getConversationSelector, +} from '../selectors/conversations'; + +import { LocalizerType } from '../../types/Util'; + +type ExternalProps = { + conversationId: string; +}; + +export const SmartContactName = (props: ExternalProps) => { + const { conversationId } = props; + const i18n = useSelector(getIntl); + const getConversation = useSelector( + getConversationSelector + ); + + const conversation = getConversation(conversationId); + if (!conversation) { + throw new Error(`Conversation id ${conversationId} not found!`); + } + + return ; +}; diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx index 7b007576f..cf0b26286 100644 --- a/ts/state/smart/TimelineItem.tsx +++ b/ts/state/smart/TimelineItem.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; @@ -10,11 +11,21 @@ import { getSelectedMessage, } from '../selectors/conversations'; +import { SmartContactName } from './ContactName'; + type ExternalProps = { id: string; conversationId: string; }; +// Workaround: A react component's required properties are filtering up through connect() +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 +const FilteredSmartContactName = SmartContactName as any; + +function renderContact(conversationId: string): JSX.Element { + return ; +} + const mapStateToProps = (state: StateType, props: ExternalProps) => { const { id, conversationId } = props; @@ -29,6 +40,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { id, conversationId, isSelected, + renderContact, i18n: getIntl(state), }; }; diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 0abe620e4..bb5de6b4b 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -6,6 +6,7 @@ import { } from './libsignal.d'; import Crypto from './textsecure/Crypto'; import MessageReceiver from './textsecure/MessageReceiver'; +import MessageSender from './textsecure/SendMessage'; import EventTarget from './textsecure/EventTarget'; import { ByteBufferClass } from './window.d'; import SendMessage, { SendOptionsType } from './textsecure/SendMessage'; @@ -75,11 +76,7 @@ export type TextSecureType = { remove: (key: string | Array) => Promise; protocol: StorageProtocolType; }; - messageReceiver: { - downloadAttachment: ( - attachment: AttachmentPointerClass - ) => Promise; - }; + messageReceiver: MessageReceiver; messaging?: SendMessage; protobuf: ProtobufCollectionType; utils: typeof utils; @@ -145,7 +142,44 @@ export type StorageProtocolType = StorageType & { // Protobufs -type StorageServiceProtobufTypes = { +type DeviceMessagesProtobufTypes = { + ProvisioningUuid: typeof ProvisioningUuidClass; + ProvisionEnvelope: typeof ProvisionEnvelopeClass; + ProvisionMessage: typeof ProvisionMessageClass; +}; + +type DeviceNameProtobufTypes = { + DeviceName: typeof DeviceNameClass; +}; + +type GroupsProtobufTypes = { + AvatarUploadAttributes: typeof AvatarUploadAttributesClass; + Member: typeof MemberClass; + PendingMember: typeof PendingMemberClass; + AccessControl: typeof AccessControlClass; + Group: typeof GroupClass; + GroupChange: typeof GroupChangeClass; + GroupChanges: typeof GroupChangesClass; + GroupAttributeBlob: typeof GroupAttributeBlobClass; +}; + +type SignalServiceProtobufTypes = { + AttachmentPointer: typeof AttachmentPointerClass; + ContactDetails: typeof ContactDetailsClass; + Content: typeof ContentClass; + DataMessage: typeof DataMessageClass; + Envelope: typeof EnvelopeClass; + GroupContext: typeof GroupContextClass; + GroupContextV2: typeof GroupContextV2Class; + GroupDetails: typeof GroupDetailsClass; + NullMessage: typeof NullMessageClass; + ReceiptMessage: typeof ReceiptMessageClass; + SyncMessage: typeof SyncMessageClass; + TypingMessage: typeof TypingMessageClass; + Verified: typeof VerifiedClass; +}; + +type SignalStorageProtobufTypes = { AccountRecord: typeof AccountRecordClass; ContactRecord: typeof ContactRecordClass; GroupV1Record: typeof GroupV1RecordClass; @@ -159,35 +193,252 @@ type StorageServiceProtobufTypes = { WriteOperation: typeof WriteOperationClass; }; -type ProtobufCollectionType = StorageServiceProtobufTypes & { - AttachmentPointer: typeof AttachmentPointerClass; - ContactDetails: typeof ContactDetailsClass; - Content: typeof ContentClass; - DataMessage: typeof DataMessageClass; - DeviceName: typeof DeviceNameClass; - Envelope: typeof EnvelopeClass; - GroupContext: typeof GroupContextClass; - GroupContextV2: typeof GroupContextV2Class; - GroupDetails: typeof GroupDetailsClass; - NullMessage: typeof NullMessageClass; - ProvisioningUuid: typeof ProvisioningUuidClass; - ProvisionEnvelope: typeof ProvisionEnvelopeClass; - ProvisionMessage: typeof ProvisionMessageClass; - ReceiptMessage: typeof ReceiptMessageClass; - SyncMessage: typeof SyncMessageClass; - TypingMessage: typeof TypingMessageClass; - Verified: typeof VerifiedClass; +type SubProtocolProtobufTypes = { WebSocketMessage: typeof WebSocketMessageClass; WebSocketRequestMessage: typeof WebSocketRequestMessageClass; WebSocketResponseMessage: typeof WebSocketResponseMessageClass; }; +type ProtobufCollectionType = DeviceMessagesProtobufTypes & + DeviceNameProtobufTypes & + GroupsProtobufTypes & + SignalServiceProtobufTypes & + SignalStorageProtobufTypes & + SubProtocolProtobufTypes; + // Note: there are a lot of places in the code that overwrite a field like this // with a type that the app can use. Being more rigorous with these // types would require code changes, out of scope for now. type ProtoBinaryType = any; type ProtoBigNumberType = any; +// Groups.proto + +export declare class AvatarUploadAttributesClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => AvatarUploadAttributesClass; + + key?: string; + credential?: string; + acl?: string; + algorithm?: string; + date?: string; + policy?: string; + signature?: string; +} + +export declare class MemberClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => MemberClass; + + userId?: ProtoBinaryType; + role?: MemberRoleEnum; + profileKey?: ProtoBinaryType; + presentation?: ProtoBinaryType; + joinedAtVersion?: number; +} + +type MemberRoleEnum = number; + +// Note: we need to use namespaces to express nested classes in Typescript +export declare namespace MemberClass { + class Role { + static UNKNOWN: number; + static DEFAULT: number; + static ADMINISTRATOR: number; + } +} + +export declare class PendingMemberClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => PendingMemberClass; + + member?: MemberClass; + addedByUserId?: ProtoBinaryType; + timestamp?: ProtoBigNumberType; +} + +type AccessRequiredEnum = number; + +export declare class AccessControlClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => AccessControlClass; + + attributes?: AccessRequiredEnum; + members?: AccessRequiredEnum; +} + +// Note: we need to use namespaces to express nested classes in Typescript +export declare namespace AccessControlClass { + class AccessRequired { + static UNKNOWN: number; + static MEMBER: number; + static ADMINISTRATOR: number; + } +} + +export declare class GroupClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => GroupClass; + toArrayBuffer: () => ArrayBuffer; + + publicKey?: ProtoBinaryType; + title?: ProtoBinaryType; + avatar?: string; + disappearingMessagesTimer?: ProtoBinaryType; + accessControl?: AccessControlClass; + version?: number; + members?: Array; + pendingMembers?: Array; +} + +export declare class GroupChangeClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => GroupChangeClass; + + actions?: ProtoBinaryType; + serverSignature?: ProtoBinaryType; +} + +// Note: we need to use namespaces to express nested classes in Typescript +export declare namespace GroupChangeClass { + class Actions { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => Actions; + toArrayBuffer: () => ArrayBuffer; + + sourceUuid?: ProtoBinaryType; + version?: number; + addMembers?: Array; + deleteMembers?: Array; + modifyMemberRoles?: Array; + modifyMemberProfileKeys?: Array< + GroupChangeClass.Actions.ModifyMemberProfileKeyAction + >; + addPendingMembers?: Array; + deletePendingMembers?: Array< + GroupChangeClass.Actions.DeletePendingMemberAction + >; + promotePendingMembers?: Array< + GroupChangeClass.Actions.PromotePendingMemberAction + >; + modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction; + modifyAvatar?: GroupChangeClass.Actions.ModifyAvatarAction; + modifyDisappearingMessagesTimer?: GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction; + modifyAttributesAccess?: GroupChangeClass.Actions.ModifyAttributesAccessControlAction; + modifyMemberAccess?: GroupChangeClass.Actions.ModifyMembersAccessControlAction; + } +} + +// Note: we need to use namespaces to express nested classes in Typescript +export declare namespace GroupChangeClass.Actions { + class AddMemberAction { + added?: MemberClass; + } + + class DeleteMemberAction { + deletedUserId?: ProtoBinaryType; + } + + class ModifyMemberRoleAction { + userId?: ProtoBinaryType; + role?: MemberRoleEnum; + } + + class ModifyMemberProfileKeyAction { + presentation?: ProtoBinaryType; + + // The result of decryption + profileKey: ArrayBuffer; + uuid: string; + } + + class AddPendingMemberAction { + added?: PendingMemberClass; + } + + class DeletePendingMemberAction { + deletedUserId?: ProtoBinaryType; + } + + class PromotePendingMemberAction { + presentation?: ProtoBinaryType; + + // The result of decryption + profileKey: ArrayBuffer; + uuid: string; + } + + class ModifyTitleAction { + title?: ProtoBinaryType; + } + + class ModifyAvatarAction { + avatar?: string; + } + + class ModifyDisappearingMessagesTimerAction { + timer?: ProtoBinaryType; + } + + class ModifyAttributesAccessControlAction { + attributesAccess?: AccessRequiredEnum; + } + + class ModifyMembersAccessControlAction { + membersAccess?: AccessRequiredEnum; + } +} + +export declare class GroupChangesClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => GroupChangesClass; + + groupChanges?: Array; +} + +// Note: we need to use namespaces to express nested classes in Typescript +export declare namespace GroupChangesClass { + class GroupChangeState { + groupChange?: GroupChangeClass; + groupState?: GroupClass; + } +} + +export declare class GroupAttributeBlobClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => GroupAttributeBlobClass; + toArrayBuffer(): ArrayBuffer; + + title?: string; + avatar?: ProtoBinaryType; + disappearingMessagesDuration?: number; + + // Note: this isn't part of the proto, but our protobuf library tells us which + // field has been set with this prop. + content: 'title' | 'avatar' | 'disappearingMessagesDuration'; +} + +// Previous protos + export declare class AttachmentPointerClass { static decode: ( data: ArrayBuffer | ByteBufferClass, @@ -435,6 +686,11 @@ export declare class GroupContextV2Class { masterKey?: ProtoBinaryType; revision?: number; groupChange?: ProtoBinaryType; + + // Note: these additional properties are added in the course of processing + id?: string; + secretParams?: string; + publicParams?: string; } // Note: we need to use namespaces to express nested classes in Typescript @@ -674,7 +930,7 @@ export declare class GroupV2RecordClass { ) => GroupV2RecordClass; toArrayBuffer: () => ArrayBuffer; - masterKey?: ByteBufferClass | null; + masterKey?: ProtoBinaryType | null; blocked?: boolean | null; whitelisted?: boolean | null; archived?: boolean | null; @@ -754,6 +1010,7 @@ export declare namespace SyncMessageClass { unidentifiedDeliveryIndicators?: boolean; typingIndicators?: boolean; linkPreviews?: boolean; + provisioningVersion?: number; } class Contacts { blob?: AttachmentPointerClass; diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 32cd6eb6c..1a66c8e60 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -610,6 +610,11 @@ export default class AccountManager extends EventTarget { store.clearSessionStore(), ]); } + + async getGroupCredentials(startDay: number, endDay: number) { + return this.server.getGroupCredentials(startDay, endDay); + } + // Takes the same object returned by generateKeys async confirmKeys(keys: GeneratedKeysType) { const store = window.textsecure.storage.protocol; diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index a9e2ee0b8..773178846 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -30,6 +30,8 @@ import { VerifiedClass, } from '../textsecure.d'; +import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups'; + const RETRY_TIMEOUT = 2 * 60 * 1000; declare global { @@ -435,6 +437,9 @@ class MessageReceiverInner extends EventTarget { return promise; } + hasEmptied(): boolean { + return Boolean(this.isEmptied); + } onEmpty() { const emitEmpty = () => { window.log.info("MessageReceiver: emitting 'empty' event"); @@ -1070,14 +1075,6 @@ class MessageReceiverInner extends EventTarget { throw new Error('MessageReceiver.handleSentMessage: message was falsey!'); } - if (msg.groupV2) { - window.log.warn( - 'MessageReceiver.handleSentMessage: Dropping GroupsV2 message' - ); - this.removeFromCache(envelope); - return; - } - let p: Promise = Promise.resolve(); // eslint-disable-next-line no-bitwise if ( @@ -1094,7 +1091,8 @@ class MessageReceiverInner extends EventTarget { } return p.then(async () => this.processDecrypted(envelope, msg).then(message => { - const groupId = message.group && message.group.id; + // prettier-ignore + const groupId = this.getGroupId(message); const isBlocked = this.isGroupBlocked(groupId); const { source, sourceUuid } = envelope; const ourE164 = window.textsecure.storage.user.getNumber(); @@ -1103,7 +1101,8 @@ class MessageReceiverInner extends EventTarget { (source && ourE164 && source === ourE164) || (sourceUuid && ourUuid && sourceUuid === ourUuid); const isLeavingGroup = Boolean( - message.group && + !message.groupV2 && + message.group && message.group.type === window.textsecure.protobuf.GroupContext.Type.QUIT ); @@ -1148,14 +1147,16 @@ class MessageReceiverInner extends EventTarget { ); } - if (msg.groupV2) { - window.log.warn( - 'MessageReceiver.handleDataMessage: Dropping GroupsV2 message' - ); + if (!window.GV2 && msg.groupV2) { this.removeFromCache(envelope); + window.log.info( + 'MessageReceiver.handleDataMessage: dropping GroupV2 message' + ); return; } + this.deriveGroupsV2Data(msg); + if ( msg.flags && msg.flags & window.textsecure.protobuf.DataMessage.Flags.END_SESSION @@ -1180,7 +1181,8 @@ class MessageReceiverInner extends EventTarget { return p.then(async () => this.processDecrypted(envelope, msg).then(message => { - const groupId = message.group && message.group.id; + // prettier-ignore + const groupId = this.getGroupId(message); const isBlocked = this.isGroupBlocked(groupId); const { source, sourceUuid } = envelope; const ourE164 = window.textsecure.storage.user.getNumber(); @@ -1189,7 +1191,8 @@ class MessageReceiverInner extends EventTarget { (source && ourE164 && source === ourE164) || (sourceUuid && ourUuid && sourceUuid === ourUuid); const isLeavingGroup = Boolean( - message.group && + !message.groupV2 && + message.group && message.group.type === window.textsecure.protobuf.GroupContext.Type.QUIT ); @@ -1336,23 +1339,26 @@ class MessageReceiverInner extends EventTarget { } } + const { groupId, timestamp, action } = typingMessage; + ev.sender = envelope.source; ev.senderUuid = envelope.sourceUuid; ev.senderDevice = envelope.sourceDevice; ev.typing = { typingMessage, - timestamp: typingMessage.timestamp - ? typingMessage.timestamp.toNumber() - : Date.now(), - groupId: typingMessage.groupId - ? typingMessage.groupId.toString('binary') - : null, + timestamp: timestamp ? timestamp.toNumber() : Date.now(), + groupId: + groupId && groupId.buffer.byteLength < 45 + ? groupId.toString('binary') + : null, + groupV2Id: + groupId && groupId.buffer.byteLength >= 45 + ? groupId.toString('base64') + : null, started: - typingMessage.action === - window.textsecure.protobuf.TypingMessage.Action.STARTED, + action === window.textsecure.protobuf.TypingMessage.Action.STARTED, stopped: - typingMessage.action === - window.textsecure.protobuf.TypingMessage.Action.STOPPED, + action === window.textsecure.protobuf.TypingMessage.Action.STOPPED, }; return this.dispatchEvent(ev); @@ -1362,6 +1368,60 @@ class MessageReceiverInner extends EventTarget { this.removeFromCache(envelope); } + deriveGroupsV2Data(message: DataMessageClass) { + const { groupV2 } = message; + + if (!groupV2) { + return; + } + + if (!isNumber(groupV2.revision)) { + throw new Error('deriveGroupsV2Data: revision was not a number'); + } + if (!groupV2.masterKey) { + throw new Error('deriveGroupsV2Data: had falsey masterKey'); + } + + const toBase64 = MessageReceiverInner.arrayBufferToStringBase64; + const masterKey: ArrayBuffer = groupV2.masterKey.toArrayBuffer(); + const length = masterKey.byteLength; + if (length !== MASTER_KEY_LENGTH) { + throw new Error( + `deriveGroupsV2Data: masterKey had length ${length}, expected ${MASTER_KEY_LENGTH}` + ); + } + + const fields = deriveGroupFields(masterKey); + groupV2.masterKey = toBase64(masterKey); + groupV2.secretParams = toBase64(fields.secretParams); + groupV2.publicParams = toBase64(fields.publicParams); + groupV2.id = toBase64(fields.id); + + if (groupV2.groupChange) { + groupV2.groupChange = groupV2.groupChange.toString('base64'); + } + } + getGroupId(message: DataMessageClass) { + if (message.groupV2) { + return message.groupV2.id; + } + if (message.group) { + return message.group.id.toString('binary'); + } + + return null; + } + + getDestination(sentMessage: SyncMessageClass.Sent) { + if (sentMessage.message && sentMessage.message.groupV2) { + return `groupv2(${sentMessage.message.groupV2.id})`; + } else if (sentMessage.message && sentMessage.message.group) { + return `group(${sentMessage.message.group.id.toBinary()})`; + } else { + return sentMessage.destination || sentMessage.destinationUuid; + } + } + // tslint:disable-next-line cyclomatic-complexity async handleSyncMessage( envelope: EnvelopeClass, @@ -1399,13 +1459,20 @@ class MessageReceiverInner extends EventTarget { 'MessageReceiver.handleSyncMessage: sync sent message was missing message' ); } - const to = sentMessage.message.group - ? `group(${sentMessage.message.group.id.toBinary()})` - : sentMessage.destination || sentMessage.destinationUuid; + + if (!window.GV2 && sentMessage.message.groupV2) { + this.removeFromCache(envelope); + window.log.info( + 'MessageReceiver.handleSyncMessage: dropping GroupV2 message' + ); + return; + } + + this.deriveGroupsV2Data(sentMessage.message); window.log.info( 'sent message to', - to, + this.getDestination(sentMessage), sentMessage.timestamp.toNumber(), 'from', this.getEnvelopeId(envelope) @@ -1939,6 +2006,13 @@ class MessageReceiverInner extends EventTarget { } } + const { reaction } = decrypted; + if (reaction) { + if (reaction.targetTimestamp) { + reaction.targetTimestamp = reaction.targetTimestamp.toNumber(); + } + } + return Promise.resolve(decrypted); /* eslint-enable no-bitwise, no-param-reassign */ } @@ -1964,11 +2038,11 @@ export default class MessageReceiver { ); this.addEventListener = inner.addEventListener.bind(inner); - this.removeEventListener = inner.removeEventListener.bind(inner); - this.getStatus = inner.getStatus.bind(inner); this.close = inner.close.bind(inner); - this.downloadAttachment = inner.downloadAttachment.bind(inner); + this.getStatus = inner.getStatus.bind(inner); + this.hasEmptied = inner.hasEmptied.bind(inner); + this.removeEventListener = inner.removeEventListener.bind(inner); this.stopProcessing = inner.stopProcessing.bind(inner); this.unregisterBatchers = inner.unregisterBatchers.bind(inner); @@ -1976,12 +2050,13 @@ export default class MessageReceiver { } addEventListener: (name: string, handler: Function) => void; - removeEventListener: (name: string, handler: Function) => void; - getStatus: () => number; close: () => Promise; downloadAttachment: ( attachment: AttachmentPointerClass ) => Promise; + getStatus: () => number; + hasEmptied: () => boolean; + removeEventListener: (name: string, handler: Function) => void; stopProcessing: () => Promise; unregisterBatchers: () => void; diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index c05529d31..aaf6dc313 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -2,6 +2,7 @@ import { reject } from 'lodash'; import { ServerKeysType, WebAPIType } from './WebAPI'; +import { isEnabled as isRemoteFlagEnabled } from '../RemoteConfig'; import { SignalProtocolAddressClass } from '../libsignal.d'; import { ContentClass, DataMessageClass } from '../textsecure.d'; import { @@ -574,34 +575,36 @@ export default class OutgoingMessage { async sendToIdentifier(providedIdentifier: string) { let identifier = providedIdentifier; try { - if (window.isValidGuid(identifier)) { - // We're good! - } else if (isValidNumber(identifier)) { - if (!window.textsecure.messaging) { - throw new Error( - 'sendToIdentifier: window.textsecure.messaging is not available!' - ); - } - const lookup = await window.textsecure.messaging.getUuidsForE164s([ - identifier, - ]); - const uuid = lookup[identifier]; - if (uuid) { - this.discoveredIdentifierPairs.push({ - uuid, - e164: identifier, - }); - identifier = uuid; - } else { - throw new UnregisteredUserError( + if (isRemoteFlagEnabled('desktop.cds')) { + if (window.isValidGuid(identifier)) { + // We're good! + } else if (isValidNumber(identifier)) { + if (!window.textsecure.messaging) { + throw new Error( + 'sendToIdentifier: window.textsecure.messaging is not available!' + ); + } + const lookup = await window.textsecure.messaging.getUuidsForE164s([ identifier, - new Error('User is not registered') + ]); + const uuid = lookup[identifier]; + if (uuid) { + this.discoveredIdentifierPairs.push({ + uuid, + e164: identifier, + }); + identifier = uuid; + } else { + throw new UnregisteredUserError( + identifier, + new Error('User is not registered') + ); + } + } else { + throw new Error( + `sendToIdentifier: identifier ${identifier} was neither a UUID or E164` ); } - } else { - throw new Error( - `sendToIdentifier: identifier ${identifier} was neither a UUID or E164` - ); } const updateDevices = await this.getStaleDeviceIdsForIdentifier( diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 62df4109f..0646cec13 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1,17 +1,31 @@ // tslint:disable no-bitwise no-default-export -import { without } from 'lodash'; +import { Dictionary, without } from 'lodash'; import PQueue from 'p-queue'; -import { ProxiedRequestOptionsType, WebAPIType } from './WebAPI'; +import { + GroupCredentialsType, + GroupLogResponseType, + ProxiedRequestOptionsType, + WebAPIType, +} from './WebAPI'; import createTaskWithTimeout from './TaskWithTimeout'; import OutgoingMessage from './OutgoingMessage'; import Crypto from './Crypto'; +import { + base64ToArrayBuffer, + concatenateBytes, + fromEncodedBinaryToArrayBuffer, + getZeroes, + hexToArrayBuffer, +} from '../Crypto'; import { AttachmentPointerClass, CallingMessageClass, ContentClass, DataMessageClass, + GroupChangeClass, + GroupClass, StorageServiceCallOptionsType, StorageServiceCredentials, } from '../textsecure.d'; @@ -28,12 +42,6 @@ function stringToArrayBuffer(str: string): ArrayBuffer { } return res; } -function hexStringToArrayBuffer(string: string): ArrayBuffer { - return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer(); -} -function base64ToArrayBuffer(string: string): ArrayBuffer { - return window.dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer(); -} export type SendMetadataType = { [identifier: string]: { @@ -70,6 +78,17 @@ type QuoteAttachmentType = { attachmentPointer?: AttachmentPointerClass; }; +type GroupV2InfoType = { + groupChange?: ArrayBuffer; + masterKey: ArrayBuffer; + revision: number; + members: Array; +}; +type GroupV1InfoType = { + id: string; + members: Array; +}; + type MessageOptionsType = { attachments?: Array | null; body?: string; @@ -79,6 +98,7 @@ type MessageOptionsType = { id: string; type: number; }; + groupV2?: GroupV2InfoType; needsSync?: boolean; preview?: Array | null; profileKey?: ArrayBuffer; @@ -98,6 +118,7 @@ class Message { id: string; type: number; }; + groupV2?: GroupV2InfoType; needsSync?: boolean; preview: any; profileKey?: ArrayBuffer; @@ -117,6 +138,7 @@ class Message { this.expireTimer = options.expireTimer; this.flags = options.flags; this.group = options.group; + this.groupV2 = options.groupV2; this.needsSync = options.needsSync; this.preview = options.preview; this.profileKey = options.profileKey; @@ -130,7 +152,7 @@ class Message { throw new Error('Invalid recipient list'); } - if (!this.group && this.recipients.length !== 1) { + if (!this.group && !this.groupV2 && this.recipients.length !== 1) { throw new Error('Invalid recipient list for non-group'); } @@ -202,14 +224,19 @@ class Message { if (this.flags) { proto.flags = this.flags; } - if (this.group) { + if (this.groupV2) { + proto.groupV2 = new window.textsecure.protobuf.GroupContextV2(); + proto.groupV2.masterKey = this.groupV2.masterKey; + proto.groupV2.revision = this.groupV2.revision; + proto.groupV2.groupChange = this.groupV2.groupChange || null; + } else if (this.group) { proto.group = new window.textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(this.group.id); proto.group.type = this.group.type; } if (this.sticker) { proto.sticker = new window.textsecure.protobuf.DataMessage.Sticker(); - proto.sticker.packId = hexStringToArrayBuffer(this.sticker.packId); + proto.sticker.packId = hexToArrayBuffer(this.sticker.packId); proto.sticker.packKey = base64ToArrayBuffer(this.sticker.packKey); proto.sticker.stickerId = this.sticker.stickerId; @@ -306,9 +333,9 @@ export default class MessageSender { getPaddedAttachment(data: ArrayBuffer) { const size = data.byteLength; const paddedSize = this._getAttachmentSizeBucket(size); - const padding = window.Signal.Crypto.getZeroes(paddedSize - size); + const padding = getZeroes(paddedSize - size); - return window.Signal.Crypto.concatenateBytes(data, padding); + return concatenateBytes(data, padding); } async makeAttachmentPointer(attachment: AttachmentType) { @@ -704,7 +731,9 @@ export default class MessageSender { return this.server.getProfile(number, options); } - async getUuidsForE164s(numbers: Array) { + async getUuidsForE164s( + numbers: Array + ): Promise> { return this.server.getUuidsForE164s(numbers); } @@ -882,14 +911,14 @@ export default class MessageSender { options: { recipientId: string; groupId: string; - groupNumbers: Array; + groupMembers: Array; isTyping: boolean; timestamp: number; }, sendOptions: SendOptionsType = {} ) { const ACTION_ENUM = window.textsecure.protobuf.TypingMessage.Action; - const { recipientId, groupId, groupNumbers, isTyping, timestamp } = options; + const { recipientId, groupId, groupMembers, isTyping, timestamp } = options; // We don't want to send typing messages to our other devices, but we will // in the group case. @@ -904,10 +933,10 @@ export default class MessageSender { } const recipients = groupId - ? (without(groupNumbers, myNumber, myUuid) as Array) + ? (without(groupMembers, myNumber, myUuid) as Array) : [recipientId]; const groupIdBuffer = groupId - ? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(groupId) + ? fromEncodedBinaryToArrayBuffer(groupId) : null; const action = isTyping ? ACTION_ENUM.STARTED : ACTION_ENUM.STOPPED; @@ -1175,7 +1204,7 @@ export default class MessageSender { const { packId, packKey, installed } = item; const operation = new window.textsecure.protobuf.SyncMessage.StickerPackOperation(); - operation.packId = hexStringToArrayBuffer(packId); + operation.packId = hexToArrayBuffer(packId); operation.packKey = base64ToArrayBuffer(packKey); operation.type = installed ? ENUM.INSTALL : ENUM.REMOVE; @@ -1466,21 +1495,48 @@ export default class MessageSender { } async sendMessageToGroup( - groupId: string, - recipients: Array, - messageText: string, - attachments: Array, - quote: any, - preview: any, - sticker: any, - reaction: any, - timestamp: number, - expireTimer: number | undefined, - profileKey?: ArrayBuffer, + { + attachments, + expireTimer, + groupV2, + groupV1, + messageText, + preview, + profileKey, + quote, + reaction, + sticker, + timestamp, + }: { + attachments?: Array; + expireTimer?: number; + groupV2?: GroupV2InfoType; + groupV1?: GroupV1InfoType; + messageText?: string; + preview?: any; + profileKey?: ArrayBuffer; + quote?: any; + reaction?: any; + sticker?: any; + timestamp: number; + }, options?: SendOptionsType ): Promise { + if (!groupV1 && !groupV2) { + throw new Error( + 'sendMessageToGroup: Neither group1 nor groupv2 information provided!' + ); + } + const myE164 = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getNumber(); + // prettier-ignore + const recipients = groupV2 + ? groupV2.members + : groupV1 + ? groupV1.members + : []; + const attrs = { recipients: recipients.filter(r => r !== myE164 && r !== myUuid), body: messageText, @@ -1492,10 +1548,13 @@ export default class MessageSender { reaction, expireTimer, profileKey, - group: { - id: groupId, - type: window.textsecure.protobuf.GroupContext.Type.DELIVER, - }, + groupV2, + group: groupV1 + ? { + id: groupV1.id, + type: window.textsecure.protobuf.GroupContext.Type.DELIVER, + } + : undefined, }; if (recipients.length === 0) { @@ -1512,138 +1571,25 @@ export default class MessageSender { return this.sendMessage(attrs, options); } - async createGroup( - targetIdentifiers: Array, - id: string, - name: string, - avatar: AttachmentType, - options?: SendOptionsType - ) { - const proto = new window.textsecure.protobuf.DataMessage(); - proto.group = new window.textsecure.protobuf.GroupContext(); - proto.group.id = stringToArrayBuffer(id); - - proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.membersE164 = targetIdentifiers; - proto.group.name = name; - - return this.makeAttachmentPointer(avatar).then(async attachment => { - if (!proto.group) { - throw new Error('createGroup: proto.group was set to null'); - } - proto.group.avatar = attachment; - return this.sendGroupProto( - targetIdentifiers, - proto, - Date.now(), - options - ).then(() => { - if (!proto.group) { - throw new Error('createGroup: proto.group was set to null'); - } - - return proto.group.id; - }); - }); + async getGroup(options: GroupCredentialsType): Promise { + return this.server.getGroup(options); + } + async getGroupLog( + startVersion: number, + options: GroupCredentialsType + ): Promise { + return this.server.getGroupLog(startVersion, options); + } + async getGroupAvatar(key: string): Promise { + return this.server.getGroupAvatar(key); + } + async modifyGroup( + changes: GroupChangeClass.Actions, + options: GroupCredentialsType + ): Promise { + return this.server.modifyGroup(changes, options); } - async updateGroup( - groupId: string, - name: string, - avatar: AttachmentType, - targetIdentifiers: Array, - options?: SendOptionsType - ) { - const proto = new window.textsecure.protobuf.DataMessage(); - proto.group = new window.textsecure.protobuf.GroupContext(); - - proto.group.id = stringToArrayBuffer(groupId); - proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.name = name; - proto.group.membersE164 = targetIdentifiers; - - return this.makeAttachmentPointer(avatar).then(async attachment => { - if (!proto.group) { - throw new Error('updateGroup: proto.group was set to null'); - } - - proto.group.avatar = attachment; - return this.sendGroupProto( - targetIdentifiers, - proto, - Date.now(), - options - ).then(() => { - if (!proto.group) { - throw new Error('updateGroup: proto.group was set to null'); - } - return proto.group.id; - }); - }); - } - - async addIdentifierToGroup( - groupId: string, - newIdentifiers: Array, - options: SendOptionsType - ) { - const proto = new window.textsecure.protobuf.DataMessage(); - proto.group = new window.textsecure.protobuf.GroupContext(); - proto.group.id = stringToArrayBuffer(groupId); - proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.membersE164 = newIdentifiers; - return this.sendGroupProto(newIdentifiers, proto, Date.now(), options); - } - - async setGroupName( - groupId: string, - name: string, - groupIdentifiers: Array, - options: SendOptionsType - ) { - const proto = new window.textsecure.protobuf.DataMessage(); - proto.group = new window.textsecure.protobuf.GroupContext(); - proto.group.id = stringToArrayBuffer(groupId); - proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.name = name; - proto.group.membersE164 = groupIdentifiers; - - return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options); - } - - async setGroupAvatar( - groupId: string, - avatar: AttachmentType, - groupIdentifiers: Array, - options: SendOptionsType - ) { - const proto = new window.textsecure.protobuf.DataMessage(); - proto.group = new window.textsecure.protobuf.GroupContext(); - proto.group.id = stringToArrayBuffer(groupId); - proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.membersE164 = groupIdentifiers; - - return this.makeAttachmentPointer(avatar).then(async attachment => { - if (!proto.group) { - throw new Error('setGroupAvatar: proto.group was set to null'); - } - - proto.group.avatar = attachment; - return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options); - }); - } - - async leaveGroup( - groupId: string, - groupIdentifiers: Array, - options?: SendOptionsType - ) { - const proto = new window.textsecure.protobuf.DataMessage(); - proto.group = new window.textsecure.protobuf.GroupContext(); - proto.group.id = stringToArrayBuffer(groupId); - proto.group.type = window.textsecure.protobuf.GroupContext.Type.QUIT; - return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options); - } async sendExpirationTimerUpdateToGroup( groupId: string, groupIdentifiers: Array, @@ -1683,6 +1629,7 @@ export default class MessageSender { return this.sendMessage(attrs, options); } + async sendExpirationTimerUpdateToIdentifier( identifier: string, expireTimer: number | undefined, diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index bbe61e7b8..5fef012dd 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -35,6 +35,10 @@ import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; import { + AvatarUploadAttributesClass, + GroupChangeClass, + GroupChangesClass, + GroupClass, StorageServiceCallOptionsType, StorageServiceCredentials, } from '../textsecure.d'; @@ -283,6 +287,7 @@ type RedactUrl = (url: string) => string; type PromiseAjaxOptionsType = { accessKey?: string; + basicAuth?: string; certificateAuthority?: string; contentType?: string; data?: ArrayBuffer | Buffer | string; @@ -309,7 +314,12 @@ type PromiseAjaxOptionsType = { type JSONWithDetailsType = { data: any; - contentType: string; + contentType: string | null; + response: Response; +}; +type ArrayBufferWithDetailsType = { + data: ArrayBuffer; + contentType: string | null; response: Response; }; @@ -377,8 +387,10 @@ async function _promiseAjax( fetchOptions.headers['Content-Length'] = contentLength.toString(); } - const { accessKey, unauthenticated } = options; - if (unauthenticated) { + const { accessKey, basicAuth, unauthenticated } = options; + if (basicAuth) { + fetchOptions.headers.Authorization = `Basic ${basicAuth}`; + } else if (unauthenticated) { if (!accessKey) { throw new Error( '_promiseAjax: mode is aunathenticated, but accessKey was not provided' @@ -416,6 +428,7 @@ async function _promiseAjax( resultPromise = response.textConverted(); } + // tslint:disable-next-line max-func-body-length return resultPromise.then(result => { if ( options.responseType === 'arraybuffer' || @@ -468,18 +481,29 @@ async function _promiseAjax( } else { window.log.info(options.type, url, response.status, 'Success'); } - if ( - options.responseType === 'arraybufferwithdetails' || - options.responseType === 'jsonwithdetails' - ) { - resolve({ + if (options.responseType === 'arraybufferwithdetails') { + const fullResult: ArrayBufferWithDetailsType = { data: result, contentType: getContentType(response), response, - }); + }; + + resolve(fullResult); return; } + if (options.responseType === 'jsonwithdetails') { + const fullResult: JSONWithDetailsType = { + data: result, + contentType: getContentType(response), + response, + }; + + resolve(fullResult); + + return; + } + resolve(result); return; @@ -575,29 +599,32 @@ function makeHTTPError( const URL_CALLS = { accounts: 'v1/accounts', - updateDeviceName: 'v1/accounts/name', - removeSignalingKey: 'v1/accounts/signaling_key', - getIceServers: 'v1/accounts/turn', attachmentId: 'v2/attachments/form/upload', + attestation: 'v1/attestation', + config: 'v1/config', deliveryCert: 'v1/certificate/delivery', devices: 'v1/devices', + directoryAuth: 'v1/directory/auth', + discovery: 'v1/discovery', + getGroupAvatarUpload: '/v1/groups/avatar/form', + getGroupCredentials: 'v1/certificate/group', + getIceServers: 'v1/accounts/turn', + getStickerPackUpload: 'v1/sticker/pack/form', + groupLog: 'v1/groups/logs', + groups: 'v1/groups', keys: 'v2/keys', messages: 'v1/messages', profile: 'v1/profile', registerCapabilities: 'v1/devices/capabilities', + removeSignalingKey: 'v1/accounts/signaling_key', signed: 'v2/keys/signed', storageManifest: 'v1/storage/manifest', storageModify: 'v1/storage/', storageRead: 'v1/storage/read', storageToken: 'v1/storage/auth', supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', - getStickerPackUpload: 'v1/sticker/pack/form', + updateDeviceName: 'v1/accounts/name', whoami: 'v1/accounts/whoami', - config: 'v1/config', - directoryAuth: 'v1/directory/auth', - // CDS endpoints - attestation: 'v1/attestation', - discovery: 'v1/discovery', }; type InitializeOptionsType = { @@ -625,6 +652,7 @@ type MessageType = any; type AjaxOptionsType = { accessKey?: string; + basicAuth?: string; call: keyof typeof URL_CALLS; contentType?: string; data?: ArrayBuffer | Buffer | string; @@ -648,6 +676,21 @@ export type WebAPIConnectType = { type StickerPackManifestType = any; +export type GroupCredentialType = { + credential: string; + redemptionTime: number; +}; +export type GroupCredentialsType = { + groupPublicParamsHex: string; + authCredentialPresentationHex: string; +}; +export type GroupLogResponseType = { + currentRevision?: number; + start?: number; + end?: number; + changes: GroupChangesClass; +}; + export type WebAPIType = { confirmCode: ( number: string, @@ -657,9 +700,23 @@ export type WebAPIType = { deviceName?: string | null, options?: { accessKey?: ArrayBuffer } ) => Promise; + createGroup: ( + group: GroupClass, + options: GroupCredentialsType + ) => Promise; getAttachment: (cdnKey: string, cdnNumber: number) => Promise; getAvatar: (path: string) => Promise; getDevices: () => Promise; + getGroup: (options: GroupCredentialsType) => Promise; + getGroupAvatar: (key: string) => Promise; + getGroupCredentials: ( + startDay: number, + endDay: number + ) => Promise>; + getGroupLog: ( + startVersion: number, + options: GroupCredentialsType + ) => Promise; getIceServers: () => Promise; getKeysForIdentifier: ( identifier: string, @@ -701,6 +758,10 @@ export type WebAPIType = { targetUrl: string, options?: ProxiedRequestOptionsType ) => Promise; + modifyGroup: ( + changes: GroupChangeClass.Actions, + options: GroupCredentialsType + ) => Promise; modifyStorageRecords: MessageSender['modifyStorageRecords']; putAttachment: (encryptedBin: ArrayBuffer) => Promise; registerCapabilities: (capabilities: any) => Promise; @@ -731,6 +792,10 @@ export type WebAPIType = { ) => Promise; setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise; updateDeviceName: (deviceName: string) => Promise; + uploadGroupAvatar: ( + avatarData: ArrayBuffer, + options: GroupCredentialsType + ) => Promise; whoami: () => Promise; getConfig: () => Promise>; }; @@ -838,13 +903,20 @@ export function initialize({ let username = initialUsername; let password = initialPassword; const PARSE_RANGE_HEADER = /\/(\d+)$/; + const PARSE_GROUP_LOG_RANGE_HEADER = /$versions (\d{1,10})-(\d{1,10})\/(d{1,10})/; // Thanks, function hoisting! return { confirmCode, + createGroup, getAttachment, getAvatar, + getConfig, getDevices, + getGroup, + getGroupAvatar, + getGroupCredentials, + getGroupLog, getIceServers, getKeysForIdentifier, getKeysForIdentifierUnauth, @@ -861,10 +933,11 @@ export function initialize({ getStorageRecords, getUuidsForE164s, makeProxiedRequest, + modifyGroup, modifyStorageRecords, putAttachment, - registerCapabilities, putStickers, + registerCapabilities, registerKeys, registerSupportForUnauthenticatedDelivery, removeSignalingKey, @@ -874,8 +947,8 @@ export function initialize({ sendMessagesUnauth, setSignedPreKey, updateDeviceName, + uploadGroupAvatar, whoami, - getConfig, }; async function _ajax(param: AjaxOptionsType): Promise { @@ -884,6 +957,7 @@ export function initialize({ } return _outerAjax(null, { + basicAuth: param.basicAuth, certificateAuthority, contentType: param.contentType || 'application/json; charset=utf-8', data: param.data || (param.jsonData && _jsonThing(param.jsonData)), @@ -1169,11 +1243,9 @@ export function initialize({ ) { const { accessKey } = options; const jsonData: any = { - // tslint:disable-next-line: no-suspicious-comment - // TODO: uncomment this once we want to start registering UUID support - // capabilities: { - // uuid: true, - // }, + capabilities: { + gv2: true, + }, fetchesMessages: true, name: deviceName ? deviceName : undefined, registrationId, @@ -1695,13 +1767,13 @@ export function initialize({ return result; } - const { response } = result; + const { response } = result as ArrayBufferWithDetailsType; if (!response.headers || !response.headers.get) { throw new Error('makeProxiedRequest: Problem retrieving header value'); } const range = response.headers.get('content-range'); - const match = PARSE_RANGE_HEADER.exec(range); + const match = PARSE_RANGE_HEADER.exec(range || ''); if (!match || !match[1]) { throw new Error( @@ -1717,6 +1789,228 @@ export function initialize({ }; } + // Groups + + function generateGroupAuth( + groupPublicParamsHex: string, + authCredentialPresentationHex: string + ) { + return _btoa(`${groupPublicParamsHex}:${authCredentialPresentationHex}`); + } + + type CredentialResponseType = { + credentials: Array; + }; + + async function getGroupCredentials( + startDay: number, + endDay: number + ): Promise> { + const response: CredentialResponseType = await _ajax({ + call: 'getGroupCredentials', + urlParameters: `/${startDay}/${endDay}`, + httpType: 'GET', + responseType: 'json', + }); + + return response.credentials; + } + + function verifyAttributes(attributes: AvatarUploadAttributesClass) { + const { + key, + credential, + acl, + algorithm, + date, + policy, + signature, + } = attributes; + + if ( + !key || + !credential || + !acl || + !algorithm || + !date || + !policy || + !signature + ) { + throw new Error( + 'verifyAttributes: Missing value from AvatarUploadAttributes' + ); + } + + return { + key, + credential, + acl, + algorithm, + date, + policy, + signature, + }; + } + + async function uploadGroupAvatar( + avatarData: ArrayBuffer, + options: GroupCredentialsType + ): Promise { + const basicAuth = generateGroupAuth( + options.groupPublicParamsHex, + options.authCredentialPresentationHex + ); + + const response: ArrayBuffer = await _ajax({ + basicAuth, + call: 'getGroupAvatarUpload', + httpType: 'GET', + responseType: 'arraybuffer', + host: storageUrl, + }); + const attributes = window.textsecure.protobuf.AvatarUploadAttributes.decode( + response + ); + + const verified = verifyAttributes(attributes); + const { key } = verified; + + const manifestParams = makePutParams(verified, avatarData); + + await _outerAjax(`${cdnUrlObject['0']}/`, { + ...manifestParams, + certificateAuthority, + proxyUrl, + timeout: 0, + type: 'POST', + version, + }); + + return key; + } + + async function getGroupAvatar(key: string): Promise { + return _outerAjax(`${cdnUrlObject['0']}/${key}`, { + certificateAuthority, + proxyUrl, + responseType: 'arraybuffer', + timeout: 0, + type: 'GET', + version, + }); + } + + async function createGroup( + group: GroupClass, + options: GroupCredentialsType + ): Promise { + const basicAuth = generateGroupAuth( + options.groupPublicParamsHex, + options.authCredentialPresentationHex + ); + const data = group.toArrayBuffer(); + + await _ajax({ + basicAuth, + call: 'groups', + httpType: 'PUT', + data, + host: storageUrl, + }); + } + + async function getGroup( + options: GroupCredentialsType + ): Promise { + const basicAuth = generateGroupAuth( + options.groupPublicParamsHex, + options.authCredentialPresentationHex + ); + + const response: ArrayBuffer = await _ajax({ + basicAuth, + call: 'groups', + httpType: 'GET', + contentType: 'application/x-protobuf', + responseType: 'arraybuffer', + host: storageUrl, + }); + + return window.textsecure.protobuf.Group.decode(response); + } + + async function modifyGroup( + changes: GroupChangeClass.Actions, + options: GroupCredentialsType + ): Promise { + const basicAuth = generateGroupAuth( + options.groupPublicParamsHex, + options.authCredentialPresentationHex + ); + const data = changes.toArrayBuffer(); + + const response: ArrayBuffer = await _ajax({ + basicAuth, + call: 'groups', + httpType: 'PATCH', + data, + contentType: 'application/x-protobuf', + responseType: 'arraybuffer', + host: storageUrl, + }); + + return window.textsecure.protobuf.GroupChange.decode(response); + } + + async function getGroupLog( + startVersion: number, + options: GroupCredentialsType + ): Promise { + const basicAuth = generateGroupAuth( + options.groupPublicParamsHex, + options.authCredentialPresentationHex + ); + + const withDetails: ArrayBufferWithDetailsType = await _ajax({ + basicAuth, + call: 'groupLog', + urlParameters: `/${startVersion}`, + httpType: 'GET', + contentType: 'application/x-protobuf', + responseType: 'arraybufferwithdetails', + host: storageUrl, + }); + const { data, response } = withDetails; + const changes = window.textsecure.protobuf.GroupChanges.decode(data); + + if (response && response.status === 206) { + const range = response.headers.get('Content-Range'); + const match = PARSE_GROUP_LOG_RANGE_HEADER.exec(range || ''); + + const start = match ? parseInt(match[0], 10) : undefined; + const end = match ? parseInt(match[1], 10) : undefined; + const currentRevision = match ? parseInt(match[2], 10) : undefined; + + if ( + match && + is.number(start) && + is.number(end) && + is.number(currentRevision) + ) { + return { + changes, + start, + end, + currentRevision, + }; + } + } + + return { + changes, + }; + } + function getMessageSocket() { window.log.info('opening message socket', url); const fixedScheme = url diff --git a/ts/util/deleteForEveryone.ts b/ts/util/deleteForEveryone.ts index f77a928b9..16b8cc3d9 100644 --- a/ts/util/deleteForEveryone.ts +++ b/ts/util/deleteForEveryone.ts @@ -10,7 +10,7 @@ export async function deleteForEveryone( // Make sure the server timestamps for the DOE and the matching message // are less than one day apart const delta = Math.abs( - doe.get('serverTimestamp') - message.get('serverTimestamp') + doe.get('serverTimestamp') - (message.get('serverTimestamp') || 0) ); if (delta > ONE_DAY) { window.log.info('Received late DOE. Dropping.', { diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 05bb4edd8..bfa90d34b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -12852,38 +12852,6 @@ "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" }, - { - "rule": "jQuery-wrap(", - "path": "ts/textsecure/SendMessage.js", - "line": " return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();", - "lineNumber": 25, - "reasonCategory": "falseMatch", - "updated": "2020-04-05T23:45:16.746Z" - }, - { - "rule": "jQuery-wrap(", - "path": "ts/textsecure/SendMessage.js", - "line": " return window.dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();", - "lineNumber": 28, - "reasonCategory": "falseMatch", - "updated": "2020-04-05T23:45:16.746Z" - }, - { - "rule": "jQuery-wrap(", - "path": "ts/textsecure/SendMessage.ts", - "line": " return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();", - "lineNumber": 32, - "reasonCategory": "falseMatch", - "updated": "2020-05-28T18:08:02.658Z" - }, - { - "rule": "jQuery-wrap(", - "path": "ts/textsecure/SendMessage.ts", - "line": " return window.dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();", - "lineNumber": 35, - "reasonCategory": "falseMatch", - "updated": "2020-05-28T18:08:02.658Z" - }, { "rule": "jQuery-wrap(", "path": "ts/textsecure/SyncRequest.js", @@ -12952,7 +12920,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/WebAPI.js", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);", - "lineNumber": 1057, + "lineNumber": 1213, "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" }, @@ -12960,8 +12928,8 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/WebAPI.ts", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(", - "lineNumber": 1769, + "lineNumber": 2063, "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" } -] +] \ No newline at end of file diff --git a/ts/util/zkgroup.ts b/ts/util/zkgroup.ts index fe017e85a..a008a1b31 100644 --- a/ts/util/zkgroup.ts +++ b/ts/util/zkgroup.ts @@ -1,13 +1,21 @@ export * from 'zkgroup'; import { + AuthCredential, + ClientZkAuthOperations, + ClientZkGroupCipher, ClientZkProfileOperations, FFICompatArray, FFICompatArrayType, + GroupMasterKey, + GroupSecretParams, ProfileKey, + ProfileKeyCiphertext, + ProfileKeyCredentialPresentation, ProfileKeyCredentialRequestContext, ProfileKeyCredentialResponse, ServerPublicParams, + UuidCiphertext, } from 'zkgroup'; import { arrayBufferToBase64, @@ -16,6 +24,8 @@ import { typedArrayToArrayBuffer, } from '../Crypto'; +// Simple utility functions + export function arrayBufferToCompatArray( arrayBuffer: ArrayBuffer ): FFICompatArrayType { @@ -42,6 +52,68 @@ export function compatArrayToHex(compatArray: FFICompatArrayType): string { return arrayBufferToHex(compatArrayToArrayBuffer(compatArray)); } +// Scenarios + +export function decryptGroupBlob( + clientZkGroupCipher: ClientZkGroupCipher, + ciphertext: ArrayBuffer +) { + return compatArrayToArrayBuffer( + clientZkGroupCipher.decryptBlob(arrayBufferToCompatArray(ciphertext)) + ); +} + +export function decryptProfileKeyCredentialPresentation( + clientZkGroupCipher: ClientZkGroupCipher, + presentationBuffer: ArrayBuffer +): { profileKey: ArrayBuffer; uuid: string } { + const presentation = new ProfileKeyCredentialPresentation( + arrayBufferToCompatArray(presentationBuffer) + ); + + const uuidCiphertext = presentation.getUuidCiphertext(); + const uuid = clientZkGroupCipher.decryptUuid(uuidCiphertext); + + const profileKeyCiphertext = presentation.getProfileKeyCiphertext(); + const profileKey = clientZkGroupCipher.decryptProfileKey( + profileKeyCiphertext, + uuid + ); + + return { + profileKey: compatArrayToArrayBuffer(profileKey.serialize()), + uuid, + }; +} + +export function decryptProfileKey( + clientZkGroupCipher: ClientZkGroupCipher, + profileKeyCiphertextBuffer: ArrayBuffer, + uuid: string +): ArrayBuffer { + const profileKeyCiphertext = new ProfileKeyCiphertext( + arrayBufferToCompatArray(profileKeyCiphertextBuffer) + ); + + const profileKey = clientZkGroupCipher.decryptProfileKey( + profileKeyCiphertext, + uuid + ); + + return compatArrayToArrayBuffer(profileKey.serialize()); +} + +export function decryptUuid( + clientZkGroupCipher: ClientZkGroupCipher, + uuidCiphertextBuffer: ArrayBuffer +): string { + const uuidCiphertext = new UuidCiphertext( + arrayBufferToCompatArray(uuidCiphertextBuffer) + ); + + return clientZkGroupCipher.decryptUuid(uuidCiphertext); +} + export function deriveProfileKeyVersion( profileKeyBase64: string, uuid: string @@ -54,13 +126,56 @@ export function deriveProfileKeyVersion( return profileKeyVersion.toString(); } -export function getClientZkProfileOperations( - serverPublicParamsBase64: string -): ClientZkProfileOperations { - const serverPublicParamsArray = base64ToCompatArray(serverPublicParamsBase64); - const serverPublicParams = new ServerPublicParams(serverPublicParamsArray); +export function deriveGroupPublicParams(groupSecretParamsBuffer: ArrayBuffer) { + const groupSecretParams = new GroupSecretParams( + arrayBufferToCompatArray(groupSecretParamsBuffer) + ); - return new ClientZkProfileOperations(serverPublicParams); + return compatArrayToArrayBuffer( + groupSecretParams.getPublicParams().serialize() + ); +} + +export function deriveGroupID(groupSecretParamsBuffer: ArrayBuffer) { + const groupSecretParams = new GroupSecretParams( + arrayBufferToCompatArray(groupSecretParamsBuffer) + ); + + return compatArrayToArrayBuffer( + groupSecretParams + .getPublicParams() + .getGroupIdentifier() + .serialize() + ); +} + +export function deriveGroupSecretParams( + masterKeyBuffer: ArrayBuffer +): ArrayBuffer { + const masterKey = new GroupMasterKey( + arrayBufferToCompatArray(masterKeyBuffer) + ); + const groupSecretParams = GroupSecretParams.deriveFromMasterKey(masterKey); + + return compatArrayToArrayBuffer(groupSecretParams.serialize()); +} + +export function encryptGroupBlob( + clientZkGroupCipher: ClientZkGroupCipher, + plaintext: ArrayBuffer +) { + return compatArrayToArrayBuffer( + clientZkGroupCipher.encryptBlob(arrayBufferToCompatArray(plaintext)) + ); +} + +export function encryptUuid( + clientZkGroupCipher: ClientZkGroupCipher, + uuidPlaintext: string +): ArrayBuffer { + const uuidCiphertext = clientZkGroupCipher.encryptUuid(uuidPlaintext); + + return compatArrayToArrayBuffer(uuidCiphertext.serialize()); } export function generateProfileKeyCredentialRequest( @@ -84,13 +199,63 @@ export function generateProfileKeyCredentialRequest( }; } +export function getAuthCredentialPresentation( + clientZkAuthOperations: ClientZkAuthOperations, + authCredentialBase64: string, + groupSecretParamsBase64: string +) { + const authCredential = new AuthCredential( + base64ToCompatArray(authCredentialBase64) + ); + const secretParams = new GroupSecretParams( + base64ToCompatArray(groupSecretParamsBase64) + ); + + const presentation = clientZkAuthOperations.createAuthCredentialPresentation( + secretParams, + authCredential + ); + return compatArrayToArrayBuffer(presentation.serialize()); +} + +export function getClientZkAuthOperations( + serverPublicParamsBase64: string +): ClientZkAuthOperations { + const serverPublicParams = new ServerPublicParams( + base64ToCompatArray(serverPublicParamsBase64) + ); + + return new ClientZkAuthOperations(serverPublicParams); +} + +export function getClientZkGroupCipher( + groupSecretParamsBase64: string +): ClientZkGroupCipher { + const serverPublicParams = new GroupSecretParams( + base64ToCompatArray(groupSecretParamsBase64) + ); + + return new ClientZkGroupCipher(serverPublicParams); +} + +export function getClientZkProfileOperations( + serverPublicParamsBase64: string +): ClientZkProfileOperations { + const serverPublicParams = new ServerPublicParams( + base64ToCompatArray(serverPublicParamsBase64) + ); + + return new ClientZkProfileOperations(serverPublicParams); +} + export function handleProfileKeyCredential( clientZkProfileCipher: ClientZkProfileOperations, context: ProfileKeyCredentialRequestContext, responseBase64: string ): string { - const responseArray = base64ToCompatArray(responseBase64); - const response = new ProfileKeyCredentialResponse(responseArray); + const response = new ProfileKeyCredentialResponse( + base64ToCompatArray(responseBase64) + ); const profileKeyCredential = clientZkProfileCipher.receiveProfileKeyCredential( context, response diff --git a/ts/window.d.ts b/ts/window.d.ts index 27d863334..5adb8dbb7 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -23,6 +23,7 @@ import { CallHistoryDetailsType } from './types/Calling'; import { ColorType } from './types/Colors'; import { ConversationController } from './ConversationController'; import { SendOptionsType } from './textsecure/SendMessage'; +import AccountManager from './textsecure/AccountManager'; import Data from './sql/Client'; export { Long } from 'long'; @@ -32,6 +33,7 @@ type TaskResultType = any; declare global { interface Window { dcodeIO: DCodeIOType; + getAccountManager: () => AccountManager | undefined; getAlwaysRelayCalls: () => Promise; getCallRingtoneNotification: () => Promise; getCallSystemNotification: () => Promise; @@ -43,8 +45,10 @@ declare global { getIncomingCallNotification: () => Promise; getMediaCameraPermissions: () => Promise; getMediaPermissions: () => Promise; + getServerPublicParams: () => string; getSocketStatus: () => number; getTitle: () => string; + waitForEmptyEventQueue: () => Promise; showCallingPermissionsPopup: (forCamera: boolean) => Promise; i18n: LocalizerType; isValidGuid: (maybeGuid: string) => boolean; @@ -88,13 +92,24 @@ declare global { Services: { calling: CallingClass; }; + Migrations: { + deleteAttachmentData: (path: string) => Promise; + writeNewAttachmentData: (data: ArrayBuffer) => Promise; + }; + Types: { + Message: { + CURRENT_SCHEMA_VERSION: number; + }; + }; }; ConversationController: ConversationController; + MessageController: MessageControllerType; WebAPI: WebAPIConnectType; Whisper: WhisperType; // Flags CALLING: boolean; + GV2: boolean; } interface Error { @@ -114,6 +129,10 @@ export type DCodeIOType = { }; }; +type MessageControllerType = { + register: (id: string, model: MessageModelType) => MessageModelType; +}; + export class CertificateValidatorType { validate: (cerficate: any, certificateTime: number) => Promise; }