diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..9a92b58d5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,58 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Mocha Tests", + "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", + "args": [ + "--recursive", + "--exit", + "test/app", + "test/modules", + "ts/test", + "libloki/test/node" + ], + "internalConsoleOptions": "openOnSessionStart" + }, + { + "type": "node", + "request": "launch", + "name": "Launch node Program", + "program": "${file}" + }, + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "program": "${file}" + }, + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}" + }, + { + "name": "Debug Main Process", + "type": "node", + "request": "launch", + "env": { + "NODE_APP_INSTANCE": "1" + }, + "cwd": "${workspaceRoot}", + "console": "integratedTerminal", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", + "windows": { + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" + }, + "args": ["."], + "sourceMaps": true, + "outputCapture": "std" + } + ] +} diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e58951412..5b511b5f0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -926,6 +926,30 @@ "cancel": { "message": "Cancel" }, + "skip": { + "message": "Skip" + }, + "close": { + "message": "Close" + }, + "pairNewDevice": { + "message": "Pair new Device" + }, + "devicePairingAccepted": { + "message": "Device Pairing Accepted" + }, + "devicePairingReceived": { + "message": "Device Pairing Received" + }, + "waitingForDeviceToRegister": { + "message": "Waiting for device to register..." + }, + "pairedDevices": { + "message": "Paired Devices" + }, + "allowPairing": { + "message": "Allow Pairing" + }, "clear": { "message": "Clear" }, diff --git a/app/sql.js b/app/sql.js index 491ca5403..0be90dbd6 100644 --- a/app/sql.js +++ b/app/sql.js @@ -73,6 +73,14 @@ module.exports = { removeContactSignedPreKeyByIdentityKey, removeAllContactSignedPreKeys, + createOrUpdatePairingAuthorisation, + removePairingAuthorisationForSecondaryPubKey, + getAuthorisationForSecondaryPubKey, + getGrantAuthorisationsForPrimaryPubKey, + getSecondaryDevicesFor, + getPrimaryDeviceFor, + getPairedDevicesFor, + createOrUpdateItem, getItemById, getAllItems, @@ -100,13 +108,15 @@ module.exports = { updateConversation, removeConversation, getAllConversations, + getConversationsWithFriendStatus, getAllRssFeedConversations, getAllPublicConversations, getPublicConversationsByServer, - getPubKeysWithFriendStatus, getAllConversationIds, getAllPrivateConversations, getAllGroupsInvolvingId, + removeAllConversations, + removeAllPrivateConversations, searchConversations, searchMessages, @@ -780,7 +790,10 @@ async function updateSchema(instance) { await updateLokiSchema(instance); } -const LOKI_SCHEMA_VERSIONS = [updateToLokiSchemaVersion1]; +const LOKI_SCHEMA_VERSIONS = [ + updateToLokiSchemaVersion1, + updateToLokiSchemaVersion2, +]; async function updateToLokiSchemaVersion1(currentVersion, instance) { if (currentVersion >= 1) { @@ -914,6 +927,34 @@ async function updateToLokiSchemaVersion1(currentVersion, instance) { console.log('updateToLokiSchemaVersion1: success!'); } +async function updateToLokiSchemaVersion2(currentVersion, instance) { + if (currentVersion >= 2) { + return; + } + console.log('updateToLokiSchemaVersion2: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + await instance.run( + `CREATE TABLE pairingAuthorisations( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + primaryDevicePubKey VARCHAR(255), + secondaryDevicePubKey VARCHAR(255), + isGranted BOOLEAN, + json TEXT + );` + ); + + await instance.run( + `INSERT INTO loki_schema ( + version + ) values ( + 2 + );` + ); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToLokiSchemaVersion2: success!'); +} + async function updateLokiSchema(instance) { const result = await instance.get( "SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';" @@ -1271,7 +1312,7 @@ async function getContactSignedPreKeyById(id) { } async function getContactSignedPreKeyByIdentityKey(key) { const row = await db.get( - `SELECT * FROM ${CONTACT_SIGNED_PRE_KEYS_TABLE} WHERE identityKeyString = $identityKeyString;`, + `SELECT * FROM ${CONTACT_SIGNED_PRE_KEYS_TABLE} WHERE identityKeyString = $identityKeyString ORDER BY keyId DESC;`, { $identityKeyString: key, } @@ -1331,6 +1372,114 @@ async function removeAllSignedPreKeys() { return removeAllFromTable(SIGNED_PRE_KEYS_TABLE); } +const PAIRING_AUTHORISATIONS_TABLE = 'pairingAuthorisations'; +async function getAuthorisationForSecondaryPubKey(pubKey, options) { + const granted = options && options.granted; + let filter = ''; + if (granted) { + filter = 'AND isGranted = 1'; + } + const row = await db.get( + `SELECT json FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey ${filter};`, + { + $secondaryDevicePubKey: pubKey, + } + ); + + if (!row) { + return null; + } + + return jsonToObject(row.json); +} + +async function getGrantAuthorisationsForPrimaryPubKey(primaryDevicePubKey) { + const rows = await db.all( + `SELECT json FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE primaryDevicePubKey = $primaryDevicePubKey AND isGranted = 1 ORDER BY secondaryDevicePubKey ASC;`, + { + $primaryDevicePubKey: primaryDevicePubKey, + } + ); + return map(rows, row => jsonToObject(row.json)); +} + +async function createOrUpdatePairingAuthorisation(data) { + const { primaryDevicePubKey, secondaryDevicePubKey, grantSignature } = data; + + await db.run( + `INSERT OR REPLACE INTO ${PAIRING_AUTHORISATIONS_TABLE} ( + primaryDevicePubKey, + secondaryDevicePubKey, + isGranted, + json + ) values ( + $primaryDevicePubKey, + $secondaryDevicePubKey, + $isGranted, + $json + )`, + { + $primaryDevicePubKey: primaryDevicePubKey, + $secondaryDevicePubKey: secondaryDevicePubKey, + $isGranted: Boolean(grantSignature), + $json: objectToJSON(data), + } + ); +} + +async function removePairingAuthorisationForSecondaryPubKey(pubKey) { + await db.run( + `DELETE FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey;`, + { + $secondaryDevicePubKey: pubKey, + } + ); +} + +async function getSecondaryDevicesFor(primaryDevicePubKey) { + const authorisations = await getGrantAuthorisationsForPrimaryPubKey( + primaryDevicePubKey + ); + return map(authorisations, row => row.secondaryDevicePubKey); +} + +async function getPrimaryDeviceFor(secondaryDevicePubKey) { + const row = await db.get( + `SELECT primaryDevicePubKey FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey AND isGranted = 1;`, + { + $secondaryDevicePubKey: secondaryDevicePubKey, + } + ); + + if (!row) { + return null; + } + + return row.primaryDevicePubKey; +} + +// Return all the paired pubkeys for a specific pubkey (excluded), +// irrespective of their Primary or Secondary status. +async function getPairedDevicesFor(pubKey) { + let results = []; + + // get primary pubkey (only works if the pubkey is a secondary pubkey) + const primaryPubKey = await getPrimaryDeviceFor(pubKey); + if (primaryPubKey) { + results.push(primaryPubKey); + } + // get secondary pubkeys (only works if the pubkey is a primary pubkey) + const secondaryPubKeys = await getSecondaryDevicesFor( + primaryPubKey || pubKey + ); + results = results.concat(secondaryPubKeys); + + // ensure the input pubkey is not in the results + results = results.filter(x => x !== pubKey); + + return results; +} + const ITEMS_TABLE = 'items'; async function createOrUpdateItem(data) { return createOrUpdate(ITEMS_TABLE, data); @@ -1497,12 +1646,13 @@ async function getSwarmNodesByPubkey(pubkey) { return jsonToObject(row.json).swarmNodes; } +const CONVERSATIONS_TABLE = 'conversations'; async function getConversationCount() { - const row = await db.get('SELECT count(*) from conversations;'); + const row = await db.get(`SELECT count(*) from ${CONVERSATIONS_TABLE};`); if (!row) { throw new Error( - 'getConversationCount: Unable to get count of conversations' + `getConversationCount: Unable to get count of ${CONVERSATIONS_TABLE}` ); } @@ -1522,7 +1672,7 @@ async function saveConversation(data) { } = data; await db.run( - `INSERT INTO conversations ( + `INSERT INTO ${CONVERSATIONS_TABLE} ( id, json, @@ -1586,7 +1736,7 @@ async function updateConversation(data) { } = data; await db.run( - `UPDATE conversations SET + `UPDATE ${CONVERSATIONS_TABLE} SET json = $json, active_at = $active_at, @@ -1612,7 +1762,9 @@ async function updateConversation(data) { async function removeConversation(id) { if (!Array.isArray(id)) { - await db.run('DELETE FROM conversations WHERE id = $id;', { $id: id }); + await db.run(`DELETE FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`, { + $id: id, + }); return; } @@ -1622,7 +1774,7 @@ async function removeConversation(id) { // Our node interface doesn't seem to allow you to replace one single ? with an array await db.run( - `DELETE FROM conversations WHERE id IN ( ${id + `DELETE FROM ${CONVERSATIONS_TABLE} WHERE id IN ( ${id .map(() => '?') .join(', ')} );`, id @@ -1662,9 +1814,12 @@ async function getPublicServerTokenByServerUrl(serverUrl) { } async function getConversationById(id) { - const row = await db.get('SELECT * FROM conversations WHERE id = $id;', { - $id: id, - }); + const row = await db.get( + `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`, + { + $id: id, + } + ); if (!row) { return null; @@ -1674,30 +1829,35 @@ async function getConversationById(id) { } async function getAllConversations() { - const rows = await db.all('SELECT json FROM conversations ORDER BY id ASC;'); + const rows = await db.all( + `SELECT json FROM ${CONVERSATIONS_TABLE} ORDER BY id ASC;` + ); return map(rows, row => jsonToObject(row.json)); } -async function getPubKeysWithFriendStatus(status) { +async function getConversationsWithFriendStatus(status) { const rows = await db.all( - `SELECT id FROM conversations WHERE + `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE friendRequestStatus = $status + AND type = 'private' ORDER BY id ASC;`, { $status: status, } ); - return map(rows, row => row.id); + return map(rows, row => jsonToObject(row.json)); } async function getAllConversationIds() { - const rows = await db.all('SELECT id FROM conversations ORDER BY id ASC;'); + const rows = await db.all( + `SELECT id FROM ${CONVERSATIONS_TABLE} ORDER BY id ASC;` + ); return map(rows, row => row.id); } async function getAllPrivateConversations() { const rows = await db.all( - `SELECT json FROM conversations WHERE + `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE type = 'private' ORDER BY id ASC;` ); @@ -1742,7 +1902,7 @@ async function getPublicConversationsByServer(server) { async function getAllGroupsInvolvingId(id) { const rows = await db.all( - `SELECT json FROM conversations WHERE + `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE type = 'group' AND members LIKE $id ORDER BY id ASC;`, @@ -1756,7 +1916,7 @@ async function getAllGroupsInvolvingId(id) { async function searchConversations(query, { limit } = {}) { const rows = await db.all( - `SELECT json FROM conversations WHERE + `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE ( id LIKE $id OR name LIKE $name OR @@ -2504,6 +2664,14 @@ async function removeAllConfiguration() { await promise; } +async function removeAllConversations() { + await removeAllFromTable(CONVERSATIONS_TABLE); +} + +async function removeAllPrivateConversations() { + await db.run(`DELETE FROM ${CONVERSATIONS_TABLE} WHERE type = 'private'`); +} + async function getMessagesNeedingUpgrade(limit, { maxVersion }) { const rows = await db.all( `SELECT json FROM messages diff --git a/background.html b/background.html index e3bbc00ba..1f6db1eb1 100644 --- a/background.html +++ b/background.html @@ -246,6 +246,52 @@ + + + diff --git a/js/background.js b/js/background.js index 710471086..2f5c7c47a 100644 --- a/js/background.js +++ b/js/background.js @@ -172,6 +172,8 @@ return -1; }; Whisper.events = _.clone(Backbone.Events); + Whisper.events.isListenedTo = eventName => + Whisper.events._events ? !!Whisper.events._events[eventName] : false; let accountManager; window.getAccountManager = () => { if (!accountManager) { @@ -182,6 +184,7 @@ const user = { regionCode: window.storage.get('regionCode'), ourNumber: textsecure.storage.user.getNumber(), + isSecondaryDevice: !!textsecure.storage.get('isSecondaryDevice'), }; Whisper.events.trigger('userChanged', user); @@ -205,7 +208,11 @@ window.log.info('Storage fetch'); storage.fetch(); + let specialConvInited = false; const initSpecialConversations = async () => { + if (specialConvInited) { + return; + } const rssFeedConversations = await window.Signal.Data.getAllRssFeedConversations( { ConversationCollection: Whisper.ConversationCollection, @@ -223,9 +230,14 @@ // weird but create the object and does everything we need conversation.getPublicSendData(); }); + specialConvInited = true; }; + let initialisedAPI = false; const initAPIs = async () => { + if (initialisedAPI) { + return; + } const ourKey = textsecure.storage.user.getNumber(); window.feeds = []; window.lokiMessageAPI = new window.LokiMessageAPI(ourKey); @@ -242,6 +254,11 @@ }); window.lokiP2pAPI.on('online', ConversationController._handleOnline); window.lokiP2pAPI.on('offline', ConversationController._handleOffline); + initialisedAPI = true; + + if (storage.get('isSecondaryDevice')) { + window.lokiFileServerAPI.updateOurDeviceMapping(); + } }; function mapOldThemeToNew(theme) { @@ -259,6 +276,9 @@ } function startLocalLokiServer() { + if (window.localLokiServer) { + return; + } const pems = window.getSelfSignedCert(); window.localLokiServer = new window.LocalLokiServer(pems); } @@ -628,7 +648,10 @@ if (Whisper.Import.isIncomplete()) { window.log.info('Import was interrupted, showing import error screen'); appView.openImporter(); - } else if (Whisper.Registration.everDone()) { + } else if ( + Whisper.Registration.isDone() && + !Whisper.Registration.ongoingSecondaryDeviceRegistration() + ) { // listeners Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); // window.Signal.RefreshSenderCertificate.initialize({ @@ -756,6 +779,12 @@ } }); + Whisper.events.on('showDevicePairingDialog', async () => { + if (appView) { + appView.showDevicePairingDialog(); + } + }); + Whisper.events.on('calculatingPoW', ({ pubKey, timestamp }) => { try { const conversation = ConversationController.get(pubKey); @@ -791,6 +820,22 @@ appView.inboxView.trigger('password-updated'); } }); + + Whisper.events.on('devicePairingRequestAccepted', async (pubKey, cb) => { + try { + await getAccountManager().authoriseSecondaryDevice(pubKey); + cb(null); + } catch (e) { + cb(e); + } + }); + + Whisper.events.on('devicePairingRequestRejected', async pubKey => { + await window.libloki.storage.removeContactPreKeyBundle(pubKey); + await window.libloki.storage.removePairingAuthorisationForSecondaryPubKey( + pubKey + ); + }); } window.getSyncRequest = () => @@ -836,14 +881,14 @@ ); } - function disconnect() { + async function disconnect() { window.log.info('disconnect'); // Clear timer, since we're only called when the timer is expired disconnectTimer = null; if (messageReceiver) { - messageReceiver.close(); + await messageReceiver.close(); } window.Signal.AttachmentDownloads.stop(); } @@ -873,7 +918,7 @@ } if (messageReceiver) { - messageReceiver.close(); + await messageReceiver.close(); } const USERNAME = storage.get('number_id'); @@ -888,6 +933,26 @@ Whisper.Notifications.disable(); // avoid notification flood until empty + if (Whisper.Registration.ongoingSecondaryDeviceRegistration()) { + const ourKey = textsecure.storage.user.getNumber(); + window.lokiMessageAPI = new window.LokiMessageAPI(ourKey); + window.localLokiServer = null; + window.lokiPublicChatAPI = null; + window.feeds = []; + messageReceiver = new textsecure.MessageReceiver( + USERNAME, + PASSWORD, + mySignalingKey, + options + ); + messageReceiver.addEventListener('message', onMessageReceived); + window.textsecure.messaging = new textsecure.MessageSender( + USERNAME, + PASSWORD + ); + return; + } + // initialize the socket and start listening for messages startLocalLokiServer(); await initAPIs(); @@ -1075,7 +1140,7 @@ ev.confirm(); } - function onTyping(ev) { + async function onTyping(ev) { const { typing, sender, senderDevice } = ev; const { groupId, started } = typing || {}; @@ -1084,7 +1149,17 @@ return; } - const conversation = ConversationController.get(groupId || sender); + let primaryDevice = null; + const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey( + sender + ); + if (authorisation) { + primaryDevice = authorisation.primaryDevicePubKey; + } + + const conversation = ConversationController.get( + groupId || primaryDevice || sender + ); if (conversation) { conversation.notifyTyping({ @@ -1148,12 +1223,19 @@ } } + // Do not set name to allow working with lokiProfile and nicknames conversation.set({ - name: details.name, + // name: details.name, color: details.color, active_at: activeAt, }); + await conversation.setLokiProfile({ displayName: details.name }); + + if (details.nickname) { + await conversation.setNickname(details.nickname); + } + // Update the conversation avatar only if new avatar exists and hash differs const { avatar } = details; if (avatar && avatar.data) { @@ -1298,6 +1380,14 @@ const messageDescriptor = getMessageDescriptor(data); + // Funnel messages to primary device conversation if multi-device + const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey( + messageDescriptor.id + ); + if (authorisation) { + messageDescriptor.id = authorisation.primaryDevicePubKey; + } + const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags; // eslint-disable-next-line no-bitwise const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE); diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index ea52a6822..c2fa998ef 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -3,7 +3,8 @@ Whisper, ConversationController, MessageController, - _ + _, + libloki, */ /* eslint-disable more/no-then */ @@ -34,6 +35,15 @@ if (messages.length === 0) { return null; } + + const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey( + source + ); + if (authorisation) { + // eslint-disable-next-line no-param-reassign + source = authorisation.primaryDevicePubKey; + } + const message = messages.find( item => !item.isIncoming() && source === item.get('conversationId') ); diff --git a/js/models/messages.js b/js/models/messages.js index c0301ec05..49084a411 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -10,7 +10,8 @@ Signal, textsecure, Whisper, - clipboard + clipboard, + libloki, */ /* eslint-disable more/no-then */ @@ -443,6 +444,8 @@ await window.Signal.Data.saveMessage(this.attributes, { Message: Whisper.Message, }); + const pubKey = this.get('conversationId'); + await libloki.storage.saveAllPairingAuthorisationsFor(pubKey); conversation.onAcceptFriendRequest(); }, async declineFriendRequest() { @@ -931,6 +934,7 @@ // that contact. Otherwise, it will be a standalone entry. const errors = _.reject(allErrors, error => Boolean(error.number)); const errorsGroupedById = _.groupBy(allErrors, 'number'); + const primaryDevicePubKey = this.get('conversationId'); const finalContacts = (phoneNumbers || []).map(id => { const errorsForContact = errorsGroupedById[id]; const isOutgoingKeyError = Boolean( @@ -940,12 +944,20 @@ storage.get('unidentifiedDeliveryIndicators') && this.isUnidentifiedDelivery(id, unidentifiedLookup); + const isPrimaryDevice = id === primaryDevicePubKey; + + const contact = this.findAndFormatContact(id); + const profileName = isPrimaryDevice + ? contact.profileName + : `${contact.profileName} (Secondary Device)`; return { - ...this.findAndFormatContact(id), + ...contact, status: this.getStatus(id), errors: errorsForContact, isOutgoingKeyError, isUnidentifiedDelivery, + isPrimaryDevice, + profileName, onSendAnyway: () => this.trigger('force-send', { contact: this.findContact(id), @@ -960,7 +972,8 @@ // first; otherwise it's alphabetical const sortedContacts = _.sortBy( finalContacts, - contact => `${contact.errors ? '0' : '1'}${contact.title}` + contact => + `${contact.isPrimaryDevice ? '0' : '1'}${contact.phoneNumber}` ); return { @@ -1760,7 +1773,7 @@ return message; }, - handleDataMessage(initialMessage, confirm) { + async handleDataMessage(initialMessage, confirm) { // This function is called from the background script in a few scenarios: // 1. on an incoming message // 2. on a sent message sync'd from another device @@ -1770,9 +1783,15 @@ const source = message.get('source'); const type = message.get('type'); let conversationId = message.get('conversationId'); + const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey( + source + ); if (initialMessage.group) { conversationId = initialMessage.group.id; + } else if (authorisation) { + conversationId = authorisation.primaryDevicePubKey; } + const GROUP_TYPES = textsecure.protobuf.GroupContext.Type; const conversation = ConversationController.get(conversationId); @@ -2023,10 +2042,7 @@ } ); } - } else if ( - source !== textsecure.storage.user.getNumber() && - dataMessage.profile - ) { + } else if (dataMessage.profile) { ConversationController.getOrCreateAndWait(source, 'private').then( sender => { sender.setLokiProfile(dataMessage.profile); diff --git a/js/modules/data.d.ts b/js/modules/data.d.ts index 1cec9dcb9..29e42df98 100644 --- a/js/modules/data.d.ts +++ b/js/modules/data.d.ts @@ -1,2 +1,3 @@ export function searchMessages(query: string): Promise>; export function searchConversations(query: string): Promise>; +export function getPrimaryDeviceFor(pubKey: string): Promise; diff --git a/js/modules/data.js b/js/modules/data.js index 14c9dddac..d56b3cbf3 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -1,4 +1,4 @@ -/* global window, setTimeout, IDBKeyRange */ +/* global window, setTimeout, IDBKeyRange, dcodeIO */ const electron = require('electron'); @@ -12,6 +12,7 @@ const { merge, set, omit, + isArrayBuffer, } = require('lodash'); const { base64ToArrayBuffer, arrayBufferToBase64 } = require('./crypto'); @@ -88,6 +89,15 @@ module.exports = { removeContactSignedPreKeyByIdentityKey, removeAllContactSignedPreKeys, + createOrUpdatePairingAuthorisation, + removePairingAuthorisationForSecondaryPubKey, + getGrantAuthorisationForSecondaryPubKey, + getAuthorisationForSecondaryPubKey, + getGrantAuthorisationsForPrimaryPubKey, + getSecondaryDevicesFor, + getPrimaryDeviceFor, + getPairedDevicesFor, + createOrUpdateItem, getItemById, getAllItems, @@ -116,6 +126,7 @@ module.exports = { getAllConversations, getPubKeysWithFriendStatus, + getConversationsWithFriendStatus, getAllConversationIds, getAllPrivateConversations, getAllRssFeedConversations, @@ -177,6 +188,8 @@ module.exports = { removeAll, removeAllConfiguration, + removeAllConversations, + removeAllPrivateConversations, removeOtherData, cleanupOrphanedAttachments, @@ -576,6 +589,63 @@ async function removeAllContactSignedPreKeys() { await channels.removeAllContactSignedPreKeys(); } +function signatureToBase64(signature) { + if (signature.constructor === dcodeIO.ByteBuffer) { + return dcodeIO.ByteBuffer.wrap(signature).toString('base64'); + } else if (isArrayBuffer(signature)) { + return arrayBufferToBase64(signature); + } else if (typeof signature === 'string') { + // assume it's already base64 + return signature; + } + throw new Error( + 'Invalid signature provided in createOrUpdatePairingAuthorisation. Needs to be either ArrayBuffer or ByteBuffer.' + ); +} + +async function createOrUpdatePairingAuthorisation(data) { + const { requestSignature, grantSignature } = data; + + return channels.createOrUpdatePairingAuthorisation({ + ...data, + requestSignature: signatureToBase64(requestSignature), + grantSignature: grantSignature ? signatureToBase64(grantSignature) : null, + }); +} + +async function removePairingAuthorisationForSecondaryPubKey(pubKey) { + if (!pubKey) { + return; + } + await channels.removePairingAuthorisationForSecondaryPubKey(pubKey); +} + +async function getGrantAuthorisationForSecondaryPubKey(pubKey) { + return channels.getAuthorisationForSecondaryPubKey(pubKey, { + granted: true, + }); +} + +async function getGrantAuthorisationsForPrimaryPubKey(pubKey) { + return channels.getGrantAuthorisationsForPrimaryPubKey(pubKey); +} + +function getAuthorisationForSecondaryPubKey(pubKey) { + return channels.getAuthorisationForSecondaryPubKey(pubKey); +} + +function getSecondaryDevicesFor(primaryDevicePubKey) { + return channels.getSecondaryDevicesFor(primaryDevicePubKey); +} + +function getPrimaryDeviceFor(secondaryDevicePubKey) { + return channels.getPrimaryDeviceFor(secondaryDevicePubKey); +} + +function getPairedDevicesFor(pubKey) { + return channels.getPairedDevicesFor(pubKey); +} + // Items const ITEM_KEYS = { @@ -730,8 +800,20 @@ async function _removeConversations(ids) { await channels.removeConversation(ids); } +async function getConversationsWithFriendStatus( + status, + { ConversationCollection } +) { + const conversations = await channels.getConversationsWithFriendStatus(status); + + const collection = new ConversationCollection(); + collection.add(conversations); + return collection; +} + async function getPubKeysWithFriendStatus(status) { - return channels.getPubKeysWithFriendStatus(status); + const conversations = await getConversationsWithFriendStatus(status); + return conversations.map(row => row.id); } async function getAllConversations({ ConversationCollection }) { @@ -1108,6 +1190,14 @@ async function removeAllConfiguration() { await channels.removeAllConfiguration(); } +async function removeAllConversations() { + await channels.removeAllConversations(); +} + +async function removeAllPrivateConversations() { + await channels.removeAllPrivateConversations(); +} + async function cleanupOrphanedAttachments() { await callChannel(CLEANUP_ORPHANED_ATTACHMENTS_KEY); } diff --git a/js/modules/loki_app_dot_net_api.js b/js/modules/loki_app_dot_net_api.js index 968d838e5..12155cc8a 100644 --- a/js/modules/loki_app_dot_net_api.js +++ b/js/modules/loki_app_dot_net_api.js @@ -19,6 +19,10 @@ class LokiAppDotNetAPI extends EventEmitter { this.myPrivateKey = false; } + async close() { + await Promise.all(this.servers.map(server => server.close())); + } + async getPrivateKey() { if (!this.myPrivateKey) { const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair(); @@ -89,6 +93,13 @@ class LokiAppDotNetServerAPI { })(); } + async close() { + this.channels.forEach(channel => channel.stop()); + if (this.tokenPromise) { + await this.tokenPromise; + } + } + // channel getter/factory findOrCreateChannel(channelId, conversationId) { let thisChannel = this.channels.find( diff --git a/js/modules/loki_file_server_api.js b/js/modules/loki_file_server_api.js index e460b8972..ed12c61ad 100644 --- a/js/modules/loki_file_server_api.js +++ b/js/modules/loki_file_server_api.js @@ -1,3 +1,6 @@ +/* global storage: false */ +/* global Signal: false */ + const LokiAppDotNetAPI = require('./loki_app_dot_net_api'); const DEVICE_MAPPING_ANNOTATION_KEY = 'network.loki.messenger.devicemapping'; @@ -16,17 +19,33 @@ class LokiFileServerAPI { async getUserDeviceMapping(pubKey) { const annotations = await this._server.getUserAnnotations(pubKey); - return annotations.find( + const deviceMapping = annotations.find( annotation => annotation.type === DEVICE_MAPPING_ANNOTATION_KEY ); + return deviceMapping ? deviceMapping.value : null; + } + + async updateOurDeviceMapping() { + const isPrimary = !storage.get('isSecondaryDevice'); + let authorisations; + if (isPrimary) { + authorisations = await Signal.Data.getGrantAuthorisationsForPrimaryPubKey( + this.ourKey + ); + } else { + authorisations = [ + await Signal.Data.getGrantAuthorisationForSecondaryPubKey(this.ourKey), + ]; + } + return this._setOurDeviceMapping(authorisations, isPrimary); } async getDeviceMappingForUsers(pubKeys) { const users = await this._server.getUsersAnnotations(pubKeys); - return users + return users; } - setOurDeviceMapping(authorisations, isPrimary) { + _setOurDeviceMapping(authorisations, isPrimary) { const content = { isPrimary: isPrimary ? '1' : '0', authorisations, diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js index ba794adca..36d051c98 100644 --- a/js/modules/loki_message_api.js +++ b/js/modules/loki_message_api.js @@ -39,6 +39,9 @@ const calcNonce = (messageEventData, pubKey, data64, timestamp, ttl) => { }; const trySendP2p = async (pubKey, data64, isPing, messageEventData) => { + if (typeof lokiP2pAPI === 'undefined') { + return false; + } const p2pDetails = lokiP2pAPI.getContactP2pDetails(pubKey); if (!p2pDetails || (!isPing && !p2pDetails.isOnline)) { return false; diff --git a/js/registration.js b/js/registration.js index 499e981bf..301e8cb88 100644 --- a/js/registration.js +++ b/js/registration.js @@ -21,6 +21,9 @@ storage.get('chromiumRegistrationDone') === '' ); }, + ongoingSecondaryDeviceRegistration() { + return storage.get('secondaryDeviceStatus') === 'ongoing'; + }, remove() { storage.remove('chromiumRegistrationDone'); }, diff --git a/js/rotate_signed_prekey_listener.js b/js/rotate_signed_prekey_listener.js index 98617ecda..fe0725f29 100644 --- a/js/rotate_signed_prekey_listener.js +++ b/js/rotate_signed_prekey_listener.js @@ -8,6 +8,7 @@ const ROTATION_INTERVAL = 48 * 60 * 60 * 1000; let timeout; let scheduledTime; + let shouldStop = false; function scheduleNextRotation() { const now = Date.now(); @@ -16,6 +17,9 @@ } function run() { + if (shouldStop) { + return; + } window.log.info('Rotating signed prekey...'); getAccountManager() .rotateSignedPreKey() @@ -64,7 +68,11 @@ clearTimeout(timeout); timeout = setTimeout(runWhenOnline, waitTime); } - + function onTimeTravel() { + if (Whisper.Registration.isDone()) { + setTimeoutForNextRun(); + } + } let initComplete; Whisper.RotateSignedPreKeyListener = { init(events, newVersion) { @@ -73,6 +81,7 @@ return; } initComplete = true; + shouldStop = false; if (newVersion) { runWhenOnline(); @@ -80,11 +89,13 @@ setTimeoutForNextRun(); } - events.on('timetravel', () => { - if (Whisper.Registration.isDone()) { - setTimeoutForNextRun(); - } - }); + events.on('timetravel', onTimeTravel); + }, + stop(events) { + initComplete = false; + shouldStop = true; + events.off('timetravel', onTimeTravel); + clearTimeout(timeout); }, }; })(); diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index 33d07d2f1..631433d81 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -39,6 +39,21 @@ return false; } + function convertVerifiedStatusToProtoState(status) { + switch (status) { + case VerifiedStatus.VERIFIED: + return textsecure.protobuf.Verified.State.VERIFIED; + + case VerifiedStatus.UNVERIFIED: + return textsecure.protobuf.Verified.State.VERIFIED; + + case VerifiedStatus.DEFAULT: + // intentional fallthrough + default: + return textsecure.protobuf.Verified.State.DEFAULT; + } + } + const StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__; const StaticArrayBufferProto = new ArrayBuffer().__proto__; const StaticUint8ArrayProto = new Uint8Array().__proto__; @@ -913,4 +928,5 @@ window.SignalProtocolStore = SignalProtocolStore; window.SignalProtocolStore.prototype.Direction = Direction; window.SignalProtocolStore.prototype.VerifiedStatus = VerifiedStatus; + window.SignalProtocolStore.prototype.convertVerifiedStatusToProtoState = convertVerifiedStatusToProtoState; })(); diff --git a/js/views/app_view.js b/js/views/app_view.js index 31235bf9f..ad01d38c3 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -200,5 +200,29 @@ const dialog = new Whisper.QRDialogView({ string }); this.el.append(dialog.el); }, + showDevicePairingDialog() { + const dialog = new Whisper.DevicePairingDialogView(); + + dialog.on('startReceivingRequests', () => { + Whisper.events.on('devicePairingRequestReceived', pubKey => + dialog.requestReceived(pubKey) + ); + }); + + dialog.on('stopReceivingRequests', () => { + Whisper.events.off('devicePairingRequestReceived'); + }); + + dialog.once('devicePairingRequestAccepted', (pubKey, cb) => + Whisper.events.trigger('devicePairingRequestAccepted', pubKey, cb) + ); + dialog.on('devicePairingRequestRejected', pubKey => + Whisper.events.trigger('devicePairingRequestRejected', pubKey) + ); + dialog.once('close', () => { + Whisper.events.off('devicePairingRequestReceived'); + }); + this.el.append(dialog.el); + }, }); })(); diff --git a/js/views/device_pairing_dialog_view.js b/js/views/device_pairing_dialog_view.js new file mode 100644 index 000000000..0ab194776 --- /dev/null +++ b/js/views/device_pairing_dialog_view.js @@ -0,0 +1,136 @@ +/* global Whisper, i18n, libloki, textsecure */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Whisper = window.Whisper || {}; + + Whisper.DevicePairingDialogView = Whisper.View.extend({ + className: 'loki-dialog device-pairing-dialog modal', + templateName: 'device-pairing-dialog', + initialize() { + this.pubKeyRequests = []; + this.reset(); + this.render(); + this.showView(); + }, + reset() { + this.pubKey = null; + this.accepted = false; + this.isListening = false; + }, + events: { + 'click #startPairing': 'startReceivingRequests', + 'click #close': 'close', + 'click .waitingForRequestView .cancel': 'stopReceivingRequests', + 'click .requestReceivedView .skip': 'skipDevice', + 'click #allowPairing': 'allowDevice', + 'click .requestAcceptedView .ok': 'stopReceivingRequests', + }, + render_attributes() { + return { + defaultTitle: i18n('pairedDevices'), + waitingForRequestTitle: i18n('waitingForDeviceToRegister'), + requestReceivedTitle: i18n('devicePairingReceived'), + requestAcceptedTitle: i18n('devicePairingAccepted'), + startPairingText: i18n('pairNewDevice'), + cancelText: i18n('cancel'), + closeText: i18n('close'), + skipText: i18n('skip'), + okText: i18n('ok'), + allowPairingText: i18n('allowPairing'), + }; + }, + startReceivingRequests() { + this.trigger('startReceivingRequests'); + this.isListening = true; + this.showView(); + }, + stopReceivingRequests() { + this.trigger('stopReceivingRequests'); + this.reset(); + this.showView(); + }, + requestReceived(secondaryDevicePubKey) { + // FIFO: push at the front of the array with unshift() + this.pubKeyRequests.unshift(secondaryDevicePubKey); + if (!this.pubKey) { + this.nextPubKey(); + this.showView('requestReceived'); + } + }, + allowDevice() { + this.accepted = true; + this.trigger('devicePairingRequestAccepted', this.pubKey, errors => + this.transmisssionCB(errors) + ); + this.showView(); + }, + transmisssionCB(errors) { + if (!errors) { + this.$('.transmissionStatus').text(i18n('sent')); + } else { + this.$('.transmissionStatus').text(errors); + } + this.$('.requestAcceptedView .ok').show(); + }, + skipDevice() { + this.trigger('devicePairingRequestRejected', this.pubKey); + this.nextPubKey(); + this.showView(); + }, + nextPubKey() { + // FIFO: pop at the back of the array using pop() + this.pubKey = this.pubKeyRequests.pop(); + }, + async showView() { + const defaultView = this.$('.defaultView'); + const waitingForRequestView = this.$('.waitingForRequestView'); + const requestReceivedView = this.$('.requestReceivedView'); + const requestAcceptedView = this.$('.requestAcceptedView'); + if (!this.isListening) { + const ourPubKey = textsecure.storage.user.getNumber(); + defaultView.show(); + requestReceivedView.hide(); + waitingForRequestView.hide(); + requestAcceptedView.hide(); + const pubKeys = await libloki.storage.getSecondaryDevicesFor(ourPubKey); + if (pubKeys && pubKeys.length > 0) { + this.$('#pairedPubKeys').empty(); + pubKeys.forEach(x => { + this.$('#pairedPubKeys').append(`
  • ${x}
  • `); + }); + } + } else if (this.accepted) { + defaultView.hide(); + requestReceivedView.hide(); + waitingForRequestView.hide(); + requestAcceptedView.show(); + } else if (this.pubKey) { + const secretWords = window.mnemonic + .mn_encode(this.pubKey.slice(2), 'english') + .split(' ') + .slice(-3) + .join(' '); + this.$('.secretWords').text(secretWords); + requestReceivedView.show(); + waitingForRequestView.hide(); + requestAcceptedView.hide(); + defaultView.hide(); + } else { + waitingForRequestView.show(); + requestReceivedView.hide(); + requestAcceptedView.hide(); + defaultView.hide(); + } + }, + close() { + this.remove(); + if (this.pubKey && !this.accepted) { + this.trigger('devicePairingRequestRejected', this.pubKey); + } + this.trigger('close'); + }, + }); +})(); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 01bb43136..9fa7c703b 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -156,6 +156,7 @@ user: { regionCode: window.storage.get('regionCode'), ourNumber: textsecure.storage.user.getNumber(), + isSecondaryDevice: !!window.storage.get('isSecondaryDevice'), i18n: window.i18n, }, }; diff --git a/js/views/standalone_registration_view.js b/js/views/standalone_registration_view.js index 027bc0815..20c5298d5 100644 --- a/js/views/standalone_registration_view.js +++ b/js/views/standalone_registration_view.js @@ -1,4 +1,11 @@ -/* global Whisper, $, getAccountManager, textsecure, i18n, passwordUtil, _ */ +/* global Whisper, + $, + getAccountManager, + textsecure, + i18n, + passwordUtil, + _, +*/ /* eslint-disable more/no-then */ @@ -13,6 +20,8 @@ className: 'full-screen-flow standalone-fullscreen', initialize() { this.accountManager = getAccountManager(); + // Clean status in case the app closed unexpectedly + textsecure.storage.remove('secondaryDeviceStatus'); this.render(); @@ -26,6 +35,7 @@ this.$('#error').hide(); this.$('.standalone-mnemonic').hide(); + this.$('.standalone-secondary-device').hide(); this.onGenerateMnemonic(); @@ -49,9 +59,14 @@ this.registrationParams = {}; this.$pages = this.$('.page'); + this.pairingInterval = null; this.showRegisterPage(); this.onValidatePassword(); + + this.onSecondaryDeviceRegistered = this.onSecondaryDeviceRegistered.bind( + this + ); }, events: { 'validation input.number': 'onValidation', @@ -60,6 +75,8 @@ 'change #code': 'onChangeCode', 'click #register': 'registerWithoutMnemonic', 'click #register-mnemonic': 'registerWithMnemonic', + 'click #register-secondary-device': 'registerSecondaryDevice', + 'click #cancel-secondary-device': 'cancelSecondaryDevice', 'click #back-button': 'onBack', 'click #save-button': 'onSaveProfile', 'change #mnemonic': 'onChangeMnemonic', @@ -103,7 +120,12 @@ const input = this.trim(this.$passwordInput.val()); + // Ensure we clear the secondary device registration status + textsecure.storage.remove('secondaryDeviceStatus'); + try { + await this.resetRegistration(); + await window.setPassword(input); await this.accountManager.registerSingleDevice( mnemonic, @@ -123,6 +145,101 @@ const language = this.$('#mnemonic-display-language').val(); this.showProfilePage(mnemonic, language); }, + async onSecondaryDeviceRegistered() { + clearInterval(this.pairingInterval); + // Ensure the left menu is updated + Whisper.events.trigger('userChanged', { isSecondaryDevice: true }); + // will re-run the background initialisation + Whisper.events.trigger('registration_done'); + this.$el.trigger('openInbox'); + }, + async resetRegistration() { + await window.Signal.Data.removeAllIdentityKeys(); + await window.Signal.Data.removeAllPrivateConversations(); + Whisper.Registration.remove(); + // Do not remove all items since they are only set + // at startup. + textsecure.storage.remove('identityKey'); + textsecure.storage.remove('secondaryDeviceStatus'); + window.ConversationController.reset(); + await window.ConversationController.load(); + Whisper.RotateSignedPreKeyListener.stop(Whisper.events); + }, + async cancelSecondaryDevice() { + Whisper.events.off( + 'secondaryDeviceRegistration', + this.onSecondaryDeviceRegistered + ); + this.$('#register-secondary-device') + .removeAttr('disabled') + .text('Link'); + this.$('#cancel-secondary-device').hide(); + this.$('.standalone-secondary-device #pubkey').text(''); + await this.resetRegistration(); + }, + async registerSecondaryDevice() { + if (textsecure.storage.get('secondaryDeviceStatus') === 'ongoing') { + return; + } + await this.resetRegistration(); + textsecure.storage.put('secondaryDeviceStatus', 'ongoing'); + this.$('#register-secondary-device') + .attr('disabled', 'disabled') + .text('Sending...'); + this.$('#cancel-secondary-device').show(); + const mnemonic = this.$('#mnemonic-display').text(); + const language = this.$('#mnemonic-display-language').val(); + const primaryPubKey = this.$('#primary-pubkey').val(); + this.$('.standalone-secondary-device #error').hide(); + // Ensure only one listener + Whisper.events.off( + 'secondaryDeviceRegistration', + this.onSecondaryDeviceRegistered + ); + Whisper.events.once( + 'secondaryDeviceRegistration', + this.onSecondaryDeviceRegistered + ); + const onError = async error => { + this.$('.standalone-secondary-device #error') + .text(error) + .show(); + await this.resetRegistration(); + this.$('#register-secondary-device') + .removeAttr('disabled') + .text('Link'); + this.$('#cancel-secondary-device').hide(); + }; + const c = new Whisper.Conversation({ + id: primaryPubKey, + type: 'private', + }); + const validationError = c.validateNumber(); + if (validationError) { + onError('Invalid public key'); + return; + } + try { + await this.accountManager.registerSingleDevice( + mnemonic, + language, + null + ); + await this.accountManager.requestPairing(primaryPubKey); + const pubkey = textsecure.storage.user.getNumber(); + const words = window.mnemonic + .mn_encode(pubkey.slice(2), 'english') + .split(' ') + .slice(-3) + .join(' '); + + this.$('.standalone-secondary-device #pubkey').text( + `Here is your secret:\n${words}` + ); + } catch (e) { + onError(e); + } + }, registerWithMnemonic() { const mnemonic = this.$('#mnemonic').val(); const language = this.$('#mnemonic-language').val(); diff --git a/libloki/api.js b/libloki/api.js index ec5ef5688..095f2b8be 100644 --- a/libloki/api.js +++ b/libloki/api.js @@ -1,4 +1,4 @@ -/* global window, textsecure, log */ +/* global window, textsecure, log, Whisper, dcodeIO, StringView */ // eslint-disable-next-line func-names (function() { @@ -65,9 +65,155 @@ await outgoingMessage.sendToNumber(pubKey); } + function createPairingAuthorisationProtoMessage({ + primaryDevicePubKey, + secondaryDevicePubKey, + requestSignature, + grantSignature, + type, + }) { + if ( + !primaryDevicePubKey || + !secondaryDevicePubKey || + !requestSignature || + typeof type !== 'number' + ) { + throw new Error( + 'createPairingAuthorisationProtoMessage: pubkeys or type is not set' + ); + } + if (requestSignature.constructor !== ArrayBuffer) { + throw new Error( + 'createPairingAuthorisationProtoMessage expects a signature as ArrayBuffer' + ); + } + if (grantSignature && grantSignature.constructor !== ArrayBuffer) { + throw new Error( + 'createPairingAuthorisationProtoMessage expects a signature as ArrayBuffer' + ); + } + return new textsecure.protobuf.PairingAuthorisationMessage({ + requestSignature: new Uint8Array(requestSignature), + grantSignature: grantSignature ? new Uint8Array(grantSignature) : null, + primaryDevicePubKey, + secondaryDevicePubKey, + type, + }); + } + // Serialise as ... + // This is an implementation of the reciprocal of contacts_parser.js + function serialiseByteBuffers(buffers) { + const result = new dcodeIO.ByteBuffer(); + buffers.forEach(buffer => { + // bytebuffer container expands and increments + // offset automatically + result.writeVarint32(buffer.limit); + result.append(buffer); + }); + result.limit = result.offset; + result.reset(); + return result; + } + async function createContactSyncProtoMessage() { + const conversations = await window.Signal.Data.getConversationsWithFriendStatus( + window.friends.friendRequestStatusEnum.friends, + { ConversationCollection: Whisper.ConversationCollection } + ); + // Extract required contacts information out of conversations + const rawContacts = conversations.map(conversation => { + const profile = conversation.getLokiProfile(); + const number = conversation.getNumber(); + const name = profile + ? profile.displayName + : conversation.getProfileName(); + const status = conversation.safeGetVerified(); + const protoState = textsecure.storage.protocol.convertVerifiedStatusToProtoState( + status + ); + const verified = new textsecure.protobuf.Verified({ + state: protoState, + destination: number, + identityKey: StringView.hexToArrayBuffer(number), + }); + return { + name, + verified, + number, + nickname: conversation.getNickname(), + blocked: conversation.isBlocked(), + expireTimer: conversation.get('expireTimer'), + }; + }); + // Convert raw contacts to an array of buffers + const contactDetails = rawContacts + .filter(x => x.number !== textsecure.storage.user.getNumber()) + .map(x => new textsecure.protobuf.ContactDetails(x)) + .map(x => x.encode()); + // Serialise array of byteBuffers into 1 byteBuffer + const byteBuffer = serialiseByteBuffers(contactDetails); + const data = new Uint8Array(byteBuffer.toArrayBuffer()); + const contacts = new textsecure.protobuf.SyncMessage.Contacts({ + data, + }); + const syncMessage = new textsecure.protobuf.SyncMessage({ + contacts, + }); + return syncMessage; + } + async function sendPairingAuthorisation(authorisation, recipientPubKey) { + const pairingAuthorisation = createPairingAuthorisationProtoMessage( + authorisation + ); + // Send profile name to secondary device + const ourNumber = textsecure.storage.user.getNumber(); + const conversation = await window.ConversationController.getOrCreateAndWait( + ourNumber, + 'private' + ); + const lokiProfile = conversation.getLokiProfile(); + const profile = new textsecure.protobuf.DataMessage.LokiProfile( + lokiProfile + ); + const dataMessage = new textsecure.protobuf.DataMessage({ + profile, + }); + // Attach contact list + const syncMessage = await createContactSyncProtoMessage(); + const content = new textsecure.protobuf.Content({ + pairingAuthorisation, + dataMessage, + syncMessage, + }); + // Send + const options = { messageType: 'pairing-request' }; + const p = new Promise((resolve, reject) => { + const outgoingMessage = new textsecure.OutgoingMessage( + null, // server + Date.now(), // timestamp, + [recipientPubKey], // numbers + content, // message + true, // silent + result => { + // callback + if (result.errors.length > 0) { + reject(result.errors[0]); + } else { + resolve(); + } + }, + options + ); + outgoingMessage.sendToNumber(recipientPubKey); + }); + return p; + } + window.libloki.api = { sendBackgroundMessage, sendOnlineBroadcastMessage, broadcastOnlineStatus, + sendPairingAuthorisation, + createPairingAuthorisationProtoMessage, + createContactSyncProtoMessage, }; })(); diff --git a/libloki/crypto.js b/libloki/crypto.js index f8ad8d7f2..a1f85d113 100644 --- a/libloki/crypto.js +++ b/libloki/crypto.js @@ -158,6 +158,125 @@ } } + async function generateSignatureForPairing(secondaryPubKey, type) { + const pubKeyArrayBuffer = StringView.hexToArrayBuffer(secondaryPubKey); + // Make sure the signature includes the pairing action (pairing or unpairing) + const len = pubKeyArrayBuffer.byteLength; + const data = new Uint8Array(len + 1); + data.set(new Uint8Array(pubKeyArrayBuffer), 0); + data[len] = type; + + const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair(); + const signature = await libsignal.Curve.async.calculateSignature( + myKeyPair.privKey, + data.buffer + ); + return signature; + } + + async function validateAuthorisation(authorisation) { + const { + primaryDevicePubKey, + secondaryDevicePubKey, + requestSignature, + grantSignature, + } = authorisation; + const alreadySecondaryDevice = !!window.storage.get('isSecondaryDevice'); + const ourPubKey = textsecure.storage.user.getNumber(); + const isRequest = !grantSignature; + const isGrant = !!grantSignature; + if (!primaryDevicePubKey || !secondaryDevicePubKey) { + window.log.warn( + 'Received a pairing request with missing pubkeys. Ignored.' + ); + return false; + } else if (!requestSignature) { + window.log.warn( + 'Received a pairing request with missing request signature. Ignored.' + ); + return false; + } else if (isRequest && alreadySecondaryDevice) { + window.log.warn( + 'Received a pairing request while being a secondary device. Ignored.' + ); + return false; + } else if (isRequest && authorisation.primaryDevicePubKey !== ourPubKey) { + window.log.warn( + 'Received a pairing request addressed to another pubkey. Ignored.' + ); + return false; + } else if (isRequest && authorisation.secondaryDevicePubKey === ourPubKey) { + window.log.warn('Received a pairing request from ourselves. Ignored.'); + return false; + } + const verify = async (signature, signatureType) => { + const encoding = typeof signature === 'string' ? 'base64' : undefined; + await this.verifyPairingSignature( + primaryDevicePubKey, + secondaryDevicePubKey, + dcodeIO.ByteBuffer.wrap(signature, encoding).toArrayBuffer(), + signatureType + ); + }; + try { + await verify( + requestSignature, + textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST + ); + } catch (e) { + window.log.warn( + 'Could not verify pairing request authorisation signature. Ignoring message.' + ); + window.log.error(e); + return false; + } + if (isGrant) { + try { + await verify( + grantSignature, + textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT + ); + } catch (e) { + window.log.warn( + 'Could not verify pairing grant authorisation signature. Ignoring message.' + ); + window.log.error(e); + return false; + } + } + return true; + } + + async function verifyPairingSignature( + primaryDevicePubKey, + secondaryPubKey, + signature, + type + ) { + const secondaryPubKeyArrayBuffer = StringView.hexToArrayBuffer( + secondaryPubKey + ); + const primaryDevicePubKeyArrayBuffer = StringView.hexToArrayBuffer( + primaryDevicePubKey + ); + const len = secondaryPubKeyArrayBuffer.byteLength; + const data = new Uint8Array(len + 1); + // For REQUEST type message, the secondary device signs the primary device pubkey + // For GRANT type message, the primary device signs the secondary device pubkey + let issuer; + if (type === textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT) { + data.set(new Uint8Array(secondaryPubKeyArrayBuffer)); + issuer = primaryDevicePubKeyArrayBuffer; + } else if ( + type === textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST + ) { + data.set(new Uint8Array(primaryDevicePubKeyArrayBuffer)); + issuer = secondaryPubKeyArrayBuffer; + } + data[len] = type; + // Throws for invalid signature + await libsignal.Curve.async.verifySignature(issuer, data.buffer, signature); + } async function decryptToken({ cipherText64, serverPubKey64 }) { const ivAndCiphertext = new Uint8Array( dcodeIO.ByteBuffer.fromBase64(cipherText64).toArrayBuffer() @@ -177,7 +296,6 @@ const tokenString = dcodeIO.ByteBuffer.wrap(token).toString('utf8'); return tokenString; } - const snodeCipher = new LokiSnodeChannel(); window.libloki.crypto = { @@ -187,6 +305,9 @@ FallBackDecryptionError, snodeCipher, decryptToken, + generateSignatureForPairing, + verifyPairingSignature, + validateAuthorisation, // for testing _LokiSnodeChannel: LokiSnodeChannel, _decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey, diff --git a/libloki/storage.js b/libloki/storage.js index 8d61881bd..733d3df92 100644 --- a/libloki/storage.js +++ b/libloki/storage.js @@ -1,4 +1,4 @@ -/* global window, libsignal, textsecure */ +/* global window, libsignal, textsecure, Signal, lokiFileServerAPI */ // eslint-disable-next-line func-names (function() { @@ -113,11 +113,112 @@ } } + // fetches device mappings from server. + async function getPrimaryDeviceMapping(pubKey) { + const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping(pubKey); + if (!deviceMapping) { + return []; + } + let { authorisations } = deviceMapping; + if (!authorisations) { + return []; + } + if (deviceMapping.isPrimary !== '1') { + const { primaryDevicePubKey } = authorisations.find( + authorisation => authorisation.secondaryDevicePubKey === pubKey + ); + if (primaryDevicePubKey) { + // do NOT call getprimaryDeviceMapping recursively + // in case both devices are out of sync and think they are + // each others' secondary pubkey. + ({ authorisations } = await lokiFileServerAPI.getUserDeviceMapping( + primaryDevicePubKey + )); + } + } + return authorisations || []; + } + // if the device is a secondary device, + // fetch the device mappings for its primary device + async function saveAllPairingAuthorisationsFor(pubKey) { + const authorisations = await getPrimaryDeviceMapping(pubKey); + await Promise.all( + authorisations.map(authorisation => + savePairingAuthorisation(authorisation) + ) + ); + } + + function savePairingAuthorisation(authorisation) { + return window.Signal.Data.createOrUpdatePairingAuthorisation(authorisation); + } + + function removePairingAuthorisationForSecondaryPubKey(pubKey) { + return window.Signal.Data.removePairingAuthorisationForSecondaryPubKey( + pubKey + ); + } + + // Transforms signatures from base64 to ArrayBuffer! + async function getGrantAuthorisationForSecondaryPubKey(secondaryPubKey) { + const authorisation = await window.Signal.Data.getGrantAuthorisationForSecondaryPubKey( + secondaryPubKey + ); + if (!authorisation) { + return null; + } + return { + ...authorisation, + requestSignature: Signal.Crypto.base64ToArrayBuffer( + authorisation.requestSignature + ), + grantSignature: Signal.Crypto.base64ToArrayBuffer( + authorisation.grantSignature + ), + }; + } + + // Transforms signatures from base64 to ArrayBuffer! + async function getAuthorisationForSecondaryPubKey(secondaryPubKey) { + const authorisation = await window.Signal.Data.getAuthorisationForSecondaryPubKey( + secondaryPubKey + ); + if (!authorisation) { + return null; + } + return { + ...authorisation, + requestSignature: Signal.Crypto.base64ToArrayBuffer( + authorisation.requestSignature + ), + grantSignature: authorisation.grantSignature + ? Signal.Crypto.base64ToArrayBuffer(authorisation.grantSignature) + : null, + }; + } + + function getSecondaryDevicesFor(primaryDevicePubKey) { + return window.Signal.Data.getSecondaryDevicesFor(primaryDevicePubKey); + } + + async function getAllDevicePubKeysForPrimaryPubKey(primaryDevicePubKey) { + const secondaryPubKeys = + (await getSecondaryDevicesFor(primaryDevicePubKey)) || []; + return secondaryPubKeys.concat(primaryDevicePubKey); + } + window.libloki.storage = { getPreKeyBundleForContact, saveContactPreKeyBundle, removeContactPreKeyBundle, verifyFriendRequestAcceptPreKey, + savePairingAuthorisation, + saveAllPairingAuthorisationsFor, + removePairingAuthorisationForSecondaryPubKey, + getGrantAuthorisationForSecondaryPubKey, + getAuthorisationForSecondaryPubKey, + getAllDevicePubKeysForPrimaryPubKey, + getSecondaryDevicesFor, }; // Libloki protocol store diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index e12f5db41..73b458f02 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -2,6 +2,8 @@ window, textsecure, libsignal, + libloki, + lokiFileServerAPI, mnemonic, btoa, Signal, @@ -11,7 +13,8 @@ StringView, log, Event, - ConversationController + ConversationController, + Whisper */ /* eslint-disable more/no-then */ @@ -552,6 +555,90 @@ this.dispatchEvent(new Event('registration')); }, + async requestPairing(primaryDevicePubKey) { + // throws if invalid + this.validatePubKeyHex(primaryDevicePubKey); + // we need a conversation for sending a message + await ConversationController.getOrCreateAndWait( + primaryDevicePubKey, + 'private' + ); + const ourPubKey = textsecure.storage.user.getNumber(); + if (primaryDevicePubKey === ourPubKey) { + throw new Error('Cannot request to pair with ourselves'); + } + const requestType = + textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST; + const requestSignature = await libloki.crypto.generateSignatureForPairing( + primaryDevicePubKey, + requestType + ); + const authorisation = { + primaryDevicePubKey, + secondaryDevicePubKey: ourPubKey, + requestSignature, + type: requestType, + }; + await libloki.api.sendPairingAuthorisation( + authorisation, + primaryDevicePubKey + ); + }, + async authoriseSecondaryDevice(secondaryDevicePubKey) { + const ourPubKey = textsecure.storage.user.getNumber(); + if (secondaryDevicePubKey === ourPubKey) { + throw new Error( + 'Cannot register primary device pubkey as secondary device' + ); + } + + // throws if invalid + this.validatePubKeyHex(secondaryDevicePubKey); + // we need a conversation for sending a message + await ConversationController.getOrCreateAndWait( + secondaryDevicePubKey, + 'private' + ); + const grantType = + textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT; + const grantSignature = await libloki.crypto.generateSignatureForPairing( + secondaryDevicePubKey, + grantType + ); + const existingAuthorisation = await libloki.storage.getAuthorisationForSecondaryPubKey( + secondaryDevicePubKey + ); + if (!existingAuthorisation) { + throw new Error( + 'authoriseSecondaryDevice: request signature missing from database!' + ); + } + const { requestSignature } = existingAuthorisation; + const authorisation = { + primaryDevicePubKey: ourPubKey, + secondaryDevicePubKey, + requestSignature, + grantSignature, + type: grantType, + }; + // Update authorisation in database with the new grant signature + await libloki.storage.savePairingAuthorisation(authorisation); + await lokiFileServerAPI.updateOurDeviceMapping(); + await libloki.api.sendPairingAuthorisation( + authorisation, + secondaryDevicePubKey + ); + }, + validatePubKeyHex(pubKey) { + const c = new Whisper.Conversation({ + id: pubKey, + type: 'private', + }); + const validationError = c.validateNumber(); + if (validationError) { + throw new Error(validationError); + } + }, }); textsecure.AccountManager = AccountManager; })(); diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index c7454c8e5..bac52c8eb 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -18,6 +18,8 @@ /* global lokiMessageAPI: false */ /* global lokiP2pAPI: false */ /* global feeds: false */ +/* global Whisper: false */ +/* global lokiFileServerAPI: false */ /* eslint-disable more/no-then */ /* eslint-disable no-unreachable */ @@ -76,11 +78,15 @@ MessageReceiver.prototype.extend({ handleRequest: this.handleRequest.bind(this), }); this.httpPollingResource.pollServer(); - localLokiServer.on('message', this.handleP2pMessage.bind(this)); - lokiPublicChatAPI.on( - 'publicMessage', - this.handleUnencryptedMessage.bind(this) - ); + if (localLokiServer) { + localLokiServer.on('message', this.handleP2pMessage.bind(this)); + } + if (lokiPublicChatAPI) { + lokiPublicChatAPI.on( + 'publicMessage', + this.handleUnencryptedMessage.bind(this) + ); + } // set up pollers for any RSS feeds feeds.forEach(feed => { feed.on('rssMessage', this.handleUnencryptedMessage.bind(this)); @@ -117,6 +123,9 @@ MessageReceiver.prototype.extend({ this.incoming = [this.pending]; }, async startLocalServer() { + if (!localLokiServer) { + return; + } try { // clearnet change: getMyLokiIp -> getMyClearIp // const myLokiIp = await window.lokiSnodeAPI.getMyLokiIp(); @@ -183,7 +192,7 @@ MessageReceiver.prototype.extend({ ); } }, - close() { + async close() { window.log.info('MessageReceiver.close()'); this.calledClose = true; @@ -197,6 +206,10 @@ MessageReceiver.prototype.extend({ localLokiServer.close(); } + if (lokiPublicChatAPI) { + await lokiPublicChatAPI.close(); + } + if (this.httpPollingResource) { this.httpPollingResource.close(); } @@ -1046,6 +1059,166 @@ MessageReceiver.prototype.extend({ } return this.removeFromCache(envelope); }, + async handlePairingRequest(envelope, pairingRequest) { + const valid = await libloki.crypto.validateAuthorisation(pairingRequest); + if (valid) { + // Pairing dialog is open and is listening + if (Whisper.events.isListenedTo('devicePairingRequestReceived')) { + await window.libloki.storage.savePairingAuthorisation(pairingRequest); + Whisper.events.trigger( + 'devicePairingRequestReceived', + pairingRequest.secondaryDevicePubKey + ); + } + // Ignore requests if the dialog is closed + } + return this.removeFromCache(envelope); + }, + async handleAuthorisationForSelf( + envelope, + pairingAuthorisation, + { dataMessage, syncMessage } + ) { + const valid = await libloki.crypto.validateAuthorisation( + pairingAuthorisation + ); + const alreadySecondaryDevice = !!window.storage.get('isSecondaryDevice'); + let removedFromCache = false; + if (alreadySecondaryDevice) { + window.log.warn( + 'Received an unexpected pairing authorisation (device is already paired as secondary device). Ignoring.' + ); + } else if (!valid) { + window.log.warn( + 'Received invalid pairing authorisation for self. Could not verify signature. Ignoring.' + ); + } else { + const { type, primaryDevicePubKey } = pairingAuthorisation; + if (type === textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT) { + // Authorisation received to become a secondary device + window.log.info( + `Received pairing authorisation from ${primaryDevicePubKey}` + ); + await libloki.storage.savePairingAuthorisation(pairingAuthorisation); + // Set current device as secondary. + // This will ensure the authorisation is sent + // along with each friend request. + window.storage.remove('secondaryDeviceStatus'); + window.storage.put('isSecondaryDevice', true); + Whisper.events.trigger('secondaryDeviceRegistration'); + // Update profile name + if (dataMessage && dataMessage.profile) { + const ourNumber = textsecure.storage.user.getNumber(); + const me = window.ConversationController.get(ourNumber); + if (me) { + me.setLokiProfile(dataMessage.profile); + } + } + // Update contact list + if (syncMessage && syncMessage.contacts) { + // This call already removes the envelope from the cache + await this.handleContacts(envelope, syncMessage.contacts); + removedFromCache = true; + await this.sendFriendRequestsToSyncContacts(syncMessage.contacts); + } + } else { + window.log.warn('Unimplemented pairing authorisation message type'); + } + } + if (!removedFromCache) { + await this.removeFromCache(envelope); + } + }, + async sendFriendRequestsToSyncContacts(contacts) { + const attachmentPointer = await this.handleAttachment(contacts); + const contactBuffer = new ContactBuffer(attachmentPointer.data); + let contactDetails = contactBuffer.next(); + // Extract just the pubkeys + const friendPubKeys = []; + while (contactDetails !== undefined) { + friendPubKeys.push(contactDetails.number); + contactDetails = contactBuffer.next(); + } + return Promise.all( + friendPubKeys.map(async pubKey => { + const c = await window.ConversationController.getOrCreateAndWait( + pubKey, + 'private' + ); + if (!c) { + return null; + } + const attachments = []; + const quote = null; + const linkPreview = null; + // Send an empty message, the underlying logic will know + // it should send a friend request + return c.sendMessage('', attachments, quote, linkPreview); + }) + ); + }, + async handleAuthorisationForContact(envelope) { + window.log.error( + 'Unexpected pairing request/authorisation received, ignoring.' + ); + return this.removeFromCache(envelope); + }, + async handlePairingAuthorisationMessage(envelope, content) { + const { pairingAuthorisation } = content; + const { type, secondaryDevicePubKey } = pairingAuthorisation; + if (type === textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST) { + return this.handlePairingRequest(envelope, pairingAuthorisation); + } else if (secondaryDevicePubKey === textsecure.storage.user.getNumber()) { + return this.handleAuthorisationForSelf( + envelope, + pairingAuthorisation, + content + ); + } + return this.handleAuthorisationForContact(envelope); + }, + + async handleSecondaryDeviceFriendRequest(pubKey, deviceMapping) { + if (!deviceMapping) { + return false; + } + // Only handle secondary pubkeys + if (deviceMapping.isPrimary === '1' || !deviceMapping.authorisations) { + return false; + } + const { authorisations } = deviceMapping; + // Secondary devices should only have 1 authorisation from a primary device + if (authorisations.length !== 1) { + return false; + } + const authorisation = authorisations[0]; + if (!authorisation) { + return false; + } + if (!authorisation.grantSignature) { + return false; + } + const isValid = await libloki.crypto.validateAuthorisation(authorisation); + if (!isValid) { + return false; + } + const correctSender = pubKey === authorisation.secondaryDevicePubKey; + if (!correctSender) { + return false; + } + const { primaryDevicePubKey } = authorisation; + // ensure the primary device is a friend + const c = window.ConversationController.get(primaryDevicePubKey); + if (!c || !c.isFriend()) { + return false; + } + await libloki.storage.savePairingAuthorisation(authorisation); + // sending a message back = accepting friend request + window.libloki.api.sendBackgroundMessage(pubKey); + + return true; + }, + handleDataMessage(envelope, msg) { if (!envelope.isP2p) { const timestamp = envelope.timestamp.toNumber(); @@ -1085,9 +1258,25 @@ MessageReceiver.prototype.extend({ await conversation.setLokiProfile(profile); } - if (friendRequest && isMe) { - window.log.info('refusing to add a friend request to ourselves'); - throw new Error('Cannot add a friend request for ourselves!'); + if (friendRequest) { + if (isMe) { + window.log.info('refusing to add a friend request to ourselves'); + throw new Error('Cannot add a friend request for ourselves!'); + } else { + const senderPubKey = envelope.source; + // fetch the device mapping from the server + const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping( + senderPubKey + ); + // auto-accept friend request if the device is paired to one of our friend + const autoAccepted = await this.handleSecondaryDeviceFriendRequest( + senderPubKey, + deviceMapping + ); + if (autoAccepted) { + return this.removeFromCache(envelope); + } + } } if (groupId && isBlocked && !(isMe && isLeavingGroup)) { @@ -1157,6 +1346,9 @@ MessageReceiver.prototype.extend({ content.lokiAddressMessage ); } + if (content.pairingAuthorisation) { + return this.handlePairingAuthorisationMessage(envelope, content); + } if (content.syncMessage) { return this.handleSyncMessage(envelope, content.syncMessage); } @@ -1328,11 +1520,11 @@ MessageReceiver.prototype.extend({ }, handleContacts(envelope, contacts) { window.log.info('contact sync'); - const { blob } = contacts; + // const { blob } = contacts; // Note: we do not return here because we don't want to block the next message on // this attachment download and a lot of processing of that attachment. - this.handleAttachment(blob).then(attachmentPointer => { + this.handleAttachment(contacts).then(attachmentPointer => { const results = []; const contactBuffer = new ContactBuffer(attachmentPointer.data); let contactDetails = contactBuffer.next(); @@ -1435,8 +1627,8 @@ MessageReceiver.prototype.extend({ }; }, async downloadAttachment(attachment) { - window.log.info('Not downloading attachments.'); - return Promise.reject(); + // window.log.info('Not downloading attachments.'); + // return Promise.reject(); const encrypted = await this.server.getAttachment(attachment.id); const { key, digest, size } = attachment; @@ -1461,8 +1653,11 @@ MessageReceiver.prototype.extend({ }; }, handleAttachment(attachment) { - window.log.info('Not handling attachments.'); - return Promise.reject(); + // window.log.info('Not handling attachments.'); + return Promise.resolve({ + ...attachment, + data: dcodeIO.ByteBuffer.wrap(attachment.data).toArrayBuffer(), // ByteBuffer to ArrayBuffer + }); const cleaned = this.cleanAttachment(attachment); return this.downloadAttachment(cleaned); diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index ca4142313..e098689de 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -96,18 +96,20 @@ OutgoingMessage.prototype = { }, reloadDevicesAndSend(number, recurse) { return () => - textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => { - if (deviceIds.length === 0) { - // eslint-disable-next-line no-param-reassign - deviceIds = [1]; - // return this.registerError( - // number, - // 'Got empty device list when loading device keys', - // null - // ); - } - return this.doSendMessage(number, deviceIds, recurse); - }); + libloki.storage + .getAllDevicePubKeysForPrimaryPubKey(number) + .then(devicesPubKeys => { + if (devicesPubKeys.length === 0) { + // eslint-disable-next-line no-param-reassign + devicesPubKeys = [number]; + // return this.registerError( + // number, + // 'Got empty device list when loading device keys', + // null + // ); + } + return this.doSendMessage(number, devicesPubKeys, recurse); + }); }, getKeysForNumber(number, updateDevices) { @@ -271,7 +273,7 @@ OutgoingMessage.prototype = { const bytes = new Uint8Array(websocketMessage.encode().toArrayBuffer()); return bytes; }, - doSendMessage(number, deviceIds, recurse) { + doSendMessage(number, devicesPubKeys, recurse) { const ciphers = {}; if (this.isPublic) { return this.transmitMessage( @@ -289,6 +291,8 @@ OutgoingMessage.prototype = { }); } + this.numbers = devicesPubKeys; + /* Disabled because i'm not sure how senderCertificate works :thinking: const { numberInfo, senderCertificate } = this; const info = numberInfo && numberInfo[number] ? numberInfo[number] : {}; @@ -320,8 +324,14 @@ OutgoingMessage.prototype = { */ return Promise.all( - deviceIds.map(async deviceId => { - const address = new libsignal.SignalProtocolAddress(number, deviceId); + devicesPubKeys.map(async devicePubKey => { + // Loki Messenger doesn't use the deviceId scheme, it's always 1. + // Instead, there are multiple device public keys. + const deviceId = 1; + const address = new libsignal.SignalProtocolAddress( + devicePubKey, + deviceId + ); const ourKey = textsecure.storage.user.getNumber(); const options = {}; const fallBackCipher = new libloki.crypto.FallBackSessionCipher( @@ -331,12 +341,13 @@ OutgoingMessage.prototype = { // Check if we need to attach the preKeys let sessionCipher; const isFriendRequest = this.messageType === 'friend-request'; + this.fallBackEncryption = this.fallBackEncryption || isFriendRequest; const flags = this.message.dataMessage ? this.message.dataMessage.get_flags() : null; const isEndSession = flags === textsecure.protobuf.DataMessage.Flags.END_SESSION; - if (isFriendRequest || isEndSession) { + if (this.fallBackEncryption || isEndSession) { // Encrypt them with the fallback const pkb = await libloki.storage.getPreKeyBundleForContact(number); const preKeyBundleMessage = new textsecure.protobuf.PreKeyBundleMessage( @@ -345,7 +356,7 @@ OutgoingMessage.prototype = { this.message.preKeyBundleMessage = preKeyBundleMessage; window.log.info('attaching prekeys to outgoing message'); } - if (isFriendRequest) { + if (this.fallBackEncryption) { sessionCipher = fallBackCipher; } else { sessionCipher = new libsignal.SessionCipher( @@ -371,17 +382,21 @@ OutgoingMessage.prototype = { dcodeIO.ByteBuffer.wrap(ciphertext.body, 'binary').toArrayBuffer() ); } - let ttl; - if (this.messageType === 'friend-request') { - ttl = 4 * 24 * 60 * 60 * 1000; // 4 days for friend request message - } else if (this.messageType === 'onlineBroadcast') { - ttl = 60 * 1000; // 1 minute for online broadcast message - } else if (this.messageType === 'typing') { - ttl = 60 * 1000; // 1 minute for typing indicators - } else { - const hours = window.getMessageTTL() || 24; // 1 day default for any other message - ttl = hours * 60 * 60 * 1000; - } + const getTTL = type => { + switch (type) { + case 'friend-request': + return 4 * 24 * 60 * 60 * 1000; // 4 days for friend request message + case 'onlineBroadcast': + return 60 * 1000; // 1 minute for online broadcast message + case 'typing': + return 60 * 1000; // 1 minute for typing indicators + case 'pairing-request': + return 2 * 60 * 1000; // 2 minutes for pairing requests + default: + return (window.getMessageTTL() || 24) * 60 * 60 * 1000; // 1 day default for any other message + } + }; + const ttl = getTTL(this.messageType); return { type: ciphertext.type, // FallBackSessionCipher sets this to FRIEND_REQUEST @@ -390,20 +405,41 @@ OutgoingMessage.prototype = { sourceDevice: 1, destinationRegistrationId: ciphertext.registrationId, content: ciphertext.body, + pubKey: devicePubKey, }; }) ) .then(async outgoingObjects => { // TODO: handle multiple devices/messages per transmit - const outgoingObject = outgoingObjects[0]; - const socketMessage = await this.wrapInWebsocketMessage(outgoingObject); - await this.transmitMessage( - number, - socketMessage, - this.timestamp, - outgoingObject.ttl - ); - this.successfulNumbers[this.successfulNumbers.length] = number; + const promises = outgoingObjects.map(async outgoingObject => { + const destination = outgoingObject.pubKey; + try { + const socketMessage = await this.wrapInWebsocketMessage( + outgoingObject + ); + await this.transmitMessage( + destination, + socketMessage, + this.timestamp, + outgoingObject.ttl + ); + this.successfulNumbers.push(destination); + } catch (e) { + e.number = destination; + this.errors.push(e); + } + }); + await Promise.all(promises); + // TODO: the retrySend should only send to the devices + // for which the transmission failed. + + // ensure numberCompleted() will execute the callback + this.numbersCompleted += + this.errors.length + this.successfulNumbers.length; + // Absorb errors if message sent to at least 1 device + if (this.successfulNumbers.length > 0) { + this.errors = []; + } this.numberCompleted(); }) .catch(error => { @@ -459,7 +495,7 @@ OutgoingMessage.prototype = { window.log.error( 'Got "key changed" error from encrypt - no identityKey for application layer', number, - deviceIds + devicesPubKeys ); throw error; } else { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index be745b285..bfb5a362e 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -36,6 +36,7 @@ message Content { optional TypingMessage typingMessage = 6; optional PreKeyBundleMessage preKeyBundleMessage = 101; optional LokiAddressMessage lokiAddressMessage = 102; + optional PairingAuthorisationMessage pairingAuthorisation = 103; } message LokiAddressMessage { @@ -48,6 +49,19 @@ message LokiAddressMessage { optional Type type = 3; } +message PairingAuthorisationMessage { + enum Type { + REQUEST = 1; + GRANT = 2; + REVOKE = 3; + } + optional string primaryDevicePubKey = 1; + optional string secondaryDevicePubKey = 2; + optional bytes requestSignature = 3; + optional bytes grantSignature = 4; + optional Type type = 5; +} + message PreKeyBundleMessage { optional bytes identityKey = 1; optional uint32 deviceId = 2; @@ -258,6 +272,7 @@ message SyncMessage { message Contacts { optional AttachmentPointer blob = 1; optional bool complete = 2 [default = false]; + optional bytes data = 101; } message Groups { @@ -351,6 +366,7 @@ message ContactDetails { optional bytes profileKey = 6; optional bool blocked = 7; optional uint32 expireTimer = 8; + optional string nickname = 101; } message GroupDetails { diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index da7f21736..6927b521d 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -714,6 +714,12 @@ $loading-height: 16px; .button { background: $color-loki-green-gradient; border-radius: 100px; + + &:disabled, + &:disabled:hover { + background: $color-loki-dark-gray; + cursor: default; + } } #mnemonic-display { diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index 8f099dfd3..cb8871f9a 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -13,6 +13,7 @@ import { ContactName } from './conversation/ContactName'; import { cleanSearchTerm } from '../util/cleanSearchTerm'; import { LocalizerType } from '../types/Util'; +import { SearchOptions } from '../types/Search'; import { clipboard } from 'electron'; import { validateNumber } from '../types/PhoneNumber'; @@ -37,17 +38,11 @@ export interface Props { verified: boolean; profileName?: string; avatarPath?: string; + isSecondaryDevice: boolean; i18n: LocalizerType; updateSearchTerm: (searchTerm: string) => void; - search: ( - query: string, - options: { - regionCode: string; - ourNumber: string; - noteToSelf: string; - } - ) => void; + search: (query: string, options: SearchOptions) => void; clearSearch: () => void; onClick?: () => void; @@ -88,7 +83,9 @@ export class MainHeader extends React.Component { setInterval(() => { const clipboardText = clipboard.readText(); - this.setState({ clipboardText }); + if (this.state.clipboardText !== clipboardText) { + this.setState({ clipboardText }); + } }, 100); } @@ -98,18 +95,29 @@ export class MainHeader extends React.Component { } public componentDidUpdate(_prevProps: Props, prevState: any) { - if (prevState.hasPass !== this.state.hasPass) { + if ( + prevState.hasPass !== this.state.hasPass || + _prevProps.isSecondaryDevice !== this.props.isSecondaryDevice + ) { this.updateMenuItems(); } } public search() { - const { searchTerm, search, i18n, ourNumber, regionCode } = this.props; + const { + searchTerm, + search, + i18n, + ourNumber, + regionCode, + isSecondaryDevice, + } = this.props; if (search) { search(searchTerm, { noteToSelf: i18n('noteToSelf').toLowerCase(), ourNumber, regionCode, + isSecondaryDevice, }); } } @@ -304,7 +312,7 @@ export class MainHeader extends React.Component { } private updateMenuItems() { - const { i18n, onCopyPublicKey } = this.props; + const { i18n, onCopyPublicKey, isSecondaryDevice } = this.props; const { hasPass } = this.state; const menuItems = [ @@ -358,6 +366,16 @@ export class MainHeader extends React.Component { menuItems.push(passItem('set')); } + if (!isSecondaryDevice) { + menuItems.push({ + id: 'pairNewDevice', + name: 'Device Pairing', + onClick: () => { + trigger('showDevicePairingDialog'); + }, + }); + } + this.setState({ menuItems }); } } diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 99c18cb27..ffd4e85e0 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -1,10 +1,12 @@ import { omit, reject } from 'lodash'; import { normalize } from '../../types/PhoneNumber'; +import { SearchOptions } from '../../types/Search'; import { trigger } from '../../shims/events'; // import { getMessageModel } from '../../shims/Whisper'; // import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import { + getPrimaryDeviceFor, searchConversations /*, searchMessages */, } from '../../../js/modules/data'; import { makeLookup } from '../../util/makeLookup'; @@ -81,7 +83,7 @@ export const actions = { function search( query: string, - options: { regionCode: string; ourNumber: string; noteToSelf: string } + options: SearchOptions ): SearchResultsKickoffActionType { return { type: 'SEARCH_RESULTS', @@ -91,16 +93,12 @@ function search( async function doSearch( query: string, - options: { - regionCode: string; - ourNumber: string; - noteToSelf: string; - } + options: SearchOptions ): Promise { - const { regionCode, ourNumber, noteToSelf } = options; + const { regionCode } = options; const [discussions /*, messages */] = await Promise.all([ - queryConversationsAndContacts(query, { ourNumber, noteToSelf }), + queryConversationsAndContacts(query, options), // queryMessages(query), ]); const { conversations, contacts } = discussions; @@ -170,23 +168,46 @@ function startNewConversation( async function queryConversationsAndContacts( providedQuery: string, - options: { ourNumber: string; noteToSelf: string } + options: SearchOptions ) { - const { ourNumber, noteToSelf } = options; + const { ourNumber, noteToSelf, isSecondaryDevice } = options; const query = providedQuery.replace(/[+-.()]*/g, ''); const searchResults: Array = await searchConversations( query ); + const ourPrimaryDevice = isSecondaryDevice + ? await getPrimaryDeviceFor(ourNumber) + : ourNumber; + + const resultPrimaryDevices: Array = await Promise.all( + searchResults.map( + async conversation => + conversation.id === ourPrimaryDevice + ? Promise.resolve(ourPrimaryDevice) + : getPrimaryDeviceFor(conversation.id) + ) + ); + // Split into two groups - active conversations and items just from address book let conversations: Array = []; let contacts: Array = []; const max = searchResults.length; for (let i = 0; i < max; i += 1) { const conversation = searchResults[i]; - - if (conversation.type === 'direct' && !Boolean(conversation.lastMessage)) { + const primaryDevice = resultPrimaryDevices[i]; + + if (primaryDevice) { + if (isSecondaryDevice && primaryDevice === ourPrimaryDevice) { + conversations.push(ourNumber); + } else { + conversations.push(primaryDevice); + } + } else if ( + conversation.type === 'direct' && + !Boolean(conversation.lastMessage) + ) { contacts.push(conversation.id); } else { conversations.push(conversation.id); diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index 4123170cd..c49cf6386 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -5,6 +5,7 @@ import { LocalizerType } from '../../types/Util'; export type UserStateType = { ourNumber: string; regionCode: string; + isSecondaryDevice: boolean; i18n: LocalizerType; }; @@ -15,6 +16,7 @@ type UserChangedActionType = { payload: { ourNumber: string; regionCode: string; + isSecondaryDevice: boolean; }; }; @@ -29,6 +31,7 @@ export const actions = { function userChanged(attributes: { ourNumber: string; regionCode: string; + isSecondaryDevice: boolean; }): UserChangedActionType { return { type: 'USER_CHANGED', @@ -42,6 +45,7 @@ function getEmptyState(): UserStateType { return { ourNumber: 'missing', regionCode: 'missing', + isSecondaryDevice: false, i18n: () => 'missing', }; } diff --git a/ts/state/selectors/user.ts b/ts/state/selectors/user.ts index 67478ab9f..6963ad8a3 100644 --- a/ts/state/selectors/user.ts +++ b/ts/state/selectors/user.ts @@ -21,3 +21,8 @@ export const getIntl = createSelector( getUser, (state: UserStateType): LocalizerType => state.i18n ); + +export const getIsSecondaryDevice = createSelector( + getUser, + (state: UserStateType): boolean => state.isSecondaryDevice +); diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx index e9db0c481..1c3a8e8cc 100644 --- a/ts/state/smart/MainHeader.tsx +++ b/ts/state/smart/MainHeader.tsx @@ -5,7 +5,12 @@ import { MainHeader } from '../../components/MainHeader'; import { StateType } from '../reducer'; import { getQuery } from '../selectors/search'; -import { getIntl, getRegionCode, getUserNumber } from '../selectors/user'; +import { + getIntl, + getIsSecondaryDevice, + getRegionCode, + getUserNumber, +} from '../selectors/user'; import { getMe } from '../selectors/conversations'; const mapStateToProps = (state: StateType) => { @@ -13,6 +18,7 @@ const mapStateToProps = (state: StateType) => { searchTerm: getQuery(state), regionCode: getRegionCode(state), ourNumber: getUserNumber(state), + isSecondaryDevice: getIsSecondaryDevice(state), ...getMe(state), i18n: getIntl(state), }; diff --git a/ts/types/Search.ts b/ts/types/Search.ts new file mode 100644 index 000000000..debd559a0 --- /dev/null +++ b/ts/types/Search.ts @@ -0,0 +1,6 @@ +export type SearchOptions = { + regionCode: string; + ourNumber: string; + noteToSelf: string; + isSecondaryDevice: boolean; +};