From a90246cbe556c257b5122c7dee4ebf180145e3ad Mon Sep 17 00:00:00 2001 From: Ken Powers Date: Thu, 5 Mar 2020 13:14:58 -0800 Subject: [PATCH] Passive UUID support Co-authored-by: Scott Nonnenberg --- app/sql.js | 310 +++++++++++++-- js/background.js | 253 +++++++++--- js/conversation_controller.js | 61 ++- js/delivery_receipts.js | 24 +- js/models/blockedNumbers.js | 25 ++ js/models/conversations.js | 371 +++++++++++++----- js/models/messages.js | 216 ++++++---- js/modules/backup.js | 8 +- js/modules/data.js | 33 +- js/modules/metadata/SecretSessionCipher.js | 31 +- js/modules/privacy.js | 13 +- js/modules/refresh_sender_certificate.js | 47 ++- js/modules/web_api.js | 57 ++- js/read_syncs.js | 5 +- js/signal_protocol_store.js | 295 +++++++++----- js/view_syncs.js | 6 +- js/views/contact_list_view.js | 4 +- js/views/conversation_view.js | 14 +- js/views/key_verification_view.js | 20 +- libtextsecure/account_manager.js | 77 ++-- libtextsecure/contacts_parser.js | 16 + libtextsecure/libsignal-protocol.js | 19 +- libtextsecure/message_receiver.js | 211 ++++++++-- libtextsecure/outgoing_message.js | 222 ++++++----- libtextsecure/sendmessage.js | 266 ++++++++----- libtextsecure/storage/user.js | 20 + libtextsecure/test/contacts_parser_test.js | 26 +- libtextsecure/test/fake_web_api.js | 12 +- .../test/in_memory_signal_protocol_store.js | 6 + libtextsecure/test/index.html | 11 +- libtextsecure/test/message_receiver_test.js | 5 +- libtextsecure/test/storage_test.js | 14 +- main.js | 3 +- preload.js | 25 ++ protos/DeviceMessages.proto | 22 +- protos/SignalService.proto | 54 ++- protos/UnidentifiedDelivery.proto | 11 +- sticker-creator/preload.js | 11 +- test/backup_test.js | 6 +- test/modules/privacy_test.js | 14 + test/storage_test.js | 9 +- ts/components/MainHeader.tsx | 8 + ts/state/ducks/search.ts | 28 +- ts/state/ducks/user.ts | 8 + ts/state/selectors/conversations.ts | 10 +- ts/state/selectors/search.ts | 1 + ts/state/selectors/user.ts | 10 + ts/state/smart/MainHeader.tsx | 10 +- ts/util/lint/exceptions.json | 74 ++-- 49 files changed, 2226 insertions(+), 776 deletions(-) diff --git a/app/sql.js b/app/sql.js index c426b37588..799c28f571 100644 --- a/app/sql.js +++ b/app/sql.js @@ -1,6 +1,7 @@ const { join } = require('path'); const mkdirp = require('mkdirp'); const rimraf = require('rimraf'); +const Queue = require('p-queue').default; const sql = require('@journeyapps/sqlcipher'); const { app, dialog, clipboard } = require('electron'); const { redactAll } = require('../js/modules/privacy'); @@ -15,6 +16,7 @@ const { isNumber, isObject, isString, + keyBy, last, map, pick, @@ -57,10 +59,10 @@ module.exports = { createOrUpdateSession, createOrUpdateSessions, getSessionById, - getSessionsByNumber, + getSessionsById, bulkAddSessions, removeSessionById, - removeSessionsByNumber, + removeSessionsById, removeAllSessions, getAllSessions, @@ -1181,6 +1183,7 @@ async function updateToSchemaVersion18(currentVersion, instance) { throw error; } } + async function updateToSchemaVersion19(currentVersion, instance) { if (currentVersion >= 19) { return; @@ -1210,6 +1213,230 @@ async function updateToSchemaVersion19(currentVersion, instance) { throw error; } } + +async function updateToSchemaVersion20(currentVersion, instance) { + if (currentVersion >= 20) { + return; + } + + console.log('updateToSchemaVersion20: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + try { + const migrationJobQueue = new Queue({ concurrency: 10 }); + // The triggers on the messages table slow down this migration + // significantly, so we drop them and recreate them later. + // Drop triggers + const triggers = await instance.all( + 'SELECT * FROM sqlite_master WHERE type = "trigger" AND tbl_name = "messages"' + ); + + // eslint-disable-next-line no-restricted-syntax + for (const trigger of triggers) { + // eslint-disable-next-line no-await-in-loop + await instance.run(`DROP TRIGGER ${trigger.name}`); + } + + // Create new columns and indices + await instance.run('ALTER TABLE conversations ADD COLUMN e164 TEXT;'); + await instance.run('ALTER TABLE conversations ADD COLUMN uuid TEXT;'); + await instance.run('ALTER TABLE conversations ADD COLUMN groupId TEXT;'); + await instance.run('ALTER TABLE messages ADD COLUMN sourceUuid TEXT;'); + await instance.run( + 'ALTER TABLE sessions RENAME COLUMN number TO conversationId;' + ); + await instance.run( + 'CREATE INDEX conversations_e164 ON conversations(e164);' + ); + await instance.run( + 'CREATE INDEX conversations_uuid ON conversations(uuid);' + ); + await instance.run( + 'CREATE INDEX conversations_groupId ON conversations(groupId);' + ); + await instance.run( + 'CREATE INDEX messages_sourceUuid on messages(sourceUuid);' + ); + + // Migrate existing IDs + await instance.run( + "UPDATE conversations SET e164 = '+' || id WHERE type = 'private';" + ); + await instance.run( + "UPDATE conversations SET groupId = id WHERE type = 'group';" + ); + + // Drop invalid groups and any associated messages + const maybeInvalidGroups = await instance.all( + "SELECT * FROM conversations WHERE type = 'group' AND members IS NULL;" + ); + // eslint-disable-next-line no-restricted-syntax + for (const group of maybeInvalidGroups) { + const json = JSON.parse(group.json); + if (!json.members || !json.members.length) { + // eslint-disable-next-line no-await-in-loop + await instance.run('DELETE FROM conversations WHERE id = $id;', { + $id: json.id, + }); + // eslint-disable-next-line no-await-in-loop + await instance.run('DELETE FROM messages WHERE conversationId = $id;', { + $id: json.id, + }); + // eslint-disable-next-line no-await-in-loop + // await instance.run('DELETE FROM sessions WHERE conversationId = $id;', { + // $id: json.id, + // }); + } + } + + // Generate new IDs and alter data + const allConversations = await instance.all('SELECT * FROM conversations;'); + const allConversationsByOldId = keyBy(allConversations, 'id'); + + // eslint-disable-next-line no-restricted-syntax + for (const row of allConversations) { + const oldId = row.id; + const newId = generateUUID(); + allConversationsByOldId[oldId].id = newId; + const patchObj = { id: newId }; + if (row.type === 'private') { + patchObj.e164 = `+${oldId}`; + } else if (row.type === 'group') { + patchObj.groupId = oldId; + } + const patch = JSON.stringify(patchObj); + // eslint-disable-next-line no-await-in-loop + await instance.run( + 'UPDATE conversations SET id = $newId, json = JSON_PATCH(json, $patch) WHERE id = $oldId', + { + $newId: newId, + $oldId: oldId, + $patch: patch, + } + ); + const messagePatch = JSON.stringify({ conversationId: newId }); + // eslint-disable-next-line no-await-in-loop + await instance.run( + 'UPDATE messages SET conversationId = $newId, json = JSON_PATCH(json, $patch) WHERE conversationId = $oldId', + { $newId: newId, $oldId: oldId, $patch: messagePatch } + ); + } + + const groupConverations = await instance.all( + "SELECT * FROM conversations WHERE type = 'group';" + ); + + // Update group conversations, point members at new conversation ids + migrationJobQueue.addAll( + groupConverations.map(groupRow => async () => { + const members = groupRow.members.split(/\s?\+/).filter(Boolean); + const newMembers = []; + // eslint-disable-next-line no-restricted-syntax + for (const m of members) { + const memberRow = allConversationsByOldId[m]; + + if (memberRow) { + newMembers.push(memberRow.id); + } else { + // We didn't previously have a private conversation for this member, + // we need to create one + const id = generateUUID(); + // eslint-disable-next-line no-await-in-loop + await saveConversation( + { + id, + e164: m, + type: 'private', + version: 2, + unreadCount: 0, + verified: 0, + }, + instance + ); + + newMembers.push(id); + } + } + const json = { ...jsonToObject(groupRow.json), members: newMembers }; + const newMembersValue = newMembers.join(' '); + await instance.run( + 'UPDATE conversations SET members = $newMembersValue, json = $newJsonValue WHERE id = $id', + { + $id: groupRow.id, + $newMembersValue: newMembersValue, + $newJsonValue: objectToJSON(json), + } + ); + }) + ); + // Wait for group conversation updates to finish + await migrationJobQueue.onEmpty(); + + // Update sessions to stable IDs + const allSessions = await instance.all('SELECT * FROM sessions;'); + // eslint-disable-next-line no-restricted-syntax + for (const session of allSessions) { + // Not using patch here so we can explicitly delete a property rather than + // implicitly delete via null + const newJson = JSON.parse(session.json); + const conversation = allConversationsByOldId[newJson.number.substr(1)]; + if (conversation) { + newJson.conversationId = conversation.id; + newJson.id = `${newJson.conversationId}.${newJson.deviceId}`; + } + delete newJson.number; + // eslint-disable-next-line no-await-in-loop + await instance.run( + ` + UPDATE sessions + SET id = $newId, json = $newJson, conversationId = $newConversationId + WHERE id = $oldId + `, + { + $newId: newJson.id, + $newJson: objectToJSON(newJson), + $oldId: session.id, + $newConversationId: newJson.conversationId, + } + ); + } + + // Update identity keys to stable IDs + const allIdentityKeys = await instance.all('SELECT * FROM identityKeys;'); + // eslint-disable-next-line no-restricted-syntax + for (const identityKey of allIdentityKeys) { + const newJson = JSON.parse(identityKey.json); + newJson.id = allConversationsByOldId[newJson.id]; + // eslint-disable-next-line no-await-in-loop + await instance.run( + ` + UPDATE identityKeys + SET id = $newId, json = $newJson + WHERE id = $oldId + `, + { + $newId: newJson.id, + $newJson: objectToJSON(newJson), + $oldId: identityKey.id, + } + ); + } + + // Recreate triggers + // eslint-disable-next-line no-restricted-syntax + for (const trigger of triggers) { + // eslint-disable-next-line no-await-in-loop + await instance.run(trigger.sql); + } + + await instance.run('PRAGMA user_version = 20;'); + await instance.run('COMMIT TRANSACTION;'); + } catch (error) { + await instance.run('ROLLBACK;'); + throw error; + } +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -1230,6 +1457,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion17, updateToSchemaVersion18, updateToSchemaVersion19, + updateToSchemaVersion20, ]; async function updateSchema(instance) { @@ -1479,31 +1707,31 @@ async function removeAllItems() { const SESSIONS_TABLE = 'sessions'; async function createOrUpdateSession(data) { - const { id, number } = data; + const { id, conversationId } = data; if (!id) { throw new Error( 'createOrUpdateSession: Provided data did not have a truthy id' ); } - if (!number) { + if (!conversationId) { throw new Error( - 'createOrUpdateSession: Provided data did not have a truthy number' + 'createOrUpdateSession: Provided data did not have a truthy conversationId' ); } await db.run( `INSERT OR REPLACE INTO sessions ( id, - number, + conversationId, json ) values ( $id, - $number, + $conversationId, $json )`, { $id: id, - $number: number, + $conversationId: conversationId, $json: objectToJSON(data), } ); @@ -1524,10 +1752,13 @@ createOrUpdateSessions.needsSerial = true; async function getSessionById(id) { return getById(SESSIONS_TABLE, id); } -async function getSessionsByNumber(number) { - const rows = await db.all('SELECT * FROM sessions WHERE number = $number;', { - $number: number, - }); +async function getSessionsById(id) { + const rows = await db.all( + 'SELECT * FROM sessions WHERE conversationId = $id;', + { + $id: id, + } + ); return map(rows, row => jsonToObject(row.json)); } async function bulkAddSessions(array) { @@ -1536,9 +1767,9 @@ async function bulkAddSessions(array) { async function removeSessionById(id) { return removeById(SESSIONS_TABLE, id); } -async function removeSessionsByNumber(number) { - await db.run('DELETE FROM sessions WHERE number = $number;', { - $number: number, +async function removeSessionsById(id) { + await db.run('DELETE FROM sessions WHERE conversationId = $id;', { + $id: id, }); } async function removeAllSessions() { @@ -1634,23 +1865,30 @@ async function getConversationCount() { return row['count(*)']; } -async function saveConversation(data) { +async function saveConversation(data, instance = db) { const { - id, // eslint-disable-next-line camelcase active_at, - type, + e164, + groupId, + id, members, name, - profileName, profileFamilyName, + profileName, + type, + uuid, } = data; - await db.run( + await instance.run( `INSERT INTO conversations ( id, json, + e164, + uuid, + groupId, + active_at, type, members, @@ -1662,6 +1900,10 @@ async function saveConversation(data) { $id, $json, + $e164, + $uuid, + $groupId, + $active_at, $type, $members, @@ -1674,6 +1916,10 @@ async function saveConversation(data) { $id: id, $json: objectToJSON(data), + $e164: e164, + $uuid: uuid, + $groupId: groupId, + $active_at: active_at, $type: type, $members: members ? members.join(' ') : null, @@ -1713,12 +1959,17 @@ async function updateConversation(data) { name, profileName, profileFamilyName, + e164, + uuid, } = data; await db.run( `UPDATE conversations SET json = $json, + e164 = $e164, + uuid = $uuid, + active_at = $active_at, type = $type, members = $members, @@ -1731,6 +1982,9 @@ async function updateConversation(data) { $id: id, $json: objectToJSON(data), + $e164: e164, + $uuid: uuid, + $active_at: active_at, $type: type, $members: members ? members.join(' ') : null, @@ -1920,6 +2174,7 @@ async function saveMessage(data, { forceSave } = {}) { // eslint-disable-next-line camelcase sent_at, source, + sourceUuid, sourceDevice, type, unread, @@ -1945,6 +2200,7 @@ async function saveMessage(data, { forceSave } = {}) { $schemaVersion: schemaVersion, $sent_at: sent_at, $source: source, + $sourceUuid: sourceUuid, $sourceDevice: sourceDevice, $type: type, $unread: unread, @@ -1970,6 +2226,7 @@ async function saveMessage(data, { forceSave } = {}) { schemaVersion = $schemaVersion, sent_at = $sent_at, source = $source, + sourceUuid = $sourceUuid, sourceDevice = $sourceDevice, type = $type, unread = $unread @@ -2004,6 +2261,7 @@ async function saveMessage(data, { forceSave } = {}) { schemaVersion, sent_at, source, + sourceUuid, sourceDevice, type, unread @@ -2025,6 +2283,7 @@ async function saveMessage(data, { forceSave } = {}) { $schemaVersion, $sent_at, $source, + $sourceUuid, $sourceDevice, $type, $unread @@ -2095,14 +2354,21 @@ async function getAllMessageIds() { } // eslint-disable-next-line camelcase -async function getMessageBySender({ source, sourceDevice, sent_at }) { +async function getMessageBySender({ + source, + sourceUuid, + sourceDevice, + // eslint-disable-next-line camelcase + sent_at, +}) { const rows = await db.all( `SELECT json FROM messages WHERE - source = $source AND + (source = $source OR sourceUuid = $sourceUuid) AND sourceDevice = $sourceDevice AND sent_at = $sent_at;`, { $source: source, + $sourceUuid: sourceUuid, $sourceDevice: sourceDevice, $sent_at: sent_at, } diff --git a/js/background.js b/js/background.js index c97defeff4..fd308bf38a 100644 --- a/js/background.js +++ b/js/background.js @@ -25,7 +25,7 @@ wait: 500, maxSize: 500, processBatch: async items => { - const bySource = _.groupBy(items, item => item.source); + const bySource = _.groupBy(items, item => item.source || item.sourceUuid); const sources = Object.keys(bySource); for (let i = 0, max = sources.length; i < max; i += 1) { @@ -33,13 +33,15 @@ const timestamps = bySource[source].map(item => item.timestamp); try { + const c = ConversationController.get(source); const { wrap, sendOptions } = ConversationController.prepareForSend( - source + c.get('id') ); // eslint-disable-next-line no-await-in-loop await wrap( textsecure.messaging.sendDeliveryReceipt( - source, + c.get('e164'), + c.get('uuid'), timestamps, sendOptions ) @@ -234,13 +236,23 @@ let accountManager; window.getAccountManager = () => { if (!accountManager) { - const USERNAME = storage.get('number_id'); + const OLD_USERNAME = storage.get('number_id'); + const USERNAME = storage.get('uuid_id'); const PASSWORD = storage.get('password'); - accountManager = new textsecure.AccountManager(USERNAME, PASSWORD); + accountManager = new textsecure.AccountManager( + USERNAME || OLD_USERNAME, + PASSWORD + ); accountManager.addEventListener('registration', () => { + const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); const user = { regionCode: window.storage.get('regionCode'), - ourNumber: textsecure.storage.user.getNumber(), + ourNumber, + ourUuid, + ourConversationId: ConversationController.getConversationId( + ourNumber || ourUuid + ), }; Whisper.events.trigger('userChanged', user); @@ -580,6 +592,11 @@ const conversations = convoCollection.map( conversation => conversation.cachedProps ); + const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); + const ourConversationId = ConversationController.getConversationId( + ourNumber || ourUuid + ); const initialState = { conversations: { conversationLookup: Signal.Util.makeLookup(conversations, 'id'), @@ -598,7 +615,9 @@ stickersPath: window.baseStickersPath, tempPath: window.baseTempPath, regionCode: window.storage.get('regionCode'), - ourNumber: textsecure.storage.user.getNumber(), + ourConversationId, + ourNumber, + ourUuid, platform: window.platform, i18n: window.i18n, interactionMode: window.getInteractionMode(), @@ -1508,7 +1527,8 @@ messageReceiver = null; } - const USERNAME = storage.get('number_id'); + const OLD_USERNAME = storage.get('number_id'); + const USERNAME = storage.get('uuid_id'); const PASSWORD = storage.get('password'); const mySignalingKey = storage.get('signaling_key'); @@ -1524,6 +1544,7 @@ // initialize the socket and start listening for messages window.log.info('Initializing socket and listening for messages'); messageReceiver = new textsecure.MessageReceiver( + OLD_USERNAME, USERNAME, PASSWORD, mySignalingKey, @@ -1569,7 +1590,7 @@ }); window.textsecure.messaging = new textsecure.MessageSender( - USERNAME, + USERNAME || OLD_USERNAME, PASSWORD ); @@ -1605,7 +1626,10 @@ const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery'; if (!storage.get(udSupportKey)) { - const server = WebAPI.connect({ username: USERNAME, password: PASSWORD }); + const server = WebAPI.connect({ + username: USERNAME || OLD_USERNAME, + password: PASSWORD, + }); try { await server.registerSupportForUnauthenticatedDelivery(); storage.put(udSupportKey, true); @@ -1617,7 +1641,50 @@ } } + const hasRegisteredUuidSupportKey = 'hasRegisteredUuidSupport'; + if ( + !storage.get(hasRegisteredUuidSupportKey) && + textsecure.storage.user.getUuid() + ) { + const server = WebAPI.connect({ + username: USERNAME || OLD_USERNAME, + password: PASSWORD, + }); + try { + await server.registerCapabilities({ uuid: true }); + storage.put(hasRegisteredUuidSupportKey, true); + } catch (error) { + window.log.error( + 'Error: Unable to register support for UUID messages.', + error && error.stack ? error.stack : error + ); + } + } + const deviceId = textsecure.storage.user.getDeviceId(); + + if (!textsecure.storage.user.getUuid()) { + const server = WebAPI.connect({ + username: OLD_USERNAME, + password: PASSWORD, + }); + try { + const { uuid } = await server.whoami(); + textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId); + const ourNumber = textsecure.storage.user.getNumber(); + const me = await ConversationController.getOrCreateAndWait( + ourNumber, + 'private' + ); + me.updateUuid(uuid); + } catch (error) { + window.log.error( + 'Error: Unable to retrieve UUID from service.', + error && error.stack ? error.stack : error + ); + } + } + if (firstRun === true && deviceId !== '1') { const hasThemeSetting = Boolean(storage.get('theme-setting')); if (!hasThemeSetting && textsecure.storage.get('userAgent') === 'OWI') { @@ -1639,9 +1706,10 @@ Whisper.events.trigger('contactsync'); }); + const ourUuid = textsecure.storage.user.getUuid(); const ourNumber = textsecure.storage.user.getNumber(); const { wrap, sendOptions } = ConversationController.prepareForSend( - ourNumber, + ourNumber || ourUuid, { syncMessage: true, } @@ -1766,7 +1834,7 @@ function onTyping(ev) { // Note: this type of message is automatically removed from cache in MessageReceiver - const { typing, sender, senderDevice } = ev; + const { typing, sender, senderUuid, senderDevice } = ev; const { groupId, started } = typing || {}; // We don't do anything with incoming typing messages if the setting is disabled @@ -1774,12 +1842,18 @@ return; } - const conversation = ConversationController.get(groupId || sender); + const conversation = ConversationController.get( + groupId || sender || senderUuid + ); + const ourUuid = textsecure.storage.user.getUuid(); const ourNumber = textsecure.storage.user.getNumber(); if (conversation) { // We drop typing notifications in groups we're not a part of - if (!conversation.isPrivate() && !conversation.hasMember(ourNumber)) { + if ( + !conversation.isPrivate() && + !conversation.hasMember(ourNumber || ourUuid) + ) { window.log.warn( `Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` ); @@ -1789,6 +1863,7 @@ conversation.notifyTyping({ isTyping: started, sender, + senderUuid, senderDevice, }); } @@ -1833,9 +1908,10 @@ async function onContactReceived(ev) { const details = ev.contactDetails; - const id = details.number; - - if (id === textsecure.storage.user.getNumber()) { + if ( + details.number === textsecure.storage.user.getNumber() || + details.uuid === textsecure.storage.user.getUuid() + ) { // special case for syncing details about ourselves if (details.profileKey) { window.log.info('Got sync message with our own profile key'); @@ -1844,9 +1920,11 @@ } const c = new Whisper.Conversation({ - id, + e164: details.number, + uuid: details.uuid, + type: 'private', }); - const validationError = c.validateNumber(); + const validationError = c.validate(); if (validationError) { window.log.error( 'Invalid contact received:', @@ -1857,7 +1935,7 @@ try { const conversation = await ConversationController.getOrCreateAndWait( - id, + details.number || details.uuid, 'private' ); let activeAt = conversation.get('active_at'); @@ -1878,10 +1956,18 @@ } if (typeof details.blocked !== 'undefined') { - if (details.blocked) { - storage.addBlockedNumber(id); + const e164 = conversation.get('e164'); + if (details.blocked && e164) { + storage.addBlockedNumber(e164); } else { - storage.removeBlockedNumber(id); + storage.removeBlockedNumber(e164); + } + + const uuid = conversation.get('uuid'); + if (details.blocked && uuid) { + storage.addBlockedUuid(uuid); + } else { + storage.removeBlockedUuid(uuid); } } @@ -1912,17 +1998,21 @@ conversation.set({ avatar: null }); } - window.Signal.Data.updateConversation(id, conversation.attributes); + window.Signal.Data.updateConversation( + details.number || details.uuid, + conversation.attributes + ); const { expireTimer } = details; const isValidExpireTimer = typeof expireTimer === 'number'; if (isValidExpireTimer) { - const source = textsecure.storage.user.getNumber(); + const sourceE164 = textsecure.storage.user.getNumber(); + const sourceUuid = textsecure.storage.user.getUuid(); const receivedAt = Date.now(); await conversation.updateExpirationTimer( expireTimer, - source, + sourceE164 || sourceUuid, receivedAt, { fromSync: true } ); @@ -1934,6 +2024,7 @@ verifiedEvent.verified = { state: verified.state, destination: verified.destination, + destinationUuid: verified.destinationUuid, identityKey: verified.identityKey.toArrayBuffer(), }; verifiedEvent.viaContactSync = true; @@ -1953,9 +2044,23 @@ 'group' ); + const memberConversations = await Promise.all( + (details.members || details.membersE164).map(member => { + if (member.e164 || member.uuid) { + return ConversationController.getOrCreateAndWait( + member.e164 || member.uuid, + 'private' + ); + } + return ConversationController.getOrCreateAndWait(member, 'private'); + }) + ); + + const members = memberConversations.map(c => c.get('id')); + const updates = { name: details.name, - members: details.members, + members, color: details.color, type: 'group', }; @@ -2004,11 +2109,17 @@ return; } - const source = textsecure.storage.user.getNumber(); + const sourceE164 = textsecure.storage.user.getNumber(); + const sourceUuid = textsecure.storage.user.getUuid(); const receivedAt = Date.now(); - await conversation.updateExpirationTimer(expireTimer, source, receivedAt, { - fromSync: true, - }); + await conversation.updateExpirationTimer( + expireTimer, + sourceE164 || sourceUuid, + receivedAt, + { + fromSync: true, + } + ); } // Descriptors @@ -2024,10 +2135,10 @@ : { type: Message.PRIVATE, id: destination }; // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: - const getDescriptorForReceived = ({ message, source }) => + const getDescriptorForReceived = ({ message, source, sourceUuid }) => message.group ? getGroupDescriptor(message.group) - : { type: Message.PRIVATE, id: source }; + : { type: Message.PRIVATE, id: source || sourceUuid }; // Received: async function handleMessageReceivedProfileUpdate({ @@ -2069,11 +2180,18 @@ const message = await initIncomingMessage(data); - await ConversationController.getOrCreateAndWait( + const result = await ConversationController.getOrCreateAndWait( messageDescriptor.id, messageDescriptor.type ); + if (messageDescriptor.type === 'private') { + result.updateE164(data.source); + if (data.sourceUuid) { + result.updateUuid(data.sourceUuid); + } + } + if (data.message.reaction) { const { reaction } = data.message; const reactionModel = Whisper.Reactions.add({ @@ -2083,7 +2201,7 @@ targetAuthorUuid: reaction.targetAuthorUuid, targetTimestamp: reaction.targetTimestamp.toNumber(), timestamp: Date.now(), - fromId: data.source, + fromId: data.source || data.sourceUuid, }); // Note: We do not wait for completion here Whisper.Reactions.onReaction(reactionModel); @@ -2112,8 +2230,12 @@ // Then we update our own profileKey if it's different from what we have const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); const profileKey = data.message.profileKey.toString('base64'); - const me = await ConversationController.getOrCreate(ourNumber, 'private'); + const me = await ConversationController.getOrCreate( + ourNumber || ourUuid, + 'private' + ); // Will do the save for us if needed await me.setProfileKey(profileKey); @@ -2136,6 +2258,7 @@ return new Whisper.Message({ source: textsecure.storage.user.getNumber(), + sourceUuid: textsecure.storage.user.getUuid(), sourceDevice: data.device, sent_at: data.timestamp, sent_to: sentTo, @@ -2176,6 +2299,7 @@ if (data.message.reaction) { const { reaction } = data.message; const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); const reactionModel = Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, @@ -2183,7 +2307,7 @@ targetAuthorUuid: reaction.targetAuthorUuid, targetTimestamp: reaction.targetTimestamp.toNumber(), timestamp: Date.now(), - fromId: ourNumber, + fromId: ourNumber || ourUuid, fromSync: true, }); // Note: We do not wait for completion here @@ -2197,20 +2321,25 @@ messageDescriptor.id, messageDescriptor.type ); - // Don't wait for handleDataMessage, as it has its own per-conversation queueing + message.handleDataMessage(data.message, event.confirm, { data, }); } async function initIncomingMessage(data) { + const targetId = data.source || data.sourceUuid; + const conversation = ConversationController.get(targetId); + const conversationId = conversation ? conversation.id : targetId; + return new Whisper.Message({ source: data.source, + sourceUuid: data.sourceUuid, sourceDevice: data.sourceDevice, sent_at: data.timestamp, received_at: data.receivedAt || Date.now(), - conversationId: data.source, + conversationId, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', unread: 1, @@ -2384,11 +2513,16 @@ async function onViewSync(ev) { ev.confirm(); - const { source, timestamp } = ev; + const { source, sourceUuid, timestamp } = ev; window.log.info(`view sync ${source} ${timestamp}`); + const conversationId = ConversationController.getConversationId( + source || sourceUuid + ); const sync = Whisper.ViewSyncs.add({ source, + sourceUuid, + conversationId, timestamp, }); @@ -2398,12 +2532,12 @@ function onReadReceipt(ev) { const readAt = ev.timestamp; const { timestamp } = ev.read; - const { reader } = ev.read; + const reader = ConversationController.getConversationId(ev.read.reader); window.log.info('read receipt', reader, timestamp); ev.confirm(); - if (!storage.get('read-receipt-setting')) { + if (!storage.get('read-receipt-setting') || !reader) { return; } @@ -2420,11 +2554,12 @@ function onReadSync(ev) { const readAt = ev.timestamp; const { timestamp } = ev.read; - const { sender } = ev.read; - window.log.info('read sync', sender, timestamp); + const { sender, senderUuid } = ev.read; + window.log.info('read sync', sender, senderUuid, timestamp); const receipt = Whisper.ReadSyncs.add({ sender, + senderUuid, timestamp, read_at: readAt, }); @@ -2437,7 +2572,8 @@ } async function onVerified(ev) { - const number = ev.verified.destination; + const e164 = ev.verified.destination; + const uuid = ev.verified.destinationUuid; const key = ev.verified.identityKey; let state; @@ -2446,12 +2582,16 @@ } const c = new Whisper.Conversation({ - id: number, + e164, + uuid, + type: 'private', }); - const error = c.validateNumber(); + const error = c.validate(); if (error) { window.log.error( 'Invalid verified sync received:', + e164, + uuid, Errors.toLogFormat(error) ); return; @@ -2473,13 +2613,14 @@ window.log.info( 'got verified sync for', - number, + e164, + uuid, state, ev.viaContactSync ? 'via contact sync' : '' ); const contact = await ConversationController.getOrCreateAndWait( - number, + e164 || uuid, 'private' ); const options = { @@ -2499,17 +2640,27 @@ function onDeliveryReceipt(ev) { const { deliveryReceipt } = ev; + const { sourceUuid, source } = deliveryReceipt; + const identifier = source || sourceUuid; + window.log.info( 'delivery receipt from', - `${deliveryReceipt.source}.${deliveryReceipt.sourceDevice}`, + `${identifier}.${deliveryReceipt.sourceDevice}`, deliveryReceipt.timestamp ); ev.confirm(); + const deliveredTo = ConversationController.getConversationId(identifier); + + if (!deliveredTo) { + window.log.info('no conversation for identifier', identifier); + return; + } + const receipt = Whisper.DeliveryReceipts.add({ timestamp: deliveryReceipt.timestamp, - source: deliveryReceipt.source, + deliveredTo, }); // Note: We don't wait for completion here diff --git a/js/conversation_controller.js b/js/conversation_controller.js index ace43c0d10..d856325f5a 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -1,4 +1,4 @@ -/* global _, Whisper, Backbone, storage */ +/* global _, Whisper, Backbone, storage, textsecure */ /* eslint-disable more/no-then */ @@ -67,8 +67,8 @@ dangerouslyCreateAndAdd(attributes) { return conversations.add(attributes); }, - getOrCreate(id, type) { - if (typeof id !== 'string') { + getOrCreate(identifier, type) { + if (typeof identifier !== 'string') { throw new TypeError("'id' must be a string"); } @@ -84,16 +84,41 @@ ); } - let conversation = conversations.get(id); + let conversation = conversations.get(identifier); if (conversation) { return conversation; } - conversation = conversations.add({ - id, - type, - version: 2, - }); + const id = window.getGuid(); + + if (type === 'group') { + conversation = conversations.add({ + id, + uuid: null, + e164: null, + groupId: identifier, + type, + version: 2, + }); + } else if (window.isValidGuid(identifier)) { + conversation = conversations.add({ + id, + uuid: identifier, + e164: null, + groupId: null, + type, + version: 2, + }); + } else { + conversation = conversations.add({ + id, + uuid: null, + e164: identifier, + groupId: null, + type, + version: 2, + }); + } const create = async () => { if (!conversation.isValid()) { @@ -114,7 +139,7 @@ } catch (error) { window.log.error( 'Conversation save failed! ', - id, + identifier, type, 'Error:', error && error.stack ? error.stack : error @@ -142,8 +167,22 @@ ); }); }, + getConversationId(address) { + if (!address) { + return null; + } + + const [id] = textsecure.utils.unencodeNumber(address); + const conv = this.get(id); + + if (conv) { + return conv.get('id'); + } + + return null; + }, prepareForSend(id, options) { - // id is either a group id or an individual user's id + // id is any valid conversation identifier const conversation = this.get(id); const sendOptions = conversation ? conversation.getSendOptions(options) diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index b6e6b16119..61c982d679 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -25,7 +25,7 @@ const receipts = this.filter( receipt => receipt.get('timestamp') === message.get('sent_at') && - recipients.indexOf(receipt.get('source')) > -1 + recipients.indexOf(receipt.get('deliveredTo')) > -1 ); this.remove(receipts); return receipts; @@ -34,19 +34,23 @@ if (messages.length === 0) { return null; } + const sourceId = ConversationController.getConversationId(source); const message = messages.find( - item => !item.isIncoming() && source === item.get('conversationId') + item => !item.isIncoming() && sourceId === item.get('conversationId') ); if (message) { return MessageController.register(message.id, message); } - const groups = await window.Signal.Data.getAllGroupsInvolvingId(source, { - ConversationCollection: Whisper.ConversationCollection, - }); + const groups = await window.Signal.Data.getAllGroupsInvolvingId( + sourceId, + { + ConversationCollection: Whisper.ConversationCollection, + } + ); const ids = groups.pluck('id'); - ids.push(source); + ids.push(sourceId); const target = messages.find( item => @@ -68,25 +72,25 @@ ); const message = await this.getTargetMessage( - receipt.get('source'), + receipt.get('deliveredTo'), messages ); if (!message) { window.log.info( 'No message for delivery receipt', - receipt.get('source'), + receipt.get('deliveredTo'), receipt.get('timestamp') ); return; } const deliveries = message.get('delivered') || 0; - const deliveredTo = message.get('delivered_to') || []; + const deliveredTo = message.get('deliveredTo') || []; const expirationStartTimestamp = message.get( 'expirationStartTimestamp' ); message.set({ - delivered_to: _.union(deliveredTo, [receipt.get('source')]), + delivered_to: _.union(deliveredTo, [receipt.get('deliveredTo')]), delivered: deliveries + 1, expirationStartTimestamp: expirationStartTimestamp || Date.now(), sent: true, diff --git a/js/models/blockedNumbers.js b/js/models/blockedNumbers.js index 10ef67f0f5..02c7326d98 100644 --- a/js/models/blockedNumbers.js +++ b/js/models/blockedNumbers.js @@ -5,6 +5,7 @@ 'use strict'; const BLOCKED_NUMBERS_ID = 'blocked'; + const BLOCKED_UUIDS_ID = 'blocked-uuids'; const BLOCKED_GROUPS_ID = 'blocked-groups'; storage.isBlocked = number => { @@ -31,6 +32,30 @@ storage.put(BLOCKED_NUMBERS_ID, _.without(numbers, number)); }; + storage.isUuidBlocked = uuid => { + const uuids = storage.get(BLOCKED_UUIDS_ID, []); + + return _.include(uuids, uuid); + }; + storage.addBlockedUuid = uuid => { + const uuids = storage.get(BLOCKED_UUIDS_ID, []); + if (_.include(uuids, uuid)) { + return; + } + + window.log.info('adding', uuid, 'to blocked list'); + storage.put(BLOCKED_UUIDS_ID, uuids.concat(uuid)); + }; + storage.removeBlockedUuid = uuid => { + const numbers = storage.get(BLOCKED_UUIDS_ID, []); + if (!_.include(numbers, uuid)) { + return; + } + + window.log.info('removing', uuid, 'from blocked list'); + storage.put(BLOCKED_NUMBERS_ID, _.without(numbers, uuid)); + }; + storage.isGroupBlocked = groupId => { const groupIds = storage.get(BLOCKED_GROUPS_ID, []); diff --git a/js/models/conversations.js b/js/models/conversations.js index 2551873458..7807d17b79 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -27,7 +27,7 @@ }; const { Util } = window.Signal; - const { Conversation, Contact, Message, PhoneNumber } = window.Signal.Types; + const { Conversation, Contact, Message } = window.Signal.Types; const { deleteAttachmentData, doesAttachmentExist, @@ -85,8 +85,13 @@ return collection; }, - initialize() { + initialize(attributes) { + if (window.isValidE164(attributes.id)) { + this.set({ id: window.getGuid(), e164: attributes.id }); + } + this.ourNumber = textsecure.storage.user.getNumber(); + this.ourUuid = textsecure.storage.user.getUuid(); this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; // This may be overridden by ConversationController.getOrCreate, and signify @@ -148,7 +153,11 @@ }, isMe() { - return this.id === this.ourNumber; + const e164 = this.get('e164'); + const uuid = this.get('uuid'); + return ( + (e164 && e164 === this.ourNumber) || (uuid && uuid === this.ourUuid) + ); }, hasDraft() { @@ -241,11 +250,17 @@ }, sendTypingMessage(isTyping) { - const groupId = !this.isPrivate() ? this.id : null; - const recipientId = this.isPrivate() ? this.id : null; + if (!textsecure.messaging) { + return; + } + + const groupId = !this.isPrivate() ? this.get('groupId') : null; + const maybeRecipientId = this.get('uuid') || this.get('e164'); + const recipientId = this.isPrivate() ? maybeRecipientId : null; const groupNumbers = this.getRecipients(); const sendOptions = this.getSendOptions(); + this.wrapSend( textsecure.messaging.sendTypingMessage( { @@ -356,8 +371,6 @@ return this.cachedProps; }, getProps() { - const { format } = PhoneNumber; - const regionCode = storage.get('regionCode'); const color = this.getColor(); const typingValues = _.values(this.contactTypingTimers || {}); @@ -394,9 +407,7 @@ draftPreview, draftText, - phoneNumber: format(this.id, { - ourRegionCode: regionCode, - }), + phoneNumber: this.getNumber(), lastMessage: { status: this.get('lastMessageStatus'), text: this.get('lastMessage'), @@ -406,6 +417,31 @@ return result; }, + updateE164(e164) { + const oldValue = this.get('e164'); + if (e164 !== oldValue) { + this.set('e164', e164); + window.Signal.Data.updateConversation(this.id, this.attributes); + this.trigger('idUpdated', this, 'e164', oldValue); + } + }, + updateUuid(uuid) { + const oldValue = this.get('uuid'); + if (uuid !== oldValue) { + this.set('uuid', uuid); + window.Signal.Data.updateConversation(this.id, this.attributes); + this.trigger('idUpdated', this, 'uuid', oldValue); + } + }, + updateGroupId(groupId) { + const oldValue = this.get('groupId'); + if (groupId !== oldValue) { + this.set('groupId', groupId); + window.Signal.Data.updateConversation(this.id, this.attributes); + this.trigger('idUpdated', this, 'groupId', oldValue); + } + }, + onMessageError() { this.updateVerified(); }, @@ -506,24 +542,28 @@ }); } if (!options.viaSyncMessage) { - await this.sendVerifySyncMessage(this.id, verified); + await this.sendVerifySyncMessage( + this.get('e164'), + this.get('uuid'), + verified + ); } }, - sendVerifySyncMessage(number, state) { + sendVerifySyncMessage(e164, uuid, state) { // Because syncVerification sends a (null) message to the target of the verify and // a sync message to our own devices, we need to send the accessKeys down for both // contacts. So we merge their sendOptions. const { sendOptions } = ConversationController.prepareForSend( - this.ourNumber, + this.ourNumber || this.ourUuid, { syncMessage: true } ); const contactSendOptions = this.getSendOptions(); const options = Object.assign({}, sendOptions, contactSendOptions); - const promise = textsecure.storage.protocol.loadIdentityKey(number); + const promise = textsecure.storage.protocol.loadIdentityKey(e164); return promise.then(key => this.wrapSend( - textsecure.messaging.syncVerification(number, state, key, options) + textsecure.messaging.syncVerification(e164, uuid, state, key, options) ) ); }, @@ -764,8 +804,8 @@ }); }, - validate(attributes) { - const required = ['id', 'type']; + validate(attributes = this.attributes) { + const required = ['type']; const missing = _.filter(required, attr => !attributes[attr]); if (missing.length) { return `Conversation must have ${missing}`; @@ -775,7 +815,16 @@ return `Invalid conversation type: ${attributes.type}`; } - const error = this.validateNumber(); + const atLeastOneOf = ['e164', 'uuid', 'groupId']; + const hasAtLeastOneOf = + _.filter(atLeastOneOf, attr => attributes[attr]).length > 0; + + if (!hasAtLeastOneOf) { + return 'Missing one of e164, uuid, or groupId'; + } + + const error = this.validateNumber() || this.validateUuid(); + if (error) { return error; } @@ -784,11 +833,14 @@ }, validateNumber() { - if (this.isPrivate()) { + if (this.isPrivate() && this.get('e164')) { const regionCode = storage.get('regionCode'); - const number = libphonenumber.util.parseNumber(this.id, regionCode); + const number = libphonenumber.util.parseNumber( + this.get('e164'), + regionCode + ); if (number.isValidNumber) { - this.set({ id: number.e164 }); + this.set({ e164: number.e164 }); return null; } @@ -798,6 +850,18 @@ return null; }, + validateUuid() { + if (this.isPrivate() && this.get('uuid')) { + if (window.isValidGuid(this.get('uuid'))) { + return null; + } + + return 'Invalid UUID'; + } + + return null; + }, + queueJob(callback) { this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 }); @@ -811,10 +875,15 @@ getRecipients() { if (this.isPrivate()) { - return [this.id]; + return [this.get('uuid') || this.get('e164')]; } - const me = textsecure.storage.user.getNumber(); - return _.without(this.get('members'), me); + const me = ConversationController.getConversationId( + textsecure.storage.user.getUuid() || textsecure.storage.user.getNumber() + ); + return _.without(this.get('members'), me).map(memberId => { + const c = ConversationController.get(memberId); + return c.get('uuid') || c.get('e164'); + }); }, async getQuoteAttachment(attachments, preview, sticker) { @@ -908,7 +977,8 @@ : ''; return { - author: contact.id, + author: contact.get('e164'), + authorUuid: contact.get('uuid'), id: quotedMessage.get('sent_at'), text: body || embeddedContactName, attachments: quotedMessage.isTapToView() @@ -955,7 +1025,9 @@ * @param {boolean} [reaction.remove] - Set to `true` if we are removing a * reaction with the given emoji * @param {object} target - The target of the reaction - * @param {string} target.targetAuthorE164 - The E164 address of the target + * @param {string} [target.targetAuthorE164] - The E164 address of the target + * message's author + * @param {string} [target.targetAuthorUuid] - The UUID address of the target * message's author * @param {number} target.targetTimestamp - The sent_at timestamp of the * target message @@ -965,13 +1037,17 @@ const outgoingReaction = { ...reaction, ...target }; const reactionModel = Whisper.Reactions.add({ ...outgoingReaction, - fromId: this.ourNumber || textsecure.storage.user.getNumber(), + fromId: + this.ourNumber || + this.ourUuid || + textsecure.storage.user.getNumber() || + textsecure.storage.user.getUuid(), timestamp, fromSync: true, }); Whisper.Reactions.onReaction(reactionModel); - const destination = this.id; + const destination = this.get('e164'); const recipients = this.getRecipients(); let profileKey; @@ -987,11 +1063,10 @@ timestamp ); - // Here we move attachments to disk const attributes = { id: window.getGuid(), type: 'outgoing', - conversationId: destination, + conversationId: this.get('id'), sent_at: timestamp, received_at: timestamp, recipients, @@ -1029,11 +1104,10 @@ } const options = this.getSendOptions(); - const groupNumbers = this.getRecipients(); const promise = (() => { if (this.isPrivate()) { - return textsecure.messaging.sendMessageToNumber( + return textsecure.messaging.sendMessageToIdentifier( destination, null, null, @@ -1049,8 +1123,8 @@ } return textsecure.messaging.sendMessageToGroup( - destination, - groupNumbers, + this.get('groupId'), + this.getRecipients(), null, null, null, @@ -1082,7 +1156,7 @@ const { clearUnreadMetrics } = window.reduxActions.conversations; clearUnreadMetrics(this.id); - const destination = this.id; + const destination = this.get('uuid') || this.get('e164'); const expireTimer = this.get('expireTimer'); const recipients = this.getRecipients(); @@ -1105,7 +1179,7 @@ const messageWithSchema = await upgradeMessageSchema({ type: 'outgoing', body, - conversationId: destination, + conversationId: this.id, quote, preview, attachments, @@ -1147,10 +1221,13 @@ // We're offline! if (!textsecure.messaging) { - const errors = this.contactCollection.map(contact => { + const errors = (this.contactCollection.length + ? this.contactCollection + : [this] + ).map(contact => { const error = new Error('Network is not available'); error.name = 'SendMessageNetworkError'; - error.number = contact.id; + error.number = contact.get('uuid') || contact.get('e164'); return error; }); await message.saveErrors(errors); @@ -1189,12 +1266,11 @@ const conversationType = this.get('type'); const options = this.getSendOptions(); - const groupNumbers = this.getRecipients(); const promise = (() => { switch (conversationType) { case Message.PRIVATE: - return textsecure.messaging.sendMessageToNumber( + return textsecure.messaging.sendMessageToIdentifier( destination, messageBody, finalAttachments, @@ -1209,8 +1285,8 @@ ); case Message.GROUP: return textsecure.messaging.sendMessageToGroup( - destination, - groupNumbers, + this.get('groupId'), + this.getRecipients(), messageBody, finalAttachments, quote, @@ -1239,7 +1315,7 @@ // success if (result) { await this.handleMessageSendResult( - result.failoverNumbers, + result.failoverIdentifiers, result.unidentifiedDeliveries ); } @@ -1249,7 +1325,7 @@ // failure if (result) { await this.handleMessageSendResult( - result.failoverNumbers, + result.failoverIdentifiers, result.unidentifiedDeliveries ); } @@ -1258,9 +1334,9 @@ ); }, - async handleMessageSendResult(failoverNumbers, unidentifiedDeliveries) { + async handleMessageSendResult(failoverIdentifiers, unidentifiedDeliveries) { await Promise.all( - (failoverNumbers || []).map(async number => { + (failoverIdentifiers || []).map(async number => { const conversation = ConversationController.get(number); if ( @@ -1315,25 +1391,36 @@ getSendOptions(options = {}) { const senderCertificate = storage.get('senderCertificate'); - const numberInfo = this.getNumberInfo(options); + const senderCertificateWithUuid = storage.get( + 'senderCertificateWithUuid' + ); + const sendMetadata = this.getSendMetadata(options); return { senderCertificate, - numberInfo, + senderCertificateWithUuid, + sendMetadata, }; }, - getNumberInfo(options = {}) { + getUuidCapable() { + return Boolean(_.property('uuid')(this.get('capabilities'))); + }, + + getSendMetadata(options = {}) { const { syncMessage, disableMeCheck } = options; - if (!this.ourNumber) { + if (!this.ourNumber && !this.ourUuid) { return null; } // START: this code has an Expiration date of ~2018/11/21 // We don't want to enable unidentified delivery for send unless it is // also enabled for our own account. - const me = ConversationController.getOrCreate(this.ourNumber, 'private'); + const me = ConversationController.getOrCreate( + this.ourNumber || this.ourUuid, + 'private' + ); if ( !disableMeCheck && me.get('sealedSender') === SEALED_SENDER.DISABLED @@ -1344,29 +1431,36 @@ if (!this.isPrivate()) { const infoArray = this.contactCollection.map(conversation => - conversation.getNumberInfo(options) + conversation.getSendMetadata(options) ); return Object.assign({}, ...infoArray); } const accessKey = this.get('accessKey'); const sealedSender = this.get('sealedSender'); + const uuidCapable = this.getUuidCapable(); // We never send sync messages as sealed sender - if (syncMessage && this.id === this.ourNumber) { + if (syncMessage && this.isMe()) { return null; } + const e164 = this.get('e164'); + const uuid = this.get('uuid'); + // If we've never fetched user's profile, we default to what we have if (sealedSender === SEALED_SENDER.UNKNOWN) { + const info = { + accessKey: + accessKey || + window.Signal.Crypto.arrayBufferToBase64( + window.Signal.Crypto.getRandomBytes(16) + ), + useUuidSenderCert: uuidCapable, + }; return { - [this.id]: { - accessKey: - accessKey || - window.Signal.Crypto.arrayBufferToBase64( - window.Signal.Crypto.getRandomBytes(16) - ), - }, + ...(e164 ? { [e164]: info } : {}), + ...(uuid ? { [uuid]: info } : {}), }; } @@ -1374,15 +1468,19 @@ return null; } + const info = { + accessKey: + accessKey && sealedSender === SEALED_SENDER.ENABLED + ? accessKey + : window.Signal.Crypto.arrayBufferToBase64( + window.Signal.Crypto.getRandomBytes(16) + ), + useUuidSenderCert: uuidCapable, + }; + return { - [this.id]: { - accessKey: - accessKey && sealedSender === SEALED_SENDER.ENABLED - ? accessKey - : window.Signal.Crypto.arrayBufferToBase64( - window.Signal.Crypto.getRandomBytes(16) - ), - }, + ...(e164 ? { [e164]: info } : {}), + ...(uuid ? { [uuid]: info } : {}), }; }, @@ -1465,7 +1563,10 @@ source, }); - source = source || textsecure.storage.user.getNumber(); + source = + source || + textsecure.storage.user.getNumber() || + textsecure.storage.user.getUuid(); // When we add a disappearing messages notification to the conversation, we want it // to be above the message that initiated that change, hence the subtraction. @@ -1492,7 +1593,7 @@ }); if (this.isPrivate()) { - model.set({ destination: this.id }); + model.set({ destination: this.get('uuid') || this.get('e164') }); } if (model.isOutgoing()) { model.set({ recipients: this.getRecipients() }); @@ -1522,7 +1623,7 @@ const flags = textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; const dataMessage = await textsecure.messaging.getMessageProto( - this.get('id'), + this.get('uuid') || this.get('e164'), null, [], null, @@ -1538,8 +1639,8 @@ } if (this.get('type') === 'private') { - promise = textsecure.messaging.sendExpirationTimerUpdateToNumber( - this.get('id'), + promise = textsecure.messaging.sendExpirationTimerUpdateToIdentifier( + this.get('uuid') || this.get('e164'), expireTimer, message.get('sent_at'), profileKey, @@ -1547,7 +1648,7 @@ ); } else { promise = textsecure.messaging.sendExpirationTimerUpdateToGroup( - this.get('id'), + this.get('groupId'), this.getRecipients(), expireTimer, message.get('sent_at'), @@ -1573,7 +1674,8 @@ type: 'outgoing', sent_at: now, received_at: now, - destination: this.id, + destination: this.get('e164'), + destinationUuid: this.get('uuid'), recipients: this.getRecipients(), flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, }); @@ -1589,7 +1691,11 @@ const options = this.getSendOptions(); message.send( this.wrapSend( - textsecure.messaging.resetSession(this.id, now, options) + textsecure.messaging.resetSession( + this.get('uuid') || this.get('e164'), + now, + options + ) ) ); } @@ -1720,7 +1826,7 @@ // Because syncReadMessages sends to our other devices, and sendReadReceipts goes // to a contact, we need accessKeys for both. const { sendOptions } = ConversationController.prepareForSend( - this.ourNumber, + this.ourUuid || this.ourNumber, { syncMessage: true } ); await this.wrapSend( @@ -1731,11 +1837,13 @@ const convoSendOptions = this.getSendOptions(); await Promise.all( - _.map(_.groupBy(read, 'sender'), async (receipts, sender) => { + _.map(_.groupBy(read, 'sender'), async (receipts, identifier) => { const timestamps = _.map(receipts, 'timestamp'); + const c = ConversationController.get(identifier); await this.wrapSend( textsecure.messaging.sendReadReceipts( - sender, + c.get('e164'), + c.get('uuid'), timestamps, convoSendOptions ) @@ -1756,9 +1864,14 @@ // request all conversation members' keys let ids = []; if (this.isPrivate()) { - ids = [this.id]; + ids = [this.get('uuid') || this.get('e164')]; } else { - ids = this.get('members'); + ids = this.get('members') + .map(id => { + const c = ConversationController.get(id); + return c ? c.get('uuid') || c.get('e164') : null; + }) + .filter(Boolean); } return Promise.all(_.map(ids, this.getProfile)); }, @@ -1780,8 +1893,8 @@ try { await c.deriveAccessKeyIfNeeded(); - const numberInfo = c.getNumberInfo({ disableMeCheck: true }) || {}; - const getInfo = numberInfo[c.id] || {}; + const sendMetadata = c.getSendMetadata({ disableMeCheck: true }) || {}; + const getInfo = sendMetadata[c.id] || {}; if (getInfo.accessKey) { try { @@ -1863,6 +1976,10 @@ sealedSender: SEALED_SENDER.DISABLED, }); } + + if (profile.capabilities) { + c.set({ capabilities: profile.capabilities }); + } } catch (error) { if (error.code !== 403 && error.code !== 404) { window.log.error( @@ -2027,8 +2144,9 @@ this.set({ accessKey }); }, - hasMember(number) { - return _.contains(this.get('members'), number); + hasMember(identifier) { + const cid = ConversationController.getConversationId(identifier); + return cid && _.contains(this.get('members'), cid); }, fetchContacts() { if (this.isPrivate()) { @@ -2114,7 +2232,7 @@ if (!this.isPrivate()) { return ''; } - const number = this.id; + const number = this.get('e164'); try { const parsedNumber = libphonenumber.parse(number); const regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber); @@ -2230,10 +2348,10 @@ }, notifyTyping(options = {}) { - const { isTyping, sender, senderDevice } = options; + const { isTyping, sender, senderUuid, senderDevice } = options; // We don't do anything with typing messages from our other devices - if (sender === this.ourNumber) { + if (sender === this.ourNumber || senderUuid === this.ourUuid) { return; } @@ -2289,6 +2407,83 @@ Whisper.ConversationCollection = Backbone.Collection.extend({ model: Whisper.Conversation, + /** + * Backbone defines a `_byId` field. Here we set up additional `_byE164`, + * `_byUuid`, and `_byGroupId` fields so we can track conversations by more + * than just their id. + */ + initialize() { + this._byE164 = {}; + this._byUuid = {}; + this._byGroupId = {}; + this.on('idUpdated', (model, idProp, oldValue) => { + if (oldValue) { + if (idProp === 'e164') { + delete this._byE164[oldValue]; + } + if (idProp === 'uuid') { + delete this._byUuid[oldValue]; + } + if (idProp === 'groupId') { + delete this._byGroupid[oldValue]; + } + } + if (model.get('e164')) { + this._byE164[model.get('e164')] = model; + } + if (model.get('uuid')) { + this._byUuid[model.get('uuid')] = model; + } + if (model.get('groupId')) { + this._byGroupid[model.get('groupId')] = model; + } + }); + }, + + reset(...args) { + Backbone.Collection.prototype.reset.apply(this, args); + this._byE164 = {}; + this._byUuid = {}; + this._byGroupId = {}; + }, + + add(...models) { + const res = Backbone.Collection.prototype.add.apply(this, models); + [].concat(res).forEach(model => { + const e164 = model.get('e164'); + if (e164) { + this._byE164[e164] = model; + } + + const uuid = model.get('uuid'); + if (uuid) { + this._byUuid[uuid] = model; + } + + const groupId = model.get('groupId'); + if (groupId) { + this._byGroupId[groupId] = model; + } + }); + return res; + }, + + /** + * Backbone collections have a `_byId` field that `get` defers to. Here, we + * override `get` to first access our custom `_byE164`, `_byUuid`, and + * `_byGroupId` functions, followed by falling back to the original + * Backbone implementation. + */ + get(id) { + return ( + this._byE164[id] || + this._byE164[`+${id}`] || + this._byUuid[id] || + this._byGroupId[id] || + Backbone.Collection.prototype.get.call(this, id) + ); + }, + comparator(m) { return -m.get('timestamp'); }, diff --git a/js/models/messages.js b/js/models/messages.js index 7044e61ba7..1de87e5b56 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -78,6 +78,9 @@ window.AccountCache[number] !== undefined; window.hasSignalAccount = number => window.AccountCache[number]; + const includesAny = (haystack, ...needles) => + needles.some(needle => haystack.includes(needle)); + window.Whisper.Message = Backbone.Model.extend({ initialize(attributes) { if (_.isObject(attributes)) { @@ -94,6 +97,7 @@ this.INITIAL_PROTOCOL_VERSION = textsecure.protobuf.DataMessage.ProtocolVersion.INITIAL; this.OUR_NUMBER = textsecure.storage.user.getNumber(); + this.OUR_UUID = textsecure.storage.user.getUuid(); this.on('destroy', this.onDestroy); this.on('change:expirationStartTimestamp', this.setToExpire); @@ -178,24 +182,32 @@ // Other top-level prop-generation getPropsForSearchResult() { - const fromNumber = this.getSource(); - const from = this.findAndFormatContact(fromNumber); - if (fromNumber === this.OUR_NUMBER) { - from.isMe = true; + const sourceE164 = this.getSource(); + const sourceUuid = this.getSourceUuid(); + const fromContact = this.findAndFormatContact(sourceE164 || sourceUuid); + + if ( + (sourceE164 && sourceE164 === this.OUR_NUMBER) || + (sourceUuid && sourceUuid === this.OUR_UUID) + ) { + fromContact.isMe = true; } - const toNumber = this.get('conversationId'); - let to = this.findAndFormatContact(toNumber); - if (toNumber === this.OUR_NUMBER) { + const conversation = this.getConversation(); + let to = this.findAndFormatContact(conversation.get('id')); + if (conversation.isMe()) { to.isMe = true; - } else if (fromNumber === toNumber) { + } else if ( + sourceE164 === conversation.get('e164') || + sourceUuid === conversation.get('uuid') + ) { to = { isMe: true, }; } return { - from, + from: fromContact, to, isSelected: this.isSelected, @@ -221,11 +233,15 @@ // We include numbers we didn't successfully send to so we can display errors. // Older messages don't have the recipients included on the message, so we fall // back to the conversation's current recipients - const phoneNumbers = this.isIncoming() - ? [this.get('source')] + const conversationIds = this.isIncoming() + ? [this.getConversation().get('id')] : _.union( - this.get('sent_to') || [], - this.get('recipients') || this.getConversation().getRecipients() + (this.get('sent_to') || []).map(id => + ConversationController.getConversationId(id) + ), + ( + this.get('recipients') || this.getConversation().getRecipients() + ).map(id => ConversationController.getConversationId(id)) ); // This will make the error message for outgoing key errors a bit nicer @@ -242,7 +258,7 @@ // that contact. Otherwise, it will be a standalone entry. const errors = _.reject(allErrors, error => Boolean(error.number)); const errorsGroupedById = _.groupBy(allErrors, 'number'); - const finalContacts = (phoneNumbers || []).map(id => { + const finalContacts = (conversationIds || []).map(id => { const errorsForContact = errorsGroupedById[id]; const isOutgoingKeyError = Boolean( _.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR) @@ -353,7 +369,7 @@ return null; } - const { expireTimer, fromSync, source } = timerUpdate; + const { expireTimer, fromSync, source, sourceUuid } = timerUpdate; const timespan = Whisper.ExpirationTimerOptions.getName(expireTimer || 0); const disabled = !expireTimer; @@ -369,7 +385,7 @@ ...basicProps, type: 'fromSync', }; - } else if (source === this.OUR_NUMBER) { + } else if (source === this.OUR_NUMBER || sourceUuid === this.OUR_UUID) { return { ...basicProps, type: 'fromMe', @@ -477,9 +493,10 @@ .map(attachment => this.getPropsForAttachment(attachment)); }, getPropsForMessage() { - const phoneNumber = this.getSource(); - const contact = this.findAndFormatContact(phoneNumber); - const contactModel = this.findContact(phoneNumber); + const sourceE164 = this.getSource(); + const sourceUuid = this.getSourceUuid(); + const contact = this.findAndFormatContact(sourceE164 || sourceUuid); + const contactModel = this.findContact(sourceE164 || sourceUuid); const authorColor = contactModel ? contactModel.getColor() : null; const authorAvatarPath = contactModel @@ -558,8 +575,8 @@ }, // Dependencies of prop-generation functions - findAndFormatContact(phoneNumber) { - const contactModel = this.findContact(phoneNumber); + findAndFormatContact(identifier) { + const contactModel = this.findContact(identifier); if (contactModel) { return contactModel.format(); } @@ -567,13 +584,13 @@ const { format } = PhoneNumber; const regionCode = storage.get('regionCode'); return { - phoneNumber: format(phoneNumber, { + phoneNumber: format(identifier, { ourRegionCode: regionCode, }), }; }, - findContact(phoneNumber) { - return ConversationController.get(phoneNumber); + findContact(identifier) { + return ConversationController.get(identifier); }, getConversation() { // This needs to be an unsafe call, because this method is called during @@ -700,8 +717,14 @@ const { format } = PhoneNumber; const regionCode = storage.get('regionCode'); - const { author, id: sentAt, referencedMessageNotFound } = quote; - const contact = author && ConversationController.get(author); + const { + author, + authorUuid, + id: sentAt, + referencedMessageNotFound, + } = quote; + const contact = + author && ConversationController.get(author || authorUuid); const authorColor = contact ? contact.getColor() : 'grey'; const authorPhoneNumber = format(author, { @@ -709,7 +732,7 @@ }); const authorProfileName = contact ? contact.getProfileName() : null; const authorName = contact ? contact.getName() : null; - const isFromMe = contact ? contact.id === this.OUR_NUMBER : false; + const isFromMe = contact ? contact.isMe() : false; const firstAttachment = quote.attachments && quote.attachments[0]; return { @@ -728,17 +751,26 @@ onClick: () => this.trigger('scroll-to-message'), }; }, - getStatus(number) { + getStatus(identifier) { + const conversation = ConversationController.get(identifier); + + if (!conversation) { + return null; + } + + const e164 = conversation.get('e164'); + const uuid = conversation.get('uuid'); + const readBy = this.get('read_by') || []; - if (readBy.indexOf(number) >= 0) { + if (includesAny(readBy, identifier, e164, uuid)) { return 'read'; } const deliveredTo = this.get('delivered_to') || []; - if (deliveredTo.indexOf(number) >= 0) { + if (includesAny(deliveredTo, identifier, e164, uuid)) { return 'delivered'; } const sentTo = this.get('sent_to') || []; - if (sentTo.indexOf(number) >= 0) { + if (includesAny(sentTo, identifier, e164, uuid)) { return 'sent'; } @@ -982,17 +1014,24 @@ if (!fromSync) { const sender = this.getSource(); + const senderUuid = this.getSourceUuid(); const timestamp = this.get('sent_at'); const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); const { wrap, sendOptions } = ConversationController.prepareForSend( - ourNumber, + ourNumber || ourUuid, { syncMessage: true, } ); await wrap( - textsecure.messaging.syncViewOnceOpen(sender, timestamp, sendOptions) + textsecure.messaging.syncViewOnceOpen( + sender, + senderUuid, + timestamp, + sendOptions + ) ); } }, @@ -1052,14 +1091,25 @@ return this.OUR_NUMBER; }, + getSourceUuid() { + if (this.isIncoming()) { + return this.get('sourceUuid'); + } + + return this.OUR_UUID; + }, getContact() { const source = this.getSource(); + const sourceUuid = this.getSourceUuid(); - if (!source) { + if (!source && !sourceUuid) { return null; } - return ConversationController.getOrCreate(source, 'private'); + return ConversationController.getOrCreate( + source || sourceUuid, + 'private' + ); }, isOutgoing() { return this.get('type') === 'outgoing'; @@ -1237,9 +1287,9 @@ // Special-case the self-send case - we send only a sync message if (recipients.length === 1 && recipients[0] === this.OUR_NUMBER) { - const [number] = recipients; + const [identifier] = recipients; const dataMessage = await textsecure.messaging.getMessageProto( - number, + identifier, body, attachments, quoteWithData, @@ -1257,9 +1307,9 @@ const options = conversation.getSendOptions(); if (conversation.isPrivate()) { - const [number] = recipients; - promise = textsecure.messaging.sendMessageToNumber( - number, + const [identifer] = recipients; + promise = textsecure.messaging.sendMessageToIdentifier( + identifer, body, attachments, quoteWithData, @@ -1327,8 +1377,8 @@ // Called when the user ran into an error with a specific user, wants to send to them // One caller today: ConversationView.forceSend() - async resend(number) { - const error = this.removeOutgoingErrors(number); + async resend(identifier) { + const error = this.removeOutgoingErrors(identifier); if (!error) { window.log.warn('resend: requested number was not present in errors'); return null; @@ -1349,9 +1399,9 @@ const stickerWithData = await loadStickerData(this.get('sticker')); // Special-case the self-send case - we send only a sync message - if (number === this.OUR_NUMBER) { + if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) { const dataMessage = await textsecure.messaging.getMessageProto( - number, + identifier, body, attachments, quoteWithData, @@ -1366,10 +1416,10 @@ } const { wrap, sendOptions } = ConversationController.prepareForSend( - number + identifier ); const promise = textsecure.messaging.sendMessageToNumber( - number, + identifier, body, attachments, quoteWithData, @@ -1411,7 +1461,7 @@ const sentTo = this.get('sent_to') || []; this.set({ - sent_to: _.union(sentTo, result.successfulNumbers), + sent_to: _.union(sentTo, result.successfulIdentifiers), sent: true, expirationStartTimestamp: Date.now(), unidentifiedDeliveries: result.unidentifiedDeliveries, @@ -1442,7 +1492,7 @@ promises.push(c.getProfiles()); } } else { - if (result.successfulNumbers.length > 0) { + if (result.successfulIdentifiers.length > 0) { const sentTo = this.get('sent_to') || []; // In groups, we don't treat unregistered users as a user-visible @@ -1462,7 +1512,7 @@ this.saveErrors(filteredErrors); this.set({ - sent_to: _.union(sentTo, result.successfulNumbers), + sent_to: _.union(sentTo, result.successfulIdentifiers), sent: true, expirationStartTimestamp, unidentifiedDeliveries: result.unidentifiedDeliveries, @@ -1488,12 +1538,13 @@ }, async sendSyncMessageOnly(dataMessage) { + const conv = this.getConversation(); this.set({ dataMessage }); try { this.set({ // These are the same as a normal send() - sent_to: [this.OUR_NUMBER], + sent_to: [conv.get('uuid') || conv.get('e164')], sent: true, expirationStartTimestamp: Date.now(), }); @@ -1503,8 +1554,8 @@ unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null, // These are unique to a Note to Self message - immediately read/delivered - delivered_to: [this.OUR_NUMBER], - read_by: [this.OUR_NUMBER], + delivered_to: [this.OUR_UUID || this.OUR_NUMBER], + read_by: [this.OUR_UUID || this.OUR_NUMBER], }); } catch (result) { const errors = (result && result.errors) || [ @@ -1528,8 +1579,9 @@ sendSyncMessage() { const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); const { wrap, sendOptions } = ConversationController.prepareForSend( - ourNumber, + ourUuid || ourNumber, { syncMessage: true, } @@ -1542,12 +1594,14 @@ return Promise.resolve(); } const isUpdate = Boolean(this.get('synced')); + const conv = this.getConversation(); return wrap( textsecure.messaging.sendSyncMessage( dataMessage, this.get('sent_at'), - this.get('destination'), + conv.get('e164'), + conv.get('uuid'), this.get('expirationStartTimestamp'), this.get('sent_to'), this.get('unidentifiedDeliveries'), @@ -1773,7 +1827,11 @@ const found = collection.find(item => { const messageAuthor = item.getContact(); - return messageAuthor && author === messageAuthor.id; + return ( + messageAuthor && + ConversationController.getConversationId(author) === + messageAuthor.get('id') + ); }); if (!found) { @@ -1873,6 +1931,7 @@ // still go through one of the previous two codepaths const message = this; const source = message.get('source'); + const sourceUuid = message.get('sourceUuid'); const type = message.get('type'); let conversationId = message.get('conversationId'); if (initialMessage.group) { @@ -1952,6 +2011,7 @@ // We drop incoming messages for groups we already know about, which we're not a // part of, except for group updates. + const ourUuid = textsecure.storage.user.getUuid(); const ourNumber = textsecure.storage.user.getNumber(); const isGroupUpdate = initialMessage.group && @@ -1960,7 +2020,7 @@ if ( type === 'incoming' && !conversation.isPrivate() && - !conversation.hasMember(ourNumber) && + !conversation.hasMember(ourNumber || ourUuid) && !isGroupUpdate ) { window.log.warn( @@ -1982,6 +2042,7 @@ Whisper.deliveryReceiptQueue.add(() => { Whisper.deliveryReceiptBatcher.add({ source, + sourceUuid, timestamp: this.get('sent_at'), }); }); @@ -2044,6 +2105,20 @@ }; if (dataMessage.group) { let groupUpdate = null; + const memberConversations = await Promise.all( + ( + dataMessage.group.members || dataMessage.group.membersE164 + ).map(member => { + if (member.e164 || member.uuid) { + return ConversationController.getOrCreateAndWait( + member.e164 || member.uuid, + 'private' + ); + } + return ConversationController.getOrCreateAndWait(member); + }) + ); + const members = memberConversations.map(c => c.get('id')); attributes = { ...attributes, type: 'group', @@ -2053,10 +2128,7 @@ attributes = { ...attributes, name: dataMessage.group.name, - members: _.union( - dataMessage.group.members, - conversation.get('members') - ), + members: _.union(members, conversation.get('members')), }; groupUpdate = @@ -2065,7 +2137,7 @@ ) || {}; const difference = _.difference( - attributes.members, + members, conversation.get('members') ); if (difference.length > 0) { @@ -2076,15 +2148,22 @@ attributes.left = false; } } else if (dataMessage.group.type === GROUP_TYPES.QUIT) { - if (source === textsecure.storage.user.getNumber()) { + if ( + source === textsecure.storage.user.getNumber() || + sourceUuid === textsecure.storage.user.getUuid() + ) { attributes.left = true; groupUpdate = { left: 'You' }; } else { - groupUpdate = { left: source }; + const myConversation = ConversationController.get( + source || sourceUuid + ); + groupUpdate = { left: myConversation.get('id') }; } attributes.members = _.without( conversation.get('members'), - source + source, + sourceUuid ); } @@ -2102,7 +2181,7 @@ message.set({ delivered: (message.get('delivered') || 0) + 1, delivered_to: _.union(message.get('delivered_to') || [], [ - receipt.get('source'), + receipt.get('deliveredTo'), ]), }) ); @@ -2216,13 +2295,16 @@ if (dataMessage.profileKey) { const profileKey = dataMessage.profileKey.toString('base64'); - if (source === textsecure.storage.user.getNumber()) { + if ( + source === textsecure.storage.user.getNumber() || + sourceUuid === textsecure.storage.user.getUuid() + ) { conversation.set({ profileSharing: true }); } else if (conversation.isPrivate()) { conversation.setProfileKey(profileKey); } else { ConversationController.getOrCreateAndWait( - source, + source || sourceUuid, 'private' ).then(sender => { sender.setProfileKey(profileKey); diff --git a/js/modules/backup.js b/js/modules/backup.js index 4c8e4a2458..97b4a63421 100644 --- a/js/modules/backup.js +++ b/js/modules/backup.js @@ -1118,8 +1118,14 @@ async function importConversations(dir, options) { function getMessageKey(message) { const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); const source = message.source || ourNumber; - if (source === ourNumber) { + const sourceUuid = message.sourceUuid || ourUuid; + + if ( + (source && source === ourNumber) || + (sourceUuid && sourceUuid === ourUuid) + ) { return `${source} ${message.timestamp}`; } diff --git a/js/modules/data.js b/js/modules/data.js index 6779b609ce..e23258012c 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -1,4 +1,4 @@ -/* global window, setTimeout, IDBKeyRange */ +/* global window, setTimeout, IDBKeyRange, ConversationController */ const electron = require('electron'); @@ -84,10 +84,10 @@ module.exports = { createOrUpdateSession, createOrUpdateSessions, getSessionById, - getSessionsByNumber, + getSessionsById, bulkAddSessions, removeSessionById, - removeSessionsByNumber, + removeSessionsById, removeAllSessions, getAllSessions, @@ -431,10 +431,14 @@ async function removeIndexedDBFiles() { const IDENTITY_KEY_KEYS = ['publicKey']; async function createOrUpdateIdentityKey(data) { - const updated = keysFromArrayBuffer(IDENTITY_KEY_KEYS, data); + const updated = keysFromArrayBuffer(IDENTITY_KEY_KEYS, { + ...data, + id: ConversationController.getConversationId(data.id), + }); await channels.createOrUpdateIdentityKey(updated); } -async function getIdentityKeyById(id) { +async function getIdentityKeyById(identifier) { + const id = ConversationController.getConversationId(identifier); const data = await channels.getIdentityKeyById(id); return keysToArrayBuffer(IDENTITY_KEY_KEYS, data); } @@ -444,7 +448,8 @@ async function bulkAddIdentityKeys(array) { ); await channels.bulkAddIdentityKeys(updated); } -async function removeIdentityKeyById(id) { +async function removeIdentityKeyById(identifier) { + const id = ConversationController.getConversationId(identifier); await channels.removeIdentityKeyById(id); } async function removeAllIdentityKeys() { @@ -515,6 +520,11 @@ const ITEM_KEYS = { 'value.signature', 'value.serialized', ], + senderCertificateWithUuid: [ + 'value.certificate', + 'value.signature', + 'value.serialized', + ], signaling_key: ['value'], profileKey: ['value'], }; @@ -572,8 +582,8 @@ async function getSessionById(id) { const session = await channels.getSessionById(id); return session; } -async function getSessionsByNumber(number) { - const sessions = await channels.getSessionsByNumber(number); +async function getSessionsById(id) { + const sessions = await channels.getSessionsById(id); return sessions; } async function bulkAddSessions(array) { @@ -582,8 +592,8 @@ async function bulkAddSessions(array) { async function removeSessionById(id) { await channels.removeSessionById(id); } -async function removeSessionsByNumber(number) { - await channels.removeSessionsByNumber(number); +async function removeSessionsById(id) { + await channels.removeSessionsById(id); } async function removeAllSessions(id) { await channels.removeAllSessions(id); @@ -799,11 +809,12 @@ async function getAllMessageIds() { async function getMessageBySender( // eslint-disable-next-line camelcase - { source, sourceDevice, sent_at }, + { source, sourceUuid, sourceDevice, sent_at }, { Message } ) { const messages = await channels.getMessageBySender({ source, + sourceUuid, sourceDevice, sent_at, }); diff --git a/js/modules/metadata/SecretSessionCipher.js b/js/modules/metadata/SecretSessionCipher.js index c3aad2538f..2ad04dd04d 100644 --- a/js/modules/metadata/SecretSessionCipher.js +++ b/js/modules/metadata/SecretSessionCipher.js @@ -117,13 +117,14 @@ function _createSenderCertificateFromBuffer(serialized) { !certificate.identityKey || !certificate.senderDevice || !certificate.expires || - !certificate.sender + !(certificate.sender || certificate.senderUuid) ) { throw new Error('Missing fields'); } return { sender: certificate.sender, + senderUuid: certificate.senderUuid, senderDevice: certificate.senderDevice, expires: certificate.expires.toNumber(), identityKey: certificate.identityKey.toArrayBuffer(), @@ -344,7 +345,7 @@ SecretSessionCipher.prototype = { // public Pair decrypt( // CertificateValidator validator, byte[] ciphertext, long timestamp) - async decrypt(validator, ciphertext, timestamp, me) { + async decrypt(validator, ciphertext, timestamp, me = {}) { // Capture this.xxx variables to replicate Java's implicit this syntax const signalProtocolStore = this.storage; const _calculateEphemeralKeys = this._calculateEphemeralKeys.bind(this); @@ -401,18 +402,29 @@ SecretSessionCipher.prototype = { ); } - const { sender, senderDevice } = content.senderCertificate; - const { number, deviceId } = me || {}; - if (sender === number && senderDevice === deviceId) { + const { sender, senderUuid, senderDevice } = content.senderCertificate; + if ( + ((sender && me.number && sender === me.number) || + (senderUuid && me.uuid && senderUuid === me.uuid)) && + senderDevice === me.deviceId + ) { return { isMe: true, }; } - const address = new libsignal.SignalProtocolAddress(sender, senderDevice); + const addressE164 = + sender && new libsignal.SignalProtocolAddress(sender, senderDevice); + const addressUuid = + senderUuid && + new libsignal.SignalProtocolAddress( + senderUuid.toLowerCase(), + senderDevice + ); try { return { - sender: address, + sender: addressE164, + senderUuid: addressUuid, content: await _decryptWithUnidentifiedSenderMessage(content), }; } catch (error) { @@ -421,7 +433,8 @@ SecretSessionCipher.prototype = { error = new Error('Decryption error was falsey!'); } - error.sender = address; + error.sender = addressE164; + error.senderUuid = addressUuid; throw error; } @@ -504,7 +517,7 @@ SecretSessionCipher.prototype = { const signalProtocolStore = this.storage; const sender = new libsignal.SignalProtocolAddress( - message.senderCertificate.sender, + message.senderCertificate.sender || message.senderCertificate.senderUuid, message.senderCertificate.senderDevice ); diff --git a/js/modules/privacy.js b/js/modules/privacy.js index d737f5d050..8e3acb3b51 100644 --- a/js/modules/privacy.js +++ b/js/modules/privacy.js @@ -8,6 +8,7 @@ const { escapeRegExp } = require('lodash'); const APP_ROOT_PATH = path.join(__dirname, '..', '..', '..'); const PHONE_NUMBER_PATTERN = /\+\d{7,12}(\d{3})/g; +const UUID_PATTERN = /[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{10}([0-9A-F]{2})/gi; const GROUP_ID_PATTERN = /(group\()([^)]+)(\))/g; const REDACTION_PLACEHOLDER = '[REDACTED]'; @@ -64,6 +65,15 @@ exports.redactPhoneNumbers = text => { return text.replace(PHONE_NUMBER_PATTERN, `+${REDACTION_PLACEHOLDER}$1`); }; +// redactUuids :: String -> String +exports.redactUuids = text => { + if (!is.string(text)) { + throw new TypeError("'text' must be a string"); + } + + return text.replace(UUID_PATTERN, `${REDACTION_PLACEHOLDER}$1`); +}; + // redactGroupIds :: String -> String exports.redactGroupIds = text => { if (!is.string(text)) { @@ -84,7 +94,8 @@ exports.redactSensitivePaths = exports._redactPath(APP_ROOT_PATH); exports.redactAll = compose( exports.redactSensitivePaths, exports.redactGroupIds, - exports.redactPhoneNumbers + exports.redactPhoneNumbers, + exports.redactUuids ); const removeNewlines = text => text.replace(/\r?\n|\r/g, ''); diff --git a/js/modules/refresh_sender_certificate.js b/js/modules/refresh_sender_certificate.js index efc37ff631..708b055108 100644 --- a/js/modules/refresh_sender_certificate.js +++ b/js/modules/refresh_sender_certificate.js @@ -16,7 +16,15 @@ let scheduleNext = null; function refreshOurProfile() { window.log.info('refreshOurProfile'); const ourNumber = textsecure.storage.user.getNumber(); - const conversation = ConversationController.getOrCreate(ourNumber, 'private'); + const ourUuid = textsecure.storage.user.getUuid(); + const conversation = ConversationController.getOrCreate( + // This is explicitly ourNumber first in order to avoid creating new + // conversations when an old one exists + ourNumber || ourUuid, + 'private' + ); + conversation.updateUuid(ourUuid); + conversation.updateE164(ourNumber); conversation.getProfiles(); } @@ -66,21 +74,36 @@ function initialize({ events, storage, navigator, logger }) { async function run() { logger.info('refreshSenderCertificate: Getting new certificate...'); try { - const username = storage.get('number_id'); - const password = storage.get('password'); - const server = WebAPI.connect({ username, password }); + const OLD_USERNAME = storage.get('number_id'); + const USERNAME = storage.get('uuid_id'); + const PASSWORD = storage.get('password'); + const server = WebAPI.connect({ + username: USERNAME || OLD_USERNAME, + password: PASSWORD, + }); - const { certificate } = await server.getSenderCertificate(); - const arrayBuffer = window.Signal.Crypto.base64ToArrayBuffer(certificate); - const decoded = textsecure.protobuf.SenderCertificate.decode(arrayBuffer); + await Promise.all( + [false, true].map(async withUuid => { + const { certificate } = await server.getSenderCertificate(withUuid); + const arrayBuffer = window.Signal.Crypto.base64ToArrayBuffer( + certificate + ); + const decoded = textsecure.protobuf.SenderCertificate.decode( + arrayBuffer + ); - decoded.certificate = decoded.certificate.toArrayBuffer(); - decoded.signature = decoded.signature.toArrayBuffer(); - decoded.serialized = arrayBuffer; + decoded.certificate = decoded.certificate.toArrayBuffer(); + decoded.signature = decoded.signature.toArrayBuffer(); + decoded.serialized = arrayBuffer; + + storage.put( + `senderCertificate${withUuid ? 'WithUuid' : ''}`, + decoded + ); + }) + ); - storage.put('senderCertificate', decoded); scheduledTime = null; - scheduleNextRotation(); } catch (error) { logger.error( diff --git a/js/modules/web_api.js b/js/modules/web_api.js index 5472ad2874..5787daa1f7 100644 --- a/js/modules/web_api.js +++ b/js/modules/web_api.js @@ -394,12 +394,14 @@ const URL_CALLS = { attachmentId: 'v2/attachments/form/upload', deliveryCert: 'v1/certificate/delivery', supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', + registerCapabilities: 'v1/devices/capabilities', devices: 'v1/devices', keys: 'v2/keys', messages: 'v1/messages', profile: 'v1/profile', signed: 'v2/keys/signed', getStickerPackUpload: 'v1/sticker/pack/form', + whoami: 'v1/accounts/whoami', }; module.exports = { @@ -451,8 +453,8 @@ function initialize({ getAttachment, getAvatar, getDevices, - getKeysForNumber, - getKeysForNumberUnauth, + getKeysForIdentifier, + getKeysForIdentifierUnauth, getMessageSocket, getMyKeys, getProfile, @@ -463,6 +465,7 @@ function initialize({ getStickerPackManifest, makeProxiedRequest, putAttachment, + registerCapabilities, putStickers, registerKeys, registerSupportForUnauthenticatedDelivery, @@ -473,6 +476,7 @@ function initialize({ sendMessagesUnauth, setSignedPreKey, updateDeviceName, + whoami, }; function _ajax(param) { @@ -535,12 +539,21 @@ function initialize({ }); } - function getSenderCertificate() { + function whoami() { + return _ajax({ + call: 'whoami', + httpType: 'GET', + responseType: 'json', + }); + } + + function getSenderCertificate(withUuid = false) { return _ajax({ call: 'deliveryCert', httpType: 'GET', responseType: 'json', schema: { certificate: 'string' }, + urlParameters: withUuid ? '?includeUuid=true' : undefined, }); } @@ -552,19 +565,27 @@ function initialize({ }); } - function getProfile(number) { + function registerCapabilities(capabilities) { + return _ajax({ + call: 'registerCapabilities', + httpType: 'PUT', + jsonData: { capabilities }, + }); + } + + function getProfile(identifier) { return _ajax({ call: 'profile', httpType: 'GET', - urlParameters: `/${number}`, + urlParameters: `/${identifier}`, responseType: 'json', }); } - function getProfileUnauth(number, { accessKey } = {}) { + function getProfileUnauth(identifier, { accessKey } = {}) { return _ajax({ call: 'profile', httpType: 'GET', - urlParameters: `/${number}`, + urlParameters: `/${identifier}`, responseType: 'json', unauthenticated: true, accessKey, @@ -623,17 +644,17 @@ function initialize({ let call; let urlPrefix; let schema; - let responseType; if (deviceName) { jsonData.name = deviceName; call = 'devices'; urlPrefix = '/'; - schema = { deviceId: 'number' }; - responseType = 'json'; } else { call = 'accounts'; urlPrefix = '/code/'; + jsonData.capabilities = { + uuid: true, + }; } // We update our saved username and password, since we're creating a new account @@ -643,14 +664,14 @@ function initialize({ const response = await _ajax({ call, httpType: 'PUT', + responseType: 'json', urlParameters: urlPrefix + code, jsonData, - responseType, validateResponse: schema, }); - // From here on out, our username will be our phone number combined with device - username = `${number}.${response.deviceId || 1}`; + // From here on out, our username will be our UUID or E164 combined with device + username = `${response.uuid || number}.${response.deviceId || 1}`; return response; } @@ -768,25 +789,25 @@ function initialize({ return res; } - function getKeysForNumber(number, deviceId = '*') { + function getKeysForIdentifier(identifier, deviceId = '*') { return _ajax({ call: 'keys', httpType: 'GET', - urlParameters: `/${number}/${deviceId}`, + urlParameters: `/${identifier}/${deviceId}`, responseType: 'json', validateResponse: { identityKey: 'string', devices: 'object' }, }).then(handleKeys); } - function getKeysForNumberUnauth( - number, + function getKeysForIdentifierUnauth( + identifier, deviceId = '*', { accessKey } = {} ) { return _ajax({ call: 'keys', httpType: 'GET', - urlParameters: `/${number}/${deviceId}`, + urlParameters: `/${identifier}/${deviceId}`, responseType: 'json', validateResponse: { identityKey: 'string', devices: 'object' }, unauthenticated: true, diff --git a/js/read_syncs.js b/js/read_syncs.js index 4d3375415f..294b45d55e 100644 --- a/js/read_syncs.js +++ b/js/read_syncs.js @@ -36,7 +36,9 @@ const found = messages.find( item => - item.isIncoming() && item.get('source') === receipt.get('sender') + item.isIncoming() && + (item.get('source') === receipt.get('sender') || + item.get('sourceUuid') === receipt.get('senderUuid')) ); const notificationForMessage = found ? Whisper.Notifications.findWhere({ messageId: found.id }) @@ -47,6 +49,7 @@ window.log.info( 'No message for read sync', receipt.get('sender'), + receipt.get('senderUuid'), receipt.get('timestamp') ); return; diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index 4bd51060ec..3188aebed0 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -147,6 +147,24 @@ }, }); + async function normalizeEncodedAddress(encodedAddress) { + const [identifier, deviceId] = textsecure.utils.unencodeNumber( + encodedAddress + ); + try { + const conv = await ConversationController.getOrCreateAndWait( + identifier, + 'private' + ); + return `${conv.get('id')}.${deviceId}`; + } catch (e) { + window.log.error( + `could not get conversation for identifier ${identifier}` + ); + throw e; + } + } + function SignalProtocolStore() { this.sessionUpdateBatcher = window.Signal.Util.createBatcher({ wait: 500, @@ -322,66 +340,98 @@ // Sessions - async loadSession(encodedNumber) { - if (encodedNumber === null || encodedNumber === undefined) { + async loadSession(encodedAddress) { + if (encodedAddress === null || encodedAddress === undefined) { throw new Error('Tried to get session for undefined/null number'); } - const session = this.sessions[encodedNumber]; - if (session) { - return session.record; + try { + const id = await normalizeEncodedAddress(encodedAddress); + const session = this.sessions[id]; + + if (session) { + return session.record; + } + } catch (e) { + window.log.error(`could not load session ${encodedAddress}`); } return undefined; }, - async storeSession(encodedNumber, record) { - if (encodedNumber === null || encodedNumber === undefined) { + async storeSession(encodedAddress, record) { + if (encodedAddress === null || encodedAddress === undefined) { throw new Error('Tried to put session for undefined/null number'); } - const unencoded = textsecure.utils.unencodeNumber(encodedNumber); - const number = unencoded[0]; + const unencoded = textsecure.utils.unencodeNumber(encodedAddress); const deviceId = parseInt(unencoded[1], 10); - const data = { - id: encodedNumber, - number, - deviceId, - record, - }; + try { + const id = await normalizeEncodedAddress(encodedAddress); - this.sessions[encodedNumber] = data; + const data = { + id, + conversationId: textsecure.utils.unencodeNumber(id)[0], + deviceId, + record, + }; - // Note: Because these are cached in memory, we batch and make these database - // updates out of band. - this.sessionUpdateBatcher.add(data); + this.sessions[id] = data; + + // Note: Because these are cached in memory, we batch and make these database + // updates out of band. + this.sessionUpdateBatcher.add(data); + } catch (e) { + window.log.error(`could not store session for ${encodedAddress}`); + } }, - async getDeviceIds(number) { - if (number === null || number === undefined) { + async getDeviceIds(identifier) { + if (identifier === null || identifier === undefined) { throw new Error('Tried to get device ids for undefined/null number'); } - const allSessions = Object.values(this.sessions); - const sessions = allSessions.filter(session => session.number === number); - return _.pluck(sessions, 'deviceId'); + try { + const id = ConversationController.getConversationId(identifier); + const allSessions = Object.values(this.sessions); + const sessions = allSessions.filter( + session => session.conversationId === id + ); + + return _.pluck(sessions, 'deviceId'); + } catch (e) { + window.log.error( + `could not get device ids for identifier ${identifier}` + ); + } + + return []; }, - async removeSession(encodedNumber) { - window.log.info('deleting session for ', encodedNumber); - delete this.sessions[encodedNumber]; - await window.Signal.Data.removeSessionById(encodedNumber); + async removeSession(encodedAddress) { + window.log.info('deleting session for ', encodedAddress); + try { + const id = await normalizeEncodedAddress(encodedAddress); + delete this.sessions[id]; + await window.Signal.Data.removeSessionById(id); + } catch (e) { + window.log.error(`could not delete session for ${encodedAddress}`); + } }, - async removeAllSessions(number) { - if (number === null || number === undefined) { + async removeAllSessions(identifier) { + if (identifier === null || identifier === undefined) { throw new Error('Tried to remove sessions for undefined/null number'); } + const id = ConversationController.getConversationId(identifier); + const allSessions = Object.values(this.sessions); + for (let i = 0, max = allSessions.length; i < max; i += 1) { const session = allSessions[i]; - if (session.number === number) { + if (session.conversationId === id) { delete this.sessions[session.id]; } } - await window.Signal.Data.removeSessionsByNumber(number); + + await window.Signal.Data.removeSessionsById(identifier); }, async archiveSiblingSessions(identifier) { const address = libsignal.SignalProtocolAddress.fromString(identifier); @@ -404,12 +454,15 @@ }) ); }, - async archiveAllSessions(number) { - const deviceIds = await this.getDeviceIds(number); + async archiveAllSessions(identifier) { + const deviceIds = await this.getDeviceIds(identifier); await Promise.all( deviceIds.map(async deviceId => { - const address = new libsignal.SignalProtocolAddress(number, deviceId); + const address = new libsignal.SignalProtocolAddress( + identifier, + deviceId + ); window.log.info('closing session for', address.toString()); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, @@ -426,16 +479,35 @@ // Identity Keys - async isTrustedIdentity(identifier, publicKey, direction) { - if (identifier === null || identifier === undefined) { + getIdentityRecord(identifier) { + try { + const id = ConversationController.getConversationId(identifier); + const record = this.identityKeys[id]; + + if (record) { + return record; + } + } catch (e) { + window.log.error( + `could not get identity record for identifier ${identifier}` + ); + } + + return undefined; + }, + + async isTrustedIdentity(encodedAddress, publicKey, direction) { + if (encodedAddress === null || encodedAddress === undefined) { throw new Error('Tried to get identity key for undefined/null key'); } - const number = textsecure.utils.unencodeNumber(identifier)[0]; - const isOurNumber = number === textsecure.storage.user.getNumber(); + const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; + const isOurIdentifier = + identifier === textsecure.storage.user.getNumber() || + identifier === textsecure.storage.user.getUuid(); - const identityRecord = this.identityKeys[number]; + const identityRecord = this.getIdentityRecord(identifier); - if (isOurNumber) { + if (isOurIdentifier) { const existing = identityRecord ? identityRecord.publicKey : null; return equalArrayBuffers(existing, publicKey); } @@ -482,8 +554,8 @@ if (identifier === null || identifier === undefined) { throw new Error('Tried to get identity key for undefined/null key'); } - const number = textsecure.utils.unencodeNumber(identifier)[0]; - const identityRecord = this.identityKeys[number]; + const id = textsecure.utils.unencodeNumber(identifier)[0]; + const identityRecord = this.getIdentityRecord(id); if (identityRecord) { return identityRecord.publicKey; @@ -496,8 +568,8 @@ this.identityKeys[id] = data; await window.Signal.Data.createOrUpdateIdentityKey(data); }, - async saveIdentity(identifier, publicKey, nonblockingApproval) { - if (identifier === null || identifier === undefined) { + async saveIdentity(encodedAddress, publicKey, nonblockingApproval) { + if (encodedAddress === null || encodedAddress === undefined) { throw new Error('Tried to put identity key for undefined/null key'); } if (!(publicKey instanceof ArrayBuffer)) { @@ -509,14 +581,15 @@ nonblockingApproval = false; } - const number = textsecure.utils.unencodeNumber(identifier)[0]; - const identityRecord = this.identityKeys[number]; + const identifer = textsecure.utils.unencodeNumber(encodedAddress)[0]; + const identityRecord = this.getIdentityRecord(identifer); + const id = ConversationController.getConversationId(identifer); if (!identityRecord || !identityRecord.publicKey) { // Lookup failed, or the current key was removed, so save this one. window.log.info('Saving new identity...'); await this._saveIdentityKey({ - id: number, + id, publicKey, firstUse: true, timestamp: Date.now(), @@ -542,7 +615,7 @@ } await this._saveIdentityKey({ - id: number, + id, publicKey, firstUse: false, timestamp: Date.now(), @@ -551,14 +624,14 @@ }); try { - this.trigger('keychange', number); + this.trigger('keychange', identifer); } catch (error) { window.log.error( 'saveIdentity error triggering keychange:', error && error.stack ? error.stack : error ); } - await this.archiveSiblingSessions(identifier); + await this.archiveSiblingSessions(encodedAddress); return true; } else if (this.isNonBlockingApprovalRequired(identityRecord)) { @@ -579,16 +652,21 @@ !identityRecord.nonblockingApproval ); }, - async saveIdentityWithAttributes(identifier, attributes) { - if (identifier === null || identifier === undefined) { + async saveIdentityWithAttributes(encodedAddress, attributes) { + if (encodedAddress === null || encodedAddress === undefined) { throw new Error('Tried to put identity key for undefined/null key'); } - const number = textsecure.utils.unencodeNumber(identifier)[0]; - const identityRecord = this.identityKeys[number]; + const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; + const identityRecord = this.getIdentityRecord(identifier); + const conv = await ConversationController.getOrCreateAndWait( + identifier, + 'private' + ); + const id = conv.get('id'); const updates = { - id: number, + id, ...identityRecord, ...attributes, }; @@ -600,26 +678,26 @@ throw model.validationError; } }, - async setApproval(identifier, nonblockingApproval) { - if (identifier === null || identifier === undefined) { + async setApproval(encodedAddress, nonblockingApproval) { + if (encodedAddress === null || encodedAddress === undefined) { throw new Error('Tried to set approval for undefined/null identifier'); } if (typeof nonblockingApproval !== 'boolean') { throw new Error('Invalid approval status'); } - const number = textsecure.utils.unencodeNumber(identifier)[0]; - const identityRecord = this.identityKeys[number]; + const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; + const identityRecord = this.getIdentityRecord(identifier); if (!identityRecord) { - throw new Error(`No identity record for ${number}`); + throw new Error(`No identity record for ${identifier}`); } identityRecord.nonblockingApproval = nonblockingApproval; await this._saveIdentityKey(identityRecord); }, - async setVerified(number, verifiedStatus, publicKey) { - if (number === null || number === undefined) { + async setVerified(encodedAddress, verifiedStatus, publicKey) { + if (encodedAddress === null || encodedAddress === undefined) { throw new Error('Tried to set verified for undefined/null key'); } if (!validateVerifiedStatus(verifiedStatus)) { @@ -629,9 +707,10 @@ throw new Error('Invalid public key'); } - const identityRecord = this.identityKeys[number]; + const identityRecord = this.getIdentityRecord(encodedAddress); + if (!identityRecord) { - throw new Error(`No identity record for ${number}`); + throw new Error(`No identity record for ${encodedAddress}`); } if ( @@ -650,14 +729,14 @@ window.log.info('No identity record for specified publicKey'); } }, - async getVerified(number) { - if (number === null || number === undefined) { + async getVerified(identifier) { + if (identifier === null || identifier === undefined) { throw new Error('Tried to set verified for undefined/null key'); } - const identityRecord = this.identityKeys[number]; + const identityRecord = this.getIdentityRecord(identifier); if (!identityRecord) { - throw new Error(`No identity record for ${number}`); + throw new Error(`No identity record for ${identifier}`); } const verifiedStatus = identityRecord.verified; @@ -681,15 +760,16 @@ // This function encapsulates the non-Java behavior, since the mobile apps don't // currently receive contact syncs and therefore will see a verify sync with // UNVERIFIED status - async processUnverifiedMessage(number, verifiedStatus, publicKey) { - if (number === null || number === undefined) { + async processUnverifiedMessage(identifier, verifiedStatus, publicKey) { + if (identifier === null || identifier === undefined) { throw new Error('Tried to set verified for undefined/null key'); } if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { throw new Error('Invalid public key'); } - const identityRecord = this.identityKeys[number]; + const identityRecord = this.getIdentityRecord(identifier); + const isPresent = Boolean(identityRecord); let isEqual = false; @@ -703,7 +783,7 @@ identityRecord.verified !== VerifiedStatus.UNVERIFIED ) { await textsecure.storage.protocol.setVerified( - number, + identifier, verifiedStatus, publicKey ); @@ -711,17 +791,20 @@ } if (!isPresent || !isEqual) { - await textsecure.storage.protocol.saveIdentityWithAttributes(number, { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - }); + await textsecure.storage.protocol.saveIdentityWithAttributes( + identifier, + { + publicKey, + verified: verifiedStatus, + firstUse: false, + timestamp: Date.now(), + nonblockingApproval: true, + } + ); if (isPresent && !isEqual) { try { - this.trigger('keychange', number); + this.trigger('keychange', identifier); } catch (error) { window.log.error( 'processUnverifiedMessage error triggering keychange:', @@ -729,7 +812,7 @@ ); } - await this.archiveAllSessions(number); + await this.archiveAllSessions(identifier); return true; } @@ -743,8 +826,8 @@ }, // This matches the Java method as of // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 - async processVerifiedMessage(number, verifiedStatus, publicKey) { - if (number === null || number === undefined) { + async processVerifiedMessage(identifier, verifiedStatus, publicKey) { + if (identifier === null || identifier === undefined) { throw new Error('Tried to set verified for undefined/null key'); } if (!validateVerifiedStatus(verifiedStatus)) { @@ -754,7 +837,7 @@ throw new Error('Invalid public key'); } - const identityRecord = this.identityKeys[number]; + const identityRecord = this.getIdentityRecord(identifier); const isPresent = Boolean(identityRecord); let isEqual = false; @@ -775,7 +858,7 @@ verifiedStatus === VerifiedStatus.DEFAULT ) { await textsecure.storage.protocol.setVerified( - number, + identifier, verifiedStatus, publicKey ); @@ -788,17 +871,20 @@ (isPresent && !isEqual) || (isPresent && identityRecord.verified !== VerifiedStatus.VERIFIED)) ) { - await textsecure.storage.protocol.saveIdentityWithAttributes(number, { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - }); + await textsecure.storage.protocol.saveIdentityWithAttributes( + identifier, + { + publicKey, + verified: verifiedStatus, + firstUse: false, + timestamp: Date.now(), + nonblockingApproval: true, + } + ); if (isPresent && !isEqual) { try { - this.trigger('keychange', number); + this.trigger('keychange', identifier); } catch (error) { window.log.error( 'processVerifiedMessage error triggering keychange:', @@ -806,7 +892,7 @@ ); } - await this.archiveAllSessions(number); + await this.archiveAllSessions(identifier); // true signifies that we overwrote a previous key with a new one return true; @@ -818,14 +904,14 @@ // state we had before. return false; }, - async isUntrusted(number) { - if (number === null || number === undefined) { + async isUntrusted(identifier) { + if (identifier === null || identifier === undefined) { throw new Error('Tried to set verified for undefined/null key'); } - const identityRecord = this.identityKeys[number]; + const identityRecord = this.getIdentityRecord(identifier); if (!identityRecord) { - throw new Error(`No identity record for ${number}`); + throw new Error(`No identity record for ${identifier}`); } if ( @@ -838,10 +924,13 @@ return false; }, - async removeIdentityKey(number) { - delete this.identityKeys[number]; - await window.Signal.Data.removeIdentityKeyById(number); - await textsecure.storage.protocol.removeAllSessions(number); + async removeIdentityKey(identifier) { + const id = ConversationController.getConversationId(identifier); + if (id) { + delete this.identityKeys[id]; + await window.Signal.Data.removeIdentityKeyById(id); + await textsecure.storage.protocol.removeAllSessions(id); + } }, // Not yet processed messages - for resiliency diff --git a/js/view_syncs.js b/js/view_syncs.js index 5eea0d0717..3b6fac1c39 100644 --- a/js/view_syncs.js +++ b/js/view_syncs.js @@ -14,7 +14,7 @@ Whisper.ViewSyncs = new (Backbone.Collection.extend({ forMessage(message) { const sync = this.findWhere({ - source: message.get('source'), + conversationId: message.get('conversationId'), timestamp: message.get('sent_at'), }); if (sync) { @@ -35,13 +35,15 @@ ); const found = messages.find( - item => item.get('source') === sync.get('source') + item => item.get('conversationId') === sync.get('conversationId') ); const syncSource = sync.get('source'); + const syncSourceUuid = sync.get('sourceUuid'); const syncTimestamp = sync.get('timestamp'); const wasMessageFound = Boolean(found); window.log.info('Receive view sync:', { syncSource, + syncSourceUuid, syncTimestamp, wasMessageFound, }); diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index 73c3b94357..2558b997ef 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -26,7 +26,7 @@ this.contactView = null; } - const isMe = this.ourNumber === this.model.id; + const isMe = this.model.isMe(); this.contactView = new Whisper.ReactWrapperView({ className: 'contact-wrapper', @@ -47,7 +47,7 @@ return this; }, showIdentity() { - if (this.model.id === this.ourNumber || this.loading) { + if (this.model.isMe() || this.loading) { return; } diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index ba1def770d..bf30322cf1 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -2004,7 +2004,7 @@ await contact.setApproved(); } - message.resend(contact.id); + message.resend(contact.get('e164'), contact.get('uuid')); }, }); @@ -2483,6 +2483,7 @@ try { await this.model.sendReactionMessage(reaction, { targetAuthorE164: messageModel.getSource(), + targetAuthorUuid: messageModel.getSourceUuid(), targetTimestamp: messageModel.get('sent_at'), }); } catch (error) { @@ -2668,10 +2669,17 @@ if (window.reduxStore.getState().expiration.hasExpired) { ToastView = Whisper.ExpiredToast; } - if (this.model.isPrivate() && storage.isBlocked(this.model.id)) { + if ( + this.model.isPrivate() && + (storage.isBlocked(this.model.get('e164')) || + storage.isUuidBlocked(this.model.get('uuid'))) + ) { ToastView = Whisper.BlockedToast; } - if (!this.model.isPrivate() && storage.isGroupBlocked(this.model.id)) { + if ( + !this.model.isPrivate() && + storage.isGroupBlocked(this.model.get('groupId')) + ) { ToastView = Whisper.BlockedGroupToast; } if (!this.model.isPrivate() && this.model.get('left')) { diff --git a/js/views/key_verification_view.js b/js/views/key_verification_view.js index 698fc037aa..9280588160 100644 --- a/js/views/key_verification_view.js +++ b/js/views/key_verification_view.js @@ -16,6 +16,7 @@ }, initialize(options) { this.ourNumber = textsecure.storage.user.getNumber(); + this.ourUuid = textsecure.storage.user.getUuid(); if (options.newKey) { this.theirKey = options.newKey; } @@ -44,16 +45,29 @@ ); }, loadTheirKey() { - const item = textsecure.storage.protocol.identityKeys[this.model.id]; + const item = textsecure.storage.protocol.getIdentityRecord( + this.model.get('id') + ); this.theirKey = item ? item.publicKey : null; }, loadOurKey() { - const item = textsecure.storage.protocol.identityKeys[this.ourNumber]; + const item = textsecure.storage.protocol.getIdentityRecord( + this.ourUuid || this.ourNumber + ); this.ourKey = item ? item.publicKey : null; }, generateSecurityNumber() { return new libsignal.FingerprintGenerator(5200) - .createFor(this.ourNumber, this.ourKey, this.model.id, this.theirKey) + .createFor( + // TODO: we cannot use UUIDs for safety numbers yet + // this.ourUuid || this.ourNumber, + this.ourNumber, + this.ourKey, + // TODO: we cannot use UUIDs for safety numbers yet + // this.model.get('uuid') || this.model.get('e164'), + this.model.get('e164'), + this.theirKey + ) .then(securityNumber => { this.securityNumber = securityNumber; }); diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index ecb0e7d357..9f367ed922 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -24,14 +24,14 @@ this.pending = Promise.resolve(); } - function getNumber(numberId) { - if (!numberId || !numberId.length) { - return numberId; + function getIdentifier(id) { + if (!id || !id.length) { + return id; } - const parts = numberId.split('.'); + const parts = id.split('.'); if (!parts.length) { - return numberId; + return id; } return parts[0]; @@ -136,7 +136,7 @@ .then(clearSessionsAndPreKeys) .then(generateKeys) .then(keys => registerKeys(keys).then(() => confirmKeys(keys))) - .then(() => registrationDone(number)); + .then(() => registrationDone({ number })); } ) ); @@ -212,7 +212,8 @@ provisionMessage.profileKey, deviceName, provisionMessage.userAgent, - provisionMessage.readReceipts + provisionMessage.readReceipts, + { uuid: provisionMessage.uuid } ) .then(clearSessionsAndPreKeys) .then(generateKeys) @@ -221,9 +222,7 @@ confirmKeys(keys) ) ) - .then(() => - registrationDone(provisionMessage.number) - ); + .then(() => registrationDone(provisionMessage)); } ) ) @@ -414,7 +413,8 @@ password = password.substring(0, password.length - 2); const registrationId = libsignal.KeyHelper.generateRegistrationId(); - const previousNumber = getNumber(textsecure.storage.get('number_id')); + const previousNumber = getIdentifier(textsecure.storage.get('number_id')); + const previousUuid = getIdentifier(textsecure.storage.get('uuid_id')); const encryptedDeviceName = await this.encryptDeviceName( deviceName, @@ -437,10 +437,21 @@ { accessKey } ); - if (previousNumber && previousNumber !== number) { - window.log.warn( - 'New number is different from old number; deleting all previous data' - ); + const numberChanged = previousNumber && previousNumber !== number; + const uuidChanged = + previousUuid && response.uuid && previousUuid !== response.uuid; + + if (numberChanged || uuidChanged) { + if (numberChanged) { + window.log.warn( + 'New number is different from old number; deleting all previous data' + ); + } + if (uuidChanged) { + window.log.warn( + 'New uuid is different from old uuid; deleting all previous data' + ); + } try { await textsecure.storage.protocol.removeAllData(); @@ -465,10 +476,29 @@ textsecure.storage.remove('read-receipts-setting'), ]); + // `setNumberAndDeviceId` and `setUuidAndDeviceId` need to be called + // before `saveIdentifyWithAttributes` since `saveIdentityWithAttributes` + // indirectly calls `ConversationController.getConverationId()` which + // initializes the conversation for the given number (our number) which + // calls out to the user storage API to get the stored UUID and number + // information. + await textsecure.storage.user.setNumberAndDeviceId( + number, + response.deviceId || 1, + deviceName + ); + + const setUuid = response.uuid; + if (setUuid) { + await textsecure.storage.user.setUuidAndDeviceId( + setUuid, + response.deviceId || 1 + ); + } + // update our own identity key, which may have changed // if we're relinking after a reinstall on the master device await textsecure.storage.protocol.saveIdentityWithAttributes(number, { - id: number, publicKey: identityKeyPair.pubKey, firstUse: true, timestamp: Date.now(), @@ -491,12 +521,6 @@ Boolean(readReceipts) ); - await textsecure.storage.user.setNumberAndDeviceId( - number, - response.deviceId || 1, - deviceName - ); - const regionCode = libphonenumber.util.getRegionCodeForNumber(number); await textsecure.storage.put('regionCode', regionCode); await textsecure.storage.protocol.hydrateCaches(); @@ -579,11 +603,16 @@ ); }); }, - async registrationDone(number) { + async registrationDone({ uuid, number }) { window.log.info('registration done'); // Ensure that we always have a conversation for ourself - await ConversationController.getOrCreateAndWait(number, 'private'); + const conversation = await ConversationController.getOrCreateAndWait( + number || uuid, + 'private' + ); + conversation.updateE164(number); + conversation.updateUuid(uuid); window.log.info('dispatching registration event'); diff --git a/libtextsecure/contacts_parser.js b/libtextsecure/contacts_parser.js index 710faf6ae4..7cbf85f6ab 100644 --- a/libtextsecure/contacts_parser.js +++ b/libtextsecure/contacts_parser.js @@ -36,6 +36,22 @@ ProtoParser.prototype = { proto.profileKey = proto.profileKey.toArrayBuffer(); } + if (proto.uuid) { + window.normalizeUuids( + proto, + ['uuid'], + 'ProtoParser::next (proto.uuid)' + ); + } + + if (proto.members) { + window.normalizeUuids( + proto, + proto.members.map((_member, i) => `members.${i}.uuid`), + 'ProtoParser::next (proto.members)' + ); + } + return proto; } catch (error) { window.log.error( diff --git a/libtextsecure/libsignal-protocol.js b/libtextsecure/libsignal-protocol.js index adb5e628e1..e41fa5a70c 100644 --- a/libtextsecure/libsignal-protocol.js +++ b/libtextsecure/libsignal-protocol.js @@ -36521,7 +36521,7 @@ Internal.SessionLock.queueJobForNumber = function queueJobForNumber(number, runJ })(); (function() { - var VERSION = 0; + var VERSION = shortToArrayBuffer(0); function iterateHash(data, key, count) { data = dcodeIO.ByteBuffer.concat([data, key]).toArrayBuffer(); @@ -36551,10 +36551,21 @@ Internal.SessionLock.queueJobForNumber = function queueJobForNumber(number, runJ return s; } + function decodeUuid(uuid) { + let i = 0; + let buf = new Uint8Array(16); + + uuid.replace(/[0-9A-F]{2}/ig, oct => { + buf[i++] = parseInt(oct, 16); + }); + + return buf; + } + function getDisplayStringFor(identifier, key, iterations) { - var bytes = dcodeIO.ByteBuffer.concat([ - shortToArrayBuffer(VERSION), key, identifier - ]).toArrayBuffer(); + var isUuid = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(identifier); + var encodedIdentifier = isUuid ? decodeUuid(identifier) : identifier; + var bytes = dcodeIO.ByteBuffer.concat([VERSION, key, encodedIdentifier]).toArrayBuffer(); return iterateHash(bytes, key, iterations).then(function(output) { output = new Uint8Array(output); return getEncodedChunk(output, 0) + diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 5a785d8bc2..1a6cac3d94 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -14,13 +14,20 @@ const RETRY_TIMEOUT = 2 * 60 * 1000; -function MessageReceiver(username, password, signalingKey, options = {}) { +function MessageReceiver( + oldUsername, + username, + password, + signalingKey, + options = {} +) { this.count = 0; this.signalingKey = signalingKey; - this.username = username; + this.username = oldUsername; + this.uuid = username; this.password = password; - this.server = WebAPI.connect({ username, password }); + this.server = WebAPI.connect({ username: username || oldUsername, password }); if (!options.serverTrustRoot) { throw new Error('Server trust root is required!'); @@ -29,9 +36,12 @@ function MessageReceiver(username, password, signalingKey, options = {}) { options.serverTrustRoot ); - const address = libsignal.SignalProtocolAddress.fromString(username); - this.number = address.getName(); - this.deviceId = address.getDeviceId(); + this.number_id = oldUsername + ? textsecure.utils.unencodeNumber(oldUsername)[0] + : null; + this.uuid_id = username ? textsecure.utils.unencodeNumber(username)[0] : null; + // eslint-disable-next-line prefer-destructuring + this.deviceId = textsecure.utils.unencodeNumber(username || oldUsername)[1]; this.incomingQueue = new window.PQueue({ concurrency: 1 }); this.pendingQueue = new window.PQueue({ concurrency: 1 }); @@ -176,7 +186,7 @@ MessageReceiver.prototype.extend({ } // possible 403 or network issue. Make an request to confirm return this.server - .getDevices(this.number) + .getDevices(this.number_id || this.uuid_id) .then(this.connect.bind(this)) // No HTTP error? Reconnect .catch(e => { const event = new Event('error'); @@ -213,6 +223,11 @@ MessageReceiver.prototype.extend({ try { const envelope = textsecure.protobuf.Envelope.decode(plaintext); + window.normalizeUuids( + envelope, + ['sourceUuid'], + 'message_receiver::handleRequest::job' + ); // After this point, decoding errors are not the server's // fault, and we should handle them gracefully and tell the // user they received an invalid message @@ -222,6 +237,11 @@ MessageReceiver.prototype.extend({ return; } + if (this.isUuidBlocked(envelope.sourceUuid)) { + request.respond(200, 'OK'); + return; + } + envelope.id = envelope.serverGuid || window.getGuid(); envelope.serverTimestamp = envelope.serverTimestamp ? envelope.serverTimestamp.toNumber() @@ -333,6 +353,7 @@ MessageReceiver.prototype.extend({ const envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext); envelope.id = envelope.serverGuid || item.id; envelope.source = envelope.source || item.source; + envelope.sourceUuid = envelope.sourceUuid || item.sourceUuid; envelope.sourceDevice = envelope.sourceDevice || item.sourceDevice; envelope.serverTimestamp = envelope.serverTimestamp || item.serverTimestamp; @@ -378,8 +399,8 @@ MessageReceiver.prototype.extend({ } }, getEnvelopeId(envelope) { - if (envelope.source) { - return `${envelope.source}.${ + if (envelope.sourceUuid || envelope.source) { + return `${envelope.sourceUuid || envelope.source}.${ envelope.sourceDevice } ${envelope.timestamp.toNumber()} (${envelope.id})`; } @@ -485,6 +506,7 @@ MessageReceiver.prototype.extend({ const { id } = envelope; const data = { source: envelope.source, + sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, serverTimestamp: envelope.serverTimestamp, decrypted: MessageReceiver.arrayBufferToStringBase64(plaintext), @@ -586,6 +608,7 @@ MessageReceiver.prototype.extend({ ev.deliveryReceipt = { timestamp: envelope.timestamp.toNumber(), source: envelope.source, + sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, }; this.dispatchAndWait(ev).then(resolve, reject); @@ -613,16 +636,21 @@ MessageReceiver.prototype.extend({ let promise; const address = new libsignal.SignalProtocolAddress( - envelope.source, + // Using source as opposed to sourceUuid allows us to get the existing + // session if we haven't yet harvested the incoming uuid + envelope.source || envelope.sourceUuid, envelope.sourceDevice ); const ourNumber = textsecure.storage.user.getNumber(); - const number = address.toString().split('.')[0]; + const ourUuid = textsecure.storage.user.getUuid(); const options = {}; // No limit on message keys if we're communicating with our other devices - if (ourNumber === number) { + if ( + (envelope.source && ourNumber && ourNumber === envelope.source) || + (envelope.sourceUuid && ourUuid && ourUuid === envelope.sourceUuid) + ) { options.messageKeysLimit = false; } @@ -637,6 +665,7 @@ MessageReceiver.prototype.extend({ const me = { number: ourNumber, + uuid: ourUuid, deviceId: parseInt(textsecure.storage.user.getDeviceId(), 10), }; @@ -666,7 +695,7 @@ MessageReceiver.prototype.extend({ ) .then( result => { - const { isMe, sender, content } = result; + const { isMe, sender, senderUuid, content } = result; // We need to drop incoming messages from ourself since server can't // do it for us @@ -674,7 +703,10 @@ MessageReceiver.prototype.extend({ return { isMe: true }; } - if (this.isBlocked(sender.getName())) { + if ( + (sender && this.isBlocked(sender.getName())) || + (senderUuid && this.isUuidBlocked(senderUuid.getName())) + ) { window.log.info( 'Dropping blocked message after sealed sender decryption' ); @@ -685,25 +717,41 @@ MessageReceiver.prototype.extend({ // to make the rest of the app work properly. const originalSource = envelope.source; + const originalSourceUuid = envelope.sourceUuid; // eslint-disable-next-line no-param-reassign - envelope.source = sender.getName(); + envelope.source = sender && sender.getName(); // eslint-disable-next-line no-param-reassign - envelope.sourceDevice = sender.getDeviceId(); + envelope.sourceUuid = senderUuid && senderUuid.getName(); + window.normalizeUuids( + envelope, + ['sourceUuid'], + 'message_receiver::decrypt::UNIDENTIFIED_SENDER' + ); // eslint-disable-next-line no-param-reassign - envelope.unidentifiedDeliveryReceived = !originalSource; + envelope.sourceDevice = + (sender && sender.getDeviceId()) || + (senderUuid && senderUuid.getDeviceId()); + // eslint-disable-next-line no-param-reassign + envelope.unidentifiedDeliveryReceived = !( + originalSource || originalSourceUuid + ); // Return just the content because that matches the signature of the other // decrypt methods used above. return this.unpad(content); }, error => { - const { sender } = error || {}; + const { sender, senderUuid } = error || {}; - if (sender) { + if (sender || senderUuid) { const originalSource = envelope.source; + const originalSourceUuid = envelope.sourceUuid; - if (this.isBlocked(sender.getName())) { + if ( + (sender && this.isBlocked(sender.getName())) || + (senderUuid && this.isUuidBlocked(senderUuid.getName())) + ) { window.log.info( 'Dropping blocked message with error after sealed sender decryption' ); @@ -711,11 +759,23 @@ MessageReceiver.prototype.extend({ } // eslint-disable-next-line no-param-reassign - envelope.source = sender.getName(); + envelope.source = sender && sender.getName(); // eslint-disable-next-line no-param-reassign - envelope.sourceDevice = sender.getDeviceId(); + envelope.sourceUuid = + senderUuid && senderUuid.getName().toLowerCase(); + window.normalizeUuids( + envelope, + ['sourceUuid'], + 'message_receiver::decrypt::UNIDENTIFIED_SENDER::error' + ); // eslint-disable-next-line no-param-reassign - envelope.unidentifiedDeliveryReceived = !originalSource; + envelope.sourceDevice = + (sender && sender.getDeviceId()) || + (senderUuid && senderUuid.getDeviceId()); + // eslint-disable-next-line no-param-reassign + envelope.unidentifiedDeliveryReceived = !( + originalSource || originalSourceUuid + ); throw error; } @@ -803,7 +863,12 @@ MessageReceiver.prototype.extend({ this.processDecrypted(envelope, msg).then(message => { const groupId = message.group && message.group.id; const isBlocked = this.isGroupBlocked(groupId); - const isMe = envelope.source === textsecure.storage.user.getNumber(); + const { source, sourceUuid } = envelope; + const ourE164 = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); + const isMe = + (source && ourE164 && source === ourE164) || + (sourceUuid && ourUuid && sourceUuid === ourUuid); const isLeavingGroup = Boolean( message.group && message.group.type === textsecure.protobuf.GroupContext.Type.QUIT @@ -840,13 +905,18 @@ MessageReceiver.prototype.extend({ let p = Promise.resolve(); // eslint-disable-next-line no-bitwise if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) { - p = this.handleEndSession(envelope.source); + p = this.handleEndSession(envelope.source || envelope.sourceUuid); } return p.then(() => this.processDecrypted(envelope, msg).then(message => { const groupId = message.group && message.group.id; const isBlocked = this.isGroupBlocked(groupId); - const isMe = envelope.source === textsecure.storage.user.getNumber(); + const { source, sourceUuid } = envelope; + const ourE164 = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); + const isMe = + (source && ourE164 && source === ourE164) || + (sourceUuid && ourUuid && sourceUuid === ourUuid); const isLeavingGroup = Boolean( message.group && message.group.type === textsecure.protobuf.GroupContext.Type.QUIT @@ -865,6 +935,7 @@ MessageReceiver.prototype.extend({ ev.confirm = this.removeFromCache.bind(this, envelope); ev.data = { source: envelope.source, + sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, timestamp: envelope.timestamp.toNumber(), receivedAt: envelope.receivedAt, @@ -930,6 +1001,7 @@ MessageReceiver.prototype.extend({ ev.deliveryReceipt = { timestamp: receiptMessage.timestamp[i].toNumber(), source: envelope.source, + sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, }; results.push(this.dispatchAndWait(ev)); @@ -943,7 +1015,7 @@ MessageReceiver.prototype.extend({ ev.timestamp = envelope.timestamp.toNumber(); ev.read = { timestamp: receiptMessage.timestamp[i].toNumber(), - reader: envelope.source, + reader: envelope.source || envelope.sourceUuid, }; results.push(this.dispatchAndWait(ev)); } @@ -968,6 +1040,7 @@ MessageReceiver.prototype.extend({ } ev.sender = envelope.source; + ev.senderUuid = envelope.sourceUuid; ev.senderDevice = envelope.sourceDevice; ev.typing = { typingMessage, @@ -992,7 +1065,24 @@ MessageReceiver.prototype.extend({ this.removeFromCache(envelope); }, handleSyncMessage(envelope, syncMessage) { - if (envelope.source !== this.number) { + const unidentified = syncMessage.sent + ? syncMessage.sent.unidentifiedStatus || [] + : []; + window.normalizeUuids( + syncMessage, + [ + 'sent.destinationUuid', + ...unidentified.map( + (_el, i) => `sent.unidentifiedStatus.${i}.destinationUuid` + ), + ], + 'message_receiver::handleSyncMessage' + ); + const fromSelfSource = + envelope.source && envelope.source === this.number_id; + const fromSelfSourceUuid = + envelope.sourceUuid && envelope.sourceUuid === this.uuid_id; + if (!fromSelfSource && !fromSelfSourceUuid) { throw new Error('Received sync message from another number'); } // eslint-disable-next-line eqeqeq @@ -1057,8 +1147,15 @@ MessageReceiver.prototype.extend({ const ev = new Event('viewSync'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.source = sync.sender; + ev.sourceUuid = sync.senderUuid; ev.timestamp = sync.timestamp ? sync.timestamp.toNumber() : null; + window.normalizeUuids( + ev, + ['sourceUuid'], + 'message_receiver::handleViewOnceOpen' + ); + return this.dispatchAndWait(ev); }, handleStickerPackOperation(envelope, operations) { @@ -1080,8 +1177,14 @@ MessageReceiver.prototype.extend({ ev.verified = { state: verified.state, destination: verified.destination, + destinationUuid: verified.destinationUuid, identityKey: verified.identityKey.toArrayBuffer(), }; + window.normalizeUuids( + ev, + ['verified.destinationUuid'], + 'message_receiver::handleVerified' + ); return this.dispatchAndWait(ev); }, handleRead(envelope, read) { @@ -1093,7 +1196,13 @@ MessageReceiver.prototype.extend({ ev.read = { timestamp: read[i].timestamp.toNumber(), sender: read[i].sender, + senderUuid: read[i].senderUuid, }; + window.normalizeUuids( + ev, + ['read.senderUuid'], + 'message_receiver::handleRead' + ); results.push(this.dispatchAndWait(ev)); } return Promise.all(results); @@ -1158,6 +1267,15 @@ MessageReceiver.prototype.extend({ handleBlocked(envelope, blocked) { window.log.info('Setting these numbers as blocked:', blocked.numbers); textsecure.storage.put('blocked', blocked.numbers); + if (blocked.uuids) { + window.normalizeUuids( + blocked, + blocked.uuids.map((_uuid, i) => `uuids.${i}`), + 'message_receiver::handleBlocked' + ); + window.log.info('Setting these uuids as blocked:', blocked.uuids); + textsecure.storage.put('blocked-uuids', blocked.uuids); + } const groupIds = _.map(blocked.groupIds, groupId => groupId.toBinary()); window.log.info( @@ -1169,10 +1287,13 @@ MessageReceiver.prototype.extend({ return this.removeFromCache(envelope); }, isBlocked(number) { - return textsecure.storage.get('blocked', []).indexOf(number) >= 0; + return textsecure.storage.get('blocked', []).includes(number); + }, + isUuidBlocked(uuid) { + return textsecure.storage.get('blocked-uuids', []).includes(uuid); }, isGroupBlocked(groupId) { - return textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0; + return textsecure.storage.get('blocked-groups', []).includes(groupId); }, cleanAttachment(attachment) { return { @@ -1213,13 +1334,18 @@ MessageReceiver.prototype.extend({ const cleaned = this.cleanAttachment(attachment); return this.downloadAttachment(cleaned); }, - async handleEndSession(number) { + async handleEndSession(identifier) { window.log.info('got end session'); - const deviceIds = await textsecure.storage.protocol.getDeviceIds(number); + const deviceIds = await textsecure.storage.protocol.getDeviceIds( + identifier + ); return Promise.all( deviceIds.map(deviceId => { - const address = new libsignal.SignalProtocolAddress(number, deviceId); + const address = new libsignal.SignalProtocolAddress( + identifier, + deviceId + ); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, address @@ -1274,8 +1400,6 @@ MessageReceiver.prototype.extend({ throw new Error('Unknown flags in message'); } - const promises = []; - if (decrypted.group !== null) { decrypted.group.id = decrypted.group.id.toBinary(); @@ -1290,6 +1414,7 @@ MessageReceiver.prototype.extend({ break; case textsecure.protobuf.GroupContext.Type.DELIVER: decrypted.group.name = null; + decrypted.group.membersE164 = []; decrypted.group.members = []; decrypted.group.avatar = null; break; @@ -1383,7 +1508,19 @@ MessageReceiver.prototype.extend({ } } - return Promise.all(promises).then(() => decrypted); + const groupMembers = decrypted.group ? decrypted.group.members || [] : []; + + window.normalizeUuids( + decrypted, + [ + 'quote.authorUuid', + 'reaction.targetAuthorUuid', + ...groupMembers.map((_member, i) => `group.members.${i}.uuid`), + ], + 'message_receiver::processDecrypted' + ); + + return Promise.resolve(decrypted); /* eslint-enable no-bitwise, no-param-reassign */ }, }); @@ -1392,12 +1529,14 @@ window.textsecure = window.textsecure || {}; textsecure.MessageReceiver = function MessageReceiverWrapper( username, + uuid, password, signalingKey, options ) { const messageReceiver = new MessageReceiver( username, + uuid, password, signalingKey, options diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 48abf95b9b..4849c74fea 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -5,7 +5,7 @@ function OutgoingMessage( server, timestamp, - numbers, + identifiers, message, silent, callback, @@ -19,41 +19,43 @@ function OutgoingMessage( } this.server = server; this.timestamp = timestamp; - this.numbers = numbers; + this.identifiers = identifiers; this.message = message; // ContentMessage proto this.callback = callback; this.silent = silent; - this.numbersCompleted = 0; + this.identifiersCompleted = 0; this.errors = []; - this.successfulNumbers = []; - this.failoverNumbers = []; + this.successfulIdentifiers = []; + this.failoverIdentifiers = []; this.unidentifiedDeliveries = []; - const { numberInfo, senderCertificate, online } = options || {}; - this.numberInfo = numberInfo; + const { sendMetadata, senderCertificate, senderCertificateWithUuid, online } = + options || {}; + this.sendMetadata = sendMetadata; this.senderCertificate = senderCertificate; + this.senderCertificateWithUuid = senderCertificateWithUuid; this.online = online; } OutgoingMessage.prototype = { constructor: OutgoingMessage, numberCompleted() { - this.numbersCompleted += 1; - if (this.numbersCompleted >= this.numbers.length) { + this.identifiersCompleted += 1; + if (this.identifiersCompleted >= this.identifiers.length) { this.callback({ - successfulNumbers: this.successfulNumbers, - failoverNumbers: this.failoverNumbers, + successfulIdentifiers: this.successfulIdentifiers, + failoverIdentifiers: this.failoverIdentifiers, errors: this.errors, unidentifiedDeliveries: this.unidentifiedDeliveries, }); } }, - registerError(number, reason, error) { + registerError(identifier, reason, error) { if (!error || (error.name === 'HTTPError' && error.code !== 404)) { // eslint-disable-next-line no-param-reassign error = new textsecure.OutgoingMessageError( - number, + identifier, this.message.toArrayBuffer(), this.timestamp, error @@ -61,27 +63,27 @@ OutgoingMessage.prototype = { } // eslint-disable-next-line no-param-reassign - error.number = number; + error.number = identifier; // eslint-disable-next-line no-param-reassign error.reason = reason; this.errors[this.errors.length] = error; this.numberCompleted(); }, - reloadDevicesAndSend(number, recurse) { + reloadDevicesAndSend(identifier, recurse) { return () => - textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => { + textsecure.storage.protocol.getDeviceIds(identifier).then(deviceIds => { if (deviceIds.length === 0) { return this.registerError( - number, + identifier, 'Got empty device list when loading device keys', null ); } - return this.doSendMessage(number, deviceIds, recurse); + return this.doSendMessage(identifier, deviceIds, recurse); }); }, - getKeysForNumber(number, updateDevices) { + getKeysForIdentifier(identifier, updateDevices) { const handleResult = response => Promise.all( response.devices.map(device => { @@ -92,7 +94,7 @@ OutgoingMessage.prototype = { updateDevices.indexOf(device.deviceId) > -1 ) { const address = new libsignal.SignalProtocolAddress( - number, + identifier, device.deviceId ); const builder = new libsignal.SessionBuilder( @@ -119,27 +121,30 @@ OutgoingMessage.prototype = { }) ); - const { numberInfo } = this; - const info = numberInfo && numberInfo[number] ? numberInfo[number] : {}; + const { sendMetadata } = this; + const info = + sendMetadata && sendMetadata[identifier] ? sendMetadata[identifier] : {}; const { accessKey } = info || {}; if (updateDevices === undefined) { if (accessKey) { return this.server - .getKeysForNumberUnauth(number, '*', { accessKey }) + .getKeysForIdentifierUnauth(identifier, '*', { accessKey }) .catch(error => { if (error.code === 401 || error.code === 403) { - if (this.failoverNumbers.indexOf(number) === -1) { - this.failoverNumbers.push(number); + if (this.failoverIdentifiers.indexOf(identifier) === -1) { + this.failoverIdentifiers.push(identifier); } - return this.server.getKeysForNumber(number, '*'); + return this.server.getKeysForIdentifier(identifier, '*'); } throw error; }) .then(handleResult); } - return this.server.getKeysForNumber(number, '*').then(handleResult); + return this.server + .getKeysForIdentifier(identifier, '*') + .then(handleResult); } let promise = Promise.resolve(); @@ -149,31 +154,31 @@ OutgoingMessage.prototype = { if (accessKey) { innerPromise = this.server - .getKeysForNumberUnauth(number, deviceId, { accessKey }) + .getKeysForIdentifierUnauth(identifier, deviceId, { accessKey }) .then(handleResult) .catch(error => { if (error.code === 401 || error.code === 403) { - if (this.failoverNumbers.indexOf(number) === -1) { - this.failoverNumbers.push(number); + if (this.failoverIdentifiers.indexOf(identifier) === -1) { + this.failoverIdentifiers.push(identifier); } return this.server - .getKeysForNumber(number, deviceId) + .getKeysForIdentifier(identifier, deviceId) .then(handleResult); } throw error; }); } else { innerPromise = this.server - .getKeysForNumber(number, deviceId) + .getKeysForIdentifier(identifier, deviceId) .then(handleResult); } return innerPromise.catch(e => { if (e.name === 'HTTPError' && e.code === 404) { if (deviceId !== 1) { - return this.removeDeviceIdsForNumber(number, [deviceId]); + return this.removeDeviceIdsForIdentifier(identifier, [deviceId]); } - throw new textsecure.UnregisteredUserError(number, e); + throw new textsecure.UnregisteredUserError(identifier, e); } else { throw e; } @@ -184,12 +189,12 @@ OutgoingMessage.prototype = { return promise; }, - transmitMessage(number, jsonData, timestamp, { accessKey } = {}) { + transmitMessage(identifier, jsonData, timestamp, { accessKey } = {}) { let promise; if (accessKey) { promise = this.server.sendMessagesUnauth( - number, + identifier, jsonData, timestamp, this.silent, @@ -198,7 +203,7 @@ OutgoingMessage.prototype = { ); } else { promise = this.server.sendMessages( - number, + identifier, jsonData, timestamp, this.silent, @@ -212,10 +217,10 @@ OutgoingMessage.prototype = { // 404 should throw UnregisteredUserError // all other network errors can be retried later. if (e.code === 404) { - throw new textsecure.UnregisteredUserError(number, e); + throw new textsecure.UnregisteredUserError(identifier, e); } throw new textsecure.SendMessageNetworkError( - number, + identifier, jsonData, e, timestamp @@ -248,13 +253,17 @@ OutgoingMessage.prototype = { return this.plaintext; }, - doSendMessage(number, deviceIds, recurse) { + doSendMessage(identifier, deviceIds, recurse) { const ciphers = {}; const plaintext = this.getPlaintext(); - const { numberInfo, senderCertificate } = this; - const info = numberInfo && numberInfo[number] ? numberInfo[number] : {}; - const { accessKey } = info || {}; + const { sendMetadata } = this; + const info = + sendMetadata && sendMetadata[identifier] ? sendMetadata[identifier] : {}; + const { accessKey, useUuidSenderCert } = info || {}; + const senderCertificate = useUuidSenderCert + ? this.senderCertificateWithUuid + : this.senderCertificate; if (accessKey && !senderCertificate) { window.log.warn( @@ -266,8 +275,9 @@ OutgoingMessage.prototype = { // We don't send to ourselves if unless sealedSender is enabled const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); const ourDeviceId = textsecure.storage.user.getDeviceId(); - if (number === ourNumber && !sealedSender) { + if ((identifier === ourNumber || identifier === ourUuid) && !sealedSender) { // eslint-disable-next-line no-param-reassign deviceIds = _.reject( deviceIds, @@ -279,12 +289,15 @@ OutgoingMessage.prototype = { return Promise.all( deviceIds.map(async deviceId => { - const address = new libsignal.SignalProtocolAddress(number, deviceId); + const address = new libsignal.SignalProtocolAddress( + identifier, + deviceId + ); const options = {}; // No limit on message keys if we're communicating with our other devices - if (ourNumber === number) { + if (ourNumber === identifier || ourUuid === identifier) { options.messageKeysLimit = false; } @@ -299,6 +312,7 @@ OutgoingMessage.prototype = { senderCertificate, plaintext ); + return { type: textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER, destinationDeviceId: address.getDeviceId(), @@ -327,18 +341,18 @@ OutgoingMessage.prototype = { ) .then(jsonData => { if (sealedSender) { - return this.transmitMessage(number, jsonData, this.timestamp, { + return this.transmitMessage(identifier, jsonData, this.timestamp, { accessKey, }).then( () => { - this.unidentifiedDeliveries.push(number); - this.successfulNumbers.push(number); + this.unidentifiedDeliveries.push(identifier); + this.successfulIdentifiers.push(identifier); this.numberCompleted(); }, error => { if (error.code === 401 || error.code === 403) { - if (this.failoverNumbers.indexOf(number) === -1) { - this.failoverNumbers.push(number); + if (this.failoverIdentifiers.indexOf(identifier) === -1) { + this.failoverIdentifiers.push(identifier); } if (info) { info.accessKey = null; @@ -346,7 +360,7 @@ OutgoingMessage.prototype = { // Set final parameter to true to ensure we don't hit this codepath a // second time. - return this.doSendMessage(number, deviceIds, recurse, true); + return this.doSendMessage(identifier, deviceIds, recurse, true); } throw error; @@ -354,9 +368,9 @@ OutgoingMessage.prototype = { ); } - return this.transmitMessage(number, jsonData, this.timestamp).then( + return this.transmitMessage(identifier, jsonData, this.timestamp).then( () => { - this.successfulNumbers.push(number); + this.successfulIdentifiers.push(identifier); this.numberCompleted(); } ); @@ -369,22 +383,22 @@ OutgoingMessage.prototype = { ) { if (!recurse) return this.registerError( - number, + identifier, 'Hit retry limit attempting to reload device list', error ); let p; if (error.code === 409) { - p = this.removeDeviceIdsForNumber( - number, + p = this.removeDeviceIdsForIdentifier( + identifier, error.response.extraDevices ); } else { p = Promise.all( error.response.staleDevices.map(deviceId => ciphers[deviceId].closeOpenSessionForDevice( - new libsignal.SignalProtocolAddress(number, deviceId) + new libsignal.SignalProtocolAddress(identifier, deviceId) ) ) ); @@ -395,10 +409,10 @@ OutgoingMessage.prototype = { error.code === 410 ? error.response.staleDevices : error.response.missingDevices; - return this.getKeysForNumber(number, resetDevices).then( + return this.getKeysForIdentifier(identifier, resetDevices).then( // We continue to retry as long as the error code was 409; the assumption is // that we'll request new device info and the next request will succeed. - this.reloadDevicesAndSend(number, error.code === 409) + this.reloadDevicesAndSend(identifier, error.code === 409) ); }); } else if (error.message === 'Identity key changed') { @@ -408,13 +422,12 @@ OutgoingMessage.prototype = { error.originalMessage = this.message.toArrayBuffer(); window.log.error( 'Got "key changed" error from encrypt - no identityKey for application layer', - number, + identifier, deviceIds ); - const address = new libsignal.SignalProtocolAddress(number, 1); - const identifier = address.toString(); - window.log.info('closing all sessions for', number); + window.log.info('closing all sessions for', identifier); + const address = new libsignal.SignalProtocolAddress(identifier, 1); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, @@ -425,7 +438,9 @@ OutgoingMessage.prototype = { // Primary device sessionCipher.closeOpenSessionForDevice(), // The rest of their devices - textsecure.storage.protocol.archiveSiblingSessions(identifier), + textsecure.storage.protocol.archiveSiblingSessions( + address.toString() + ), ]).then( () => { throw error; @@ -439,65 +454,76 @@ OutgoingMessage.prototype = { ); } - this.registerError(number, 'Failed to create or send message', error); + this.registerError( + identifier, + 'Failed to create or send message', + error + ); return null; }); }, - getStaleDeviceIdsForNumber(number) { - return textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => { - if (deviceIds.length === 0) { - return [1]; - } - const updateDevices = []; - return Promise.all( - deviceIds.map(deviceId => { - const address = new libsignal.SignalProtocolAddress(number, deviceId); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - address - ); - return sessionCipher.hasOpenSession().then(hasSession => { - if (!hasSession) { - updateDevices.push(deviceId); - } - }); - }) - ).then(() => updateDevices); - }); + getStaleDeviceIdsForIdentifier(identifier) { + return textsecure.storage.protocol + .getDeviceIds(identifier) + .then(deviceIds => { + if (deviceIds.length === 0) { + return [1]; + } + const updateDevices = []; + return Promise.all( + deviceIds.map(deviceId => { + const address = new libsignal.SignalProtocolAddress( + identifier, + deviceId + ); + const sessionCipher = new libsignal.SessionCipher( + textsecure.storage.protocol, + address + ); + return sessionCipher.hasOpenSession().then(hasSession => { + if (!hasSession) { + updateDevices.push(deviceId); + } + }); + }) + ).then(() => updateDevices); + }); }, - removeDeviceIdsForNumber(number, deviceIdsToRemove) { + removeDeviceIdsForIdentifier(identifier, deviceIdsToRemove) { let promise = Promise.resolve(); // eslint-disable-next-line no-restricted-syntax, guard-for-in for (const j in deviceIdsToRemove) { promise = promise.then(() => { - const encodedNumber = `${number}.${deviceIdsToRemove[j]}`; - return textsecure.storage.protocol.removeSession(encodedNumber); + const encodedAddress = `${identifier}.${deviceIdsToRemove[j]}`; + return textsecure.storage.protocol.removeSession(encodedAddress); }); } return promise; }, - async sendToNumber(number) { + async sendToIdentifier(identifier) { try { - const updateDevices = await this.getStaleDeviceIdsForNumber(number); - await this.getKeysForNumber(number, updateDevices); - await this.reloadDevicesAndSend(number, true)(); + const updateDevices = await this.getStaleDeviceIdsForIdentifier( + identifier + ); + await this.getKeysForIdentifier(identifier, updateDevices); + await this.reloadDevicesAndSend(identifier, true)(); } catch (error) { if (error.message === 'Identity key changed') { // eslint-disable-next-line no-param-reassign const newError = new textsecure.OutgoingIdentityKeyError( - number, + identifier, error.originalMessage, error.timestamp, error.identityKey ); - this.registerError(number, 'Identity key changed', newError); + this.registerError(identifier, 'Identity key changed', newError); } else { this.registerError( - number, - `Failed to retrieve new device keys for number ${number}`, + identifier, + `Failed to retrieve new device keys for number ${identifier}`, error ); } diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 8c824f4dc3..1bc74cf2a0 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -1,4 +1,5 @@ -/* global _, textsecure, WebAPI, libsignal, OutgoingMessage, window, dcodeIO */ +// eslint-disable-next-line max-len +/* global _, textsecure, WebAPI, libsignal, OutgoingMessage, window, dcodeIO, ConversationController */ /* eslint-disable more/no-then, no-bitwise */ @@ -246,18 +247,22 @@ MessageSender.prototype = { return proto; }, - queueJobForNumber(number, runJob) { - this.pendingMessages[number] = - this.pendingMessages[number] || new window.PQueue({ concurrency: 1 }); + async queueJobForIdentifier(identifier, runJob) { + const { id } = await ConversationController.getOrCreateAndWait( + identifier, + 'private' + ); + this.pendingMessages[id] = + this.pendingMessages[id] || new window.PQueue({ concurrency: 1 }); - const queue = this.pendingMessages[number]; + const queue = this.pendingMessages[id]; const taskWithTimeout = textsecure.createTaskWithTimeout( runJob, - `queueJobForNumber ${number}` + `queueJobForIdentifier ${identifier} ${id}` ); - queue.add(taskWithTimeout); + return queue.add(taskWithTimeout); }, uploadAttachments(message) { @@ -361,7 +366,7 @@ MessageSender.prototype = { new Promise((resolve, reject) => { this.sendMessageProto( message.timestamp, - message.recipients, + message.recipients || [], message.toProto(), res => { res.dataMessage = message.toArrayBuffer(); @@ -379,8 +384,8 @@ MessageSender.prototype = { }, sendMessageProto( timestamp, - numbers, - message, + recipients, + messageProto, callback, silent, options = {} @@ -388,8 +393,8 @@ MessageSender.prototype = { const rejections = textsecure.storage.get('signedKeyRotationRejected', 0); if (rejections > 5) { throw new textsecure.SignedPreKeyRotationError( - numbers, - message.toArrayBuffer(), + recipients, + messageProto.toArrayBuffer(), timestamp ); } @@ -397,19 +402,27 @@ MessageSender.prototype = { const outgoing = new OutgoingMessage( this.server, timestamp, - numbers, - message, + recipients, + messageProto, silent, callback, options ); - numbers.forEach(number => { - this.queueJobForNumber(number, () => outgoing.sendToNumber(number)); + recipients.forEach(identifier => { + this.queueJobForIdentifier(identifier, () => + outgoing.sendToIdentifier(identifier) + ); }); }, - sendMessageProtoAndWait(timestamp, numbers, message, silent, options = {}) { + sendMessageProtoAndWait( + timestamp, + identifiers, + messageProto, + silent, + options = {} + ) { return new Promise((resolve, reject) => { const callback = result => { if (result && result.errors && result.errors.length > 0) { @@ -421,8 +434,8 @@ MessageSender.prototype = { this.sendMessageProto( timestamp, - numbers, - message, + identifiers, + messageProto, callback, silent, options @@ -430,7 +443,7 @@ MessageSender.prototype = { }); }, - sendIndividualProto(number, proto, timestamp, silent, options = {}) { + sendIndividualProto(identifier, proto, timestamp, silent, options = {}) { return new Promise((resolve, reject) => { const callback = res => { if (res && res.errors && res.errors.length > 0) { @@ -441,7 +454,7 @@ MessageSender.prototype = { }; this.sendMessageProto( timestamp, - [number], + [identifier], proto, callback, silent, @@ -467,6 +480,7 @@ MessageSender.prototype = { encodedDataMessage, timestamp, destination, + destinationUuid, expirationStartTimestamp, sentTo = [], unidentifiedDeliveries = [], @@ -474,7 +488,9 @@ MessageSender.prototype = { options ) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); + if (myDevice === 1 || myDevice === '1') { return Promise.resolve(); } @@ -488,6 +504,9 @@ MessageSender.prototype = { if (destination) { sentMessage.destination = destination; } + if (destinationUuid) { + sentMessage.destinationUuid = destinationUuid; + } if (expirationStartTimestamp) { sentMessage.expirationStartTimestamp = expirationStartTimestamp; } @@ -508,10 +527,16 @@ MessageSender.prototype = { // Though this field has 'unidenified' in the name, it should have entries for each // number we sent to. if (sentTo && sentTo.length) { - sentMessage.unidentifiedStatus = sentTo.map(number => { + sentMessage.unidentifiedStatus = sentTo.map(identifier => { const status = new textsecure.protobuf.SyncMessage.Sent.UnidentifiedDeliveryStatus(); - status.destination = number; - status.unidentified = Boolean(unidentifiedLookup[number]); + const conv = ConversationController.get(identifier); + if (conv && conv.get('e164')) { + status.destination = conv.get('e164'); + } + if (conv && conv.get('uuid')) { + status.destinationUuid = conv.get('uuid'); + } + status.unidentified = Boolean(unidentifiedLookup[identifier]); return status; }); } @@ -523,7 +548,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, timestamp, silent, @@ -552,6 +577,7 @@ MessageSender.prototype = { sendRequestBlockSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { const request = new textsecure.protobuf.SyncMessage.Request(); @@ -563,7 +589,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, Date.now(), silent, @@ -576,6 +602,7 @@ MessageSender.prototype = { sendRequestConfigurationSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { const request = new textsecure.protobuf.SyncMessage.Request(); @@ -587,7 +614,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, Date.now(), silent, @@ -600,6 +627,7 @@ MessageSender.prototype = { sendRequestGroupSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { const request = new textsecure.protobuf.SyncMessage.Request(); @@ -611,7 +639,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, Date.now(), silent, @@ -624,6 +652,8 @@ MessageSender.prototype = { sendRequestContactSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); + const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { const request = new textsecure.protobuf.SyncMessage.Request(); @@ -635,7 +665,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, Date.now(), silent, @@ -653,7 +683,8 @@ MessageSender.prototype = { // We don't want to send typing messages to our other devices, but we will // in the group case. const myNumber = textsecure.storage.user.getNumber(); - if (recipientId && myNumber === recipientId) { + const myUuid = textsecure.storage.user.getUuid(); + if (recipientId && (myNumber === recipientId || myUuid === recipientId)) { return null; } @@ -662,7 +693,7 @@ MessageSender.prototype = { } const recipients = groupId - ? _.without(groupNumbers, myNumber) + ? _.without(groupNumbers, myNumber, myUuid) : [recipientId]; const groupIdBuffer = groupId ? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(groupId) @@ -694,10 +725,14 @@ MessageSender.prototype = { ); }, - sendDeliveryReceipt(recipientId, timestamps, options) { + sendDeliveryReceipt(recipientE164, recipientUuid, timestamps, options) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); - if (myNumber === recipientId && (myDevice === 1 || myDevice === '1')) { + if ( + (myNumber === recipientE164 || myUuid === recipientUuid) && + (myDevice === 1 || myDevice === '1') + ) { return Promise.resolve(); } @@ -710,7 +745,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - recipientId, + recipientUuid || recipientE164, contentMessage, Date.now(), silent, @@ -718,7 +753,7 @@ MessageSender.prototype = { ); }, - sendReadReceipts(sender, timestamps, options) { + sendReadReceipts(senderE164, senderUuid, timestamps, options) { const receiptMessage = new textsecure.protobuf.ReceiptMessage(); receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ; receiptMessage.timestamp = timestamps; @@ -728,7 +763,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - sender, + senderUuid || senderE164, contentMessage, Date.now(), silent, @@ -737,6 +772,7 @@ MessageSender.prototype = { }, syncReadMessages(reads, options) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { const syncMessage = this.createSyncMessage(); @@ -745,6 +781,7 @@ MessageSender.prototype = { const read = new textsecure.protobuf.SyncMessage.Read(); read.timestamp = reads[i].timestamp; read.sender = reads[i].sender; + syncMessage.read.push(read); } const contentMessage = new textsecure.protobuf.Content(); @@ -752,7 +789,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, Date.now(), silent, @@ -763,8 +800,9 @@ MessageSender.prototype = { return Promise.resolve(); }, - async syncViewOnceOpen(sender, timestamp, options) { + async syncViewOnceOpen(sender, senderUuid, timestamp, options) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice === 1 || myDevice === '1') { return null; @@ -774,6 +812,7 @@ MessageSender.prototype = { const viewOnceOpen = new textsecure.protobuf.SyncMessage.ViewOnceOpen(); viewOnceOpen.sender = sender; + viewOnceOpen.senderUuid = senderUuid; viewOnceOpen.timestamp = timestamp; syncMessage.viewOnceOpen = viewOnceOpen; @@ -782,7 +821,7 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, Date.now(), silent, @@ -797,6 +836,7 @@ MessageSender.prototype = { } const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type; const packOperations = operations.map(item => { @@ -818,15 +858,23 @@ MessageSender.prototype = { const silent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, contentMessage, Date.now(), silent, options ); }, - syncVerification(destination, state, identityKey, options) { + + syncVerification( + destinationE164, + destinationUuid, + state, + identityKey, + options + ) { const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); const myDevice = textsecure.storage.user.getDeviceId(); const now = Date.now(); @@ -850,7 +898,7 @@ MessageSender.prototype = { // We want the NullMessage to look like a normal outgoing message; not silent const silent = false; const promise = this.sendIndividualProto( - destination, + destinationUuid || destinationE164, contentMessage, now, silent, @@ -860,7 +908,12 @@ MessageSender.prototype = { return promise.then(() => { const verified = new textsecure.protobuf.Verified(); verified.state = state; - verified.destination = destination; + if (destinationE164) { + verified.destination = destinationE164; + } + if (destinationUuid) { + verified.destinationUuid = destinationUuid; + } verified.identityKey = identityKey; verified.nullMessage = nullMessage.padding; @@ -872,7 +925,7 @@ MessageSender.prototype = { const innerSilent = true; return this.sendIndividualProto( - myNumber, + myUuid || myNumber, secondMessage, now, innerSilent, @@ -881,13 +934,22 @@ MessageSender.prototype = { }); }, - sendGroupProto(providedNumbers, proto, timestamp = Date.now(), options = {}) { - const me = textsecure.storage.user.getNumber(); - const numbers = providedNumbers.filter(number => number !== me); - if (numbers.length === 0) { + sendGroupProto( + providedIdentifiers, + proto, + timestamp = Date.now(), + options = {} + ) { + const myE164 = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); + const identifiers = providedIdentifiers.filter( + id => id !== myE164 && id !== myUuid + ); + + if (identifiers.length === 0) { return Promise.resolve({ - successfulNumbers: [], - failoverNumbers: [], + successfulIdentifiers: [], + failoverIdentifiers: [], errors: [], unidentifiedDeliveries: [], dataMessage: proto.toArrayBuffer(), @@ -907,7 +969,7 @@ MessageSender.prototype = { this.sendMessageProto( timestamp, - numbers, + providedIdentifiers, proto, callback, silent, @@ -917,7 +979,7 @@ MessageSender.prototype = { }, async getMessageProto( - number, + destination, body, attachments, quote, @@ -930,7 +992,8 @@ MessageSender.prototype = { flags ) { const attributes = { - recipients: [number], + recipients: [destination], + destination, body, timestamp, attachments, @@ -958,8 +1021,8 @@ MessageSender.prototype = { return message.toArrayBuffer(); }, - sendMessageToNumber( - number, + sendMessageToIdentifier( + identifier, messageText, attachments, quote, @@ -973,7 +1036,7 @@ MessageSender.prototype = { ) { return this.sendMessage( { - recipients: [number], + recipients: [identifier], body: messageText, timestamp, attachments, @@ -988,7 +1051,7 @@ MessageSender.prototype = { ); }, - resetSession(number, timestamp, options) { + resetSession(identifier, timestamp, options) { window.log.info('resetting secure session'); const silent = false; const proto = new textsecure.protobuf.DataMessage(); @@ -1017,14 +1080,14 @@ MessageSender.prototype = { ) ); - const sendToContactPromise = deleteAllSessions(number) + const sendToContactPromise = deleteAllSessions(identifier) .catch(logError('resetSession/deleteAllSessions1 error:')) .then(() => { window.log.info( 'finished closing local sessions, now sending to contact' ); return this.sendIndividualProto( - number, + identifier, proto, timestamp, silent, @@ -1032,14 +1095,15 @@ MessageSender.prototype = { ).catch(logError('resetSession/sendToContact error:')); }) .then(() => - deleteAllSessions(number).catch( + deleteAllSessions(identifier).catch( logError('resetSession/deleteAllSessions2 error:') ) ); const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); // We already sent the reset session to our other devices in the code above! - if (number === myNumber) { + if (identifier === myNumber || identifier === myUuid) { return sendToContactPromise; } @@ -1047,7 +1111,7 @@ MessageSender.prototype = { const sendSyncPromise = this.sendSyncMessage( buffer, timestamp, - number, + identifier, null, [], [], @@ -1059,7 +1123,7 @@ MessageSender.prototype = { async sendMessageToGroup( groupId, - groupNumbers, + recipients, messageText, attachments, quote, @@ -1071,10 +1135,10 @@ MessageSender.prototype = { profileKey, options ) { - const me = textsecure.storage.user.getNumber(); - const numbers = groupNumbers.filter(number => number !== me); + const myE164 = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getNumber(); const attrs = { - recipients: numbers, + recipients: recipients.filter(r => r !== myE164 && r !== myUuid), body: messageText, timestamp, attachments, @@ -1090,10 +1154,10 @@ MessageSender.prototype = { }, }; - if (numbers.length === 0) { + if (recipients.length === 0) { return Promise.resolve({ - successfulNumbers: [], - failoverNumbers: [], + successfulIdentifiers: [], + failoverIdentifiers: [], errors: [], unidentifiedDeliveries: [], dataMessage: await this.getMessageProtoObj(attrs), @@ -1103,19 +1167,20 @@ MessageSender.prototype = { return this.sendMessage(attrs, options); }, - createGroup(targetNumbers, id, name, avatar, options) { + createGroup(targetIdentifiers, id, name, avatar, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(id); proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.members = targetNumbers; + // TODO + proto.group.members = targetIdentifiers; proto.group.name = name; return this.makeAttachmentPointer(avatar).then(attachment => { proto.group.avatar = attachment; return this.sendGroupProto( - targetNumbers, + targetIdentifiers, proto, Date.now(), options @@ -1123,19 +1188,19 @@ MessageSender.prototype = { }); }, - updateGroup(groupId, name, avatar, targetNumbers, options) { + updateGroup(groupId, name, avatar, targetIdentifiers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE; proto.group.name = name; - proto.group.members = targetNumbers; + proto.group.members = targetIdentifiers; return this.makeAttachmentPointer(avatar).then(attachment => { proto.group.avatar = attachment; return this.sendGroupProto( - targetNumbers, + targetIdentifiers, proto, Date.now(), options @@ -1143,58 +1208,61 @@ MessageSender.prototype = { }); }, - addNumberToGroup(groupId, newNumbers, options) { + addIdentifierToGroup(groupId, newIdentifiers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.members = newNumbers; - return this.sendGroupProto(newNumbers, proto, Date.now(), options); + proto.group.members = newIdentifiers; + return this.sendGroupProto(newIdentifiers, proto, Date.now(), options); }, - setGroupName(groupId, name, groupNumbers, options) { + setGroupName(groupId, name, groupIdentifiers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE; proto.group.name = name; - proto.group.members = groupNumbers; + proto.group.members = groupIdentifiers; - return this.sendGroupProto(groupNumbers, proto, Date.now(), options); + return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options); }, - setGroupAvatar(groupId, avatar, groupNumbers, options) { + setGroupAvatar(groupId, avatar, groupIdentifiers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.members = groupNumbers; + proto.group.members = groupIdentifiers; return this.makeAttachmentPointer(avatar).then(attachment => { proto.group.avatar = attachment; - return this.sendGroupProto(groupNumbers, proto, Date.now(), options); + return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options); }); }, - leaveGroup(groupId, groupNumbers, options) { + leaveGroup(groupId, groupIdentifiers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); proto.group.type = textsecure.protobuf.GroupContext.Type.QUIT; - return this.sendGroupProto(groupNumbers, proto, Date.now(), options); + return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options); }, async sendExpirationTimerUpdateToGroup( groupId, - groupNumbers, + groupIdentifiers, expireTimer, timestamp, profileKey, options ) { - const me = textsecure.storage.user.getNumber(); - const numbers = groupNumbers.filter(number => number !== me); + const myNumber = textsecure.storage.user.getNumber(); + const myUuid = textsecure.storage.user.getUuid(); + const recipients = groupIdentifiers.filter( + identifier => identifier !== myNumber && identifier !== myUuid + ); const attrs = { - recipients: numbers, + recipients, timestamp, expireTimer, profileKey, @@ -1205,10 +1273,10 @@ MessageSender.prototype = { }, }; - if (numbers.length === 0) { + if (recipients.length === 0) { return Promise.resolve({ - successfulNumbers: [], - failoverNumbers: [], + successfulIdentifiers: [], + failoverIdentifiers: [], errors: [], unidentifiedDeliveries: [], dataMessage: await this.getMessageProtoObj(attrs), @@ -1217,8 +1285,8 @@ MessageSender.prototype = { return this.sendMessage(attrs, options); }, - sendExpirationTimerUpdateToNumber( - number, + sendExpirationTimerUpdateToIdentifier( + identifier, expireTimer, timestamp, profileKey, @@ -1226,7 +1294,7 @@ MessageSender.prototype = { ) { return this.sendMessage( { - recipients: [number], + recipients: [identifier], timestamp, expireTimer, profileKey, @@ -1245,7 +1313,7 @@ window.textsecure = window.textsecure || {}; textsecure.MessageSender = function MessageSenderWrapper(username, password) { const sender = new MessageSender(username, password); - this.sendExpirationTimerUpdateToNumber = sender.sendExpirationTimerUpdateToNumber.bind( + this.sendExpirationTimerUpdateToIdentifier = sender.sendExpirationTimerUpdateToIdentifier.bind( sender ); this.sendExpirationTimerUpdateToGroup = sender.sendExpirationTimerUpdateToGroup.bind( @@ -1264,14 +1332,14 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) { sender ); - this.sendMessageToNumber = sender.sendMessageToNumber.bind(sender); + this.sendMessageToIdentifier = sender.sendMessageToIdentifier.bind(sender); this.sendMessage = sender.sendMessage.bind(sender); this.resetSession = sender.resetSession.bind(sender); this.sendMessageToGroup = sender.sendMessageToGroup.bind(sender); this.sendTypingMessage = sender.sendTypingMessage.bind(sender); this.createGroup = sender.createGroup.bind(sender); this.updateGroup = sender.updateGroup.bind(sender); - this.addNumberToGroup = sender.addNumberToGroup.bind(sender); + this.addIdentifierToGroup = sender.addIdentifierToGroup.bind(sender); this.setGroupName = sender.setGroupName.bind(sender); this.setGroupAvatar = sender.setGroupAvatar.bind(sender); this.leaveGroup = sender.leaveGroup.bind(sender); diff --git a/libtextsecure/storage/user.js b/libtextsecure/storage/user.js index b6330acf87..a13f568bd9 100644 --- a/libtextsecure/storage/user.js +++ b/libtextsecure/storage/user.js @@ -16,13 +16,33 @@ } }, + setUuidAndDeviceId(uuid, deviceId) { + textsecure.storage.put('uuid_id', `${uuid}.${deviceId}`); + }, + getNumber() { const numberId = textsecure.storage.get('number_id'); if (numberId === undefined) return undefined; return textsecure.utils.unencodeNumber(numberId)[0]; }, + getUuid() { + const uuid = textsecure.storage.get('uuid_id'); + if (uuid === undefined) return undefined; + return textsecure.utils.unencodeNumber(uuid)[0]; + }, + getDeviceId() { + return this._getDeviceIdFromUuid() || this._getDeviceIdFromNumber(); + }, + + _getDeviceIdFromUuid() { + const uuid = textsecure.storage.get('uuid_id'); + if (uuid === undefined) return undefined; + return textsecure.utils.unencodeNumber(uuid)[1]; + }, + + _getDeviceIdFromNumber() { const numberId = textsecure.storage.get('number_id'); if (numberId === undefined) return undefined; return textsecure.utils.unencodeNumber(numberId)[1]; diff --git a/libtextsecure/test/contacts_parser_test.js b/libtextsecure/test/contacts_parser_test.js index be09e2bfd0..1338851c2d 100644 --- a/libtextsecure/test/contacts_parser_test.js +++ b/libtextsecure/test/contacts_parser_test.js @@ -13,6 +13,7 @@ describe('ContactBuffer', () => { const contactInfo = new textsecure.protobuf.ContactDetails({ name: 'Zero Cool', number: '+10000000000', + uuid: '7198E1BD-1293-452A-A098-F982FF201902', avatar: { contentType: 'image/jpeg', length: avatarLen }, }); const contactInfoBuffer = contactInfo.encode().toArrayBuffer(); @@ -37,6 +38,7 @@ describe('ContactBuffer', () => { count += 1; assert.strictEqual(contact.name, 'Zero Cool'); assert.strictEqual(contact.number, '+10000000000'); + assert.strictEqual(contact.uuid, '7198e1bd-1293-452a-a098-f982ff201902'); assert.strictEqual(contact.avatar.contentType, 'image/jpeg'); assert.strictEqual(contact.avatar.length, 255); assert.strictEqual(contact.avatar.data.byteLength, 255); @@ -63,7 +65,13 @@ describe('GroupBuffer', () => { const groupInfo = new textsecure.protobuf.GroupDetails({ id: new Uint8Array([1, 3, 3, 7]).buffer, name: 'Hackers', - members: ['cereal', 'burn', 'phreak', 'joey'], + membersE164: ['cereal', 'burn', 'phreak', 'joey'], + members: [ + { uuid: '3EA23646-92E8-4604-8833-6388861971C1', e164: 'cereal' }, + { uuid: 'B8414169-7149-4736-8E3B-477191931301', e164: 'burn' }, + { uuid: '64C97B95-A782-4E1E-BBCC-5A4ACE8d71f6', e164: 'phreak' }, + { uuid: 'CA334652-C35B-4FDC-9CC7-5F2060C771EE', e164: 'joey' }, + ], avatar: { contentType: 'image/jpeg', length: avatarLen }, }); const groupInfoBuffer = groupInfo.encode().toArrayBuffer(); @@ -91,7 +99,21 @@ describe('GroupBuffer', () => { group.id.toArrayBuffer(), new Uint8Array([1, 3, 3, 7]).buffer ); - assert.sameMembers(group.members, ['cereal', 'burn', 'phreak', 'joey']); + assert.sameMembers(group.membersE164, [ + 'cereal', + 'burn', + 'phreak', + 'joey', + ]); + assert.sameDeepMembers( + group.members.map(({ uuid, e164 }) => ({ uuid, e164 })), + [ + { uuid: '3ea23646-92e8-4604-8833-6388861971c1', e164: 'cereal' }, + { uuid: 'b8414169-7149-4736-8e3b-477191931301', e164: 'burn' }, + { uuid: '64c97b95-a782-4e1e-bbcc-5a4ace8d71f6', e164: 'phreak' }, + { uuid: 'ca334652-c35b-4fdc-9cc7-5f2060c771ee', e164: 'joey' }, + ] + ); assert.strictEqual(group.avatar.contentType, 'image/jpeg'); assert.strictEqual(group.avatar.length, 255); assert.strictEqual(group.avatar.data.byteLength, 255); diff --git a/libtextsecure/test/fake_web_api.js b/libtextsecure/test/fake_web_api.js index 74959fe30d..621f1bf227 100644 --- a/libtextsecure/test/fake_web_api.js +++ b/libtextsecure/test/fake_web_api.js @@ -1,6 +1,6 @@ window.setImmediate = window.nodeSetImmediate; -const getKeysForNumberMap = {}; +const getKeysForIdentifierMap = {}; const messagesSentMap = {}; const fakeCall = () => Promise.resolve(); @@ -10,7 +10,7 @@ const fakeAPI = { getAttachment: fakeCall, getAvatar: fakeCall, getDevices: fakeCall, - // getKeysForNumber: fakeCall, + // getKeysForIdentifier : fakeCall, getMessageSocket: fakeCall, getMyKeys: fakeCall, getProfile: fakeCall, @@ -22,13 +22,13 @@ const fakeAPI = { // sendMessages: fakeCall, setSignedPreKey: fakeCall, - getKeysForNumber(number) { - const res = getKeysForNumberMap[number]; + getKeysForIdentifier(number) { + const res = getKeysForIdentifierMap[number]; if (res !== undefined) { - delete getKeysForNumberMap[number]; + delete getKeysForIdentifierMap[number]; return Promise.resolve(res); } - throw new Error('getKeysForNumber of unknown/used number'); + throw new Error('getKeysForIdentfier of unknown/used number'); }, sendMessages(destination, messageArray) { diff --git a/libtextsecure/test/in_memory_signal_protocol_store.js b/libtextsecure/test/in_memory_signal_protocol_store.js index 9b8ce813ad..580cd3744a 100644 --- a/libtextsecure/test/in_memory_signal_protocol_store.js +++ b/libtextsecure/test/in_memory_signal_protocol_store.js @@ -4,6 +4,12 @@ function SignalProtocolStore() { SignalProtocolStore.prototype = { Direction: { SENDING: 1, RECEIVING: 2 }, + VerifiedStatus: { + DEFAULT: 0, + VERIFIED: 1, + UNVERIFIED: 2, + }, + getIdentityKeyPair() { return Promise.resolve(this.get('identityKey')); }, diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index 11dd2364ea..2920723d3b 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -23,7 +23,6 @@ - @@ -34,6 +33,16 @@ + + + + + + + + + + diff --git a/libtextsecure/test/message_receiver_test.js b/libtextsecure/test/message_receiver_test.js index 0715b173d5..69e062b533 100644 --- a/libtextsecure/test/message_receiver_test.js +++ b/libtextsecure/test/message_receiver_test.js @@ -4,12 +4,14 @@ describe('MessageReceiver', () => { textsecure.storage.impl = new SignalProtocolStore(); const { WebSocket } = window; const number = '+19999999999'; + const uuid = 'AAAAAAAA-BBBB-4CCC-9DDD-EEEEEEEEEEEE'; const deviceId = 1; const signalingKey = libsignal.crypto.getRandomBytes(32 + 20); before(() => { window.WebSocket = MockSocket; textsecure.storage.user.setNumberAndDeviceId(number, deviceId, 'name'); + textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId); textsecure.storage.put('password', 'password'); textsecure.storage.put('signaling_key', signalingKey); }); @@ -21,6 +23,7 @@ describe('MessageReceiver', () => { const attrs = { type: textsecure.protobuf.Envelope.Type.CIPHERTEXT, source: number, + sourceUuid: uuid, sourceDevice: deviceId, timestamp: Date.now(), }; @@ -72,7 +75,7 @@ describe('MessageReceiver', () => { it('connects', done => { const mockServer = new MockServer( `ws://localhost:8080/v1/websocket/?login=${encodeURIComponent( - number + uuid )}.1&password=password` ); diff --git a/libtextsecure/test/storage_test.js b/libtextsecure/test/storage_test.js index b2d615edd1..3261ccd5cb 100644 --- a/libtextsecure/test/storage_test.js +++ b/libtextsecure/test/storage_test.js @@ -1,9 +1,7 @@ -/* global libsignal, textsecure */ +/* global libsignal, textsecure, storage, ConversationController */ describe('SignalProtocolStore', () => { - before(() => { - localStorage.clear(); - }); + // debugger; const store = textsecure.storage.protocol; const identifier = '+5558675309'; const identityKey = { @@ -14,6 +12,14 @@ describe('SignalProtocolStore', () => { pubKey: libsignal.crypto.getRandomBytes(33), privKey: libsignal.crypto.getRandomBytes(32), }; + before(async () => { + localStorage.clear(); + ConversationController.reset(); + // store.hydrateCaches(); + await storage.fetch(); + await ConversationController.load(); + await ConversationController.getOrCreateAndWait(identifier, 'private'); + }); it('retrieves my registration id', async () => { store.put('registrationId', 1337); diff --git a/main.js b/main.js index 8e413526b8..6b44878812 100644 --- a/main.js +++ b/main.js @@ -68,7 +68,8 @@ const userConfig = require('./app/user_config'); const importMode = process.argv.some(arg => arg === '--import') || config.get('import'); -const development = config.environment === 'development'; +const development = + config.environment === 'development' || config.environment === 'staging'; // We generally want to pull in our own modules after this point, after the user // data directory has been set. diff --git a/preload.js b/preload.js index c2f4f05cec..b81a94ad77 100644 --- a/preload.js +++ b/preload.js @@ -6,6 +6,7 @@ try { const electron = require('electron'); const semver = require('semver'); const curve = require('curve25519-n'); + const _ = require('lodash'); const { installGetter, installSetter } = require('./preload_utils'); const { deferredToPromise } = require('./js/modules/deferred_to_promise'); @@ -254,6 +255,30 @@ try { window.loadImage = require('blueimp-load-image'); window.getGuid = require('uuid/v4'); + window.isValidGuid = maybeGuid => + /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test( + maybeGuid + ); + // https://stackoverflow.com/a/23299989 + window.isValidE164 = maybeE164 => /^\+?[1-9]\d{1,14}$/.test(maybeE164); + + window.normalizeUuids = (obj, paths, context) => { + if (!obj) { + return; + } + paths.forEach(path => { + const val = _.get(obj, path); + if (val) { + if (!window.isValidGuid(val)) { + window.log.warn( + `Normalizing invalid uuid: ${val} at path ${path} in context "${context}"` + ); + } + _.set(obj, path, val.toLowerCase()); + } + }); + }; + window.React = require('react'); window.ReactDOM = require('react-dom'); window.moment = require('moment'); diff --git a/protos/DeviceMessages.proto b/protos/DeviceMessages.proto index 3e96e5f582..4368a712e7 100644 --- a/protos/DeviceMessages.proto +++ b/protos/DeviceMessages.proto @@ -11,10 +11,20 @@ message ProvisionEnvelope { } message ProvisionMessage { - optional bytes identityKeyPrivate = 2; - optional string number = 3; - optional string provisioningCode = 4; - optional string userAgent = 5; - optional bytes profileKey = 6; - optional bool readReceipts = 7; + optional bytes identityKeyPrivate = 2; + optional string number = 3; + optional string uuid = 8; + optional string provisioningCode = 4; + optional string userAgent = 5; + optional bytes profileKey = 6; + optional bool readReceipts = 7; + optional uint32 ProvisioningVersion = 9; } + +enum ProvisioningVersion { + option allow_alias = true; + + INITIAL = 0; + TABLET_SUPPORT = 1; + CURRENT = 1; +} \ No newline at end of file diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 39942c8ded..fe93a90694 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -16,6 +16,7 @@ message Envelope { optional Type type = 1; optional string source = 2; + optional string sourceUuid = 11; optional uint32 sourceDevice = 7; optional string relay = 3; optional uint64 timestamp = 5; @@ -85,6 +86,7 @@ message DataMessage { optional uint64 id = 1; optional string author = 2; + optional string authorUuid = 5; optional string text = 3; repeated QuotedAttachment attachments = 4; } @@ -236,20 +238,23 @@ message Verified { UNVERIFIED = 2; } - optional string destination = 1; - optional bytes identityKey = 2; - optional State state = 3; - optional bytes nullMessage = 4; + optional string destination = 1; + optional string destinationUuid = 5; + optional bytes identityKey = 2; + optional State state = 3; + optional bytes nullMessage = 4; } message SyncMessage { message Sent { message UnidentifiedDeliveryStatus { - optional string destination = 1; - optional bool unidentified = 2; + optional string destination = 1; + optional string destinationUuid = 3; + optional bool unidentified = 2; } optional string destination = 1; + optional string destinationUuid = 7; optional uint64 timestamp = 2; optional DataMessage message = 3; optional uint64 expirationStartTimestamp = 4; @@ -268,6 +273,7 @@ message SyncMessage { message Blocked { repeated string numbers = 1; + repeated string uuids = 3; repeated bytes groupIds = 2; } @@ -284,8 +290,9 @@ message SyncMessage { } message Read { - optional string sender = 1; - optional uint64 timestamp = 2; + optional string sender = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; } message Configuration { @@ -306,8 +313,9 @@ message SyncMessage { } message ViewOnceOpen { - optional string sender = 1; - optional uint64 timestamp = 2; + optional string sender = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; } optional Sent sent = 1; @@ -349,11 +357,18 @@ message GroupContext { QUIT = 3; REQUEST_INFO = 4; } - optional bytes id = 1; - optional Type type = 2; - optional string name = 3; - repeated string members = 4; - optional AttachmentPointer avatar = 5; + + message Member { + optional string uuid = 1; + optional string e164 = 2; + } + + optional bytes id = 1; + optional Type type = 2; + optional string name = 3; + repeated string membersE164 = 4; + repeated Member members = 6; + optional AttachmentPointer avatar = 5; } message ContactDetails { @@ -363,6 +378,7 @@ message ContactDetails { } optional string number = 1; + optional string uuid = 9; optional string name = 2; optional Avatar avatar = 3; optional string color = 4; @@ -378,9 +394,15 @@ message GroupDetails { optional uint32 length = 2; } + message Member { + optional string uuid = 1; + optional string e164 = 2; + } + optional bytes id = 1; optional string name = 2; - repeated string members = 3; + repeated string membersE164 = 3; + repeated Member members = 9; optional Avatar avatar = 4; optional bool active = 5 [default = true]; optional uint32 expireTimer = 6; diff --git a/protos/UnidentifiedDelivery.proto b/protos/UnidentifiedDelivery.proto index da9295aa6b..8a177c4df0 100644 --- a/protos/UnidentifiedDelivery.proto +++ b/protos/UnidentifiedDelivery.proto @@ -15,11 +15,12 @@ message ServerCertificate { message SenderCertificate { message Certificate { - optional string sender = 1; - optional uint32 senderDevice = 2; - optional fixed64 expires = 3; - optional bytes identityKey = 4; - optional ServerCertificate signer = 5; + optional string sender = 1; + optional string senderUuid = 6; + optional uint32 senderDevice = 2; + optional fixed64 expires = 3; + optional bytes identityKey = 4; + optional ServerCertificate signer = 5; } optional bytes certificate = 1; diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index 9ff76bd96b..06b291bf78 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -69,10 +69,11 @@ window.encryptAndUpload = async ( cover, onProgress = noop ) => { - const usernameItem = await window.Signal.Data.getItemById('number_id'); + const usernameItem = await window.Signal.Data.getItemById('uuid_id'); + const oldUsernameItem = await window.Signal.Data.getItemById('number_id'); const passwordItem = await window.Signal.Data.getItemById('password'); - if (!usernameItem || !passwordItem) { + if (!oldUsernameItem || !passwordItem) { const { message } = window.localeMessages[ 'StickerCreator--Authentication--error' ]; @@ -86,13 +87,17 @@ window.encryptAndUpload = async ( } const { value: username } = usernameItem; + const { value: oldUsername } = oldUsernameItem; const { value: password } = passwordItem; const packKey = window.libsignal.crypto.getRandomBytes(32); const encryptionKey = await deriveStickerPackKey(packKey); const iv = window.libsignal.crypto.getRandomBytes(16); - const server = WebAPI.connect({ username, password }); + const server = WebAPI.connect({ + username: username || oldUsername, + password, + }); const uniqueStickers = uniqBy([...stickers, { webp: cover }], 'webp'); diff --git a/test/backup_test.js b/test/backup_test.js index eca7f31818..7b9ac7b9cb 100644 --- a/test/backup_test.js +++ b/test/backup_test.js @@ -261,6 +261,8 @@ describe('Backup', () => { const CONTACT_ONE_NUMBER = '+12025550001'; const CONTACT_TWO_NUMBER = '+12025550002'; + const CONVERSATION_ID = 'bdaa7f4f-e9bd-493e-ab0d-8331ad604269'; + const toArrayBuffer = nodeBuffer => nodeBuffer.buffer.slice( nodeBuffer.byteOffset, @@ -405,7 +407,7 @@ describe('Backup', () => { const CONVERSATION_COUNT = 1; const messageWithAttachments = { - conversationId: CONTACT_ONE_NUMBER, + conversationId: CONVERSATION_ID, body: 'Totally!', source: OUR_NUMBER, received_at: 1524185933350, @@ -493,7 +495,7 @@ describe('Backup', () => { active_at: 1524185933350, color: 'orange', expireTimer: 0, - id: CONTACT_ONE_NUMBER, + id: CONVERSATION_ID, name: 'Someone Somewhere', profileAvatar: { contentType: 'image/jpeg', diff --git a/test/modules/privacy_test.js b/test/modules/privacy_test.js index 1c983ef136..79559e5afe 100644 --- a/test/modules/privacy_test.js +++ b/test/modules/privacy_test.js @@ -21,6 +21,20 @@ describe('Privacy', () => { }); }); + describe('redactUuids', () => { + it('should redact all uuids', () => { + const text = + 'This is a log line with a uuid 9e420799-acdf-4bf4-8dee-353d7e2096b4\n' + + 'and another one IN ALL UPPERCASE 340727FB-E43A-413B-941B-AADA033B6CA3'; + + const actual = Privacy.redactUuids(text); + const expected = + 'This is a log line with a uuid [REDACTED]b4\n' + + 'and another one IN ALL UPPERCASE [REDACTED]A3'; + assert.equal(actual, expected); + }); + }); + describe('redactGroupIds', () => { it('should redact all group IDs', () => { const text = diff --git a/test/storage_test.js b/test/storage_test.js index 40df0a6121..ba682e11fa 100644 --- a/test/storage_test.js +++ b/test/storage_test.js @@ -1,4 +1,4 @@ -/* global _, textsecure, libsignal, storage */ +/* global _, textsecure, libsignal, storage, ConversationController */ 'use strict'; @@ -8,7 +8,7 @@ describe('SignalProtocolStore', () => { let identityKey; let testKey; - before(done => { + before(async () => { store = textsecure.storage.protocol; store.hydrateCaches(); identityKey = { @@ -22,7 +22,10 @@ describe('SignalProtocolStore', () => { storage.put('registrationId', 1337); storage.put('identityKey', identityKey); - storage.fetch().then(done, done); + await storage.fetch(); + ConversationController.reset(); + await ConversationController.load(); + await ConversationController.getOrCreateAndWait(number, 'private'); }); describe('getLocalRegistrationId', () => { diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index dbfe8eb6e3..fdba6b4bdb 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -16,6 +16,8 @@ export interface PropsType { startSearchCounter: number; // To be used as an ID + ourConversationId: string; + ourUuid: string; ourNumber: string; regionCode: string; @@ -40,7 +42,9 @@ export interface PropsType { searchDiscussions: ( query: string, options: { + ourConversationId: string; ourNumber: string; + ourUuid: string; noteToSelf: string; } ) => void; @@ -147,7 +151,9 @@ export class MainHeader extends React.Component { public search = debounce((searchTerm: string) => { const { i18n, + ourConversationId, ourNumber, + ourUuid, regionCode, searchDiscussions, searchMessages, @@ -157,7 +163,9 @@ export class MainHeader extends React.Component { if (searchDiscussions && !searchConversationId) { searchDiscussions(searchTerm, { noteToSelf: i18n('noteToSelf').toLowerCase(), + ourConversationId, ourNumber, + ourUuid, }); } diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 9dace631af..0b05ec6953 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -148,7 +148,7 @@ function searchMessages( function searchDiscussions( query: string, options: { - ourNumber: string; + ourConversationId: string; noteToSelf: string; } ): SearchDiscussionsResultsKickoffActionType { @@ -180,15 +180,15 @@ async function doSearchMessages( async function doSearchDiscussions( query: string, options: { - ourNumber: string; + ourConversationId: string; noteToSelf: string; } ): Promise { - const { ourNumber, noteToSelf } = options; + const { ourConversationId, noteToSelf } = options; const { conversations, contacts } = await queryConversationsAndContacts( query, { - ourNumber, + ourConversationId, noteToSelf, } ); @@ -271,9 +271,12 @@ async function queryMessages(query: string, searchConversationId?: string) { async function queryConversationsAndContacts( providedQuery: string, - options: { ourNumber: string; noteToSelf: string } + options: { + ourConversationId: string; + noteToSelf: string; + } ) { - const { ourNumber, noteToSelf } = options; + const { ourConversationId, noteToSelf } = options; const query = providedQuery.replace(/[+-.()]*/g, ''); const searchResults: Array = await dataSearchConversations( @@ -294,13 +297,20 @@ async function queryConversationsAndContacts( } } + // // @ts-ignore + // console._log( + // '%cqueryConversationsAndContacts', + // 'background:black;color:red;', + // { searchResults, conversations, ourNumber, ourUuid } + // ); + // Inject synthetic Note to Self entry if query matches localized 'Note to Self' if (noteToSelf.indexOf(providedQuery.toLowerCase()) !== -1) { // ensure that we don't have duplicates in our results - contacts = contacts.filter(id => id !== ourNumber); - conversations = conversations.filter(id => id !== ourNumber); + contacts = contacts.filter(id => id !== ourConversationId); + conversations = conversations.filter(id => id !== ourConversationId); - contacts.unshift(ourNumber); + contacts.unshift(ourConversationId); } return { conversations, contacts }; diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index 224989e244..61b096aa59 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -6,6 +6,8 @@ export type UserStateType = { attachmentsPath: string; stickersPath: string; tempPath: string; + ourConversationId: string; + ourUuid: string; ourNumber: string; platform: string; regionCode: string; @@ -18,6 +20,8 @@ export type UserStateType = { type UserChangedActionType = { type: 'USER_CHANGED'; payload: { + ourConversationId?: string; + ourUuid?: string; ourNumber?: string; regionCode?: string; interactionMode?: 'mouse' | 'keyboard'; @@ -34,7 +38,9 @@ export const actions = { function userChanged(attributes: { interactionMode?: 'mouse' | 'keyboard'; + ourConversationId: string; ourNumber: string; + ourUuid: string; regionCode: string; }): UserChangedActionType { return { @@ -50,6 +56,8 @@ function getEmptyState(): UserStateType { attachmentsPath: 'missing', stickersPath: 'missing', tempPath: 'missing', + ourConversationId: 'missing', + ourUuid: 'missing', ourNumber: 'missing', regionCode: 'missing', platform: 'missing', diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 32fbce70af..96757da029 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -22,6 +22,7 @@ import { getInteractionMode, getIntl, getRegionCode, + getUserConversationId, getUserNumber, } from './user'; @@ -181,9 +182,12 @@ export const getLeftPaneLists = createSelector( ); export const getMe = createSelector( - [getConversationLookup, getUserNumber], - (lookup: ConversationLookupType, ourNumber: string): ConversationType => { - return lookup[ourNumber]; + [getConversationLookup, getUserConversationId], + ( + lookup: ConversationLookupType, + ourConversationId: string + ): ConversationType => { + return lookup[ourConversationId]; } ); diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index 0ce398d391..c7a384df26 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -149,6 +149,7 @@ export const getSearchResults = createSelector( }); contacts.forEach(id => { const data = lookup[id]; + items.push({ type: 'contact', data: { diff --git a/ts/state/selectors/user.ts b/ts/state/selectors/user.ts index 52a87c2a00..e7241e6e6c 100644 --- a/ts/state/selectors/user.ts +++ b/ts/state/selectors/user.ts @@ -17,6 +17,16 @@ export const getRegionCode = createSelector( (state: UserStateType): string => state.regionCode ); +export const getUserConversationId = createSelector( + getUser, + (state: UserStateType): string => state.ourConversationId +); + +export const getUserUuid = createSelector( + getUser, + (state: UserStateType): string => state.ourUuid +); + export const getIntl = createSelector( getUser, (state: UserStateType): LocalizerType => state.i18n diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx index 22899c183c..5572c8c00f 100644 --- a/ts/state/smart/MainHeader.tsx +++ b/ts/state/smart/MainHeader.tsx @@ -10,7 +10,13 @@ import { getSearchConversationName, getStartSearchCounter, } from '../selectors/search'; -import { getIntl, getRegionCode, getUserNumber } from '../selectors/user'; +import { + getIntl, + getRegionCode, + getUserConversationId, + getUserNumber, + getUserUuid, +} from '../selectors/user'; import { getMe } from '../selectors/conversations'; const mapStateToProps = (state: StateType) => { @@ -20,7 +26,9 @@ const mapStateToProps = (state: StateType) => { searchConversationName: getSearchConversationName(state), startSearchCounter: getStartSearchCounter(state), regionCode: getRegionCode(state), + ourConversationId: getUserConversationId(state), ourNumber: getUserNumber(state), + ourUuid: getUserUuid(state), ...getMe(state), i18n: getIntl(state), }; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index c3487afefb..88e0f3792e 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -164,17 +164,17 @@ "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " async load() {", - "lineNumber": 171, + "lineNumber": 210, "reasonCategory": "falseMatch", - "updated": "2019-07-31T00:19:18.696Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " this._initialPromise = load();", - "lineNumber": 216, + "lineNumber": 255, "reasonCategory": "falseMatch", - "updated": "2019-07-31T00:19:18.696Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-$(", @@ -301,9 +301,9 @@ "rule": "jQuery-load(", "path": "js/signal_protocol_store.js", "line": " await ConversationController.load();", - "lineNumber": 894, + "lineNumber": 983, "reasonCategory": "falseMatch", - "updated": "2018-09-15T00:38:04.183Z" + "updated": "2020-02-28T18:14:42.951Z" }, { "rule": "DOM-innerHTML", @@ -669,44 +669,44 @@ "rule": "jQuery-$(", "path": "js/views/key_verification_view.js", "line": " new QRCode(this.$('.qr')[0]).makeCode(", - "lineNumber": 42, + "lineNumber": 43, "reasonCategory": "usageTrusted", - "updated": "2019-07-31T00:19:18.696Z", + "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-wrap(", "path": "js/views/key_verification_view.js", "line": " dcodeIO.ByteBuffer.wrap(this.ourKey).toString('base64')", - "lineNumber": 43, + "lineNumber": 44, "reasonCategory": "falseMatch", - "updated": "2019-07-31T00:19:18.696Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-insertBefore(", "path": "js/views/key_verification_view.js", "line": " dialog.$el.insertBefore(this.el);", - "lineNumber": 72, + "lineNumber": 86, "reasonCategory": "usageTrusted", - "updated": "2019-07-31T00:19:18.696Z", + "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", "path": "js/views/key_verification_view.js", "line": " this.$('button.verify').attr('disabled', true);", - "lineNumber": 76, + "lineNumber": 90, "reasonCategory": "usageTrusted", - "updated": "2019-07-31T00:19:18.696Z", + "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-$(", "path": "js/views/key_verification_view.js", "line": " this.$('button.verify').removeAttr('disabled');", - "lineNumber": 107, + "lineNumber": 121, "reasonCategory": "usageTrusted", - "updated": "2019-07-31T00:19:18.696Z", + "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Hardcoded selector" }, { @@ -1203,65 +1203,65 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer();", - "lineNumber": 62, + "lineNumber": 72, "reasonCategory": "falseMatch", - "updated": "2019-09-20T18:36:19.909Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary');", - "lineNumber": 64, + "lineNumber": 74, "reasonCategory": "falseMatch", - "updated": "2019-09-20T18:36:19.909Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();", - "lineNumber": 66, + "lineNumber": 76, "reasonCategory": "falseMatch", - "updated": "2019-09-20T18:36:19.909Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');", - "lineNumber": 68, + "lineNumber": 78, "reasonCategory": "falseMatch", - "updated": "2019-09-20T18:36:19.909Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", - "lineNumber": 752, + "lineNumber": 812, "reasonCategory": "falseMatch", - "updated": "2019-10-21T22:30:15.622Z" + "updated": "2020-03-04T21:24:23.269Z" }, { "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", - "lineNumber": 777, + "lineNumber": 837, "reasonCategory": "falseMatch", - "updated": "2019-10-21T22:30:15.622Z" + "updated": "2020-03-04T21:24:23.269Z" }, { "rule": "jQuery-wrap(", "path": "libtextsecure/sendmessage.js", "line": " return dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();", - "lineNumber": 17, + "lineNumber": 18, "reasonCategory": "falseMatch", - "updated": "2019-04-26T17:48:30.675Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-wrap(", "path": "libtextsecure/sendmessage.js", "line": " return dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();", - "lineNumber": 20, + "lineNumber": 21, "reasonCategory": "falseMatch", - "updated": "2019-04-26T17:48:30.675Z" + "updated": "2020-02-14T20:02:37.507Z" }, { "rule": "jQuery-wrap(", @@ -11663,18 +11663,18 @@ "rule": "React-createRef", "path": "ts/components/MainHeader.js", "line": " this.inputRef = react_1.default.createRef();", - "lineNumber": 144, + "lineNumber": 146, "reasonCategory": "usageTrusted", - "updated": "2019-08-09T21:17:57.798Z", + "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Used only to set focus" }, { "rule": "React-createRef", "path": "ts/components/MainHeader.tsx", "line": " this.inputRef = React.createRef();", - "lineNumber": 65, + "lineNumber": 69, "reasonCategory": "usageTrusted", - "updated": "2019-08-09T21:17:57.798Z", + "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Used only to set focus" }, { @@ -11816,4 +11816,4 @@ "reasonCategory": "falseMatch", "updated": "2020-02-07T19:52:28.522Z" } -] \ No newline at end of file +]