Add multi device v2 support

pull/1493/head
Audric Ackermann 4 years ago
parent 64737a89d7
commit b88ea110e8
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -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');
}

@ -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() {

@ -109,6 +109,9 @@ export interface MessageRegularProps {
}
export interface MessageModel extends Backbone.Model<MessageAttributes> {
setIsPublic(arg0: boolean);
setServerId(serverId: any);
setServerTimestamp(serverTimestamp: any);
idForLogging: () => string;
isGroupUpdate: () => boolean;
isExpirationTimerUpdate: () => boolean;

@ -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();

@ -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 .",

@ -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 = () => {

@ -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();
};

@ -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<Props, State> {
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<Props, State> {
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;
}

@ -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<Props> {
private syncInterval: NodeJS.Timeout | null = null;
constructor(props: Props) {
super(props);
@ -57,6 +61,20 @@ class ActionsPanelPrivate extends React.Component<Props> {
// 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 = ({

@ -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<any, State> {
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);
}}

@ -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 (
<div className={classNames(showResetElements && 'button-group')}>

@ -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,
});

@ -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)
);

@ -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<void> {
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);
}

@ -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<void> {
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<void> {
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<void> {
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);

@ -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(

@ -302,5 +302,5 @@ export async function handlePublicMessage(messageData: any) {
},
};
await handleMessageEvent(ev);
await handleMessageEvent(ev); // open groups
}

@ -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<Uint8Array> {
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<Uint8Array> {
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<ConversationModel>) {
// 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()
);

@ -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;
}
}

@ -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<ConfigurationMessageClosedGroup>;
@ -13,13 +14,21 @@ interface ConfigurationMessageParams extends MessageParams {
}
export class ConfigurationMessage extends ContentMessage {
private readonly activeClosedGroups: Array<ConfigurationMessageClosedGroup>;
private readonly activeOpenGroups: Array<string>;
public readonly activeClosedGroups: Array<ConfigurationMessageClosedGroup>;
public readonly activeOpenGroups: Array<string>;
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 {

@ -1,6 +1,5 @@
import { Message } from '../Message';
import { SignalService } from '../../../../protobuf';
import { Constants } from '../../..';
export abstract class ContentMessage extends Message {
public plainTextBuffer(): Uint8Array {

@ -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<Preview>;
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<Preview>;
/// 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<AttachmentPointer>;
const quote = (dataMessage.quote as Quote) || undefined;
const preview = (dataMessage.preview as Array<Preview>) || [];
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();

@ -87,8 +87,4 @@ export class ClosedGroupNewMessage extends ClosedGroupMessage {
return dataMessage;
}
public isSelfSendValid() {
return true;
}
}

@ -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(

@ -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<MessageQueueInterfaceEvents>;
@ -31,9 +34,12 @@ export class MessageQueue implements MessageQueueInterface {
message: ContentMessage,
sentCb?: (message: RawMessage) => Promise<void>
): Promise<void> {
// 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<void>
): Promise<void> {
// 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<void>
): Promise<void> {
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);

@ -25,6 +25,9 @@ export interface MessageQueueInterface {
message: GroupMessageType,
sentCb?: (message?: RawMessage) => Promise<void>
): Promise<void>;
sendSyncMessage(message: any): Promise<void>;
sendSyncMessage(
message: any,
sentCb?: (message?: RawMessage) => Promise<void>
): Promise<void>;
processPending(device: PubKey): Promise<void>;
}

@ -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<string>) {
nodesForPubkey.set(pubkey, edkeys);
await Data.updateSwarmNodesForPubkey(pubkey, edkeys);
await updateSwarmNodesForPubkey(pubkey, edkeys);
}
export async function getSnodesFor(pubkey: string): Promise<Array<Snode>> {
@ -339,7 +342,7 @@ export async function getSnodesFor(pubkey: string): Promise<Array<Snode>> {
// 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;

@ -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<void> {
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 {

@ -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<string> {
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<OpenGroup | undefined> {
public static async join(
server: string,
fromSyncMessage: boolean = false
): Promise<OpenGroup | undefined> {
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);
}

@ -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<string>;
.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) {

@ -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,
};

@ -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<number | undefined> =>
(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;
};

@ -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'
);
});
});
});

@ -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<ConversationModel>;
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');
// });
});
});

@ -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),

@ -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);
});
});
});

@ -50,44 +50,36 @@ export function generateClosedGroupMessage(
interface MockConversationParams {
id?: string;
type: MockConversationType;
members?: Array<string>;
}
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];
}

@ -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;
}

Loading…
Cancel
Save