From 64737a89d7e6896811068d1480f5327d939465c6 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 4 Feb 2021 11:53:37 +1100 Subject: [PATCH 1/3] add ConfigurationMessage --- protos/SignalService.proto | 60 ++++++++----- ts/receiver/closedGroups.ts | 6 +- ts/session/group/index.ts | 14 ++- ts/session/messages/outgoing/Message.ts | 4 + .../outgoing/content/ConfigurationMessage.ts | 90 +++++++++++++++++++ .../data/group/ClosedGroupNewMessage.ts | 6 +- ts/session/utils/Messages.ts | 63 ++++++++++++- 7 files changed, 207 insertions(+), 36 deletions(-) create mode 100644 ts/session/messages/outgoing/content/ConfigurationMessage.ts diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 3ede0c1f1..3329f2b21 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -33,9 +33,17 @@ message TypingMessage { message Content { - optional DataMessage dataMessage = 1; - optional ReceiptMessage receiptMessage = 5; - optional TypingMessage typingMessage = 6; + optional DataMessage dataMessage = 1; + optional ReceiptMessage receiptMessage = 5; + optional TypingMessage typingMessage = 6; + optional ConfigurationMessage configurationMessage = 7; +} + +message KeyPair { + // @required + required bytes publicKey = 1; + // @required + required bytes privateKey = 2; } @@ -144,6 +152,7 @@ message DataMessage { optional string profilePicture = 2; } + message ClosedGroupControlMessage { enum Type { @@ -156,12 +165,7 @@ message DataMessage { MEMBER_LEFT = 7; } - message KeyPair { - // @required - required bytes publicKey = 1; - // @required - required bytes privateKey = 2; - } + message KeyPairWrapper { // @required @@ -186,20 +190,34 @@ message DataMessage { optional string serverName = 3; } - optional string body = 1; - repeated AttachmentPointer attachments = 2; - optional GroupContext group = 3; - optional uint32 flags = 4; - optional uint32 expireTimer = 5; - optional bytes profileKey = 6; - optional uint64 timestamp = 7; - optional Quote quote = 8; - repeated Contact contact = 9; - repeated Preview preview = 10; - optional LokiProfile profile = 101; - optional GroupInvitation groupInvitation = 102; + optional string body = 1; + repeated AttachmentPointer attachments = 2; + optional GroupContext group = 3; + optional uint32 flags = 4; + optional uint32 expireTimer = 5; + optional bytes profileKey = 6; + optional uint64 timestamp = 7; + optional Quote quote = 8; + repeated Contact contact = 9; + repeated Preview preview = 10; + optional LokiProfile profile = 101; + optional GroupInvitation groupInvitation = 102; optional ClosedGroupControlMessage closedGroupControlMessage = 104; + optional string syncTarget = 105; +} + +message ConfigurationMessage { + + message ClosedGroup { + optional bytes publicKey = 1; + optional string name = 2; + optional KeyPair encryptionKeyPair = 3; + repeated bytes members = 4; + repeated bytes admins = 5; + } + repeated ClosedGroup closedGroups = 1; + repeated string openGroups = 2; } message ReceiptMessage { diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index 83bdf1fb2..e38c42e02 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -385,11 +385,9 @@ async function handleClosedGroupEncryptionKeyPair( } // Parse it - let proto: SignalService.DataMessage.ClosedGroupControlMessage.KeyPair; + let proto: SignalService.KeyPair; try { - proto = SignalService.DataMessage.ClosedGroupControlMessage.KeyPair.decode( - plaintext - ); + proto = SignalService.KeyPair.decode(plaintext); if ( !proto || proto.privateKey.length === 0 || diff --git a/ts/session/group/index.ts b/ts/session/group/index.ts index c576f69ec..3b68bafec 100644 --- a/ts/session/group/index.ts +++ b/ts/session/group/index.ts @@ -673,15 +673,11 @@ export async function generateAndSendNewEncryptionKeyPair( ); return; } - const proto = new SignalService.DataMessage.ClosedGroupControlMessage.KeyPair( - { - privateKey: newKeyPair?.privateKeyData, - publicKey: newKeyPair?.publicKeyData, - } - ); - const plaintext = SignalService.DataMessage.ClosedGroupControlMessage.KeyPair.encode( - proto - ).finish(); + const proto = new SignalService.KeyPair({ + privateKey: newKeyPair?.privateKeyData, + publicKey: newKeyPair?.publicKeyData, + }); + const plaintext = SignalService.KeyPair.encode(proto).finish(); // Distribute it const wrappers = await Promise.all( diff --git a/ts/session/messages/outgoing/Message.ts b/ts/session/messages/outgoing/Message.ts index 382d5ff7c..1c84975b6 100644 --- a/ts/session/messages/outgoing/Message.ts +++ b/ts/session/messages/outgoing/Message.ts @@ -16,4 +16,8 @@ export abstract class Message { } this.identifier = identifier || uuid(); } + + public isSelfSendValid() { + return false; + } } diff --git a/ts/session/messages/outgoing/content/ConfigurationMessage.ts b/ts/session/messages/outgoing/content/ConfigurationMessage.ts new file mode 100644 index 000000000..560c60532 --- /dev/null +++ b/ts/session/messages/outgoing/content/ConfigurationMessage.ts @@ -0,0 +1,90 @@ +// this is not a very good name, but a configuration message is a message sent to our other devices so sync our current public and closed groups + +import { ContentMessage } from './ContentMessage'; +import { SignalService } from '../../../../protobuf'; +import { MessageParams } from '../Message'; +import { Constants } from '../../..'; +import { ECKeyPair } from '../../../../receiver/keypairs'; +import { fromHexToArray } from '../../../utils/String'; + +interface ConfigurationMessageParams extends MessageParams { + activeClosedGroups: Array; + activeOpenGroups: Array; +} + +export class ConfigurationMessage extends ContentMessage { + private readonly activeClosedGroups: Array; + private readonly activeOpenGroups: Array; + + constructor(params: ConfigurationMessageParams) { + super({ timestamp: params.timestamp, identifier: params.identifier }); + this.activeClosedGroups = params.activeClosedGroups; + this.activeOpenGroups = params.activeOpenGroups; + } + + public ttl(): number { + return Constants.TTL_DEFAULT.TYPING_MESSAGE; + } + + public contentProto(): SignalService.Content { + return new SignalService.Content({ + configurationMessage: this.configurationProto(), + }); + } + + protected configurationProto(): SignalService.ConfigurationMessage { + return new SignalService.ConfigurationMessage({ + closedGroups: this.mapClosedGroupsObjectToProto(this.activeClosedGroups), + openGroups: this.activeOpenGroups, + }); + } + + private mapClosedGroupsObjectToProto( + closedGroups: Array + ): Array { + return (closedGroups || []).map(m => + new ConfigurationMessageClosedGroup(m).toProto() + ); + } +} + +export class ConfigurationMessageClosedGroup { + public publicKey: string; + public name: string; + public encryptionKeyPair: ECKeyPair; + public members: Array; + public admins: Array; + + public constructor({ + publicKey, + name, + encryptionKeyPair, + members, + admins, + }: { + publicKey: string; + name: string; + encryptionKeyPair: ECKeyPair; + members: Array; + admins: Array; + }) { + this.publicKey = publicKey; + this.name = name; + this.encryptionKeyPair = encryptionKeyPair; + this.members = members; + this.admins = admins; + } + + public toProto(): SignalService.ConfigurationMessage.ClosedGroup { + return new SignalService.ConfigurationMessage.ClosedGroup({ + publicKey: fromHexToArray(this.publicKey), + name: this.name, + encryptionKeyPair: { + publicKey: this.encryptionKeyPair.publicKeyData, + privateKey: this.encryptionKeyPair.privateKeyData, + }, + members: this.members.map(fromHexToArray), + admins: this.admins.map(fromHexToArray), + }); + } +} diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts index d42de8327..eeb47839b 100644 --- a/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts @@ -73,7 +73,7 @@ export class ClosedGroupNewMessage extends ClosedGroupMessage { fromHexToArray ); try { - dataMessage.closedGroupControlMessage.encryptionKeyPair = new SignalService.DataMessage.ClosedGroupControlMessage.KeyPair(); + dataMessage.closedGroupControlMessage.encryptionKeyPair = new SignalService.KeyPair(); dataMessage.closedGroupControlMessage.encryptionKeyPair.privateKey = new Uint8Array( this.keypair.privateKeyData ); @@ -87,4 +87,8 @@ export class ClosedGroupNewMessage extends ClosedGroupMessage { return dataMessage; } + + public isSelfSendValid() { + return true; + } } diff --git a/ts/session/utils/Messages.ts b/ts/session/utils/Messages.ts index 937ac2e3d..8f6931862 100644 --- a/ts/session/utils/Messages.ts +++ b/ts/session/utils/Messages.ts @@ -2,11 +2,20 @@ import { RawMessage } from '../types/RawMessage'; import { ContentMessage, ExpirationTimerUpdateMessage, - TypingMessage, } from '../messages/outgoing'; import { EncryptionType, PubKey } from '../types'; import { ClosedGroupMessage } from '../messages/outgoing/content/data/group/ClosedGroupMessage'; import { ClosedGroupNewMessage } from '../messages/outgoing/content/data/group/ClosedGroupNewMessage'; +import { ConversationModel } from '../../../js/models/conversations'; +import { + ConfigurationMessage, + ConfigurationMessageClosedGroup, +} from '../messages/outgoing/content/ConfigurationMessage'; +import uuid from 'uuid'; +import * as Data from '../../../js/modules/data'; +import { UserUtils } from '.'; +import { ECKeyPair } from '../../receiver/keypairs'; +import _ from 'lodash'; export function getEncryptionTypeFromMessageType( message: ContentMessage @@ -51,3 +60,55 @@ export async function toRawMessage( return rawMessage; } + +export const getCurrentConfigurationMessage = async ( + convos: Array +) => { + const ourPubKey = (await UserUtils.getOurNumber()).key; + const openGroupsIds = convos + .filter( + c => + !!c.get('active_at') && + c.get('members').includes(ourPubKey) && + c.isPublic() && + !c.get('left') + ) + .map(c => c.id) as Array; + const closedGroupModels = convos.filter( + c => + !!c.get('active_at') && + c.isMediumGroup() && + !c.get('left') && + !c.get('isKickedFromGroup') + ); + + const closedGroups = await Promise.all( + closedGroupModels.map(async c => { + const groupPubKey = c.get('id'); + const fetchEncryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair( + groupPubKey + ); + if (!fetchEncryptionKeyPair) { + return null; + } + + return new ConfigurationMessageClosedGroup({ + publicKey: groupPubKey, + name: c.get('name'), + members: c.get('members') || [], + admins: c.get('groupAdmins') || [], + encryptionKeyPair: ECKeyPair.fromHexKeyPair(fetchEncryptionKeyPair), + }); + }) + ); + + const onlyValidClosedGroup = closedGroups.filter(m => m !== null) as Array< + ConfigurationMessageClosedGroup + >; + return new ConfigurationMessage({ + identifier: uuid(), + timestamp: Date.now(), + activeOpenGroups: openGroupsIds, + activeClosedGroups: onlyValidClosedGroup, + }); +}; From b88ea110e89a18a97a79b9d32e9bbe0313cfc896 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 5 Feb 2021 16:29:37 +1100 Subject: [PATCH 2/3] Add multi device v2 support --- js/background.js | 6 +- js/models/conversations.js | 32 +- js/models/messages.d.ts | 3 + js/models/messages.js | 367 +++++++++--------- package.json | 2 +- password_preload.js | 6 - preload.js | 25 +- ts/components/EditProfileDialog.tsx | 8 +- ts/components/session/ActionsPanel.tsx | 18 + ts/components/session/RegistrationTabs.tsx | 5 +- .../session/SessionPasswordPrompt.tsx | 8 +- ts/receiver/attachments.ts | 10 +- ts/receiver/closedGroups.ts | 47 ++- ts/receiver/contentMessage.ts | 81 +++- ts/receiver/dataMessage.ts | 70 ++-- ts/receiver/queuedJob.ts | 2 +- ts/receiver/receiver.ts | 2 +- ts/session/group/index.ts | 26 +- ts/session/messages/outgoing/Message.ts | 7 +- .../outgoing/content/ConfigurationMessage.ts | 38 +- .../outgoing/content/ContentMessage.ts | 1 - .../outgoing/content/data/ChatMessage.ts | 65 ++++ .../data/group/ClosedGroupNewMessage.ts | 4 - ts/session/onions/index.ts | 4 +- ts/session/sending/MessageQueue.ts | 42 +- ts/session/sending/MessageQueueInterface.ts | 5 +- ts/session/snode_api/snodePool.ts | 9 +- ts/session/snode_api/swarmPolling.ts | 15 +- ts/session/types/OpenGroup.ts | 21 +- ts/session/utils/Messages.ts | 20 +- ts/session/utils/index.ts | 2 + ts/session/utils/syncUtils.ts | 80 ++++ .../messages/ConfigurationMessage_test.ts | 141 +++++++ ts/test/session/unit/utils/Messages_test.ts | 70 +++- ts/test/session/unit/utils/Password.ts | 2 +- ts/test/session/unit/utils/SyncUtils_test.ts | 31 ++ ts/test/test-utils/utils/message.ts | 42 +- ts/util/passwordUtils.ts | 7 +- 38 files changed, 951 insertions(+), 373 deletions(-) create mode 100644 ts/session/utils/syncUtils.ts create mode 100644 ts/test/session/unit/messages/ConfigurationMessage_test.ts create mode 100644 ts/test/session/unit/utils/SyncUtils_test.ts diff --git a/js/background.js b/js/background.js index 73a18ae64..4a614f831 100644 --- a/js/background.js +++ b/js/background.js @@ -912,13 +912,13 @@ ({ identifier, pubKey, timestamp, serverId, serverTimestamp }) => { try { const conversation = window.getConversationController().get(pubKey); - conversation.onPublicMessageSent( + conversation.onPublicMessageSent({ identifier, pubKey, timestamp, serverId, - serverTimestamp - ); + serverTimestamp, + }); } catch (e) { window.log.error('Error setting public on message'); } diff --git a/js/models/conversations.js b/js/models/conversations.js index 80de7e568..3ce9435f7 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -323,7 +323,7 @@ await Promise.all(messages.map(m => m.setCalculatingPoW())); }, - async onPublicMessageSent(identifier, serverId, serverTimestamp) { + async onPublicMessageSent({ identifier, serverId, serverTimestamp }) { const registeredMessage = window.getMessageController().get(identifier); if (!registeredMessage || !registeredMessage.message) { @@ -648,6 +648,12 @@ const destinationPubkey = new libsession.Types.PubKey(destination); if (this.isPrivate()) { + if (this.isMe()) { + chatMessage.syncTarget = this.id; + return await libsession + .getMessageQueue() + .sendSyncMessage(chatMessage); + } // Handle Group Invitation Message if (message.get('groupInvitation')) { const groupInvitation = message.get('groupInvitation'); @@ -737,13 +743,11 @@ recipients, }); - if (this.isPublic()) { - // Public chats require this data to detect duplicates - messageWithSchema.source = textsecure.storage.user.getNumber(); - messageWithSchema.sourceDevice = 1; - } else { + if (!this.isPublic()) { messageWithSchema.destination = destination; } + messageWithSchema.source = textsecure.storage.user.getNumber(); + messageWithSchema.sourceDevice = 1; const attributes = { ...messageWithSchema, @@ -940,7 +944,7 @@ return message.sendSyncMessageOnly(expirationTimerMessage); } - if (this.get('type') === 'private') { + if (this.isPrivate()) { const expirationTimerMessage = new libsession.Messages.Outgoing.ExpirationTimerUpdateMessage( expireUpdate ); @@ -953,15 +957,6 @@ const expirationTimerMessage = new libsession.Messages.Outgoing.ExpirationTimerUpdateMessage( expireUpdate ); - // special case when we are the only member of a closed group - const ourNumber = textsecure.storage.user.getNumber(); - - if ( - this.get('members').length === 1 && - this.get('members')[0] === ourNumber - ) { - return message.sendSyncMessageOnly(expirationTimerMessage); - } await libsession.getMessageQueue().sendToGroup(expirationTimerMessage); } return message; @@ -1414,14 +1409,15 @@ return toDeleteLocally; }, - removeMessage(messageId) { - window.Signal.Data.removeMessage(messageId, { + async removeMessage(messageId) { + await window.Signal.Data.removeMessage(messageId, { Message: Whisper.Message, }); window.Whisper.events.trigger('messageDeleted', { conversationKey: this.id, messageId, }); + this.updateLastMessage(); }, deleteMessages() { diff --git a/js/models/messages.d.ts b/js/models/messages.d.ts index 58efbe351..aaa24037b 100644 --- a/js/models/messages.d.ts +++ b/js/models/messages.d.ts @@ -109,6 +109,9 @@ export interface MessageRegularProps { } export interface MessageModel extends Backbone.Model { + setIsPublic(arg0: boolean); + setServerId(serverId: any); + setServerTimestamp(serverTimestamp: any); idForLogging: () => string; isGroupUpdate: () => boolean; isExpirationTimerUpdate: () => boolean; diff --git a/js/models/messages.js b/js/models/messages.js index fc64831fd..e8611b208 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -829,171 +829,171 @@ }, // One caller today: event handler for the 'Retry Send' entry in triple-dot menu - async retrySend() { - if (!textsecure.messaging) { - window.log.error('retrySend: Cannot retry since we are offline!'); - return null; - } - - this.set({ errors: null }); - await this.commit(); - try { - const conversation = this.getConversation(); - const intendedRecipients = this.get('recipients') || []; - const successfulRecipients = this.get('sent_to') || []; - const currentRecipients = conversation.getRecipients(); - - if (conversation.isPublic()) { - const openGroup = { - server: conversation.get('server'), - channel: conversation.get('channelId'), - conversationId: conversation.id, - }; - const { body, attachments, preview, quote } = await this.uploadData(); - - const openGroupParams = { - identifier: this.id, - body, - timestamp: Date.now(), - group: openGroup, - attachments, - preview, - quote, - }; - const openGroupMessage = new libsession.Messages.Outgoing.OpenGroupMessage( - openGroupParams - ); - return libsession.getMessageQueue().sendToGroup(openGroupMessage); - } - - let recipients = _.intersection(intendedRecipients, currentRecipients); - recipients = recipients.filter( - key => !successfulRecipients.includes(key) - ); - - if (!recipients.length) { - window.log.warn('retrySend: Nobody to send to!'); - - return this.commit(); - } - - const { body, attachments, preview, quote } = await this.uploadData(); - const ourNumber = window.storage.get('primaryDevicePubKey'); - const ourConversation = window - .getConversationController() - .get(ourNumber); - - const chatParams = { - identifier: this.id, - body, - timestamp: this.get('sent_at'), - expireTimer: this.get('expireTimer'), - attachments, - preview, - quote, - }; - if (ourConversation) { - chatParams.lokiProfile = ourConversation.getOurProfile(); - } - - const chatMessage = new libsession.Messages.Outgoing.ChatMessage( - chatParams - ); - - // Special-case the self-send case - we send only a sync message - if (recipients.length === 1) { - const isOurDevice = await libsession.Utils.UserUtils.isUs( - recipients[0] - ); - if (isOurDevice) { - return this.sendSyncMessageOnly(chatMessage); - } - } - - if (conversation.isPrivate()) { - const [number] = recipients; - const recipientPubKey = new libsession.Types.PubKey(number); - - return libsession - .getMessageQueue() - .sendToPubKey(recipientPubKey, chatMessage); - } - - // TODO should we handle medium groups message here too? - // Not sure there is the concept of retrySend for those - const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( - { - identifier: this.id, - chatMessage, - groupId: this.get('conversationId'), - } - ); - // Because this is a partial group send, we send the message with the groupId field set, but individually - // to each recipient listed - return Promise.all( - recipients.map(async r => { - const recipientPubKey = new libsession.Types.PubKey(r); - return libsession - .getMessageQueue() - .sendToPubKey(recipientPubKey, closedGroupChatMessage); - }) - ); - } catch (e) { - await this.saveErrors(e); - return null; - } - }, - - // Called when the user ran into an error with a specific user, wants to send to them - async resend(number) { - const error = this.removeOutgoingErrors(number); - if (!error) { - window.log.warn('resend: requested number was not present in errors'); - return null; - } - - try { - const { body, attachments, preview, quote } = await this.uploadData(); - - const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ - identifier: this.id, - body, - timestamp: this.get('sent_at'), - expireTimer: this.get('expireTimer'), - attachments, - preview, - quote, - }); - - // Special-case the self-send case - we send only a sync message - if (number === textsecure.storage.user.getNumber()) { - return this.sendSyncMessageOnly(chatMessage); - } - - const conversation = this.getConversation(); - const recipientPubKey = new libsession.Types.PubKey(number); - - if (conversation.isPrivate()) { - return libsession - .getMessageQueue() - .sendToPubKey(recipientPubKey, chatMessage); - } - - const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( - { - chatMessage, - groupId: this.get('conversationId'), - } - ); - // resend tries to send the message to that specific user only in the context of a closed group - return libsession - .getMessageQueue() - .sendToPubKey(recipientPubKey, closedGroupChatMessage); - } catch (e) { - await this.saveErrors(e); - return null; - } - }, + // async retrySend() { + // if (!textsecure.messaging) { + // window.log.error('retrySend: Cannot retry since we are offline!'); + // return null; + // } + + // this.set({ errors: null }); + // await this.commit(); + // try { + // const conversation = this.getConversation(); + // const intendedRecipients = this.get('recipients') || []; + // const successfulRecipients = this.get('sent_to') || []; + // const currentRecipients = conversation.getRecipients(); + + // if (conversation.isPublic()) { + // const openGroup = { + // server: conversation.get('server'), + // channel: conversation.get('channelId'), + // conversationId: conversation.id, + // }; + // const { body, attachments, preview, quote } = await this.uploadData(); + + // const openGroupParams = { + // identifier: this.id, + // body, + // timestamp: Date.now(), + // group: openGroup, + // attachments, + // preview, + // quote, + // }; + // const openGroupMessage = new libsession.Messages.Outgoing.OpenGroupMessage( + // openGroupParams + // ); + // return libsession.getMessageQueue().sendToGroup(openGroupMessage); + // } + + // let recipients = _.intersection(intendedRecipients, currentRecipients); + // recipients = recipients.filter( + // key => !successfulRecipients.includes(key) + // ); + + // if (!recipients.length) { + // window.log.warn('retrySend: Nobody to send to!'); + + // return this.commit(); + // } + + // const { body, attachments, preview, quote } = await this.uploadData(); + // const ourNumber = window.storage.get('primaryDevicePubKey'); + // const ourConversation = window + // .getConversationController() + // .get(ourNumber); + + // const chatParams = { + // identifier: this.id, + // body, + // timestamp: this.get('sent_at'), + // expireTimer: this.get('expireTimer'), + // attachments, + // preview, + // quote, + // }; + // if (ourConversation) { + // chatParams.lokiProfile = ourConversation.getOurProfile(); + // } + + // const chatMessage = new libsession.Messages.Outgoing.ChatMessage( + // chatParams + // ); + + // // Special-case the self-send case - we send only a sync message + // if (recipients.length === 1) { + // const isOurDevice = await libsession.Utils.UserUtils.isUs( + // recipients[0] + // ); + // if (isOurDevice) { + // return this.sendSyncMessageOnly(chatMessage); + // } + // } + + // if (conversation.isPrivate()) { + // const [number] = recipients; + // const recipientPubKey = new libsession.Types.PubKey(number); + + // return libsession + // .getMessageQueue() + // .sendToPubKey(recipientPubKey, chatMessage); + // } + + // // TODO should we handle medium groups message here too? + // // Not sure there is the concept of retrySend for those + // const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( + // { + // identifier: this.id, + // chatMessage, + // groupId: this.get('conversationId'), + // } + // ); + // // Because this is a partial group send, we send the message with the groupId field set, but individually + // // to each recipient listed + // return Promise.all( + // recipients.map(async r => { + // const recipientPubKey = new libsession.Types.PubKey(r); + // return libsession + // .getMessageQueue() + // .sendToPubKey(recipientPubKey, closedGroupChatMessage); + // }) + // ); + // } catch (e) { + // await this.saveErrors(e); + // return null; + // } + // }, + + // // Called when the user ran into an error with a specific user, wants to send to them + // async resend(number) { + // const error = this.removeOutgoingErrors(number); + // if (!error) { + // window.log.warn('resend: requested number was not present in errors'); + // return null; + // } + + // try { + // const { body, attachments, preview, quote } = await this.uploadData(); + + // const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ + // identifier: this.id, + // body, + // timestamp: this.get('sent_at'), + // expireTimer: this.get('expireTimer'), + // attachments, + // preview, + // quote, + // }); + + // // Special-case the self-send case - we send only a sync message + // if (number === textsecure.storage.user.getNumber()) { + // return this.sendSyncMessageOnly(chatMessage); + // } + + // const conversation = this.getConversation(); + // const recipientPubKey = new libsession.Types.PubKey(number); + + // if (conversation.isPrivate()) { + // return libsession + // .getMessageQueue() + // .sendToPubKey(recipientPubKey, chatMessage); + // } + + // const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( + // { + // chatMessage, + // groupId: this.get('conversationId'), + // } + // ); + // // resend tries to send the message to that specific user only in the context of a closed group + // return libsession + // .getMessageQueue() + // .sendToPubKey(recipientPubKey, closedGroupChatMessage); + // } catch (e) { + // await this.saveErrors(e); + // return null; + // } + // }, removeOutgoingErrors(number) { const errors = _.partition( this.get('errors'), @@ -1085,7 +1085,7 @@ // Handle the sync logic here if (shouldTriggerSyncMessage) { if (dataMessage) { - await this.sendSyncMessage(dataMessage); + await this.sendSyncMessage(dataMessage, sentMessage.timestamp); } } else if (shouldMarkMessageAsSynced) { this.set({ synced: true }); @@ -1098,6 +1098,7 @@ sent_to: sentTo, sent: true, expirationStartTimestamp: Date.now(), + sent_at: sentMessage.timestamp, }); await this.commit(); @@ -1283,7 +1284,7 @@ await this.sendSyncMessage(data); }, - async sendSyncMessage(/* dataMessage */) { + async sendSyncMessage(dataMessage, sentTimestamp) { if (this.get('synced') || this.get('sentSync')) { return; } @@ -1291,23 +1292,23 @@ window.log.error( 'sendSyncMessage to upgrade to multi device protocol v2' ); + // if this message needs to be synced + if ( + (dataMessage.body && dataMessage.body.length) || + dataMessage.attachments.length + ) { + const syncMessage = libsession.Messages.Outgoing.ChatMessage.buildSyncMessage( + dataMessage, + this.getConversation().id, + sentTimestamp + ); + await libsession.getMessageQueue().sendSyncMessage(syncMessage); + } - // const data = - // dataMessage instanceof libsession.Messages.Outgoing.DataMessage - // ? dataMessage.dataProto() - // : dataMessage; - - // const syncMessage = new libsession.Messages.Outgoing.SentSyncMessage({ - // timestamp: this.get('sent_at'), - // identifier: this.id, - // dataMessage: data, - // destination: this.get('destination'), - // expirationStartTimestamp: this.get('expirationStartTimestamp'), - // sent_to: this.get('sent_to'), - // unidentifiedDeliveries: this.get('unidentifiedDeliveries'), - // }); - - // await libsession.getMessageQueue().sendSyncMessage(syncMessage); + // - copy all fields from dataMessage and create a new ChatMessage + // - set the syncTarget on it + // - send it as syncMessage + // what to do with groups? this.set({ sentSync: true }); await this.commit(); diff --git a/package.json b/package.json index 19a9a0477..97c4258b4 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "test-electron": "yarn grunt test", "test-integration": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --full-trace --timeout 10000 ts/test/session/integration/integration_itest.js", "test-node": "mocha --recursive --exit --timeout 10000 test/app test/modules \"./ts/test/**/*_test.js\" libloki/test/node ", - "test-audric": "mocha --recursive --exit --timeout 10000 ts/test/session/unit/receiving/", + "test-audric": "mocha --recursive --exit --timeout 10000 ts/test/session/unit/", "eslint": "eslint --cache .", "eslint-fix": "eslint --fix .", "eslint-full": "eslint .", diff --git a/password_preload.js b/password_preload.js index 2e4f0693e..8d04b1f6f 100644 --- a/password_preload.js +++ b/password_preload.js @@ -34,12 +34,6 @@ window.Signal = { }, }; -window.CONSTANTS = { - MAX_LOGIN_TRIES: 3, - MAX_PASSWORD_LENGTH: 64, - MAX_USERNAME_LENGTH: 20, -}; - window.Signal.Logs = require('./js/modules/logs'); window.resetDatabase = () => { diff --git a/preload.js b/preload.js index 5a6f99f17..73ed478ee 100644 --- a/preload.js +++ b/preload.js @@ -78,14 +78,11 @@ window.isBeforeVersion = (toCheck, baseVersion) => { // eslint-disable-next-line func-names window.CONSTANTS = new (function() { - this.MAX_LOGIN_TRIES = 3; - this.MAX_PASSWORD_LENGTH = 64; - this.MAX_USERNAME_LENGTH = 20; this.MAX_GROUP_NAME_LENGTH = 64; this.DEFAULT_PUBLIC_CHAT_URL = appConfig.get('defaultPublicChatServer'); this.MAX_LINKED_DEVICES = 1; this.MAX_CONNECTION_DURATION = 5000; - this.CLOSED_GROUP_SIZE_LIMIT = 20; + this.CLOSED_GROUP_SIZE_LIMIT = 100; // Number of seconds to turn on notifications after reconnect/start of app this.NOTIFICATION_ENABLE_TIMEOUT_SECONDS = 10; this.SESSION_ID_LENGTH = 66; @@ -497,9 +494,10 @@ const { window.BlockedNumberController = BlockedNumberController; window.deleteAccount = async reason => { - try { - window.log.info('Deleting everything!'); - + const syncedMessageSent = async () => { + window.log.info( + 'configuration message sent successfully. Deleting everything' + ); await window.Signal.Logs.deleteAll(); await window.Signal.Data.removeAll(); await window.Signal.Data.close(); @@ -507,11 +505,24 @@ window.deleteAccount = async reason => { await window.Signal.Data.removeOtherData(); // 'unlink' => toast will be shown on app restart window.localStorage.setItem('restart-reason', reason); + }; + try { + window.log.info('DeleteAccount => Sending a last SyncConfiguration'); + // be sure to wait for the message being effectively sent. Otherwise we won't be able to encrypt it for our devices ! + await window.libsession.Utils.SyncUtils.forceSyncConfigurationNowIfNeeded( + true + ); + await syncedMessageSent(); } catch (error) { window.log.error( 'Something went wrong deleting all data:', error && error.stack ? error.stack : error ); + try { + await syncedMessageSent(); + } catch (e) { + window.log.error(e); + } } window.restart(); }; diff --git a/ts/components/EditProfileDialog.tsx b/ts/components/EditProfileDialog.tsx index 86c3d78a3..fed5ca092 100644 --- a/ts/components/EditProfileDialog.tsx +++ b/ts/components/EditProfileDialog.tsx @@ -19,6 +19,7 @@ import { SessionModal } from './session/SessionModal'; import { PillDivider } from './session/PillDivider'; import { ToastUtils } from '../session/utils'; import { DefaultTheme } from 'styled-components'; +import { MAX_USERNAME_LENGTH } from './session/RegistrationTabs'; interface Props { i18n: any; @@ -217,7 +218,7 @@ export class EditProfileDialog extends React.Component { value={this.state.profileName} placeholder={placeholderText} onChange={this.onNameEdited} - maxLength={window.CONSTANTS.MAX_USERNAME_LENGTH} + maxLength={MAX_USERNAME_LENGTH} tabIndex={0} required={true} aria-required={true} @@ -296,10 +297,7 @@ export class EditProfileDialog extends React.Component { private onClickOK() { const newName = this.state.profileName.trim(); - if ( - newName.length === 0 || - newName.length > window.CONSTANTS.MAX_USERNAME_LENGTH - ) { + if (newName.length === 0 || newName.length > MAX_USERNAME_LENGTH) { return; } diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index eec18a63f..243f5bd42 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -14,6 +14,8 @@ import { getFocusedSection } from '../../state/selectors/section'; import { getTheme } from '../../state/selectors/theme'; import { getOurNumber } from '../../state/selectors/user'; import { UserUtils } from '../../session/utils'; +import { syncConfigurationIfNeeded } from '../../session/utils/syncUtils'; +import { DAYS } from '../../session/utils/Number'; // tslint:disable-next-line: no-import-side-effect no-submodule-imports export enum SectionType { @@ -36,6 +38,8 @@ interface Props { } class ActionsPanelPrivate extends React.Component { + private syncInterval: NodeJS.Timeout | null = null; + constructor(props: Props) { super(props); @@ -57,6 +61,20 @@ class ActionsPanelPrivate extends React.Component { // remove existing prekeys, sign prekeys and sessions void window.getAccountManager().clearSessionsAndPreKeys(); + + // trigger a sync message if needed for our other devices + void syncConfigurationIfNeeded(); + + this.syncInterval = global.setInterval(() => { + void syncConfigurationIfNeeded(); + }, DAYS * 2); + } + + public componentWillUnmount() { + if (this.syncInterval) { + clearInterval(this.syncInterval); + this.syncInterval = null; + } } public Section = ({ diff --git a/ts/components/session/RegistrationTabs.tsx b/ts/components/session/RegistrationTabs.tsx index 2f5bc34e1..2f0542730 100644 --- a/ts/components/session/RegistrationTabs.tsx +++ b/ts/components/session/RegistrationTabs.tsx @@ -10,12 +10,13 @@ import { import { trigger } from '../../shims/events'; import { SessionHtmlRenderer } from './SessionHTMLRenderer'; import { SessionIdEditable } from './SessionIdEditable'; -import { SessionSpinner } from './SessionSpinner'; import { StringUtils, ToastUtils } from '../../session/utils'; import { lightTheme } from '../../state/ducks/SessionTheme'; import { ConversationController } from '../../session/conversations'; import { PasswordUtil } from '../../util'; +export const MAX_USERNAME_LENGTH = 20; + enum SignInMode { Default, UsingRecoveryPhrase, @@ -440,7 +441,7 @@ export class RegistrationTabs extends React.Component { type="text" placeholder={window.i18n('enterDisplayName')} value={this.state.displayName} - maxLength={window.CONSTANTS.MAX_USERNAME_LENGTH} + maxLength={MAX_USERNAME_LENGTH} onValueChanged={(val: string) => { this.onDisplayNameChanged(val); }} diff --git a/ts/components/session/SessionPasswordPrompt.tsx b/ts/components/session/SessionPasswordPrompt.tsx index 07b30ff00..250731b7c 100644 --- a/ts/components/session/SessionPasswordPrompt.tsx +++ b/ts/components/session/SessionPasswordPrompt.tsx @@ -16,6 +16,8 @@ interface State { clearDataView: boolean; } +export const MAX_LOGIN_TRIES = 3; + class SessionPasswordPromptInner extends React.PureComponent< { theme: DefaultTheme }, State @@ -44,8 +46,7 @@ class SessionPasswordPromptInner extends React.PureComponent< } public render() { - const showResetElements = - this.state.errorCount >= window.CONSTANTS.MAX_LOGIN_TRIES; + const showResetElements = this.state.errorCount >= MAX_LOGIN_TRIES; const wrapperClass = this.state.clearDataView ? 'clear-data-wrapper' @@ -163,8 +164,7 @@ class SessionPasswordPromptInner extends React.PureComponent< } private renderPasswordViewButtons(): JSX.Element { - const showResetElements = - this.state.errorCount >= window.CONSTANTS.MAX_LOGIN_TRIES; + const showResetElements = this.state.errorCount >= MAX_LOGIN_TRIES; return (
diff --git a/ts/receiver/attachments.ts b/ts/receiver/attachments.ts index 91616c963..70a51c7d0 100644 --- a/ts/receiver/attachments.ts +++ b/ts/receiver/attachments.ts @@ -1,7 +1,7 @@ import { MessageModel } from '../../js/models/messages'; import _ from 'lodash'; -import * as Data from '../../js/modules/data'; +import { saveMessage } from '../../js/modules/data'; export async function downloadAttachment(attachment: any) { const serverUrl = new URL(attachment.url).origin; @@ -56,6 +56,12 @@ export async function downloadAttachment(attachment: any) { if (!attachment.isRaw) { const { key, digest, size } = attachment; + if (!key || !digest) { + throw new Error( + 'Attachment is not raw but we do not have a key to decode it' + ); + } + data = await window.textsecure.crypto.decryptAttachment( data, window.Signal.Crypto.base64ToArrayBuffer(key), @@ -236,7 +242,7 @@ export async function queueAttachmentDownloads( } if (count > 0) { - await Data.saveMessage(message.attributes, { + await saveMessage(message.attributes, { Message: Whisper.Message, }); diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index e38c42e02..a45f025fc 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -12,7 +12,10 @@ import { } from '../session/crypto'; import { getMessageQueue } from '../session'; import { decryptWithSessionProtocol } from './contentMessage'; -import * as Data from '../../js/modules/data'; +import { + addClosedGroupEncryptionKeyPair, + removeAllClosedGroupEncryptionKeyPairs, +} from '../../js/modules/data'; import { ClosedGroupNewMessage, ClosedGroupNewMessageParams, @@ -23,6 +26,7 @@ import { getOurNumber } from '../session/utils/User'; import { UserUtils } from '../session/utils'; import { ConversationModel } from '../../js/models/conversations'; import _ from 'lodash'; +import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils'; export async function handleClosedGroupControlMessage( envelope: EnvelopePlus, @@ -30,13 +34,16 @@ export async function handleClosedGroupControlMessage( ) { const { type } = groupUpdate; const { Type } = SignalService.DataMessage.ClosedGroupControlMessage; + window.log.info( + ` handle closed group update from ${envelope.senderIdentity} about group ${envelope.source}` + ); if (BlockedNumberController.isGroupBlocked(PubKey.cast(envelope.source))) { window.log.warn('Message ignored; destined for blocked group'); await removeFromCache(envelope); return; } - + // We drop New closed group message from our other devices, as they will come as ConfigurationMessage instead if (type === Type.ENCRYPTION_KEY_PAIR) { await handleClosedGroupEncryptionKeyPair(envelope, groupUpdate); } else if (type === Type.NEW) { @@ -117,7 +124,7 @@ function sanityCheckNewGroup( return true; } -async function handleNewClosedGroup( +export async function handleNewClosedGroup( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage ) { @@ -134,6 +141,14 @@ async function handleNewClosedGroup( await removeFromCache(envelope); return; } + const ourPrimary = await UserUtils.getOurNumber(); + + if (envelope.senderIdentity === ourPrimary.key) { + window.log.warn( + 'Dropping new closed group updatemessage from our other device.' + ); + return removeFromCache(envelope); + } const { name, @@ -147,7 +162,6 @@ async function handleNewClosedGroup( const members = membersAsData.map(toHex); const admins = adminsAsData.map(toHex); - const ourPrimary = await UserUtils.getOurNumber(); if (!members.includes(ourPrimary.key)) { log.info( 'Got a new group message but apparently we are not a member of it. Dropping it.' @@ -219,7 +233,7 @@ async function handleNewClosedGroup( ); window.log.info(`Received a the encryptionKeyPair for new group ${groupId}`); - await Data.addClosedGroupEncryptionKeyPair(groupId, ecKeyPair.toHexKeyPair()); + await addClosedGroupEncryptionKeyPair(groupId, ecKeyPair.toHexKeyPair()); // start polling for this new group window.SwarmPolling.addGroupId(PubKey.cast(groupId)); @@ -258,9 +272,7 @@ async function handleUpdateClosedGroup( await removeFromCache(envelope); return; } - await window.Signal.Data.removeAllClosedGroupEncryptionKeyPairs( - groupPublicKey - ); + await removeAllClosedGroupEncryptionKeyPairs(groupPublicKey); // Disable typing: convo.set('isKickedFromGroup', true); window.SwarmPolling.removePubkey(groupPublicKey); @@ -320,6 +332,7 @@ async function handleClosedGroupEncryptionKeyPair( ) { return; } + window.log.info( `Got a group update for group ${envelope.source}, type: ENCRYPTION_KEY_PAIR` ); @@ -414,10 +427,7 @@ async function handleClosedGroupEncryptionKeyPair( ); // Store it - await Data.addClosedGroupEncryptionKeyPair( - groupPublicKey, - keyPair.toHexKeyPair() - ); + await addClosedGroupEncryptionKeyPair(groupPublicKey, keyPair.toHexKeyPair()); await removeFromCache(envelope); } @@ -520,7 +530,6 @@ async function handleClosedGroupMembersAdded( const membersNotAlreadyPresent = addedMembers.filter( m => !oldMembers.includes(m) ); - console.warn('membersNotAlreadyPresent', membersNotAlreadyPresent); window.log.info( `Got a group update for group ${envelope.source}, type: MEMBERS_ADDED` ); @@ -587,9 +596,7 @@ async function handleClosedGroupMembersRemoved( const ourPubKey = await UserUtils.getOurNumber(); const wasCurrentUserRemoved = !membersAfterUpdate.includes(ourPubKey.key); if (wasCurrentUserRemoved) { - await window.Signal.Data.removeAllClosedGroupEncryptionKeyPairs( - groupPubKey - ); + await removeAllClosedGroupEncryptionKeyPairs(groupPubKey); // Disable typing: convo.set('isKickedFromGroup', true); window.SwarmPolling.removePubkey(groupPubKey); @@ -658,9 +665,7 @@ async function handleClosedGroupMemberLeft( } if (didAdminLeave) { - await window.Signal.Data.removeAllClosedGroupEncryptionKeyPairs( - groupPublicKey - ); + await removeAllClosedGroupEncryptionKeyPairs(groupPublicKey); // Disable typing: convo.set('isKickedFromGroup', true); window.SwarmPolling.removePubkey(groupPublicKey); @@ -754,7 +759,7 @@ export async function createClosedGroup( `Creating a new group and an encryptionKeyPair for group ${groupPublicKey}` ); // tslint:disable-next-line: no-non-null-assertion - await Data.addClosedGroupEncryptionKeyPair( + await addClosedGroupEncryptionKeyPair( groupPublicKey, encryptionKeyPair.toHexKeyPair() ); @@ -766,6 +771,8 @@ export async function createClosedGroup( await Promise.all(promises); + await forceSyncConfigurationNowIfNeeded(); + window.inboxStore.dispatch( window.actionsCreators.openConversationExternal(groupPublicKey) ); diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index bf2f52096..a5667f9f1 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -5,15 +5,16 @@ import { getEnvelopeId } from './common'; import { removeFromCache, updateCache } from './cache'; import { SignalService } from '../protobuf'; import * as Lodash from 'lodash'; -import { PubKey } from '../session/types'; +import { OpenGroup, PubKey } from '../session/types'; import { BlockedNumberController } from '../util/blockedNumberController'; import { GroupUtils, UserUtils } from '../session/utils'; import { fromHexToArray, toHex } from '../session/utils/String'; import { concatUInt8Array, getSodium } from '../session/crypto'; import { ConversationController } from '../session/conversations'; -import * as Data from '../../js/modules/data'; +import { getAllEncryptionKeyPairsForGroup } from '../../js/modules/data'; import { ECKeyPair } from './keypairs'; +import { handleNewClosedGroup } from './closedGroups'; export async function handleContentMessage(envelope: EnvelopePlus) { try { @@ -45,7 +46,7 @@ async function decryptForClosedGroup( ); throw new Error('Invalid group public key'); // invalidGroupPublicKey } - const encryptionKeyPairs = await Data.getAllEncryptionKeyPairsForGroup( + const encryptionKeyPairs = await getAllEncryptionKeyPairsForGroup( hexEncodedGroupPublicKey ); const encryptionKeyPairsCount = encryptionKeyPairs?.length; @@ -101,18 +102,6 @@ async function decryptForClosedGroup( 'ClosedGroup Message decrypted successfully with keyIndex:', keyIndex ); - const ourDevicePubKey = await UserUtils.getCurrentDevicePubKey(); - - if ( - envelope.senderIdentity && - envelope.senderIdentity === ourDevicePubKey - ) { - await removeFromCache(envelope); - window.log.info( - 'Dropping message from our current device after decrypt for closed group' - ); - return null; - } return unpad(decryptedContent); } catch (e) { @@ -391,6 +380,14 @@ export async function innerHandleContentMessage( await handleTypingMessage(envelope, content.typingMessage); return; } + + if (content.configurationMessage) { + await handleConfigurationMessage( + envelope, + content.configurationMessage as SignalService.ConfigurationMessage + ); + return; + } } catch (e) { window.log.warn(e); } @@ -500,3 +497,57 @@ async function handleTypingMessage( }); } } + +async function handleConfigurationMessage( + envelope: EnvelopePlus, + configurationMessage: SignalService.ConfigurationMessage +): Promise { + const ourPubkey = await UserUtils.getCurrentDevicePubKey(); + if (!ourPubkey) { + return; + } + + if (envelope.source !== ourPubkey) { + window.log.info('dropping configuration change from someone else than us.'); + return removeFromCache(envelope); + } + + const numberClosedGroup = configurationMessage.closedGroups?.length || 0; + + window.log.warn( + `Received ${numberClosedGroup} closed group on configuration. Creating them... ` + ); + + await Promise.all( + configurationMessage.closedGroups.map(async c => { + const groupUpdate = new SignalService.DataMessage.ClosedGroupControlMessage( + { + type: SignalService.DataMessage.ClosedGroupControlMessage.Type.NEW, + encryptionKeyPair: c.encryptionKeyPair, + name: c.name, + admins: c.admins, + members: c.members, + publicKey: c.publicKey, + } + ); + await handleNewClosedGroup(envelope, groupUpdate); + }) + ); + + const allOpenGroups = OpenGroup.getAllAlreadyJoinedOpenGroupsUrl(); + const numberOpenGroup = configurationMessage.openGroups?.length || 0; + + // Trigger a join for all open groups we are not already in. + // Currently, if you left an open group but kept the conversation, you won't rejoin it here. + for (let i = 0; i < numberOpenGroup; i++) { + const current = configurationMessage.openGroups[i]; + if (!allOpenGroups.includes(current)) { + window.log.info( + `triggering join of public chat '${current}' from ConfigurationMessage` + ); + void OpenGroup.join(current); + } + } + + await removeFromCache(envelope); +} diff --git a/ts/receiver/dataMessage.ts b/ts/receiver/dataMessage.ts index cad896093..29f051a4b 100644 --- a/ts/receiver/dataMessage.ts +++ b/ts/receiver/dataMessage.ts @@ -151,7 +151,10 @@ function cleanAttachments(decrypted: any) { } } -export async function processDecrypted(envelope: EnvelopePlus, decrypted: any) { +export async function processDecrypted( + envelope: EnvelopePlus, + decrypted: SignalService.IDataMessage +) { /* tslint:disable:no-bitwise */ const FLAGS = SignalService.DataMessage.Flags; @@ -174,7 +177,7 @@ export async function processDecrypted(envelope: EnvelopePlus, decrypted: any) { } if (decrypted.group) { - decrypted.group.id = new TextDecoder('utf-8').decode(decrypted.group.id); + // decrypted.group.id = new TextDecoder('utf-8').decode(decrypted.group.id); switch (decrypted.group.type) { case SignalService.GroupContext.Type.UPDATE: @@ -200,7 +203,7 @@ export async function processDecrypted(envelope: EnvelopePlus, decrypted: any) { } } - const attachmentCount = decrypted.attachments.length; + const attachmentCount = decrypted?.attachments?.length || 0; const ATTACHMENT_MAX = 32; if (attachmentCount > ATTACHMENT_MAX) { await removeFromCache(envelope); @@ -211,7 +214,7 @@ export async function processDecrypted(envelope: EnvelopePlus, decrypted: any) { cleanAttachments(decrypted); - return decrypted; + return decrypted as SignalService.DataMessage; /* tslint:disable:no-bitwise */ } @@ -244,12 +247,22 @@ function isBodyEmpty(body: string) { return _.isEmpty(body); } +/** + * We have a few origins possible + * - if the message is from a private conversation with a friend and he wrote to us, + * the conversation to add the message to is our friend pubkey, so envelope.source + * - if the message is from a medium group conversation + * * envelope.source is the medium group pubkey + * * envelope.senderIdentity is the author pubkey (the one who sent the message) + * - at last, if the message is a syncMessage, + * * envelope.source is our pubkey (our other device has the same pubkey as us) + * * dataMessage.syncTarget is either the group public key OR the private conversation this message is about. + */ export async function handleDataMessage( envelope: EnvelopePlus, dataMessage: SignalService.IDataMessage ): Promise { - window.log.info('data message from', getEnvelopeId(envelope)); - + // we handle group updates from our other devices in handleClosedGroupControlMessage() if (dataMessage.closedGroupControlMessage) { await handleClosedGroupControlMessage( envelope, @@ -259,10 +272,23 @@ export async function handleDataMessage( } const message = await processDecrypted(envelope, dataMessage); - const ourPubKey = window.textsecure.storage.user.getNumber(); - const source = envelope.source; + const source = dataMessage.syncTarget || envelope.source; const senderPubKey = envelope.senderIdentity || envelope.source; - const isMe = senderPubKey === ourPubKey; + const isMe = await isUs(senderPubKey); + const isSyncMessage = Boolean(dataMessage.syncTarget?.length); + + window.log.info(`Handle dataMessage from ${source} `); + + if (isSyncMessage && !isMe) { + window.log.warn( + 'Got a sync message from someone else than me. Dropping it.' + ); + return removeFromCache(envelope); + } else if (isSyncMessage && dataMessage.syncTarget) { + // override the envelope source + envelope.source = dataMessage.syncTarget; + } + const senderConversation = await ConversationController.getInstance().getOrCreateAndWait( senderPubKey, 'private' @@ -281,13 +307,8 @@ export async function handleDataMessage( return removeFromCache(envelope); } - const ownDevice = await isUs(senderPubKey); - - const sourceConversation = ConversationController.getInstance().get(source); - const ownMessage = sourceConversation?.isMediumGroup() && ownDevice; - const ev: any = {}; - if (ownMessage) { + if (isMe) { // Data messages for medium groups don't arrive as sync messages. Instead, // linked devices poll for group messages independently, thus they need // to recognise some of those messages at their own. @@ -298,13 +319,14 @@ export async function handleDataMessage( if (envelope.senderIdentity) { message.group = { - id: envelope.source, + id: envelope.source as any, // FIXME Uint8Array vs string }; } ev.confirm = () => removeFromCache(envelope); ev.data = { source: senderPubKey, + destination: isMe ? message.syncTarget : undefined, sourceDevice: 1, timestamp: _.toNumber(envelope.timestamp), receivedAt: envelope.receivedAt, @@ -337,6 +359,7 @@ async function isMessageDuplicate({ Message: window.Whisper.Message, } ); + if (!result) { return false; } @@ -466,6 +489,7 @@ function createSentMessage(data: MessageCreationData): MessageModel { const { timestamp, serverTimestamp, + serverId, isPublic, receivedAt, sourceDevice, @@ -499,8 +523,10 @@ function createSentMessage(data: MessageCreationData): MessageModel { source: window.textsecure.storage.user.getNumber(), sourceDevice, serverTimestamp, + serverId, sent_at: timestamp, received_at: isPublic ? receivedAt : now, + isPublic, conversationId: destination, // conversation ID will might change later (if it is a group) type: 'outgoing', ...sentSpecificFields, @@ -529,7 +555,7 @@ function sendDeliveryReceipt(source: string, timestamp: any) { void getMessageQueue().sendToPubKey(device, receiptMessage); } -interface MessageEvent { +export interface MessageEvent { data: any; type: string; confirm: () => void; @@ -573,12 +599,11 @@ export async function handleMessageEvent(event: MessageEvent): Promise { source = source || msg.get('source'); if (await isMessageDuplicate(data)) { + window.log.info('Received duplicate message. Dropping it.'); confirm(); return; } - // TODO: this shouldn't be called when source is not a pubkey!!! - const isOurDevice = await UserUtils.isUs(source); const shouldSendReceipt = isIncoming && !isGroupMessage && !isOurDevice; @@ -613,9 +638,10 @@ export async function handleMessageEvent(event: MessageEvent): Promise { conversationId = source; } - // the conversation with the primary device of that source (can be the same as conversationOrigin) - - const conversation = ConversationController.getInstance().get(conversationId); + const conversation = await ConversationController.getInstance().getOrCreateAndWait( + conversationId, + isGroupMessage ? 'group' : 'private' + ); if (!conversation) { window.log.warn('Skipping handleJob for unknown convo: ', conversationId); diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index b0cdf5968..93fbb65db 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -366,7 +366,7 @@ async function handleRegularMessage( const now = new Date().getTime(); - // Medium grups might have `group` set even if with group chat messages... + // Medium groups might have `group` set even if with group chat messages... if (dataMessage.group && !conversation.isMediumGroup()) { // This is not necessarily a group update message, it could also be a regular group message const groupUpdate = await handleGroups( diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index 8c9e3ace9..02140be0e 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -302,5 +302,5 @@ export async function handlePublicMessage(messageData: any) { }, }; - await handleMessageEvent(ev); + await handleMessageEvent(ev); // open groups } diff --git a/ts/session/group/index.ts b/ts/session/group/index.ts index 3b68bafec..86aeb0024 100644 --- a/ts/session/group/index.ts +++ b/ts/session/group/index.ts @@ -1,5 +1,5 @@ import { PubKey } from '../types'; -import * as Data from '../../../js/modules/data'; + import _ from 'lodash'; import { fromHex, fromHexToArray, toHex } from '../utils/String'; @@ -14,6 +14,12 @@ import { ClosedGroupNewMessage, ExpirationTimerUpdateMessage, } from '../messages/outgoing'; +import { + addClosedGroupEncryptionKeyPair, + getIdentityKeyById, + getLatestClosedGroupEncryptionKeyPair, + removeAllClosedGroupEncryptionKeyPairs, +} from '../../../js/modules/data'; import uuid from 'uuid'; import { SignalService } from '../../protobuf'; import { generateCurve25519KeyPairWithoutPrefix } from '../crypto'; @@ -56,7 +62,7 @@ export interface MemberChanges { } export async function getGroupSecretKey(groupId: string): Promise { - const groupIdentity = await Data.getIdentityKeyById(groupId); + const groupIdentity = await getIdentityKeyById(groupId); if (!groupIdentity) { throw new Error(`Could not load secret key for group ${groupId}`); } @@ -72,12 +78,6 @@ export async function getGroupSecretKey(groupId: string): Promise { return new Uint8Array(fromHex(secretKey)); } -// Secondary devices are not expected to already have the group, so -// we send messages of type NEW -export async function syncMediumGroups(groups: Array) { - // await Promise.all(groups.map(syncMediumGroup)); -} - // tslint:disable: max-func-body-length // tslint:disable: cyclomatic-complexity export async function initiateGroupUpdate( @@ -127,7 +127,7 @@ export async function initiateGroupUpdate( const dbMessageAdded = await addUpdateMessage(convo, diff, 'outgoing'); window.getMessageController().register(dbMessageAdded.id, dbMessageAdded); // Check preconditions - const hexEncryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair( + const hexEncryptionKeyPair = await getLatestClosedGroupEncryptionKeyPair( groupId ); if (!hexEncryptionKeyPair) { @@ -180,7 +180,7 @@ export async function initiateGroupUpdate( window.log.info( `Leaving message sent ${groupId}. Removing everything related to this group.` ); - await Data.removeAllClosedGroupEncryptionKeyPairs(groupId); + await removeAllClosedGroupEncryptionKeyPairs(groupId); }); } else { // Send the group update, and only once sent, generate and distribute a new encryption key pair if needed @@ -482,7 +482,7 @@ export async function leaveClosedGroup(groupId: string) { window.log.info( `Leaving message sent ${groupId}. Removing everything related to this group.` ); - await Data.removeAllClosedGroupEncryptionKeyPairs(groupId); + await removeAllClosedGroupEncryptionKeyPairs(groupId); }); } @@ -524,7 +524,7 @@ async function sendAddedMembers( const admins = groupUpdate.admins || []; // Check preconditions - const hexEncryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair( + const hexEncryptionKeyPair = await getLatestClosedGroupEncryptionKeyPair( groupId ); if (!hexEncryptionKeyPair) { @@ -709,7 +709,7 @@ export async function generateAndSendNewEncryptionKeyPair( `KeyPairMessage for ClosedGroup ${groupPublicKey} is sent. Saving the new encryptionKeyPair.` ); - await Data.addClosedGroupEncryptionKeyPair( + await addClosedGroupEncryptionKeyPair( toHex(groupId), newKeyPair.toHexKeyPair() ); diff --git a/ts/session/messages/outgoing/Message.ts b/ts/session/messages/outgoing/Message.ts index 1c84975b6..98716b8b8 100644 --- a/ts/session/messages/outgoing/Message.ts +++ b/ts/session/messages/outgoing/Message.ts @@ -14,10 +14,9 @@ export abstract class Message { if (identifier && identifier.length === 0) { throw new Error('Cannot set empty identifier'); } + if (!timestamp) { + throw new Error('Cannot set undefined timestamp'); + } this.identifier = identifier || uuid(); } - - public isSelfSendValid() { - return false; - } } diff --git a/ts/session/messages/outgoing/content/ConfigurationMessage.ts b/ts/session/messages/outgoing/content/ConfigurationMessage.ts index 560c60532..a71f24af9 100644 --- a/ts/session/messages/outgoing/content/ConfigurationMessage.ts +++ b/ts/session/messages/outgoing/content/ConfigurationMessage.ts @@ -6,6 +6,7 @@ import { MessageParams } from '../Message'; import { Constants } from '../../..'; import { ECKeyPair } from '../../../../receiver/keypairs'; import { fromHexToArray } from '../../../utils/String'; +import { PubKey } from '../../../types'; interface ConfigurationMessageParams extends MessageParams { activeClosedGroups: Array; @@ -13,13 +14,21 @@ interface ConfigurationMessageParams extends MessageParams { } export class ConfigurationMessage extends ContentMessage { - private readonly activeClosedGroups: Array; - private readonly activeOpenGroups: Array; + public readonly activeClosedGroups: Array; + public readonly activeOpenGroups: Array; constructor(params: ConfigurationMessageParams) { super({ timestamp: params.timestamp, identifier: params.identifier }); this.activeClosedGroups = params.activeClosedGroups; this.activeOpenGroups = params.activeOpenGroups; + + if (!this.activeClosedGroups) { + throw new Error('closed group must be set'); + } + + if (!this.activeOpenGroups) { + throw new Error('open group must be set'); + } } public ttl(): number { @@ -73,6 +82,31 @@ export class ConfigurationMessageClosedGroup { this.encryptionKeyPair = encryptionKeyPair; this.members = members; this.admins = admins; + + // will throw if publik key is invalid + PubKey.cast(publicKey); + + if ( + !encryptionKeyPair?.privateKeyData?.byteLength || + !encryptionKeyPair?.publicKeyData?.byteLength + ) { + throw new Error('Encryption key pair looks invalid'); + } + + if (!this.name?.length) { + throw new Error('name must be set'); + } + + if (!this.members?.length) { + throw new Error('members must be set'); + } + if (!this.admins?.length) { + throw new Error('admins must be set'); + } + + if (this.admins.some(a => !this.members.includes(a))) { + throw new Error('some admins are not members'); + } } public toProto(): SignalService.ConfigurationMessage.ClosedGroup { diff --git a/ts/session/messages/outgoing/content/ContentMessage.ts b/ts/session/messages/outgoing/content/ContentMessage.ts index 4c7be2134..3ce436c36 100644 --- a/ts/session/messages/outgoing/content/ContentMessage.ts +++ b/ts/session/messages/outgoing/content/ContentMessage.ts @@ -1,6 +1,5 @@ import { Message } from '../Message'; import { SignalService } from '../../../../protobuf'; -import { Constants } from '../../..'; export abstract class ContentMessage extends Message { public plainTextBuffer(): Uint8Array { diff --git a/ts/session/messages/outgoing/content/data/ChatMessage.ts b/ts/session/messages/outgoing/content/data/ChatMessage.ts index 57ac293ab..147f65dff 100644 --- a/ts/session/messages/outgoing/content/data/ChatMessage.ts +++ b/ts/session/messages/outgoing/content/data/ChatMessage.ts @@ -4,6 +4,7 @@ import { MessageParams } from '../../Message'; import { LokiProfile } from '../../../../../types/Message'; import ByteBuffer from 'bytebuffer'; import { Constants } from '../../../..'; +import { isNumber, toNumber } from 'lodash'; export interface AttachmentPointer { id?: number; @@ -46,6 +47,7 @@ export interface ChatMessageParams extends MessageParams { expireTimer?: number; lokiProfile?: LokiProfile; preview?: Array; + syncTarget?: string; // null means it is not a synced message } export class ChatMessage extends DataMessage { @@ -59,6 +61,10 @@ export class ChatMessage extends DataMessage { private readonly avatarPointer?: string; private readonly preview?: Array; + /// In the case of a sync message, the public key of the person the message was targeted at. + /// - Note: `null or undefined` if this isn't a sync message. + private readonly syncTarget?: string; + constructor(params: ChatMessageParams) { super({ timestamp: params.timestamp, identifier: params.identifier }); this.attachments = params.attachments; @@ -74,6 +80,62 @@ export class ChatMessage extends DataMessage { this.displayName = params.lokiProfile && params.lokiProfile.displayName; this.avatarPointer = params.lokiProfile && params.lokiProfile.avatarPointer; this.preview = params.preview; + this.syncTarget = params.syncTarget; + } + + public static buildSyncMessage( + dataMessage: SignalService.IDataMessage, + syncTarget: string, + sentTimestamp: number + ) { + const lokiProfile: any = { + profileKey: dataMessage.profileKey, + }; + + if ((dataMessage as any)?.$type?.name !== 'DataMessage') { + throw new Error( + 'Tried to build a sync message from something else than a DataMessage' + ); + } + + if (!sentTimestamp || !isNumber(sentTimestamp)) { + throw new Error('Tried to build a sync message without a sentTimestamp'); + } + + if (dataMessage.profile) { + if (dataMessage.profile?.displayName) { + lokiProfile.displayName = dataMessage.profile.displayName; + } + if (dataMessage.profile?.profilePicture) { + lokiProfile.avatarPointer = dataMessage.profile.profilePicture; + } + } + + const timestamp = toNumber(sentTimestamp); + const body = dataMessage.body || undefined; + const attachments = (dataMessage.attachments || []).map(attachment => { + return { + ...attachment, + key: attachment.key + ? new Uint8Array((attachment.key as any).toArrayBuffer()) + : undefined, + digest: attachment.digest + ? new Uint8Array((attachment.digest as any).toArrayBuffer()) + : undefined, + }; + }) as Array; + const quote = (dataMessage.quote as Quote) || undefined; + const preview = (dataMessage.preview as Array) || []; + + return new ChatMessage({ + timestamp, + attachments, + body, + quote, + lokiProfile, + preview, + syncTarget, + }); } public ttl(): number { @@ -96,6 +158,9 @@ export class ChatMessage extends DataMessage { if (this.preview) { dataMessage.preview = this.preview; } + if (this.syncTarget) { + dataMessage.syncTarget = this.syncTarget; + } if (this.avatarPointer || this.displayName) { const profile = new SignalService.DataMessage.LokiProfile(); diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts index eeb47839b..24b5f63bd 100644 --- a/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupNewMessage.ts @@ -87,8 +87,4 @@ export class ClosedGroupNewMessage extends ClosedGroupMessage { return dataMessage; } - - public isSelfSendValid() { - return true; - } } diff --git a/ts/session/onions/index.ts b/ts/session/onions/index.ts index 53a2c1cd4..166d6f32a 100644 --- a/ts/session/onions/index.ts +++ b/ts/session/onions/index.ts @@ -1,5 +1,5 @@ import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives'; -import * as Data from '../../../js/modules/data'; +import { getGuardNodes } from '../../../js/modules/data'; import * as SnodePool from '../snode_api/snodePool'; import _ from 'lodash'; import fetch from 'node-fetch'; @@ -234,7 +234,7 @@ class OnionPaths { if (this.guardNodes.length === 0) { // Not cached, load from DB - const nodes = await Data.getGuardNodes(); + const nodes = await getGuardNodes(); if (nodes.length === 0) { log.warn( diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts index 8ad9c8697..0beb1884a 100644 --- a/ts/session/sending/MessageQueue.ts +++ b/ts/session/sending/MessageQueue.ts @@ -5,7 +5,9 @@ import { MessageQueueInterfaceEvents, } from './MessageQueueInterface'; import { + ChatMessage, ContentMessage, + DataMessage, ExpirationTimerUpdateMessage, OpenGroupMessage, } from '../messages/outgoing'; @@ -14,6 +16,7 @@ import { JobQueue, TypedEventEmitter, UserUtils } from '../utils'; import { PubKey, RawMessage } from '../types'; import { MessageSender } from '.'; import { ClosedGroupMessage } from '../messages/outgoing/content/data/group/ClosedGroupMessage'; +import { ConfigurationMessage } from '../messages/outgoing/content/ConfigurationMessage'; export class MessageQueue implements MessageQueueInterface { public readonly events: TypedEventEmitter; @@ -31,9 +34,12 @@ export class MessageQueue implements MessageQueueInterface { message: ContentMessage, sentCb?: (message: RawMessage) => Promise ): Promise { - // if (message instanceof SyncMessage) { - // return this.sendSyncMessage(message); - // } + if ( + message instanceof ConfigurationMessage || + !!(message as any).syncTarget + ) { + throw new Error('SyncMessage needs to be sent with sendSyncMessage'); + } await this.sendMessageToDevices([user], message); } @@ -43,9 +49,12 @@ export class MessageQueue implements MessageQueueInterface { message: ContentMessage, sentCb?: (message: RawMessage) => Promise ): Promise { - // if (message instanceof SyncMessage) { - // return this.sendSyncMessage(message); - // } + if ( + message instanceof ConfigurationMessage || + !!(message as any).syncTarget + ) { + throw new Error('SyncMessage needs to be sent with sendSyncMessage'); + } await this.sendMessageToDevices([device], message, sentCb); } @@ -107,20 +116,26 @@ export class MessageQueue implements MessageQueueInterface { } public async sendSyncMessage( - message: any | undefined, + message?: ContentMessage, sentCb?: (message: RawMessage) => Promise ): Promise { if (!message) { return; } + if ( + !(message instanceof ConfigurationMessage) && + !(message as any)?.syncTarget + ) { + throw new Error('Invalid message given to sendSyncMessage'); + } + const ourPubKey = await UserUtils.getCurrentDevicePubKey(); if (!ourPubKey) { throw new Error('ourNumber is not set'); } - window.log.warn('sendSyncMessage TODO with syncTarget'); await this.sendMessageToDevices([PubKey.cast(ourPubKey)], message, sentCb); } @@ -184,7 +199,16 @@ export class MessageQueue implements MessageQueueInterface { // Don't send to ourselves const currentDevice = await UserUtils.getCurrentDevicePubKey(); if (currentDevice && device.isEqual(currentDevice)) { - return; + // We allow a message for ourselve only if it's a ConfigurationMessage or a message with a syncTarget set + if ( + message instanceof ConfigurationMessage || + (message as any).syncTarget?.length > 0 + ) { + window.log.warn('Processing sync message'); + } else { + window.log.warn('Dropping message in process() to be sent to ourself'); + return; + } } await this.pendingMessageCache.add(device, message, sentCb); diff --git a/ts/session/sending/MessageQueueInterface.ts b/ts/session/sending/MessageQueueInterface.ts index e6a2ad260..4fe5ac837 100644 --- a/ts/session/sending/MessageQueueInterface.ts +++ b/ts/session/sending/MessageQueueInterface.ts @@ -25,6 +25,9 @@ export interface MessageQueueInterface { message: GroupMessageType, sentCb?: (message?: RawMessage) => Promise ): Promise; - sendSyncMessage(message: any): Promise; + sendSyncMessage( + message: any, + sentCb?: (message?: RawMessage) => Promise + ): Promise; processPending(device: PubKey): Promise; } diff --git a/ts/session/snode_api/snodePool.ts b/ts/session/snode_api/snodePool.ts index 13da2178f..b5c61cf98 100644 --- a/ts/session/snode_api/snodePool.ts +++ b/ts/session/snode_api/snodePool.ts @@ -9,7 +9,10 @@ import { requestSnodesForPubkey, } from './serviceNodeAPI'; -import * as Data from '../../../js/modules/data'; +import { + getSwarmNodesForPubkey, + updateSwarmNodesForPubkey, +} from '../../../js/modules/data'; import semver from 'semver'; import _ from 'lodash'; @@ -329,7 +332,7 @@ export async function updateSnodesFor( async function internalUpdateSnodesFor(pubkey: string, edkeys: Array) { nodesForPubkey.set(pubkey, edkeys); - await Data.updateSwarmNodesForPubkey(pubkey, edkeys); + await updateSwarmNodesForPubkey(pubkey, edkeys); } export async function getSnodesFor(pubkey: string): Promise> { @@ -339,7 +342,7 @@ export async function getSnodesFor(pubkey: string): Promise> { // NOTE: important that maybeNodes is not [] here if (maybeNodes === undefined) { // First time access, try the database: - nodes = await Data.getSwarmNodesForPubkey(pubkey); + nodes = await getSwarmNodesForPubkey(pubkey); nodesForPubkey.set(pubkey, nodes); } else { nodes = maybeNodes; diff --git a/ts/session/snode_api/swarmPolling.ts b/ts/session/snode_api/swarmPolling.ts index 613562366..8099671d4 100644 --- a/ts/session/snode_api/swarmPolling.ts +++ b/ts/session/snode_api/swarmPolling.ts @@ -4,7 +4,12 @@ import { retrieveNextMessages } from './serviceNodeAPI'; import { SignalService } from '../../protobuf'; import * as Receiver from '../../receiver/receiver'; import _ from 'lodash'; -import * as Data from '../../../js/modules/data'; +import { + getLastHashBySnode, + getSeenMessagesByHashList, + saveSeenMessageHashes, + updateLastHash, +} from '../../../js/modules/data'; import { StringUtils } from '../../session/utils'; import { ConversationController } from '../conversations/ConversationController'; @@ -180,7 +185,7 @@ export class SwarmPolling { const incomingHashes = messages.map((m: Message) => m.hash); - const dupHashes = await Data.getSeenMessagesByHashList(incomingHashes); + const dupHashes = await getSeenMessagesByHashList(incomingHashes); const newMessages = messages.filter( (m: Message) => !dupHashes.includes(m.hash) ); @@ -190,7 +195,7 @@ export class SwarmPolling { expiresAt: m.expiration, hash: m.hash, })); - await Data.saveSeenMessageHashes(newHashes); + await saveSeenMessageHashes(newHashes); } return newMessages; } @@ -220,7 +225,7 @@ export class SwarmPolling { ): Promise { const pkStr = pubkey.key; - await Data.updateLastHash({ + await updateLastHash({ convoId: pkStr, snode: edkey, hash, @@ -243,7 +248,7 @@ export class SwarmPolling { const nodeRecords = this.lastHashes[nodeEdKey]; if (!nodeRecords || !nodeRecords[pubkey]) { - const lastHash = await Data.getLastHashBySnode(pubkey, nodeEdKey); + const lastHash = await getLastHashBySnode(pubkey, nodeEdKey); return lastHash || ''; } else { diff --git a/ts/session/types/OpenGroup.ts b/ts/session/types/OpenGroup.ts index e4e938843..04266456e 100644 --- a/ts/session/types/OpenGroup.ts +++ b/ts/session/types/OpenGroup.ts @@ -1,6 +1,7 @@ import { ConversationModel } from '../../../js/models/conversations'; import { ConversationController } from '../conversations'; import { PromiseUtils } from '../utils'; +import { forceSyncConfigurationNowIfNeeded } from '../utils/syncUtils'; interface OpenGroupParams { server: string; @@ -52,6 +53,15 @@ export class OpenGroup { return this.serverRegex.test(serverUrl); } + public static getAllAlreadyJoinedOpenGroupsUrl(): Array { + const convos = ConversationController.getInstance().getConversations(); + return convos + .filter(c => !!c.get('active_at') && c.isPublic() && !c.get('left')) + .map(c => c.id.substring((c.id as string).lastIndexOf('@') + 1)) as Array< + string + >; + } + /** * Try to make a new instance of `OpenGroup`. * This does NOT respect `ConversationController` and does not guarentee the conversation's existence. @@ -95,7 +105,10 @@ export class OpenGroup { * @param onLoading Callback function to be called once server begins connecting * @returns `OpenGroup` if connection success or if already connected */ - public static async join(server: string): Promise { + public static async join( + server: string, + fromSyncMessage: boolean = false + ): Promise { const prefixedServer = OpenGroup.prefixify(server); if (!OpenGroup.validate(server)) { return; @@ -130,6 +143,12 @@ export class OpenGroup { throw new Error(window.i18n('connectToServerFail')); } conversationId = (conversation as any)?.cid; + + // here we managed to connect to the group. + // if this is not a Sync Message, we should trigger one + if (!fromSyncMessage) { + await forceSyncConfigurationNowIfNeeded(); + } } catch (e) { throw new Error(e); } diff --git a/ts/session/utils/Messages.ts b/ts/session/utils/Messages.ts index 8f6931862..623875d4c 100644 --- a/ts/session/utils/Messages.ts +++ b/ts/session/utils/Messages.ts @@ -12,7 +12,7 @@ import { ConfigurationMessageClosedGroup, } from '../messages/outgoing/content/ConfigurationMessage'; import uuid from 'uuid'; -import * as Data from '../../../js/modules/data'; +import { getLatestClosedGroupEncryptionKeyPair } from '../../../js/modules/data'; import { UserUtils } from '.'; import { ECKeyPair } from '../../receiver/keypairs'; import _ from 'lodash'; @@ -66,26 +66,24 @@ export const getCurrentConfigurationMessage = async ( ) => { const ourPubKey = (await UserUtils.getOurNumber()).key; const openGroupsIds = convos - .filter( - c => - !!c.get('active_at') && - c.get('members').includes(ourPubKey) && - c.isPublic() && - !c.get('left') - ) - .map(c => c.id) as Array; + .filter(c => !!c.get('active_at') && c.isPublic() && !c.get('left')) + .map(c => c.id.substring((c.id as string).lastIndexOf('@') + 1)) as Array< + string + >; const closedGroupModels = convos.filter( c => !!c.get('active_at') && c.isMediumGroup() && + c.get('members').includes(ourPubKey) && !c.get('left') && - !c.get('isKickedFromGroup') + !c.get('isKickedFromGroup') && + !c.isBlocked() ); const closedGroups = await Promise.all( closedGroupModels.map(async c => { const groupPubKey = c.get('id'); - const fetchEncryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair( + const fetchEncryptionKeyPair = await getLatestClosedGroupEncryptionKeyPair( groupPubKey ); if (!fetchEncryptionKeyPair) { diff --git a/ts/session/utils/index.ts b/ts/session/utils/index.ts index 642c5171b..85a377513 100644 --- a/ts/session/utils/index.ts +++ b/ts/session/utils/index.ts @@ -7,6 +7,7 @@ import * as ProtobufUtils from './Protobuf'; import * as MenuUtils from '../../components/session/menu/Menu'; import * as ToastUtils from './Toast'; import * as UserUtils from './User'; +import * as SyncUtils from './syncUtils'; export * from './Attachments'; export * from './TypedEmitter'; @@ -22,4 +23,5 @@ export { MenuUtils, ToastUtils, UserUtils, + SyncUtils, }; diff --git a/ts/session/utils/syncUtils.ts b/ts/session/utils/syncUtils.ts new file mode 100644 index 000000000..dc0a62b2e --- /dev/null +++ b/ts/session/utils/syncUtils.ts @@ -0,0 +1,80 @@ +import { createOrUpdateItem, getItemById } from '../../../js/modules/data'; +import { getMessageQueue } from '..'; +import { ConversationController } from '../conversations'; +import { getCurrentConfigurationMessage } from './Messages'; +import { RawMessage } from '../types'; +import { DAYS } from './Number'; + +const ITEM_ID_LAST_SYNC_TIMESTAMP = 'lastSyncedTimestamp'; + +const getLastSyncTimestampFromDb = async (): Promise => + (await getItemById(ITEM_ID_LAST_SYNC_TIMESTAMP))?.value; + +const writeLastSyncTimestampToDb = async (timestamp: number) => + createOrUpdateItem({ id: ITEM_ID_LAST_SYNC_TIMESTAMP, value: timestamp }); + +export const syncConfigurationIfNeeded = async () => { + const lastSyncedTimestamp = (await getLastSyncTimestampFromDb()) || 0; + const now = Date.now(); + + // if the last sync was less than 2 days before, return early. + if (Math.abs(now - lastSyncedTimestamp) < DAYS * 2) { + return; + } + + const allConvos = ConversationController.getInstance().getConversations(); + const configMessage = await getCurrentConfigurationMessage(allConvos); + try { + window.log.info('syncConfigurationIfNeeded with', configMessage); + + await getMessageQueue().sendSyncMessage(configMessage); + } catch (e) { + window.log.warn( + 'Caught an error while sending our ConfigurationMessage:', + e + ); + // we do return early so that next time we use the old timestamp again + // and so try again to trigger a sync + return; + } + await writeLastSyncTimestampToDb(now); +}; + +export const forceSyncConfigurationNowIfNeeded = async ( + waitForMessageSent = false +) => { + const allConvos = ConversationController.getInstance().getConversations(); + const configMessage = await getCurrentConfigurationMessage(allConvos); + window.log.info('forceSyncConfigurationNowIfNeeded with', configMessage); + + const waitForMessageSentEvent = new Promise(resolve => { + const ourResolver = (message: any) => { + if (message.identifier === configMessage.identifier) { + getMessageQueue().events.off('sendSuccess', ourResolver); + getMessageQueue().events.off('sendFail', ourResolver); + resolve(true); + } + }; + getMessageQueue().events.on('sendSuccess', ourResolver); + getMessageQueue().events.on('sendFail', ourResolver); + }); + + try { + // this just adds the message to the sending queue. + // if waitForMessageSent is set, we need to effectively wait until then + await Promise.all([ + getMessageQueue().sendSyncMessage(configMessage), + waitForMessageSentEvent, + ]); + } catch (e) { + window.log.warn( + 'Caught an error while sending our ConfigurationMessage:', + e + ); + } + if (!waitForMessageSent) { + return; + } + + return waitForMessageSentEvent; +}; diff --git a/ts/test/session/unit/messages/ConfigurationMessage_test.ts b/ts/test/session/unit/messages/ConfigurationMessage_test.ts new file mode 100644 index 000000000..8ceb988fb --- /dev/null +++ b/ts/test/session/unit/messages/ConfigurationMessage_test.ts @@ -0,0 +1,141 @@ +import { expect } from 'chai'; +import { ECKeyPair } from '../../../../receiver/keypairs'; + +import { + ConfigurationMessage, + ConfigurationMessageClosedGroup, +} from '../../../../session/messages/outgoing/content/ConfigurationMessage'; +import { PubKey } from '../../../../session/types'; +import { TestUtils } from '../../../test-utils'; + +describe('ConfigurationMessage', () => { + it('throw if closed group is not set', () => { + const activeClosedGroups = null as any; + const params = { + activeClosedGroups, + activeOpenGroups: [], + timestamp: Date.now(), + }; + expect(() => new ConfigurationMessage(params)).to.throw( + 'closed group must be set' + ); + }); + + it('throw if open group is not set', () => { + const activeOpenGroups = null as any; + const params = { + activeClosedGroups: [], + activeOpenGroups, + timestamp: Date.now(), + }; + expect(() => new ConfigurationMessage(params)).to.throw( + 'open group must be set' + ); + }); + + describe('ConfigurationMessageClosedGroup', () => { + it('throw if closed group has no encryptionkeypair', () => { + const member = TestUtils.generateFakePubKey().key; + const params = { + publicKey: TestUtils.generateFakePubKey().key, + name: 'groupname', + members: [member], + admins: [member], + encryptionKeyPair: undefined as any, + }; + + expect(() => new ConfigurationMessageClosedGroup(params)).to.throw( + 'Encryption key pair looks invalid' + ); + }); + + it('throw if closed group has invalid encryptionkeypair', () => { + const member = TestUtils.generateFakePubKey().key; + const params = { + publicKey: TestUtils.generateFakePubKey().key, + name: 'groupname', + members: [member], + admins: [member], + encryptionKeyPair: new ECKeyPair(new Uint8Array(), new Uint8Array()), + }; + + expect(() => new ConfigurationMessageClosedGroup(params)).to.throw( + 'Encryption key pair looks invalid' + ); + }); + + it('throw if closed group has invalid pubkey', () => { + const member = TestUtils.generateFakePubKey().key; + const params = { + publicKey: 'invalidpubkey', + name: 'groupname', + members: [member], + admins: [member], + encryptionKeyPair: TestUtils.generateFakeECKeyPair(), + }; + + expect(() => new ConfigurationMessageClosedGroup(params)).to.throw(); + }); + + it('throw if closed group has invalid name', () => { + const member = TestUtils.generateFakePubKey().key; + const params = { + publicKey: TestUtils.generateFakePubKey().key, + name: '', + members: [member], + admins: [member], + encryptionKeyPair: TestUtils.generateFakeECKeyPair(), + }; + + expect(() => new ConfigurationMessageClosedGroup(params)).to.throw( + 'name must be set' + ); + }); + + it('throw if members is empty', () => { + const member = TestUtils.generateFakePubKey().key; + const params = { + publicKey: TestUtils.generateFakePubKey().key, + name: 'groupname', + members: [], + admins: [member], + encryptionKeyPair: TestUtils.generateFakeECKeyPair(), + }; + + expect(() => new ConfigurationMessageClosedGroup(params)).to.throw( + 'members must be set' + ); + }); + + it('throw if admins is empty', () => { + const member = TestUtils.generateFakePubKey().key; + const params = { + publicKey: TestUtils.generateFakePubKey().key, + name: 'groupname', + members: [member], + admins: [], + encryptionKeyPair: TestUtils.generateFakeECKeyPair(), + }; + + expect(() => new ConfigurationMessageClosedGroup(params)).to.throw( + 'admins must be set' + ); + }); + + it('throw if some admins are not members', () => { + const member = TestUtils.generateFakePubKey().key; + const admin = TestUtils.generateFakePubKey().key; + const params = { + publicKey: TestUtils.generateFakePubKey().key, + name: 'groupname', + members: [member], + admins: [admin], + encryptionKeyPair: TestUtils.generateFakeECKeyPair(), + }; + + expect(() => new ConfigurationMessageClosedGroup(params)).to.throw( + 'some admins are not members' + ); + }); + }); +}); diff --git a/ts/test/session/unit/utils/Messages_test.ts b/ts/test/session/unit/utils/Messages_test.ts index 68dfd0003..b9b4d91e7 100644 --- a/ts/test/session/unit/utils/Messages_test.ts +++ b/ts/test/session/unit/utils/Messages_test.ts @@ -1,7 +1,7 @@ import chai from 'chai'; import * as sinon from 'sinon'; import { TestUtils } from '../../../test-utils'; -import { MessageUtils } from '../../../../session/utils'; +import { MessageUtils, UserUtils } from '../../../../session/utils'; import { EncryptionType, PubKey } from '../../../../session/types'; import { ClosedGroupChatMessage } from '../../../../session/messages/outgoing/content/data/group/ClosedGroupChatMessage'; import { @@ -14,6 +14,9 @@ import { ClosedGroupNameChangeMessage, ClosedGroupRemovedMembersMessage, } from '../../../../session/messages/outgoing/content/data/group'; +import { ConversationModel } from '../../../../../js/models/conversations'; +import { MockConversation } from '../../../test-utils/utils'; +import { ConfigurationMessage } from '../../../../session/messages/outgoing/content/ConfigurationMessage'; // tslint:disable-next-line: no-require-imports no-var-requires const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); @@ -203,5 +206,70 @@ describe('Message Utils', () => { const rawMessage = await MessageUtils.toRawMessage(device, msg); expect(rawMessage.encryption).to.equal(EncryptionType.ClosedGroup); }); + + it('passing a ConfigurationMessage returns Fallback', async () => { + const device = TestUtils.generateFakePubKey(); + + const msg = new ConfigurationMessage({ + timestamp: Date.now(), + activeOpenGroups: [], + activeClosedGroups: [], + }); + const rawMessage = await MessageUtils.toRawMessage(device, msg); + expect(rawMessage.encryption).to.equal(EncryptionType.Fallback); + }); + }); + + describe('getCurrentConfigurationMessage', () => { + const ourNumber = TestUtils.generateFakePubKey().key; + + let convos: Array; + const mockValidOpenGroup = new MockConversation({ + type: 'public', + id: 'publicChat:1@chat-dev.lokinet.org', + }); + + const mockValidOpenGroup2 = new MockConversation({ + type: 'public', + id: 'publicChat:1@chat-dev2.lokinet.org', + }); + + const mockValidClosedGroup = new MockConversation({ + type: 'group', + }); + + const mockValidPrivate = { + id: TestUtils.generateFakePubKey(), + isMediumGroup: () => false, + isPublic: () => false, + }; + + beforeEach(() => { + convos = []; + sandbox.stub(UserUtils, 'getCurrentDevicePubKey').resolves(ourNumber); + sandbox.stub(UserUtils, 'getOurNumber').resolves(PubKey.cast(ourNumber)); + }); + + beforeEach(() => { + convos = []; + sandbox.restore(); + }); + + // it('filter out non active open groups', async () => { + // // override the first open group and make it inactive + // (mockValidOpenGroup as any).attributes.active_at = undefined; + + // convos.push( + // mockValidOpenGroup as any, + // mockValidOpenGroup as any, + // mockValidPrivate as any, + // mockValidClosedGroup as any, + // mockValidOpenGroup2 as any + // ); + + // const configMessage = await getCurrentConfigurationMessage(convos); + // expect(configMessage.activeOpenGroups.length).to.equal(1); + // expect(configMessage.activeOpenGroups[0]).to.equal('chat-dev2.lokinet.org'); + // }); }); }); diff --git a/ts/test/session/unit/utils/Password.ts b/ts/test/session/unit/utils/Password.ts index f52d78e2f..d27d66891 100644 --- a/ts/test/session/unit/utils/Password.ts +++ b/ts/test/session/unit/utils/Password.ts @@ -58,7 +58,7 @@ describe('Password Util', () => { }); it('should return an error if password is not between 6 and 64 characters', () => { - const invalid = ['a', 'abcde', '#'.repeat(51), '#'.repeat(100)]; + const invalid = ['a', 'abcde', '#'.repeat(65), '#'.repeat(100)]; invalid.forEach(pass => { assert.strictEqual( PasswordUtil.validatePassword(pass), diff --git a/ts/test/session/unit/utils/SyncUtils_test.ts b/ts/test/session/unit/utils/SyncUtils_test.ts new file mode 100644 index 000000000..d3c951775 --- /dev/null +++ b/ts/test/session/unit/utils/SyncUtils_test.ts @@ -0,0 +1,31 @@ +import chai from 'chai'; +import * as sinon from 'sinon'; +import { ConversationController } from '../../../../session/conversations'; +import * as MessageUtils from '../../../../session/utils/Messages'; +import { syncConfigurationIfNeeded } from '../../../../session/utils/syncUtils'; +import { TestUtils } from '../../../test-utils'; +import { restoreStubs } from '../../../test-utils/utils'; +// tslint:disable-next-line: no-require-imports no-var-requires +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('SyncUtils', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + restoreStubs(); + }); + + describe('syncConfigurationIfNeeded', () => { + it('sync if last sync undefined', async () => { + // TestUtils.stubData('getItemById').resolves(undefined); + // sandbox.stub(ConversationController.getInstance(), 'getConversations').returns([]); + // const getCurrentConfigurationMessageSpy = sandbox.spy(MessageUtils, 'getCurrentConfigurationMessage'); + // await syncConfigurationIfNeeded(); + // expect(getCurrentConfigurationMessageSpy.callCount).equal(1); + }); + }); +}); diff --git a/ts/test/test-utils/utils/message.ts b/ts/test/test-utils/utils/message.ts index 5fe96c987..d7754bc83 100644 --- a/ts/test/test-utils/utils/message.ts +++ b/ts/test/test-utils/utils/message.ts @@ -50,44 +50,36 @@ export function generateClosedGroupMessage( interface MockConversationParams { id?: string; - type: MockConversationType; members?: Array; -} - -export enum MockConversationType { - Primary = 'primary', - Secondary = 'secondary', - Group = 'group', + type: 'private' | 'group' | 'public'; + isMediumGroup?: boolean; } export class MockConversation { public id: string; - public type: MockConversationType; + public type: 'private' | 'group' | 'public'; public attributes: ConversationAttributes; - public isPrimary?: boolean; constructor(params: MockConversationParams) { - const dayInSeconds = 86400; - - this.type = params.type; this.id = params.id ?? generateFakePubKey().key; - this.isPrimary = this.type === MockConversationType.Primary; - const members = - this.type === MockConversationType.Group - ? params.members ?? generateFakePubKeys(10).map(m => m.key) - : []; + const members = params.isMediumGroup + ? params.members ?? generateFakePubKeys(10).map(m => m.key) + : []; + + this.type = params.type; this.attributes = { id: this.id, name: '', - type: '', + type: params.type === 'public' ? 'group' : params.type, members, left: false, - expireTimer: dayInSeconds, + expireTimer: 0, profileSharing: true, mentionedUs: false, - unreadCount: 99, + unreadCount: 5, + isKickedFromGroup: false, active_at: Date.now(), timestamp: Date.now(), lastJoinedTimestamp: Date.now(), @@ -95,13 +87,21 @@ export class MockConversation { } public isPrivate() { - return true; + return this.type === 'private'; } public isBlocked() { return false; } + public isPublic() { + return this.id.match(/^publicChat:/); + } + + public isMediumGroup() { + return this.type === 'group'; + } + public get(obj: string) { return (this.attributes as any)[obj]; } diff --git a/ts/util/passwordUtils.ts b/ts/util/passwordUtils.ts index 48ba154a3..d91bf624f 100644 --- a/ts/util/passwordUtils.ts +++ b/ts/util/passwordUtils.ts @@ -13,6 +13,8 @@ const sha512 = (text: string) => { return hash.digest('hex'); }; +export const MAX_PASSWORD_LENGTH = 64; + export const generateHash = (phrase: string) => phrase && sha512(phrase.trim()); export const matchesHash = (phrase: string | null, hash: string) => phrase && sha512(phrase.trim()) === hash.trim(); @@ -27,10 +29,7 @@ export const validatePassword = (phrase: string, i18n?: LocalizerType) => { return i18n ? i18n('noGivenPassword') : ERRORS.LENGTH; } - if ( - trimmed.length < 6 || - trimmed.length > window.CONSTANTS.MAX_PASSWORD_LENGTH - ) { + if (trimmed.length < 6 || trimmed.length > MAX_PASSWORD_LENGTH) { return i18n ? i18n('passwordLengthError') : ERRORS.LENGTH; } From c7fa765b65bc3709d2346d300c6374eb1a0667f9 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 9 Feb 2021 14:44:23 +1100 Subject: [PATCH 3/3] be sure to have a conversationId on handleMessageEvent() --- ts/receiver/dataMessage.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ts/receiver/dataMessage.ts b/ts/receiver/dataMessage.ts index 29f051a4b..153b63560 100644 --- a/ts/receiver/dataMessage.ts +++ b/ts/receiver/dataMessage.ts @@ -583,7 +583,12 @@ export async function handleMessageEvent(event: MessageEvent): Promise { ? ConversationType.GROUP : ConversationType.PRIVATE; - let conversationId = isIncoming ? source : destination; + let conversationId = isIncoming ? source : destination || source; // for synced message + if (!conversationId) { + window.log.error('We cannot handle a message without a conversationId'); + confirm(); + return; + } if (message.profileKey?.length) { await handleProfileUpdate( message.profileKey,