diff --git a/images/group_default.png b/images/group_default.png new file mode 100644 index 000000000..6b503e9ff Binary files /dev/null and b/images/group_default.png differ diff --git a/js/modules/loki_app_dot_net_api.js b/js/modules/loki_app_dot_net_api.js index 79a853da4..1fe042ab1 100644 --- a/js/modules/loki_app_dot_net_api.js +++ b/js/modules/loki_app_dot_net_api.js @@ -287,7 +287,7 @@ const serverRequest = async (endpoint, options = {}) => { txtResponse = await result.text(); // cloudflare timeouts (504s) will be html... - response = options.textResponse ? txtResponse : JSON.parse(txtResponse); + response = options.noJson ? txtResponse : JSON.parse(txtResponse); // result.status will always be 200 // emulate the correct http code if available @@ -303,7 +303,7 @@ const serverRequest = async (endpoint, options = {}) => { e.message, `json: ${txtResponse}`, 'attempting connection to', - url + url.toString() ); } else { log.error( @@ -311,7 +311,7 @@ const serverRequest = async (endpoint, options = {}) => { e.code, e.message, 'attempting connection to', - url + url.toString() ); } diff --git a/preload.js b/preload.js index 57bb35402..358648184 100644 --- a/preload.js +++ b/preload.js @@ -61,6 +61,7 @@ window.lokiFeatureFlags = { useFileOnionRequests: true, useFileOnionRequestsV2: true, // more compact encoding of files in response onionRequestHops: 3, + useRequestEncryptionKeyPair: false, }; if ( @@ -85,7 +86,7 @@ window.isBeforeVersion = (toCheck, baseVersion) => { }; // eslint-disable-next-line func-names -window.CONSTANTS = new (function() { +window.CONSTANTS = new (function () { this.MAX_GROUP_NAME_LENGTH = 64; this.DEFAULT_PUBLIC_CHAT_URL = appConfig.get('defaultPublicChatServer'); this.MAX_LINKED_DEVICES = 1; @@ -376,7 +377,7 @@ window.callWorker = (fnName, ...args) => utilWorker.callWorker(fnName, ...args); // Linux seems to periodically let the event loop stop, so this is a global workaround setInterval(() => { - window.nodeSetImmediate(() => {}); + window.nodeSetImmediate(() => { }); }, 1000); const { autoOrientImage } = require('./js/modules/auto_orient_image'); @@ -455,9 +456,9 @@ if (process.env.USE_STUBBED_NETWORK) { } // eslint-disable-next-line no-extend-native,func-names -Promise.prototype.ignore = function() { +Promise.prototype.ignore = function () { // eslint-disable-next-line more/no-then - this.then(() => {}); + this.then(() => { }); }; if ( @@ -484,6 +485,7 @@ if (config.environment.includes('test-integration')) { useOnionRequests: false, useFileOnionRequests: false, useOnionRequestsV2: false, + useRequestEncryptionKeyPair: false, }; /* eslint-disable global-require, import/no-extraneous-dependencies */ window.sinon = require('sinon'); diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index c94034ffb..472292b42 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -33,6 +33,11 @@ import { MessageController } from '../session/messages'; import { ClosedGroupEncryptionPairReplyMessage } from '../session/messages/outgoing/content/data/group'; import { queueAllCachedFromSource } from './receiver'; +export const distributingClosedGroupEncryptionKeyPairs = new Map< + string, + ECKeyPair +>(); + export async function handleClosedGroupControlMessage( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage @@ -456,6 +461,9 @@ async function handleClosedGroupEncryptionKeyPair( ); if (isKeyPairAlreadyHere) { + const existingKeyPairs = await getAllEncryptionKeyPairsForGroup( + groupPublicKey + ); window.log.info('Dropping already saved keypair for group', groupPublicKey); await removeFromCache(envelope); return; @@ -532,11 +540,18 @@ async function performIfValid( } else if (groupUpdate.type === Type.MEMBER_LEFT) { await handleClosedGroupMemberLeft(envelope, groupUpdate, convo); } else if (groupUpdate.type === Type.ENCRYPTION_KEY_PAIR_REQUEST) { - await handleClosedGroupEncryptionKeyPairRequest( - envelope, - groupUpdate, - convo - ); + if (window.lokiFeatureFlags.useRequestEncryptionKeyPair) { + await handleClosedGroupEncryptionKeyPairRequest( + envelope, + groupUpdate, + convo + ); + } else { + window.log.warn( + 'Received ENCRYPTION_KEY_PAIR_REQUEST message but it is not enabled for now.' + ); + await removeFromCache(envelope); + } // if you add a case here, remember to add it where performIfValid is called too. } @@ -590,6 +605,15 @@ async function handleClosedGroupMembersAdded( return; } + if (await areWeAdmin(convo)) { + await sendLatestKeyPairToUsers( + envelope, + convo, + convo.id, + membersNotAlreadyPresent + ); + } + const members = [...oldMembers, ...membersNotAlreadyPresent]; // Only add update message if we have something to show @@ -604,6 +628,16 @@ async function handleClosedGroupMembersAdded( await removeFromCache(envelope); } +async function areWeAdmin(groupConvo: ConversationModel) { + if (!groupConvo) { + throw new Error('areWeAdmin needs a convo'); + } + + const groupAdmins = groupConvo.get('groupAdmins'); + const ourNumber = UserUtils.getOurPubKeyStrFromCache(); + return groupAdmins?.includes(ourNumber) || false; +} + async function handleClosedGroupMembersRemoved( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, @@ -650,8 +684,7 @@ async function handleClosedGroupMembersRemoved( window.SwarmPolling.removePubkey(groupPubKey); } // Generate and distribute a new encryption key pair if needed - const isCurrentUserAdmin = firstAdmin === ourPubKey.key; - if (isCurrentUserAdmin) { + if (await areWeAdmin(convo)) { try { await ClosedGroup.generateAndSendNewEncryptionKeyPair( groupPubKey, @@ -731,58 +764,83 @@ async function handleClosedGroupMemberLeft( await removeFromCache(envelope); } -async function handleClosedGroupEncryptionKeyPairRequest( +async function sendLatestKeyPairToUsers( envelope: EnvelopePlus, - groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, - convo: ConversationModel + groupConvo: ConversationModel, + groupPubKey: string, + targetUsers: Array ) { - const sender = envelope.senderIdentity; - const groupPublicKey = envelope.source; - // Guard against self-sends - if (UserUtils.isUsFromCache(sender)) { - window.log.info( - 'Dropping self send message of type ENCRYPTION_KEYPAIR_REQUEST' - ); - await removeFromCache(envelope); - return; - } + // use the inMemory keypair if found + const inMemoryKeyPair = distributingClosedGroupEncryptionKeyPairs.get( + groupPubKey + ); + // Get the latest encryption key pair const latestKeyPair = await getLatestClosedGroupEncryptionKeyPair( - groupPublicKey + groupPubKey ); - if (!latestKeyPair) { + if (!inMemoryKeyPair && !latestKeyPair) { window.log.info( 'We do not have the keypair ourself, so dropping this message.' ); - await removeFromCache(envelope); return; } - window.log.info( - `Responding to closed group encryption key pair request from: ${sender}` - ); - await ConversationController.getInstance().getOrCreateAndWait( - sender, - 'private' - ); + const expireTimer = groupConvo.get('expireTimer') || 0; - const wrappers = await ClosedGroup.buildEncryptionKeyPairWrappers( - [sender], - ECKeyPair.fromHexKeyPair(latestKeyPair) - ); - const expireTimer = convo.get('expireTimer') || 0; + await Promise.all( + targetUsers.map(async member => { + window.log.info( + `Sending latest closed group encryption key pair to: ${member}` + ); + await ConversationController.getInstance().getOrCreateAndWait( + member, + 'private' + ); - const keypairsMessage = new ClosedGroupEncryptionPairReplyMessage({ - groupId: groupPublicKey, - timestamp: Date.now(), - encryptedKeyPairs: wrappers, - expireTimer, - }); + const wrappers = await ClosedGroup.buildEncryptionKeyPairWrappers( + [member], + inMemoryKeyPair || ECKeyPair.fromHexKeyPair(latestKeyPair) + ); - // the encryption keypair is sent using established channels - await getMessageQueue().sendToPubKey(PubKey.cast(sender), keypairsMessage); + const keypairsMessage = new ClosedGroupEncryptionPairReplyMessage({ + groupId: groupPubKey, + timestamp: Date.now(), + encryptedKeyPairs: wrappers, + expireTimer, + }); + + // the encryption keypair is sent using established channels + await getMessageQueue().sendToPubKey( + PubKey.cast(member), + keypairsMessage + ); + }) + ); +} - await removeFromCache(envelope); +async function handleClosedGroupEncryptionKeyPairRequest( + envelope: EnvelopePlus, + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, + groupConvo: ConversationModel +) { + if (!window.lokiFeatureFlags.useRequestEncryptionKeyPair) { + throw new Error('useRequestEncryptionKeyPair is disabled'); + } + const sender = envelope.senderIdentity; + const groupPublicKey = envelope.source; + // Guard against self-sends + if (UserUtils.isUsFromCache(sender)) { + window.log.info( + 'Dropping self send message of type ENCRYPTION_KEYPAIR_REQUEST' + ); + await removeFromCache(envelope); + return; + } + await sendLatestKeyPairToUsers(envelope, groupConvo, groupPublicKey, [ + sender, + ]); + return removeFromCache(envelope); } export async function createClosedGroup( diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index ecf7c634d..765da6547 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -124,18 +124,23 @@ async function decryptForClosedGroup( 'decryptWithSessionProtocol for medium group message throw:', e ); - const keypairRequestManager = KeyPairRequestManager.getInstance(); const groupPubKey = PubKey.cast(envelope.source); - if (keypairRequestManager.canTriggerRequestWith(groupPubKey)) { - keypairRequestManager.markRequestSendFor(groupPubKey, Date.now()); - await requestEncryptionKeyPair(groupPubKey); + + // To enable back if we decide to enable encryption key pair request work again + if (window.lokiFeatureFlags.useRequestEncryptionKeyPair) { + const keypairRequestManager = KeyPairRequestManager.getInstance(); + if (keypairRequestManager.canTriggerRequestWith(groupPubKey)) { + keypairRequestManager.markRequestSendFor(groupPubKey, Date.now()); + await requestEncryptionKeyPair(groupPubKey); + } } + // IMPORTANT do not remove the message from the cache just yet. + // We will try to decrypt it once we get the encryption keypair. + // for that to work, we need to throw an error just like here. throw new Error( `Waiting for an encryption keypair to be received for group ${groupPubKey.key}` ); - // do not remove it from the cache yet. We will try to decrypt it once we get the encryption keypair - // TODO drop it if after some time we still don't get to decrypt it - // await removeFromCache(envelope); + return null; } } diff --git a/ts/session/group/index.ts b/ts/session/group/index.ts index 83bc9ab8c..560d90e4b 100644 --- a/ts/session/group/index.ts +++ b/ts/session/group/index.ts @@ -32,6 +32,7 @@ import { ConversationModel } from '../../models/conversation'; import { MessageModel } from '../../models/message'; import { MessageModelType } from '../../models/messageType'; import { MessageController } from '../messages'; +import { distributingClosedGroupEncryptionKeyPairs } from '../../receiver/closedGroups'; export interface GroupInfo { id: string; @@ -557,11 +558,15 @@ export async function generateAndSendNewEncryptionKeyPair( expireTimer, }); + distributingClosedGroupEncryptionKeyPairs.set(toHex(groupId), newKeyPair); + const messageSentCallback = async () => { window.log.info( `KeyPairMessage for ClosedGroup ${groupPublicKey} is sent. Saving the new encryptionKeyPair.` ); + distributingClosedGroupEncryptionKeyPairs.delete(toHex(groupId)); + await addClosedGroupEncryptionKeyPair( toHex(groupId), newKeyPair.toHexKeyPair() @@ -611,6 +616,10 @@ export async function buildEncryptionKeyPairWrappers( export async function requestEncryptionKeyPair( groupPublicKey: string | PubKey ) { + if (!window.lokiFeatureFlags.useRequestEncryptionKeyPair) { + throw new Error('useRequestEncryptionKeyPair is disabled'); + } + const groupConvo = ConversationController.getInstance().get( PubKey.cast(groupPublicKey).key ); diff --git a/ts/window.d.ts b/ts/window.d.ts index afc4dafe2..bf91cdefe 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -64,6 +64,7 @@ declare global { useFileOnionRequests: boolean; useFileOnionRequestsV2: boolean; onionRequestHops: number; + useRequestEncryptionKeyPair: boolean; }; lokiFileServerAPI: LokiFileServerInstance; lokiMessageAPI: LokiMessageInterface;