From a7d78c0e9bd42fb6e1fc61d6272a4b026ebb7d28 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 17 Oct 2018 18:01:21 -0700 Subject: [PATCH] Sealed Sender support https://signal.org/blog/sealed-sender/ --- _locales/en/messages.json | 5 + app/sql.js | 1 + config/default.json | 3 +- config/production.json | 3 +- js/background.js | 149 ++++- js/conversation_controller.js | 10 + js/libsignal-protocol-worker.js | 4 +- js/models/conversations.js | 334 +++++++++-- js/models/messages.js | 100 +++- js/modules/backup.js | 5 +- js/modules/crypto.js | 268 +++++++-- js/modules/metadata/CiphertextMessage.js | 13 + js/modules/metadata/SecretSessionCipher.js | 586 +++++++++++++++++++ js/modules/refresh_sender_certificate.js | 120 ++++ js/modules/signal.js | 4 + js/modules/startup.js | 19 +- js/modules/types/conversation.js | 11 +- js/modules/web_api.js | 179 ++++-- libtextsecure/account_manager.js | 60 +- libtextsecure/libsignal-protocol.js | 35 +- libtextsecure/message_receiver.js | 254 ++++---- libtextsecure/outgoing_message.js | 301 +++++++--- libtextsecure/protobufs.js | 3 + libtextsecure/sendmessage.js | 392 +++++++------ libtextsecure/sync_request.js | 11 +- libtextsecure/test/message_receiver_test.js | 7 +- main.js | 1 + preload.js | 2 + protos/SignalService.proto | 45 +- protos/UnidentifiedDelivery.proto | 45 ++ stylesheets/_modules.scss | 17 +- test/crypto_test.js | 185 +++--- test/index.html | 2 + test/metadata/SecretSessionCipher_test.js | 405 +++++++++++++ test/modules/startup_test.js | 28 + ts/components/conversation/MessageDetail.md | 38 ++ ts/components/conversation/MessageDetail.tsx | 6 + ts/util/lint/exceptions.json | 134 +++-- 38 files changed, 2996 insertions(+), 789 deletions(-) create mode 100644 js/modules/metadata/CiphertextMessage.js create mode 100644 js/modules/metadata/SecretSessionCipher.js create mode 100644 js/modules/refresh_sender_certificate.js create mode 100644 protos/UnidentifiedDelivery.proto create mode 100644 test/metadata/SecretSessionCipher_test.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2fb4f6f6bf..a5691dd4d1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -798,6 +798,11 @@ "message": "Are you sure? Clicking 'delete' will permanently remove this message from this device only." }, + "unidentifiedDelivery": { + "message": "Unidentified Delivery", + "description": + "Label shown on the message detail screen for messages sent or received with Unidentified Delivery enabled" + }, "deleteThisMessage": { "message": "Delete this message" }, diff --git a/app/sql.js b/app/sql.js index 57cea05029..6a732e44bb 100644 --- a/app/sql.js +++ b/app/sql.js @@ -325,6 +325,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion2, updateToSchemaVersion3, updateToSchemaVersion4, + // version 5 was dropped ]; async function updateSchema(instance) { diff --git a/config/default.json b/config/default.json index 7d711400a3..2f379568b0 100644 --- a/config/default.json +++ b/config/default.json @@ -6,5 +6,6 @@ "buildExpiration": 0, "certificateAuthority": "-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n", - "import": false + "import": false, + "serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx" } diff --git a/config/production.json b/config/production.json index bb077ca5e9..1d403b347c 100644 --- a/config/production.json +++ b/config/production.json @@ -1,4 +1,5 @@ { "serverUrl": "https://textsecure-service.whispersystems.org", - "cdnUrl": "https://cdn.signal.org" + "cdnUrl": "https://cdn.signal.org", + "serverTrustRoot": "BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF" } diff --git a/js/background.js b/js/background.js index 9536028bab..223df919f5 100644 --- a/js/background.js +++ b/js/background.js @@ -1,14 +1,15 @@ -/* global Backbone: false */ -/* global $: false */ - -/* global dcodeIO: false */ -/* global ConversationController: false */ -/* global getAccountManager: false */ -/* global Signal: false */ -/* global storage: false */ -/* global textsecure: false */ -/* global Whisper: false */ -/* global _: false */ +/* global + $, + _, + Backbone, + ConversationController, + getAccountManager, + Signal, + storage, + textsecure, + WebAPI + Whisper, +*/ // eslint-disable-next-line func-names (async function() { @@ -553,7 +554,16 @@ window.log.info('listening for registration events'); Whisper.events.on('registration_done', () => { window.log.info('handling registration event'); + + // listeners Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); + window.Signal.RefreshSenderCertificate.initialize({ + events: Whisper.events, + storage, + navigator, + logger: window.log, + }); + connect(true); }); @@ -570,7 +580,15 @@ window.log.info('Import was interrupted, showing import error screen'); appView.openImporter(); } else if (Whisper.Registration.everDone()) { + // listeners Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); + window.Signal.RefreshSenderCertificate.initialize({ + events: Whisper.events, + storage, + navigator, + logger: window.log, + }); + connect(); appView.openInbox({ initialLoadComplete, @@ -713,6 +731,7 @@ connectCount += 1; const options = { retryCached: connectCount === 1, + serverTrustRoot: window.getServerTrustRoot(), }; Whisper.Notifications.disable(); // avoid notification flood until empty @@ -755,14 +774,33 @@ window.getSyncRequest(); } + const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery'; + if (!storage.get(udSupportKey)) { + const server = WebAPI.connect({ username: USERNAME, password: PASSWORD }); + try { + await server.registerSupportForUnauthenticatedDelivery(); + storage.put(udSupportKey, true); + } catch (error) { + window.log.error( + 'Error: Unable to register for unauthenticated delivery support.', + error && error.stack ? error.stack : error + ); + } + } + const deviceId = textsecure.storage.user.getDeviceId(); + const ourNumber = textsecure.storage.user.getNumber(); const { sendRequestConfigurationSyncMessage } = textsecure.messaging; const status = await Signal.Startup.syncReadReceiptConfiguration({ + ourNumber, deviceId, sendRequestConfigurationSyncMessage, storage, + prepareForSend: ConversationController.prepareForSend.bind( + ConversationController + ), }); - window.log.info('Sync read receipt configuration status:', status); + window.log.info('Sync configuration status:', status); if (firstRun === true && deviceId !== '1') { const hasThemeSetting = Boolean(storage.get('theme-setting')); @@ -786,14 +824,17 @@ }); if (Whisper.Import.isComplete()) { - textsecure.messaging - .sendRequestConfigurationSyncMessage() - .catch(error => { - window.log.error( - 'Import complete, but failed to send sync message', - error && error.stack ? error.stack : error - ); - }); + const { wrap, sendOptions } = ConversationController.prepareForSend( + textsecure.storage.user.getNumber() + ); + wrap( + textsecure.messaging.sendRequestConfigurationSyncMessage(sendOptions) + ).catch(error => { + window.log.error( + 'Import complete, but failed to send sync message', + error && error.stack ? error.stack : error + ); + }); } } @@ -838,7 +879,20 @@ } } function onConfiguration(ev) { - storage.put('read-receipt-setting', ev.configuration.readReceipts); + const { configuration } = ev; + + storage.put('read-receipt-setting', configuration.readReceipts); + + const { unidentifiedDeliveryIndicators } = configuration; + if ( + unidentifiedDeliveryIndicators === true || + unidentifiedDeliveryIndicators === false + ) { + storage.put( + 'unidentifiedDeliveryIndicators', + unidentifiedDeliveryIndicators + ); + } ev.confirm(); } @@ -881,10 +935,10 @@ } if (details.profileKey) { - const profileKey = dcodeIO.ByteBuffer.wrap(details.profileKey).toString( - 'base64' + const profileKey = window.Signal.Crypto.arrayBufferToBase64( + details.profileKey ); - conversation.set({ profileKey }); + conversation.setProfileKey(profileKey); } if (typeof details.blocked !== 'undefined') { @@ -1052,7 +1106,7 @@ return handleProfileUpdate({ data, confirm, messageDescriptor }); } - const message = createMessage(data); + const message = await createMessage(data); const isDuplicate = await isMessageDuplicate(message); if (isDuplicate) { window.log.warn('Received duplicate message', message.idForLogging()); @@ -1191,15 +1245,27 @@ function createSentMessage(data) { const now = Date.now(); + let sentTo = []; + + if (data.unidentifiedStatus && data.unidentifiedStatus.length) { + sentTo = data.unidentifiedStatus.map(item => item.destination); + const unidentified = _.filter(data.unidentifiedStatus, item => + Boolean(item.unidentified) + ); + // eslint-disable-next-line no-param-reassign + data.unidentifiedDeliveries = unidentified.map(item => item.destination); + } return new Whisper.Message({ source: textsecure.storage.user.getNumber(), sourceDevice: data.device, sent_at: data.timestamp, + sent_to: sentTo, received_at: now, conversationId: data.destination, type: 'outgoing', sent: true, + unidentifiedDeliveries: data.unidentifiedDeliveries || [], expirationStartTimestamp: Math.min( data.expirationStartTimestamp || data.timestamp || Date.now(), Date.now() @@ -1227,17 +1293,46 @@ } } - function initIncomingMessage(data) { + async function initIncomingMessage(data, options = {}) { + const { isError } = options; + const message = new Whisper.Message({ source: data.source, sourceDevice: data.sourceDevice, sent_at: data.timestamp, received_at: data.receivedAt || Date.now(), conversationId: data.source, + unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', unread: 1, }); + // If we don't return early here, we can get into infinite error loops. So, no + // delivery receipts for sealed sender errors. + if (isError || !data.unidentifiedDeliveryReceived) { + return message; + } + + try { + const { wrap, sendOptions } = ConversationController.prepareForSend( + data.source + ); + await wrap( + textsecure.messaging.sendDeliveryReceipt( + data.source, + data.timestamp, + sendOptions + ) + ); + } catch (error) { + window.log.error( + `Failed to send delivery receipt to ${data.source} for message ${ + data.timestamp + }:`, + error && error.stack ? error.stack : error + ); + } + return message; } @@ -1322,7 +1417,7 @@ return; } const envelope = ev.proto; - const message = initIncomingMessage(envelope); + const message = await initIncomingMessage(envelope, { isError: true }); await message.saveErrors(error || new Error('Error was null')); const id = message.get('conversationId'); diff --git a/js/conversation_controller.js b/js/conversation_controller.js index 44855ed7c4..33ef1be7a7 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -181,6 +181,16 @@ ); }); }, + prepareForSend(id) { + // id is either a group id or an individual user's id + const conversation = this.get(id); + const sendOptions = conversation && conversation.getSendOptions(); + const wrap = conversation + ? conversation.wrapSend.bind(conversation) + : promise => promise; + + return { wrap, sendOptions }; + }, async getAllGroupsInvolvingId(id) { const groups = await window.Signal.Data.getAllGroupsInvolvingId(id, { ConversationCollection: Whisper.ConversationCollection, diff --git a/js/libsignal-protocol-worker.js b/js/libsignal-protocol-worker.js index d655508520..20679574cb 100644 --- a/js/libsignal-protocol-worker.js +++ b/js/libsignal-protocol-worker.js @@ -22848,7 +22848,7 @@ function _memset(ptr, value, num) { } } while ((ptr|0) < (stop4|0)) { - HEAP32[ptr>>2]=value4; + HEAP32[((ptr)>>2)]=value4; ptr = (ptr+4)|0; } } @@ -22904,7 +22904,7 @@ function _memcpy(dest, src, num) { num = (num-1)|0; } while ((num|0) >= 4) { - HEAP32[dest>>2]=((HEAP32[src>>2])|0); + HEAP32[((dest)>>2)]=((HEAP32[((src)>>2)])|0); dest = (dest+4)|0; src = (src+4)|0; num = (num-4)|0; diff --git a/js/models/conversations.js b/js/models/conversations.js index 9c94f34a83..28d64d5165 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1,6 +1,5 @@ /* global _: false */ /* global Backbone: false */ -/* global dcodeIO: false */ /* global libphonenumber: false */ /* global ConversationController: false */ @@ -33,8 +32,6 @@ deleteAttachmentData, } = window.Signal.Migrations; - // TODO: Factor out private and group subclasses of Conversation - const COLORS = [ 'red', 'deep_orange', @@ -324,9 +321,20 @@ } }, sendVerifySyncMessage(number, 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 + ); + const recipientSendOptions = this.getSendOptions(); + const options = Object.assign({}, sendOptions, recipientSendOptions); + const promise = textsecure.storage.protocol.loadIdentityKey(number); return promise.then(key => - textsecure.messaging.syncVerification(number, state, key) + this.wrapSend( + textsecure.messaging.syncVerification(number, state, key, options) + ) ); }, isVerified() { @@ -754,20 +762,118 @@ messageWithSchema.attachments.map(loadAttachmentData) ); + const options = this.getSendOptions(); return message.send( - sendFunction( - destination, - body, - attachmentsWithData, - quote, - now, - expireTimer, - profileKey + this.wrapSend( + sendFunction( + destination, + body, + attachmentsWithData, + quote, + now, + expireTimer, + profileKey, + options + ) ) ); }); }, + wrapSend(promise) { + return promise.then( + async result => { + // success + if ( + result && + result.failoverNumbers && + result.failoverNumbers.length + ) { + await this.handleFailover(result.failoverNumbers); + } + return result; + }, + async result => { + // failure + if ( + result && + result.failoverNumbers && + result.failoverNumbers.length + ) { + await this.handleFailover(result.failoverNumbers); + } + throw result; + } + ); + }, + + handleFailover(numberArray) { + return Promise.all( + (numberArray || []).map(async number => { + const conversation = ConversationController.get(number); + if (conversation && conversation.get('unidentifiedDelivery')) { + window.log.info( + `Marking unidentifiedDelivery false for conversation ${conversation.idForLogging()}` + ); + conversation.set({ unidentifiedDelivery: false }); + await window.Signal.Data.updateConversation( + conversation.id, + conversation.attributes, + { Conversation: Whisper.Conversation } + ); + } + }) + ); + }, + + getSendOptions() { + const senderCertificate = storage.get('senderCertificate'); + const numberInfo = this.getNumberInfo(); + + return { + senderCertificate, + numberInfo, + }; + }, + + getNumberInfo() { + const UD = 'unidentifiedDelivery'; + const UNRESTRICTED_UD = 'unidentifiedDeliveryUnrestricted'; + + // 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'); + if (!me.get(UD) && !me.get(UNRESTRICTED_UD)) { + return null; + } + + if (this.isPrivate()) { + const accessKey = this.get('accessKey'); + const unidentifiedDelivery = this.get(UD); + const unrestricted = this.get(UNRESTRICTED_UD); + + if (!unidentifiedDelivery && !unrestricted) { + return null; + } + + return { + [this.id]: { + accessKey: + accessKey && !unrestricted + ? accessKey + : window.Signal.Crypto.arrayBufferToBase64( + window.Signal.Crypto.getRandomBytes(16) + ), + }, + }; + } + + const infoArray = this.contactCollection.map(conversation => + conversation.getNumberInfo() + ); + return Object.assign({}, ...infoArray); + }, + async updateLastMessage() { if (!this.id) { return; @@ -901,14 +1007,17 @@ if (this.get('profileSharing')) { profileKey = storage.get('profileKey'); } + + const sendOptions = this.getSendOptions(); const promise = sendFunc( this.get('id'), this.get('expireTimer'), message.get('sent_at'), - profileKey + profileKey, + sendOptions ); - await message.send(promise); + await message.send(this.wrapSend(promise)); return message; }, @@ -935,7 +1044,12 @@ }); message.set({ id }); - message.send(textsecure.messaging.resetSession(this.id, now)); + const options = this.getSendOptions(); + message.send( + this.wrapSend( + textsecure.messaging.resetSession(this.id, now, options) + ) + ); } }, @@ -962,12 +1076,16 @@ }); message.set({ id }); + const options = this.getSendOptions(); message.send( - textsecure.messaging.updateGroup( - this.id, - this.get('name'), - this.get('avatar'), - this.get('members') + this.wrapSend( + textsecure.messaging.updateGroup( + this.id, + this.get('name'), + this.get('avatar'), + this.get('members'), + options + ) ) ); }, @@ -993,7 +1111,10 @@ }); message.set({ id }); - message.send(textsecure.messaging.leaveGroup(this.id)); + const options = this.getSendOptions(); + message.send( + this.wrapSend(textsecure.messaging.leaveGroup(this.id, options)) + ); } }, @@ -1054,14 +1175,32 @@ read = read.filter(item => !item.hasErrors); if (read.length && options.sendReadReceipts) { - window.log.info('Sending', read.length, 'read receipts'); - await textsecure.messaging.syncReadMessages(read); + window.log.info(`Sending ${read.length} read receipts`); + // Because syncReadMessages sends to our other devices, and sendReadReceipts goes + // to a contact, we need accessKeys for both. + const prep = ConversationController.prepareForSend(this.ourNumber); + const recipientSendOptions = this.getSendOptions(); + const sendOptions = Object.assign( + {}, + prep.sendOptions, + recipientSendOptions + ); + + await this.wrapSend( + textsecure.messaging.syncReadMessages(read, sendOptions) + ); if (storage.get('read-receipt-setting')) { await Promise.all( _.map(_.groupBy(read, 'sender'), async (receipts, sender) => { const timestamps = _.map(receipts, 'timestamp'); - await textsecure.messaging.sendReadReceipts(sender, timestamps); + await this.wrapSend( + textsecure.messaging.sendReadReceipts( + sender, + timestamps, + sendOptions + ) + ); }) ); } @@ -1092,13 +1231,56 @@ ); } - try { - const profile = await textsecure.messaging.getProfile(id); - const identityKey = dcodeIO.ByteBuffer.wrap( - profile.identityKey, - 'base64' - ).toArrayBuffer(); + const c = await ConversationController.getOrCreateAndWait(id, 'private'); + // Because we're no longer using Backbone-integrated saves, we need to manually + // clear the changed fields here so our hasChanged() check is useful. + c.changed = {}; + + try { + if (c.get('profileKey') && !c.get('accessKey')) { + const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer( + c.get('profileKey') + ); + const buffer = await window.Signal.Crypto.deriveAccessKey( + profileKeyBuffer + ); + c.set({ + accessKey: window.Signal.Crypto.arrayBufferToBase64(buffer), + }); + } + + const firstProfileFetch = !c.get('hasFetchedProfile'); + const accessKey = c.get('accessKey'); + + let profile; + if (c.get('unidentifiedDelivery') || firstProfileFetch) { + try { + profile = await textsecure.messaging.getProfile(id, { + accessKey: + accessKey || + window.Signal.Crypto.arrayBufferToBase64( + window.window.Signal.Crypto.getRandomBytes(16) + ), + }); + } catch (error) { + if (error.code === 401 || error.code === 403) { + c.set({ + unidentifiedDelivery: false, + unidentifiedDeliveryUnrestricted: false, + }); + profile = await textsecure.messaging.getProfile(id); + } else { + throw error; + } + } + } else { + profile = await textsecure.messaging.getProfile(id); + } + + const identityKey = window.Signal.Crypto.base64ToArrayBuffer( + profile.identityKey + ); const changed = await textsecure.storage.protocol.saveIdentity( `${id}.1`, identityKey, @@ -1116,49 +1298,68 @@ await sessionCipher.closeOpenSessionForDevice(); } - try { - const c = ConversationController.get(id); + c.set({ + hasFetchedProfile: true, + }); - // Because we're no longer using Backbone-integrated saves, we need to manually - // clear the changed fields here so our hasChanged() check is useful. - c.changed = {}; - await c.setProfileName(profile.name); - await c.setProfileAvatar(profile.avatar); + if ( + profile.unrestrictedUnidentifiedAccess && + profile.unidentifiedAccess + ) { + c.set({ + unidentifiedDelivery: true, + unidentifiedDeliveryUnrestricted: true, + }); + } else if (accessKey && profile.unidentifiedAccess) { + const haveCorrectKey = await window.Signal.Crypto.verifyAccessKey( + window.Signal.Crypto.base64ToArrayBuffer(accessKey), + window.Signal.Crypto.base64ToArrayBuffer(profile.unidentifiedAccess) + ); - if (c.hasChanged()) { - await window.Signal.Data.updateConversation(id, c.attributes, { - Conversation: Whisper.Conversation, - }); - } - } catch (e) { - if (e.name === 'ProfileDecryptError') { - // probably the profile key has changed. - window.log.error( - 'decryptProfile error:', - id, - e && e.stack ? e.stack : e - ); - } + window.log.info( + `Setting unidentifiedDelivery to ${haveCorrectKey} for conversation ${c.idForLogging()}` + ); + c.set({ + unidentifiedDelivery: haveCorrectKey, + unidentifiedDeliveryUnrestricted: false, + }); + } else { + c.set({ + unidentifiedDelivery: false, + unidentifiedDeliveryUnrestricted: false, + }); } + + await c.setProfileName(profile.name); + + // This might throw if we can't pull the avatar down, so we do it last + await c.setProfileAvatar(profile.avatar); } catch (error) { window.log.error( 'getProfile error:', + id, error && error.stack ? error.stack : error ); + } finally { + if (c.hasChanged()) { + await window.Signal.Data.updateConversation(id, c.attributes, { + Conversation: Whisper.Conversation, + }); + } } }, async setProfileName(encryptedName) { + if (!encryptedName) { + return; + } const key = this.get('profileKey'); if (!key) { return; } // decode - const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer(); - const data = dcodeIO.ByteBuffer.wrap( - encryptedName, - 'base64' - ).toArrayBuffer(); + const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key); + const data = window.Signal.Crypto.base64ToArrayBuffer(encryptedName); // decrypt const decrypted = await textsecure.crypto.decryptProfileName( @@ -1167,10 +1368,10 @@ ); // encode - const name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8'); + const profileName = window.Signal.Crypto.stringFromBytes(decrypted); // set - this.set({ profileName: name }); + this.set({ profileName }); }, async setProfileAvatar(avatarPath) { if (!avatarPath) { @@ -1182,7 +1383,7 @@ if (!key) { return; } - const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer(); + const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key); // decrypt const decrypted = await textsecure.crypto.decryptProfile( @@ -1204,9 +1405,20 @@ } }, async setProfileKey(profileKey) { - // profileKey is now being saved as a string + // profileKey is a string so we can compare it directly if (this.get('profileKey') !== profileKey) { - this.set({ profileKey }); + const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer( + profileKey + ); + const accessKeyBuffer = await window.Signal.Crypto.deriveAccessKey( + profileKeyBuffer + ); + const accessKey = window.Signal.Crypto.arrayBufferToBase64( + accessKeyBuffer + ); + + this.set({ profileKey, accessKey }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); diff --git a/js/models/messages.js b/js/models/messages.js index 149e9362cd..a1d9f6c493 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -599,10 +599,25 @@ : null, }; }, + isUnidentifiedDelivery(contactId, lookup) { + if (this.isIncoming()) { + return this.get('unidentifiedDeliveryReceived'); + } + + return Boolean(lookup[contactId]); + }, getPropsForMessageDetail() { const newIdentity = i18n('newIdentity'); const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; + const unidentifiedLookup = ( + this.get('unidentifiedDeliveries') || [] + ).reduce((accumulator, item) => { + // eslint-disable-next-line no-param-reassign + accumulator[item] = true; + return accumulator; + }, Object.create(null)); + // 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() @@ -628,12 +643,16 @@ const isOutgoingKeyError = Boolean( _.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR) ); + const isUnidentifiedDelivery = + storage.get('unidentifiedDeliveryIndicators') && + this.isUnidentifiedDelivery(id, unidentifiedLookup); return { ...this.findAndFormatContact(id), status: this.getStatus(id), errors: errorsForContact, isOutgoingKeyError, + isUnidentifiedDelivery, onSendAnyway: () => this.trigger('force-send', { contact: this.findContact(id), @@ -696,11 +715,12 @@ const quoteWithData = await loadQuoteData(this.get('quote')); const conversation = this.getConversation(); + const options = conversation.getSendOptions(); + let promise; if (conversation.isPrivate()) { const [number] = numbers; - promise = textsecure.messaging.sendMessageToNumber( number, this.get('body'), @@ -708,28 +728,33 @@ quoteWithData, this.get('sent_at'), this.get('expireTimer'), - profileKey + profileKey, + options ); } else { // Because this is a partial group send, we manually construct the request like // sendMessageToGroup does. - promise = textsecure.messaging.sendMessage({ - recipients: numbers, - body: this.get('body'), - timestamp: this.get('sent_at'), - attachments: attachmentsWithData, - quote: quoteWithData, - needsSync: !this.get('synced'), - expireTimer: this.get('expireTimer'), - profileKey, - group: { - id: this.get('conversationId'), - type: textsecure.protobuf.GroupContext.Type.DELIVER, + + promise = textsecure.messaging.sendMessage( + { + recipients: numbers, + body: this.get('body'), + timestamp: this.get('sent_at'), + attachments: attachmentsWithData, + quote: quoteWithData, + needsSync: !this.get('synced'), + expireTimer: this.get('expireTimer'), + profileKey, + group: { + id: this.get('conversationId'), + type: textsecure.protobuf.GroupContext.Type.DELIVER, + }, }, - }); + options + ); } - return this.send(promise); + return this.send(conversation.wrapSend(promise)); }, isReplayableError(e) { return ( @@ -752,6 +777,9 @@ ); const quoteWithData = await loadQuoteData(this.get('quote')); + const { wrap, sendOptions } = ConversationController.prepareForSend( + number + ); const promise = textsecure.messaging.sendMessageToNumber( number, this.get('body'), @@ -759,10 +787,11 @@ quoteWithData, this.get('sent_at'), this.get('expireTimer'), - profileKey + profileKey, + sendOptions ); - this.send(promise); + this.send(wrap(promise)); } }, removeOutgoingErrors(number) { @@ -860,11 +889,13 @@ sent_to: _.union(sentTo, result.successfulNumbers), sent: true, expirationStartTimestamp: Date.now(), + unidentifiedDeliveries: result.unidentifiedDeliveries, }); await window.Signal.Data.saveMessage(this.attributes, { Message: Whisper.Message, }); + this.trigger('sent', this); this.sendSyncMessage(); }) @@ -909,6 +940,7 @@ sent_to: _.union(sentTo, result.successfulNumbers), sent: true, expirationStartTimestamp, + unidentifiedDeliveries: result.unidentifiedDeliveries, }); promises.push(this.sendSyncMessage()); } else { @@ -950,28 +982,36 @@ }, sendSyncMessage() { + const ourNumber = textsecure.storage.user.getNumber(); + const { wrap, sendOptions } = ConversationController.prepareForSend( + ourNumber + ); + this.syncPromise = this.syncPromise || Promise.resolve(); this.syncPromise = this.syncPromise.then(() => { const dataMessage = this.get('dataMessage'); if (this.get('synced') || !dataMessage) { return Promise.resolve(); } - return textsecure.messaging - .sendSyncMessage( + return wrap( + textsecure.messaging.sendSyncMessage( dataMessage, this.get('sent_at'), this.get('destination'), - this.get('expirationStartTimestamp') + this.get('expirationStartTimestamp'), + this.get('sent_to'), + this.get('unidentifiedDeliveries'), + sendOptions ) - .then(() => { - this.set({ - synced: true, - dataMessage: null, - }); - return window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); + ).then(() => { + this.set({ + synced: true, + dataMessage: null, }); + return window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + }); }); }, @@ -1238,7 +1278,7 @@ if (source === textsecure.storage.user.getNumber()) { conversation.set({ profileSharing: true }); } else if (conversation.isPrivate()) { - conversation.set({ profileKey }); + conversation.setProfileKey(profileKey); } else { ConversationController.getOrCreateAndWait(source, 'private').then( sender => { diff --git a/js/modules/backup.js b/js/modules/backup.js index b33f874904..159d5026ec 100644 --- a/js/modules/backup.js +++ b/js/modules/backup.js @@ -1,6 +1,5 @@ /* global Signal: false */ /* global Whisper: false */ -/* global dcodeIO: false */ /* global _: false */ /* global textsecure: false */ /* global i18n: false */ @@ -48,7 +47,7 @@ function stringify(object) { object[key] = { type: 'ArrayBuffer', encoding: 'base64', - data: dcodeIO.ByteBuffer.wrap(val).toString('base64'), + data: crypto.arrayBufferToBase64(val), }; } else if (val instanceof Object) { object[key] = stringify(val); @@ -70,7 +69,7 @@ function unstringify(object) { val.encoding === 'base64' && typeof val.data === 'string' ) { - object[key] = dcodeIO.ByteBuffer.wrap(val.data, 'base64').toArrayBuffer(); + object[key] = crypto.base64ToArrayBuffer(val.data); } else if (val instanceof Object) { object[key] = unstringify(object[key]); } diff --git a/js/modules/crypto.js b/js/modules/crypto.js index f289c01e2b..0071e89435 100644 --- a/js/modules/crypto.js +++ b/js/modules/crypto.js @@ -1,39 +1,82 @@ /* eslint-env browser */ +/* global dcodeIO */ -/* eslint-disable camelcase */ +/* eslint-disable camelcase, no-bitwise */ module.exports = { - encryptSymmetric, - decryptSymmetric, + arrayBufferToBase64, + base64ToArrayBuffer, + bytesFromString, + concatenateBytes, constantTimeEqual, + decryptAesCtr, + decryptSymmetric, + deriveAccessKey, + encryptAesCtr, + encryptSymmetric, + fromEncodedBinaryToArrayBuffer, + getAccessKeyVerifier, + getRandomBytes, + getViewOfArrayBuffer, + getZeroes, + highBitsToInt, + hmacSha256, + intsToByteHighAndLow, + splitBytes, + stringFromBytes, + trimBytes, + verifyAccessKey, }; +// High-level Operations + +async function deriveAccessKey(profileKey) { + const iv = getZeroes(12); + const plaintext = getZeroes(16); + const accessKey = await _encrypt_aes_gcm(profileKey, iv, plaintext); + return _getFirstBytes(accessKey, 16); +} + +async function getAccessKeyVerifier(accessKey) { + const plaintext = getZeroes(32); + const hmac = await hmacSha256(accessKey, plaintext); + + return hmac; +} + +async function verifyAccessKey(accessKey, theirVerifier) { + const ourVerifier = await getAccessKeyVerifier(accessKey); + + if (constantTimeEqual(ourVerifier, theirVerifier)) { + return true; + } + + return false; +} + const IV_LENGTH = 16; const MAC_LENGTH = 16; const NONCE_LENGTH = 16; async function encryptSymmetric(key, plaintext) { - const iv = _getZeros(IV_LENGTH); - const nonce = _getRandomBytes(NONCE_LENGTH); + const iv = getZeroes(IV_LENGTH); + const nonce = getRandomBytes(NONCE_LENGTH); - const cipherKey = await _hmac_SHA256(key, nonce); - const macKey = await _hmac_SHA256(key, cipherKey); + const cipherKey = await hmacSha256(key, nonce); + const macKey = await hmacSha256(key, cipherKey); const cipherText = await _encrypt_aes256_CBC_PKCSPadding( cipherKey, iv, plaintext ); - const mac = _getFirstBytes( - await _hmac_SHA256(macKey, cipherText), - MAC_LENGTH - ); + const mac = _getFirstBytes(await hmacSha256(macKey, cipherText), MAC_LENGTH); - return _concatData([nonce, cipherText, mac]); + return concatenateBytes(nonce, cipherText, mac); } async function decryptSymmetric(key, data) { - const iv = _getZeros(IV_LENGTH); + const iv = getZeroes(IV_LENGTH); const nonce = _getFirstBytes(data, NONCE_LENGTH); const cipherText = _getBytes( @@ -43,11 +86,11 @@ async function decryptSymmetric(key, data) { ); const theirMac = _getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH); - const cipherKey = await _hmac_SHA256(key, nonce); - const macKey = await _hmac_SHA256(key, cipherKey); + const cipherKey = await hmacSha256(key, nonce); + const macKey = await hmacSha256(key, cipherKey); const ourMac = _getFirstBytes( - await _hmac_SHA256(macKey, cipherText), + await hmacSha256(macKey, cipherText), MAC_LENGTH ); if (!constantTimeEqual(theirMac, ourMac)) { @@ -73,56 +116,135 @@ function constantTimeEqual(left, right) { return result === 0; } -async function _hmac_SHA256(key, data) { +// Encryption + +async function hmacSha256(key, plaintext) { + const algorithm = { + name: 'HMAC', + hash: 'SHA-256', + }; const extractable = false; + const cryptoKey = await window.crypto.subtle.importKey( 'raw', key, - { name: 'HMAC', hash: { name: 'SHA-256' } }, + algorithm, extractable, ['sign'] ); - return window.crypto.subtle.sign( - { name: 'HMAC', hash: 'SHA-256' }, - cryptoKey, - data - ); + return window.crypto.subtle.sign(algorithm, cryptoKey, plaintext); } -async function _encrypt_aes256_CBC_PKCSPadding(key, iv, data) { +async function _encrypt_aes256_CBC_PKCSPadding(key, iv, plaintext) { + const algorithm = { + name: 'AES-CBC', + iv, + }; const extractable = false; + const cryptoKey = await window.crypto.subtle.importKey( 'raw', key, - { name: 'AES-CBC' }, + algorithm, extractable, ['encrypt'] ); - return window.crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, data); + return window.crypto.subtle.encrypt(algorithm, cryptoKey, plaintext); } -async function _decrypt_aes256_CBC_PKCSPadding(key, iv, data) { +async function _decrypt_aes256_CBC_PKCSPadding(key, iv, plaintext) { + const algorithm = { + name: 'AES-CBC', + iv, + }; const extractable = false; + const cryptoKey = await window.crypto.subtle.importKey( 'raw', key, - { name: 'AES-CBC' }, + algorithm, extractable, ['decrypt'] ); - - return window.crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, data); + return window.crypto.subtle.decrypt(algorithm, cryptoKey, plaintext); } -function _getRandomBytes(n) { +async function encryptAesCtr(key, plaintext, counter) { + const extractable = false; + const algorithm = { + name: 'AES-CTR', + counter: new Uint8Array(counter), + length: 128, + }; + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + algorithm, + extractable, + ['encrypt'] + ); + + const ciphertext = await crypto.subtle.encrypt( + algorithm, + cryptoKey, + plaintext + ); + + return ciphertext; +} + +async function decryptAesCtr(key, ciphertext, counter) { + const extractable = false; + const algorithm = { + name: 'AES-CTR', + counter: new Uint8Array(counter), + length: 128, + }; + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + algorithm, + extractable, + ['decrypt'] + ); + const plaintext = await crypto.subtle.decrypt( + algorithm, + cryptoKey, + ciphertext + ); + return plaintext; +} + +async function _encrypt_aes_gcm(key, iv, plaintext) { + const algorithm = { + name: 'AES-GCM', + iv, + }; + const extractable = false; + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + algorithm, + extractable, + ['encrypt'] + ); + return crypto.subtle.encrypt(algorithm, cryptoKey, plaintext); +} + +// Utility + +function getRandomBytes(n) { const bytes = new Uint8Array(n); window.crypto.getRandomValues(bytes); return bytes; } -function _getZeros(n) { +function getZeroes(n) { const result = new Uint8Array(n); const value = 0; @@ -133,17 +255,43 @@ function _getZeros(n) { return result; } -function _getFirstBytes(data, n) { - const source = new Uint8Array(data); - return source.subarray(0, n); +function highBitsToInt(byte) { + return (byte & 0xff) >> 4; } -function _getBytes(data, start, n) { - const source = new Uint8Array(data); - return source.subarray(start, start + n); +function intsToByteHighAndLow(highValue, lowValue) { + return ((highValue << 4) | lowValue) & 0xff; } -function _concatData(elements) { +function trimBytes(buffer, length) { + return _getFirstBytes(buffer, length); +} + +function arrayBufferToBase64(arrayBuffer) { + return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64'); +} +function base64ToArrayBuffer(base64string) { + return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer(); +} + +function fromEncodedBinaryToArrayBuffer(key) { + return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer(); +} + +function bytesFromString(string) { + return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer(); +} +function stringFromBytes(buffer) { + return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8'); +} + +function getViewOfArrayBuffer(buffer, start, finish) { + const source = new Uint8Array(buffer); + const result = source.slice(start, finish); + return result.buffer; +} + +function concatenateBytes(...elements) { const length = elements.reduce( (total, element) => total + element.byteLength, 0 @@ -161,5 +309,45 @@ function _concatData(elements) { throw new Error('problem concatenating!'); } - return result; + return result.buffer; +} + +function splitBytes(buffer, ...lengths) { + const total = lengths.reduce((acc, length) => acc + length, 0); + + if (total !== buffer.byteLength) { + throw new Error( + `Requested lengths total ${total} does not match source total ${ + buffer.byteLength + }` + ); + } + + const source = new Uint8Array(buffer); + const results = []; + let position = 0; + + for (let i = 0, max = lengths.length; i < max; i += 1) { + const length = lengths[i]; + const result = new Uint8Array(length); + const section = source.slice(position, position + length); + result.set(section); + position += result.byteLength; + + results.push(result); + } + + return results; +} + +// Internal-only + +function _getFirstBytes(data, n) { + const source = new Uint8Array(data); + return source.subarray(0, n); +} + +function _getBytes(data, start, n) { + const source = new Uint8Array(data); + return source.subarray(start, start + n); } diff --git a/js/modules/metadata/CiphertextMessage.js b/js/modules/metadata/CiphertextMessage.js new file mode 100644 index 0000000000..f381f3bec2 --- /dev/null +++ b/js/modules/metadata/CiphertextMessage.js @@ -0,0 +1,13 @@ +module.exports = { + CURRENT_VERSION: 3, + + // This matches Envelope.Type.CIPHERTEXT + WHISPER_TYPE: 1, + // This matches Envelope.Type.PREKEY_BUNDLE + PREKEY_TYPE: 3, + + SENDERKEY_TYPE: 4, + SENDERKEY_DISTRIBUTION_TYPE: 5, + + ENCRYPTED_MESSAGE_OVERHEAD: 53, +}; diff --git a/js/modules/metadata/SecretSessionCipher.js b/js/modules/metadata/SecretSessionCipher.js new file mode 100644 index 0000000000..36dff56d00 --- /dev/null +++ b/js/modules/metadata/SecretSessionCipher.js @@ -0,0 +1,586 @@ +/* global libsignal, textsecure */ + +/* eslint-disable no-bitwise */ + +const CiphertextMessage = require('./CiphertextMessage'); +const { + bytesFromString, + concatenateBytes, + constantTimeEqual, + decryptAesCtr, + encryptAesCtr, + fromEncodedBinaryToArrayBuffer, + getViewOfArrayBuffer, + getZeroes, + highBitsToInt, + hmacSha256, + intsToByteHighAndLow, + splitBytes, + trimBytes, +} = require('../crypto'); + +const REVOKED_CERTIFICATES = []; + +function SecretSessionCipher(storage) { + this.storage = storage; + + // We do this on construction because libsignal won't be available when this file loads + const { SessionCipher } = libsignal; + this.SessionCipher = SessionCipher; +} + +const CIPHERTEXT_VERSION = 1; +const UNIDENTIFIED_DELIVERY_PREFIX = 'UnidentifiedDelivery'; + +// public CertificateValidator(ECPublicKey trustRoot) +function createCertificateValidator(trustRoot) { + return { + // public void validate(SenderCertificate certificate, long validationTime) + async validate(certificate, validationTime) { + const serverCertificate = certificate.signer; + + await libsignal.Curve.async.verifySignature( + trustRoot, + serverCertificate.certificate, + serverCertificate.signature + ); + + const serverCertId = serverCertificate.certificate.id; + if (REVOKED_CERTIFICATES.includes(serverCertId)) { + throw new Error( + `Server certificate id ${serverCertId} has been revoked` + ); + } + + await libsignal.Curve.async.verifySignature( + serverCertificate.key, + certificate.certificate, + certificate.signature + ); + + if (validationTime > certificate.expires) { + throw new Error('Certificate is expired'); + } + }, + }; +} + +function _decodePoint(serialized, offset = 0) { + const view = + offset > 0 + ? getViewOfArrayBuffer(serialized, offset, serialized.byteLength) + : serialized; + + return libsignal.Curve.validatePubKeyFormat(view); +} + +// public ServerCertificate(byte[] serialized) +function _createServerCertificateFromBuffer(serialized) { + const wrapper = textsecure.protobuf.ServerCertificate.decode(serialized); + + if (!wrapper.certificate || !wrapper.signature) { + throw new Error('Missing fields'); + } + + const certificate = textsecure.protobuf.ServerCertificate.Certificate.decode( + wrapper.certificate.toArrayBuffer() + ); + + if (!certificate.id || !certificate.key) { + throw new Error('Missing fields'); + } + + return { + id: certificate.id, + key: certificate.key.toArrayBuffer(), + serialized, + certificate: wrapper.certificate.toArrayBuffer(), + + signature: wrapper.signature.toArrayBuffer(), + }; +} + +// public SenderCertificate(byte[] serialized) +function _createSenderCertificateFromBuffer(serialized) { + const wrapper = textsecure.protobuf.SenderCertificate.decode(serialized); + + if (!wrapper.signature || !wrapper.certificate) { + throw new Error('Missing fields'); + } + + const certificate = textsecure.protobuf.SenderCertificate.Certificate.decode( + wrapper.certificate.toArrayBuffer() + ); + + if ( + !certificate.signer || + !certificate.identityKey || + !certificate.senderDevice || + !certificate.expires || + !certificate.sender + ) { + throw new Error('Missing fields'); + } + + return { + sender: certificate.sender, + senderDevice: certificate.senderDevice, + expires: certificate.expires.toNumber(), + identityKey: certificate.identityKey.toArrayBuffer(), + signer: _createServerCertificateFromBuffer( + certificate.signer.toArrayBuffer() + ), + + certificate: wrapper.certificate.toArrayBuffer(), + signature: wrapper.signature.toArrayBuffer(), + + serialized, + }; +} + +// public UnidentifiedSenderMessage(byte[] serialized) +function _createUnidentifiedSenderMessageFromBuffer(serialized) { + const version = highBitsToInt(serialized[0]); + + if (version > CIPHERTEXT_VERSION) { + throw new Error(`Unknown version: ${this.version}`); + } + + const view = getViewOfArrayBuffer(serialized, 1, serialized.byteLength); + const unidentifiedSenderMessage = textsecure.protobuf.UnidentifiedSenderMessage.decode( + view + ); + + if ( + !unidentifiedSenderMessage.ephemeralPublic || + !unidentifiedSenderMessage.encryptedStatic || + !unidentifiedSenderMessage.encryptedMessage + ) { + throw new Error('Missing fields'); + } + + return { + version, + + ephemeralPublic: unidentifiedSenderMessage.ephemeralPublic.toArrayBuffer(), + encryptedStatic: unidentifiedSenderMessage.encryptedStatic.toArrayBuffer(), + encryptedMessage: unidentifiedSenderMessage.encryptedMessage.toArrayBuffer(), + + serialized, + }; +} + +// public UnidentifiedSenderMessage( +// ECPublicKey ephemeral, byte[] encryptedStatic, byte[] encryptedMessage) { +function _createUnidentifiedSenderMessage( + ephemeralPublic, + encryptedStatic, + encryptedMessage +) { + const versionBytes = new Uint8Array([ + intsToByteHighAndLow(CIPHERTEXT_VERSION, CIPHERTEXT_VERSION), + ]); + const unidentifiedSenderMessage = new textsecure.protobuf.UnidentifiedSenderMessage(); + + unidentifiedSenderMessage.encryptedMessage = encryptedMessage; + unidentifiedSenderMessage.encryptedStatic = encryptedStatic; + unidentifiedSenderMessage.ephemeralPublic = ephemeralPublic; + + const messageBytes = unidentifiedSenderMessage.encode().toArrayBuffer(); + + return { + version: CIPHERTEXT_VERSION, + + ephemeralPublic, + encryptedStatic, + encryptedMessage, + + serialized: concatenateBytes(versionBytes, messageBytes), + }; +} + +// public UnidentifiedSenderMessageContent(byte[] serialized) +function _createUnidentifiedSenderMessageContentFromBuffer(serialized) { + const TypeEnum = textsecure.protobuf.UnidentifiedSenderMessage.Message.Type; + + const message = textsecure.protobuf.UnidentifiedSenderMessage.Message.decode( + serialized + ); + + if (!message.type || !message.senderCertificate || !message.content) { + throw new Error('Missing fields'); + } + + let type; + switch (message.type) { + case TypeEnum.MESSAGE: + type = CiphertextMessage.WHISPER_TYPE; + break; + case TypeEnum.PREKEY_MESSAGE: + type = CiphertextMessage.PREKEY_TYPE; + break; + default: + throw new Error(`Unknown type: ${message.type}`); + } + + return { + type, + senderCertificate: _createSenderCertificateFromBuffer( + message.senderCertificate.toArrayBuffer() + ), + content: message.content.toArrayBuffer(), + + serialized, + }; +} + +// private int getProtoType(int type) +function _getProtoMessageType(type) { + const TypeEnum = textsecure.protobuf.UnidentifiedSenderMessage.Message.Type; + + switch (type) { + case CiphertextMessage.WHISPER_TYPE: + return TypeEnum.MESSAGE; + case CiphertextMessage.PREKEY_TYPE: + return TypeEnum.PREKEY_MESSAGE; + default: + throw new Error(`_getProtoMessageType: type '${type}' does not exist`); + } +} + +// public UnidentifiedSenderMessageContent( +// int type, SenderCertificate senderCertificate, byte[] content) +function _createUnidentifiedSenderMessageContent( + type, + senderCertificate, + content +) { + const innerMessage = new textsecure.protobuf.UnidentifiedSenderMessage.Message(); + innerMessage.type = _getProtoMessageType(type); + innerMessage.senderCertificate = textsecure.protobuf.SenderCertificate.decode( + senderCertificate.serialized + ); + innerMessage.content = content; + + return { + type, + senderCertificate, + content, + + serialized: innerMessage.encode().toArrayBuffer(), + }; +} + +SecretSessionCipher.prototype = { + // public byte[] encrypt( + // SignalProtocolAddress destinationAddress, + // SenderCertificate senderCertificate, + // byte[] paddedPlaintext + // ) + async encrypt(destinationAddress, senderCertificate, paddedPlaintext) { + // Capture this.xxx variables to replicate Java's implicit this syntax + const { SessionCipher } = this; + const signalProtocolStore = this.storage; + const _calculateEphemeralKeys = this._calculateEphemeralKeys.bind(this); + const _encryptWithSecretKeys = this._encryptWithSecretKeys.bind(this); + const _calculateStaticKeys = this._calculateStaticKeys.bind(this); + + const sessionCipher = new SessionCipher( + signalProtocolStore, + destinationAddress + ); + const sessionRecord = await sessionCipher.getRecord( + destinationAddress.toString() + ); + const openSession = sessionRecord.getOpenSession(); + if (!openSession) { + throw new Error('No active session'); + } + + const message = await sessionCipher.encrypt(paddedPlaintext); + const ourIdentity = await signalProtocolStore.getIdentityKeyPair(); + const theirIdentity = fromEncodedBinaryToArrayBuffer( + openSession.indexInfo.remoteIdentityKey + ); + + const ephemeral = await libsignal.Curve.async.generateKeyPair(); + const ephemeralSalt = concatenateBytes( + bytesFromString(UNIDENTIFIED_DELIVERY_PREFIX), + theirIdentity, + ephemeral.pubKey + ); + const ephemeralKeys = await _calculateEphemeralKeys( + theirIdentity, + ephemeral.privKey, + ephemeralSalt + ); + const staticKeyCiphertext = await _encryptWithSecretKeys( + ephemeralKeys.cipherKey, + ephemeralKeys.macKey, + ourIdentity.pubKey + ); + + const staticSalt = concatenateBytes( + ephemeralKeys.chainKey, + staticKeyCiphertext + ); + const staticKeys = await _calculateStaticKeys( + theirIdentity, + ourIdentity.privKey, + staticSalt + ); + const content = _createUnidentifiedSenderMessageContent( + message.type, + senderCertificate, + fromEncodedBinaryToArrayBuffer(message.body) + ); + const messageBytes = await _encryptWithSecretKeys( + staticKeys.cipherKey, + staticKeys.macKey, + content.serialized + ); + + const unidentifiedSenderMessage = _createUnidentifiedSenderMessage( + ephemeral.pubKey, + staticKeyCiphertext, + messageBytes + ); + + return unidentifiedSenderMessage.serialized; + }, + + // public Pair decrypt( + // CertificateValidator validator, byte[] ciphertext, long timestamp) + 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); + const _calculateStaticKeys = this._calculateStaticKeys.bind(this); + const _decryptWithUnidentifiedSenderMessage = this._decryptWithUnidentifiedSenderMessage.bind( + this + ); + const _decryptWithSecretKeys = this._decryptWithSecretKeys.bind(this); + + const ourIdentity = await signalProtocolStore.getIdentityKeyPair(); + const wrapper = _createUnidentifiedSenderMessageFromBuffer(ciphertext); + const ephemeralSalt = concatenateBytes( + bytesFromString(UNIDENTIFIED_DELIVERY_PREFIX), + ourIdentity.pubKey, + wrapper.ephemeralPublic + ); + const ephemeralKeys = await _calculateEphemeralKeys( + wrapper.ephemeralPublic, + ourIdentity.privKey, + ephemeralSalt + ); + const staticKeyBytes = await _decryptWithSecretKeys( + ephemeralKeys.cipherKey, + ephemeralKeys.macKey, + wrapper.encryptedStatic + ); + + const staticKey = _decodePoint(staticKeyBytes, 0); + const staticSalt = concatenateBytes( + ephemeralKeys.chainKey, + wrapper.encryptedStatic + ); + const staticKeys = await _calculateStaticKeys( + staticKey, + ourIdentity.privKey, + staticSalt + ); + const messageBytes = await _decryptWithSecretKeys( + staticKeys.cipherKey, + staticKeys.macKey, + wrapper.encryptedMessage + ); + + const content = _createUnidentifiedSenderMessageContentFromBuffer( + messageBytes + ); + + await validator.validate(content.senderCertificate, timestamp); + if ( + !constantTimeEqual(content.senderCertificate.identityKey, staticKeyBytes) + ) { + throw new Error( + "Sender's certificate key does not match key used in message" + ); + } + + const { sender, senderDevice } = content.senderCertificate; + const { number, deviceId } = me || {}; + if (sender === number && senderDevice === deviceId) { + return { + isMe: true, + }; + } + const address = new libsignal.SignalProtocolAddress(sender, senderDevice); + + try { + return { + sender: address, + content: await _decryptWithUnidentifiedSenderMessage(content), + }; + } catch (error) { + error.sender = address; + + throw error; + } + }, + + // public int getSessionVersion(SignalProtocolAddress remoteAddress) { + getSessionVersion(remoteAddress) { + const { SessionCipher } = this; + const signalProtocolStore = this.storage; + + const cipher = new SessionCipher(signalProtocolStore, remoteAddress); + + return cipher.getSessionVersion(); + }, + + // public int getRemoteRegistrationId(SignalProtocolAddress remoteAddress) { + getRemoteRegistrationId(remoteAddress) { + const { SessionCipher } = this; + const signalProtocolStore = this.storage; + + const cipher = new SessionCipher(signalProtocolStore, remoteAddress); + + return cipher.getRemoteRegistrationId(); + }, + + // Used by outgoing_message.js + closeOpenSessionForDevice(remoteAddress) { + const { SessionCipher } = this; + const signalProtocolStore = this.storage; + + const cipher = new SessionCipher(signalProtocolStore, remoteAddress); + + return cipher.closeOpenSessionForDevice(); + }, + + // private EphemeralKeys calculateEphemeralKeys( + // ECPublicKey ephemeralPublic, ECPrivateKey ephemeralPrivate, byte[] salt) + async _calculateEphemeralKeys(ephemeralPublic, ephemeralPrivate, salt) { + const ephemeralSecret = await libsignal.Curve.async.calculateAgreement( + ephemeralPublic, + ephemeralPrivate + ); + const ephemeralDerivedParts = await libsignal.HKDF.deriveSecrets( + ephemeralSecret, + salt, + new ArrayBuffer() + ); + + // private EphemeralKeys(byte[] chainKey, byte[] cipherKey, byte[] macKey) + return { + chainKey: ephemeralDerivedParts[0], + cipherKey: ephemeralDerivedParts[1], + macKey: ephemeralDerivedParts[2], + }; + }, + + // private StaticKeys calculateStaticKeys( + // ECPublicKey staticPublic, ECPrivateKey staticPrivate, byte[] salt) + async _calculateStaticKeys(staticPublic, staticPrivate, salt) { + const staticSecret = await libsignal.Curve.async.calculateAgreement( + staticPublic, + staticPrivate + ); + const staticDerivedParts = await libsignal.HKDF.deriveSecrets( + staticSecret, + salt, + new ArrayBuffer() + ); + + // private StaticKeys(byte[] cipherKey, byte[] macKey) + return { + cipherKey: staticDerivedParts[1], + macKey: staticDerivedParts[2], + }; + }, + + // private byte[] decrypt(UnidentifiedSenderMessageContent message) + _decryptWithUnidentifiedSenderMessage(message) { + const { SessionCipher } = this; + const signalProtocolStore = this.storage; + + const sender = new libsignal.SignalProtocolAddress( + message.senderCertificate.sender, + message.senderCertificate.senderDevice + ); + + switch (message.type) { + case CiphertextMessage.WHISPER_TYPE: + return new SessionCipher( + signalProtocolStore, + sender + ).decryptWhisperMessage(message.content); + case CiphertextMessage.PREKEY_TYPE: + return new SessionCipher( + signalProtocolStore, + sender + ).decryptPreKeyWhisperMessage(message.content); + default: + throw new Error(`Unknown type: ${message.type}`); + } + }, + + // private byte[] encrypt( + // SecretKeySpec cipherKey, SecretKeySpec macKey, byte[] plaintext) + async _encryptWithSecretKeys(cipherKey, macKey, plaintext) { + // Cipher const cipher = Cipher.getInstance('AES/CTR/NoPadding'); + // cipher.init(Cipher.ENCRYPT_MODE, cipherKey, new IvParameterSpec(new byte[16])); + + // Mac const mac = Mac.getInstance('HmacSHA256'); + // mac.init(macKey); + + // byte[] const ciphertext = cipher.doFinal(plaintext); + const ciphertext = await encryptAesCtr(cipherKey, plaintext, getZeroes(16)); + + // byte[] const ourFullMac = mac.doFinal(ciphertext); + const ourFullMac = await hmacSha256(macKey, ciphertext); + const ourMac = trimBytes(ourFullMac, 10); + + return concatenateBytes(ciphertext, ourMac); + }, + + // private byte[] decrypt( + // SecretKeySpec cipherKey, SecretKeySpec macKey, byte[] ciphertext) + async _decryptWithSecretKeys(cipherKey, macKey, ciphertext) { + if (ciphertext.byteLength < 10) { + throw new Error('Ciphertext not long enough for MAC!'); + } + + const ciphertextParts = splitBytes( + ciphertext, + ciphertext.byteLength - 10, + 10 + ); + + // Mac const mac = Mac.getInstance('HmacSHA256'); + // mac.init(macKey); + + // byte[] const digest = mac.doFinal(ciphertextParts[0]); + const digest = await hmacSha256(macKey, ciphertextParts[0]); + const ourMac = trimBytes(digest, 10); + const theirMac = ciphertextParts[1]; + + if (!constantTimeEqual(ourMac, theirMac)) { + throw new Error('Bad mac!'); + } + + // Cipher const cipher = Cipher.getInstance('AES/CTR/NoPadding'); + // cipher.init(Cipher.DECRYPT_MODE, cipherKey, new IvParameterSpec(new byte[16])); + + // return cipher.doFinal(ciphertextParts[0]); + return decryptAesCtr(cipherKey, ciphertextParts[0], getZeroes(16)); + }, +}; + +module.exports = { + SecretSessionCipher, + createCertificateValidator, + _createServerCertificateFromBuffer, + _createSenderCertificateFromBuffer, +}; diff --git a/js/modules/refresh_sender_certificate.js b/js/modules/refresh_sender_certificate.js new file mode 100644 index 0000000000..a25f10d3e5 --- /dev/null +++ b/js/modules/refresh_sender_certificate.js @@ -0,0 +1,120 @@ +/* global window, setTimeout, clearTimeout, textsecure, WebAPI, ConversationController */ + +module.exports = { + initialize, +}; + +const ONE_DAY = 24 * 60 * 60 * 1000; // one day +const MINIMUM_TIME_LEFT = 2 * 60 * 60 * 1000; // two hours + +let initialized = false; +let timeout = null; +let scheduledTime = null; + +// We need to refresh our own profile regularly to account for newly-added devices which +// do not support unidentified delivery. +function refreshOurProfile() { + const ourNumber = textsecure.storage.user.getNumber(); + const conversation = ConversationController.getOrCreate(ourNumber, 'private'); + conversation.getProfiles(); +} + +function initialize({ events, storage, navigator, logger }) { + if (initialized) { + logger.warn('refreshSenderCertificate: already initialized!'); + return; + } + initialized = true; + + runWhenOnline(); + + events.on('timetravel', () => { + if (initialized) { + scheduleNextRotation(); + } + }); + + function scheduleNextRotation() { + const now = Date.now(); + const certificate = storage.get('senderCertificate'); + if (!certificate) { + setTimeoutForNextRun(now); + + return; + } + + // The useful information in a SenderCertificate is all serialized, so we + // need to do another layer of decoding. + const decoded = textsecure.protobuf.SenderCertificate.Certificate.decode( + certificate.certificate + ); + const expires = decoded.expires.toNumber(); + + const time = Math.min(now + ONE_DAY, expires - MINIMUM_TIME_LEFT); + + setTimeoutForNextRun(time); + } + + 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 { certificate } = await server.getSenderCertificate(); + 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; + + storage.put('senderCertificate', decoded); + scheduleNextRotation(); + } catch (error) { + logger.error( + 'refreshSenderCertificate: Get failed. Trying again in two minutes...', + error && error.stack ? error.stack : error + ); + setTimeout(runWhenOnline, 2 * 60 * 1000); + } + + refreshOurProfile(); + } + + function runWhenOnline() { + if (navigator.onLine) { + run(); + } else { + logger.info( + 'refreshSenderCertificate: Offline. Will update certificate when online...' + ); + const listener = () => { + logger.info( + 'refreshSenderCertificate: Online. Now updating certificate...' + ); + window.removeEventListener('online', listener); + run(); + }; + window.addEventListener('online', listener); + } + } + + function setTimeoutForNextRun(time = Date.now()) { + const now = Date.now(); + + if (scheduledTime !== time || !timeout) { + logger.info( + 'Next sender certificate refresh scheduled for', + new Date(time).toISOString() + ); + } + + scheduledTime = time; + const waitTime = Math.max(0, time - now); + + clearTimeout(timeout); + timeout = setTimeout(runWhenOnline, waitTime); + } +} diff --git a/js/modules/signal.js b/js/modules/signal.js index 0bf5ef2972..0d63447321 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -11,6 +11,8 @@ const Settings = require('./settings'); const Startup = require('./startup'); const Util = require('../../ts/util'); const { migrateToSQL } = require('./migrate_to_sql'); +const Metadata = require('./metadata/SecretSessionCipher'); +const RefreshSenderCertificate = require('./refresh_sender_certificate'); // Components const { @@ -216,6 +218,7 @@ exports.setup = (options = {}) => { }; return { + Metadata, Backbone, Components, Crypto, @@ -225,6 +228,7 @@ exports.setup = (options = {}) => { Migrations, Notifications, OS, + RefreshSenderCertificate, Settings, Startup, Types, diff --git a/js/modules/startup.js b/js/modules/startup.js index df3f07300b..e3810e079b 100644 --- a/js/modules/startup.js +++ b/js/modules/startup.js @@ -4,20 +4,28 @@ const Errors = require('./types/errors'); const Settings = require('./settings'); exports.syncReadReceiptConfiguration = async ({ + ourNumber, deviceId, sendRequestConfigurationSyncMessage, storage, + prepareForSend, }) => { if (!is.string(deviceId)) { - throw new TypeError("'deviceId' is required"); + throw new TypeError('deviceId is required'); + } + if (!is.function(sendRequestConfigurationSyncMessage)) { + throw new TypeError('sendRequestConfigurationSyncMessage is required'); + } + if (!is.function(prepareForSend)) { + throw new TypeError('prepareForSend is required'); } - if (!is.function(sendRequestConfigurationSyncMessage)) { - throw new TypeError("'sendRequestConfigurationSyncMessage' is required"); + if (!is.string(ourNumber)) { + throw new TypeError('ourNumber is required'); } if (!is.object(storage)) { - throw new TypeError("'storage' is required"); + throw new TypeError('storage is required'); } const isPrimaryDevice = deviceId === '1'; @@ -38,7 +46,8 @@ exports.syncReadReceiptConfiguration = async ({ } try { - await sendRequestConfigurationSyncMessage(); + const { wrap, sendOptions } = prepareForSend(ourNumber); + await wrap(sendRequestConfigurationSyncMessage(sendOptions)); storage.put(settingName, true); } catch (error) { return { diff --git a/js/modules/types/conversation.js b/js/modules/types/conversation.js index 1d2aad7cd2..1a0a8fc5a7 100644 --- a/js/modules/types/conversation.js +++ b/js/modules/types/conversation.js @@ -1,21 +1,14 @@ -/* global dcodeIO, crypto */ +/* global crypto */ const { isFunction, isNumber } = require('lodash'); const { createLastMessageUpdate } = require('../../../ts/types/Conversation'); +const { arrayBufferToBase64, base64ToArrayBuffer } = require('../crypto'); async function computeHash(arraybuffer) { const hash = await crypto.subtle.digest({ name: 'SHA-512' }, arraybuffer); return arrayBufferToBase64(hash); } -function arrayBufferToBase64(arraybuffer) { - return dcodeIO.ByteBuffer.wrap(arraybuffer).toString('base64'); -} - -function base64ToArrayBuffer(base64) { - return dcodeIO.ByteBuffer.wrap(base64, 'base64').toArrayBuffer(); -} - function buildAvatarUpdater({ field }) { return async (conversation, data, options = {}) => { if (!conversation) { diff --git a/js/modules/web_api.js b/js/modules/web_api.js index 00c0c8f7ca..dacdd72d10 100644 --- a/js/modules/web_api.js +++ b/js/modules/web_api.js @@ -80,6 +80,8 @@ function _ensureStringed(thing) { return res; } else if (thing === null) { return null; + } else if (thing === undefined) { + return undefined; } throw new Error(`unsure of how to jsonify object of type ${typeof thing}`); } @@ -160,7 +162,9 @@ function _createSocket(url, { certificateAuthority, proxyUrl }) { function _promiseAjax(providedUrl, options) { return new Promise((resolve, reject) => { const url = providedUrl || `${options.host}/${options.path}`; - log.info(options.type, url); + log.info( + `${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}` + ); const timeout = typeof options.timeout !== 'undefined' ? options.timeout : 10000; @@ -188,15 +192,26 @@ function _promiseAjax(providedUrl, options) { fetchOptions.headers['Content-Length'] = contentLength; } - if (options.user && options.password) { + const { accessKey, unauthenticated } = options; + if (unauthenticated) { + if (!accessKey) { + throw new Error( + '_promiseAjax: mode is aunathenticated, but accessKey was not provided' + ); + } + // Access key is already a Base64 string + fetchOptions.headers['Unidentified-Access-Key'] = accessKey; + } else if (options.user && options.password) { const user = _getString(options.user); const password = _getString(options.password); const auth = _btoa(`${user}:${password}`); fetchOptions.headers.Authorization = `Basic ${auth}`; } + if (options.contentType) { fetchOptions.headers['Content-Type'] = options.contentType; } + fetch(url, fetchOptions) .then(response => { let resultPromise; @@ -292,12 +307,14 @@ function HTTPError(message, providedCode, response, stack) { const URL_CALLS = { accounts: 'v1/accounts', + attachment: 'v1/attachments', + deliveryCert: 'v1/certificate/delivery', + supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', devices: 'v1/devices', keys: 'v2/keys', - signed: 'v2/keys/signed', messages: 'v1/messages', - attachment: 'v1/attachments', profile: 'v1/profile', + signed: 'v2/keys/signed', }; module.exports = { @@ -335,16 +352,21 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { getAttachment, getAvatar, getDevices, + getSenderCertificate, + registerSupportForUnauthenticatedDelivery, getKeysForNumber, + getKeysForNumberUnauth, getMessageSocket, getMyKeys, getProfile, + getProfileUnauth, getProvisioningSocket, putAttachment, registerKeys, requestVerificationSMS, requestVerificationVoice, sendMessages, + sendMessagesUnauth, setSignedPreKey, }; @@ -366,6 +388,8 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { type: param.httpType, user: username, validateResponse: param.validateResponse, + unauthenticated: param.unauthenticated, + accessKey: param.accessKey, }).catch(e => { const { code } = e; if (code === 200) { @@ -405,6 +429,23 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { }); } + function getSenderCertificate() { + return _ajax({ + call: 'deliveryCert', + httpType: 'GET', + responseType: 'json', + schema: { certificate: 'string' }, + }); + } + + function registerSupportForUnauthenticatedDelivery() { + return _ajax({ + call: 'supportUnauthenticatedDelivery', + httpType: 'PUT', + responseType: 'json', + }); + } + function getProfile(number) { return _ajax({ call: 'profile', @@ -413,6 +454,16 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { responseType: 'json', }); } + function getProfileUnauth(number, { accessKey } = {}) { + return _ajax({ + call: 'profile', + httpType: 'GET', + urlParameters: `/${number}`, + responseType: 'json', + unauthenticated: true, + accessKey, + }); + } function getAvatar(path) { // Using _outerAJAX, since it's not hardcoded to the Signal Server. Unlike our @@ -449,13 +500,19 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { newPassword, signalingKey, registrationId, - deviceName + deviceName, + options = {} ) { + const { accessKey } = options; const jsonData = { signalingKey: _btoa(_getString(signalingKey)), supportsSms: false, fetchesMessages: true, registrationId, + unidentifiedAccessKey: accessKey + ? _btoa(_getString(accessKey)) + : undefined, + unrestrictedUnidentifiedAccess: false, }; let call; @@ -552,6 +609,43 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { }).then(res => res.count); } + function handleKeys(res) { + if (!Array.isArray(res.devices)) { + throw new Error('Invalid response'); + } + res.identityKey = _base64ToBytes(res.identityKey); + res.devices.forEach(device => { + if ( + !_validateResponse(device, { signedPreKey: 'object' }) || + !_validateResponse(device.signedPreKey, { + publicKey: 'string', + signature: 'string', + }) + ) { + throw new Error('Invalid signedPreKey'); + } + if (device.preKey) { + if ( + !_validateResponse(device, { preKey: 'object' }) || + !_validateResponse(device.preKey, { publicKey: 'string' }) + ) { + throw new Error('Invalid preKey'); + } + // eslint-disable-next-line no-param-reassign + device.preKey.publicKey = _base64ToBytes(device.preKey.publicKey); + } + // eslint-disable-next-line no-param-reassign + device.signedPreKey.publicKey = _base64ToBytes( + device.signedPreKey.publicKey + ); + // eslint-disable-next-line no-param-reassign + device.signedPreKey.signature = _base64ToBytes( + device.signedPreKey.signature + ); + }); + return res; + } + function getKeysForNumber(number, deviceId = '*') { return _ajax({ call: 'keys', @@ -559,41 +653,46 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { urlParameters: `/${number}/${deviceId}`, responseType: 'json', validateResponse: { identityKey: 'string', devices: 'object' }, - }).then(res => { - if (res.devices.constructor !== Array) { - throw new Error('Invalid response'); - } - res.identityKey = _base64ToBytes(res.identityKey); - res.devices.forEach(device => { - if ( - !_validateResponse(device, { signedPreKey: 'object' }) || - !_validateResponse(device.signedPreKey, { - publicKey: 'string', - signature: 'string', - }) - ) { - throw new Error('Invalid signedPreKey'); - } - if (device.preKey) { - if ( - !_validateResponse(device, { preKey: 'object' }) || - !_validateResponse(device.preKey, { publicKey: 'string' }) - ) { - throw new Error('Invalid preKey'); - } - // eslint-disable-next-line no-param-reassign - device.preKey.publicKey = _base64ToBytes(device.preKey.publicKey); - } - // eslint-disable-next-line no-param-reassign - device.signedPreKey.publicKey = _base64ToBytes( - device.signedPreKey.publicKey - ); - // eslint-disable-next-line no-param-reassign - device.signedPreKey.signature = _base64ToBytes( - device.signedPreKey.signature - ); - }); - return res; + }).then(handleKeys); + } + + function getKeysForNumberUnauth( + number, + deviceId = '*', + { accessKey } = {} + ) { + return _ajax({ + call: 'keys', + httpType: 'GET', + urlParameters: `/${number}/${deviceId}`, + responseType: 'json', + validateResponse: { identityKey: 'string', devices: 'object' }, + unauthenticated: true, + accessKey, + }).then(handleKeys); + } + + function sendMessagesUnauth( + destination, + messageArray, + timestamp, + silent, + { accessKey } = {} + ) { + const jsonData = { messages: messageArray, timestamp }; + + if (silent) { + jsonData.silent = true; + } + + return _ajax({ + call: 'messages', + httpType: 'PUT', + urlParameters: `/${destination}`, + jsonData, + responseType: 'json', + unauthenticated: true, + accessKey, }); } diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index c85e36c046..2f80743d4f 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -6,7 +6,8 @@ btoa, getString, libphonenumber, - Event + Event, + ConversationController */ /* eslint-disable more/no-then */ @@ -52,19 +53,29 @@ const confirmKeys = this.confirmKeys.bind(this); const registrationDone = this.registrationDone.bind(this); return this.queueTask(() => - libsignal.KeyHelper.generateIdentityKeyPair().then(identityKeyPair => { - const profileKey = textsecure.crypto.getRandomBytes(32); - return createAccount( - number, - verificationCode, - identityKeyPair, - profileKey - ) - .then(clearSessionsAndPreKeys) - .then(generateKeys) - .then(keys => registerKeys(keys).then(() => confirmKeys(keys))) - .then(registrationDone); - }) + libsignal.KeyHelper.generateIdentityKeyPair().then( + async identityKeyPair => { + const profileKey = textsecure.crypto.getRandomBytes(32); + const accessKey = await window.Signal.Crypto.deriveAccessKey( + profileKey + ); + + return createAccount( + number, + verificationCode, + identityKeyPair, + profileKey, + null, + null, + null, + { accessKey } + ) + .then(clearSessionsAndPreKeys) + .then(generateKeys) + .then(keys => registerKeys(keys).then(() => confirmKeys(keys))) + .then(() => registrationDone(number)); + } + ) ); }, registerSecondDevice(setProvisioningUrl, confirmNumber, progressCallback) { @@ -147,7 +158,9 @@ confirmKeys(keys) ) ) - .then(registrationDone); + .then(() => + registrationDone(provisionMessage.number) + ); } ) ) @@ -185,8 +198,6 @@ const store = textsecure.storage.protocol; const { server, cleanSignedPreKeys } = this; - // TODO: harden this against missing identity key? Otherwise, we get - // retries every five seconds. return store .getIdentityKeyPair() .then( @@ -196,6 +207,8 @@ signedKeyId ), () => { + // We swallow any error here, because we don't want to get into + // a loop of repeated retries. window.log.error( 'Failed to get identity key. Canceling key rotation.' ); @@ -329,8 +342,10 @@ profileKey, deviceName, userAgent, - readReceipts + readReceipts, + options = {} ) { + const { accessKey } = options; const signalingKey = libsignal.crypto.getRandomBytes(32 + 20); let password = btoa(getString(libsignal.crypto.getRandomBytes(16))); password = password.substring(0, password.length - 2); @@ -345,7 +360,8 @@ password, signalingKey, registrationId, - deviceName + deviceName, + { accessKey } ) .then(response => { if (previousNumber && previousNumber !== number) { @@ -499,8 +515,12 @@ ); }); }, - registrationDone() { + async registrationDone(number) { window.log.info('registration done'); + + // Ensure that we always have a conversation for ourself + await ConversationController.getOrCreateAndWait(number, 'private'); + this.dispatchEvent(new Event('registration')); }, }); diff --git a/libtextsecure/libsignal-protocol.js b/libtextsecure/libsignal-protocol.js index 019538dc9d..bb5b080a0b 100644 --- a/libtextsecure/libsignal-protocol.js +++ b/libtextsecure/libsignal-protocol.js @@ -22848,7 +22848,7 @@ function _memset(ptr, value, num) { } } while ((ptr|0) < (stop4|0)) { - HEAP32[ptr>>2]=value4; + HEAP32[((ptr)>>2)]=value4; ptr = (ptr+4)|0; } } @@ -22904,7 +22904,7 @@ function _memcpy(dest, src, num) { num = (num-1)|0; } while ((num|0) >= 4) { - HEAP32[dest>>2]=((HEAP32[src>>2])|0); + HEAP32[((dest)>>2)]=((HEAP32[((src)>>2)])|0); dest = (dest+4)|0; src = (src+4)|0; num = (num-4)|0; @@ -35093,7 +35093,6 @@ Curve25519Worker.prototype = { if (pubKey.byteLength == 33) { return pubKey.slice(1); } else { - console.error("WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey"); return pubKey; } } @@ -35179,7 +35178,10 @@ Curve25519Worker.prototype = { }, calculateSignature: function(privKey, message) { return curve.Ed25519Sign(privKey, message); - } + }, + validatePubKeyFormat: function(buffer) { + return validatePubKeyFormat(buffer); + }, }; } @@ -35272,10 +35274,6 @@ var Internal = Internal || {}; // HKDF for TextSecure has a bit of additional handling - salts always end up being 32 bytes Internal.HKDF = function(input, salt, info) { - if (salt.byteLength != 32) { - throw new Error("Got salt of incorrect length"); - } - return Internal.crypto.HKDF(input, salt, util.toArrayBuffer(info)); }; @@ -35460,7 +35458,7 @@ Internal.protoText = function() { /* vim: ts=4:sw=4 */ var Internal = Internal || {}; -Internal.protobuf = function() { +Internal.protobuf = (function() { 'use strict'; function loadProtoBufs(filename) { @@ -35473,7 +35471,7 @@ Internal.protobuf = function() { WhisperMessage : protocolMessages.WhisperMessage, PreKeyWhisperMessage : protocolMessages.PreKeyWhisperMessage }; -}(); +})(); /* * vim: ts=4:sw=4 @@ -35888,6 +35886,7 @@ SessionBuilder.prototype = { if (message.preKeyId && !preKeyPair) { console.log('Invalid prekey id', message.preKeyId); } + return this.initSession(false, preKeyPair, signedPreKeyPair, message.identityKey.toArrayBuffer(), message.baseKey.toArrayBuffer(), undefined, message.registrationId @@ -36028,6 +36027,7 @@ SessionCipher.prototype = { return Internal.SessionRecord.deserialize(serialized); }); }, + // encoding is an optional parameter - wrap() will only translate if one is provided encrypt: function(buffer, encoding) { buffer = dcodeIO.ByteBuffer.wrap(buffer, encoding).toArrayBuffer(); return Internal.SessionLock.queueJobForNumber(this.remoteAddress.toString(), function() { @@ -36370,6 +36370,20 @@ SessionCipher.prototype = { }); }); }, + getSessionVersion: function() { + return Internal.SessionLock.queueJobForNumber(this.remoteAddress.toString(), function() { + return this.getRecord(this.remoteAddress.toString()).then(function(record) { + if (record === undefined) { + return undefined; + } + var openSession = record.getOpenSession(); + if (openSession === undefined || openSession.indexInfo === undefined) { + return null; + } + return openSession.indexInfo.baseKeyType; + }); + }.bind(this)); + }, getRemoteRegistrationId: function() { return Internal.SessionLock.queueJobForNumber(this.remoteAddress.toString(), function() { return this.getRecord(this.remoteAddress.toString()).then(function(record) { @@ -36428,6 +36442,7 @@ libsignal.SessionCipher = function(storage, remoteAddress) { // returns a Promise that resolves to a ciphertext object this.encrypt = cipher.encrypt.bind(cipher); + this.getRecord = cipher.getRecord.bind(cipher); // returns a Promise that inits a session if necessary and resolves // to a decrypted plaintext array buffer diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 7ea1e91edc..ee845441df 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -123,6 +123,13 @@ function MessageReceiver(username, password, signalingKey, options = {}) { this.password = password; this.server = WebAPI.connect({ username, password }); + if (!options.serverTrustRoot) { + throw new Error('Server trust root is required!'); + } + this.serverTrustRoot = window.Signal.Crypto.base64ToArrayBuffer( + options.serverTrustRoot + ); + const address = libsignal.SignalProtocolAddress.fromString(username); this.number = address.getName(); this.deviceId = address.getDeviceId(); @@ -279,6 +286,8 @@ MessageReceiver.prototype.extend({ return request.respond(200, 'OK'); } + envelope.id = envelope.serverGuid || window.getGuid(); + return this.addToCache(envelope, plaintext).then( async () => { request.respond(200, 'OK'); @@ -437,9 +446,13 @@ MessageReceiver.prototype.extend({ } }, getEnvelopeId(envelope) { - return `${envelope.source}.${ - envelope.sourceDevice - } ${envelope.timestamp.toNumber()}`; + if (envelope.source) { + return `${envelope.source}.${ + envelope.sourceDevice + } ${envelope.timestamp.toNumber()} (${envelope.id})`; + } + + return envelope.id; }, async getAllFromCache() { window.log.info('getAllFromCache'); @@ -482,7 +495,7 @@ MessageReceiver.prototype.extend({ ); }, async addToCache(envelope, plaintext) { - const id = this.getEnvelopeId(envelope); + const { id } = envelope; const data = { id, version: 2, @@ -493,7 +506,7 @@ MessageReceiver.prototype.extend({ return textsecure.storage.unprocessed.add(data); }, async updateCache(envelope, plaintext) { - const id = this.getEnvelopeId(envelope); + const { id } = envelope; const item = await textsecure.storage.unprocessed.get(id); if (!item) { window.log.error( @@ -517,11 +530,11 @@ MessageReceiver.prototype.extend({ return textsecure.storage.unprocessed.save(item.attributes); }, removeFromCache(envelope) { - const id = this.getEnvelopeId(envelope); + const { id } = envelope; return textsecure.storage.unprocessed.remove(id); }, queueDecryptedEnvelope(envelope, plaintext) { - const id = this.getEnvelopeId(envelope); + const { id } = envelope; window.log.info('queueing decrypted envelope', id); const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext); @@ -626,6 +639,8 @@ MessageReceiver.prototype.extend({ return plaintext; }, decrypt(envelope, ciphertext) { + const { serverTrustRoot } = this; + let promise; const address = new libsignal.SignalProtocolAddress( envelope.source, @@ -646,6 +661,14 @@ MessageReceiver.prototype.extend({ address, options ); + const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher( + textsecure.storage.protocol + ); + + const me = { + number: ourNumber, + deviceId: parseInt(textsecure.storage.user.getDeviceId(), 10), + }; switch (envelope.type) { case textsecure.protobuf.Envelope.Type.CIPHERTEXT: @@ -662,13 +685,76 @@ MessageReceiver.prototype.extend({ address ); break; + case textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER: + window.log.info('received unidentified sender message'); + promise = secretSessionCipher + .decrypt( + window.Signal.Metadata.createCertificateValidator(serverTrustRoot), + ciphertext.toArrayBuffer(), + Math.min( + envelope.serverTimestamp + ? envelope.serverTimestamp.toNumber() + : Date.now(), + Date.now() + ), + me + ) + .then( + result => { + const { isMe, sender, content } = result; + + // We need to drop incoming messages from ourself since server can't + // do it for us + if (isMe) { + return { isMe: true }; + } + + // Here we take this sender information and attach it back to the envelope + // to make the rest of the app work properly. + + // eslint-disable-next-line no-param-reassign + envelope.source = sender.getName(); + // eslint-disable-next-line no-param-reassign + envelope.sourceDevice = sender.getDeviceId(); + // eslint-disable-next-line no-param-reassign + envelope.unidentifiedDeliveryReceived = true; + + // Return just the content because that matches the signature of the other + // decrypt methods used above. + return this.unpad(content); + }, + error => { + const { sender } = error || {}; + + if (sender) { + // eslint-disable-next-line no-param-reassign + envelope.source = sender.getName(); + // eslint-disable-next-line no-param-reassign + envelope.sourceDevice = sender.getDeviceId(); + // eslint-disable-next-line no-param-reassign + envelope.unidentifiedDeliveryReceived = true; + + throw error; + } + + return this.removeFromCache().then(() => { + throw error; + }); + } + ); + break; default: promise = Promise.reject(new Error('Unknown message type')); } return promise - .then(plaintext => - this.updateCache(envelope, plaintext).then( + .then(plaintext => { + const { isMe } = plaintext || {}; + if (isMe) { + return this.removeFromCache(envelope); + } + + return this.updateCache(envelope, plaintext).then( () => plaintext, error => { window.log.error( @@ -677,8 +763,8 @@ MessageReceiver.prototype.extend({ ); return plaintext; } - ) - ) + ); + }) .catch(error => { let errorToThrow = error; @@ -720,13 +806,14 @@ MessageReceiver.prototype.extend({ throw e; } }, - handleSentMessage( - envelope, - destination, - timestamp, - msg, - expirationStartTimestamp - ) { + handleSentMessage(envelope, sentContainer, msg) { + const { + destination, + timestamp, + expirationStartTimestamp, + unidentifiedStatus, + } = sentContainer; + let p = Promise.resolve(); // eslint-disable-next-line no-bitwise if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) { @@ -757,6 +844,7 @@ MessageReceiver.prototype.extend({ destination, timestamp: timestamp.toNumber(), device: envelope.sourceDevice, + unidentifiedStatus, message, }; if (expirationStartTimestamp) { @@ -799,6 +887,7 @@ MessageReceiver.prototype.extend({ sourceDevice: envelope.sourceDevice, timestamp: envelope.timestamp.toNumber(), receivedAt: envelope.receivedAt, + unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, message, }; return this.dispatchAndWait(ev); @@ -806,18 +895,26 @@ MessageReceiver.prototype.extend({ ); }, handleLegacyMessage(envelope) { - return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => - this.innerHandleLegacyMessage(envelope, plaintext) - ); + return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => { + if (!plaintext) { + window.log.warn('handleLegacyMessage: plaintext was falsey'); + return null; + } + return this.innerHandleLegacyMessage(envelope, plaintext); + }); }, innerHandleLegacyMessage(envelope, plaintext) { const message = textsecure.protobuf.DataMessage.decode(plaintext); return this.handleDataMessage(envelope, message); }, handleContentMessage(envelope) { - return this.decrypt(envelope, envelope.content).then(plaintext => - this.innerHandleContentMessage(envelope, plaintext) - ); + return this.decrypt(envelope, envelope.content).then(plaintext => { + if (!plaintext) { + window.log.warn('handleContentMessage: plaintext was falsey'); + return null; + } + return this.innerHandleContentMessage(envelope, plaintext); + }); }, innerHandleContentMessage(envelope, plaintext) { const content = textsecure.protobuf.Content.decode(plaintext); @@ -895,13 +992,7 @@ MessageReceiver.prototype.extend({ 'from', this.getEnvelopeId(envelope) ); - return this.handleSentMessage( - envelope, - sentMessage.destination, - sentMessage.timestamp, - sentMessage.message, - sentMessage.expirationStartTimestamp - ); + return this.handleSentMessage(envelope, sentMessage, sentMessage.message); } else if (syncMessage.contacts) { return this.handleContacts(envelope, syncMessage.contacts); } else if (syncMessage.groups) { @@ -922,11 +1013,10 @@ MessageReceiver.prototype.extend({ throw new Error('Got empty SyncMessage'); }, handleConfiguration(envelope, configuration) { + window.log.info('got configuration sync message'); const ev = new Event('configuration'); ev.confirm = this.removeFromCache.bind(this, envelope); - ev.configuration = { - readReceipts: configuration.readReceipts, - }; + ev.configuration = configuration; return this.dispatchAndWait(ev); }, handleVerified(envelope, verified) { @@ -1075,102 +1165,6 @@ MessageReceiver.prototype.extend({ .then(decryptAttachment) .then(updateAttachment); }, - validateRetryContentMessage(content) { - // Today this is only called for incoming identity key errors, so it can't be a sync - // message. - if (content.syncMessage) { - return false; - } - - // We want at least one field set, but not more than one - let count = 0; - count += content.dataMessage ? 1 : 0; - count += content.callMessage ? 1 : 0; - count += content.nullMessage ? 1 : 0; - if (count !== 1) { - return false; - } - - // It's most likely that dataMessage will be populated, so we look at it in detail - const data = content.dataMessage; - if ( - data && - !data.attachments.length && - !data.body && - !data.expireTimer && - !data.flags && - !data.group - ) { - return false; - } - - return true; - }, - tryMessageAgain(from, ciphertext, message) { - const address = libsignal.SignalProtocolAddress.fromString(from); - const sentAt = message.sent_at || Date.now(); - const receivedAt = message.received_at || Date.now(); - - const ourNumber = textsecure.storage.user.getNumber(); - const number = address.getName(); - const device = address.getDeviceId(); - const options = {}; - - // No limit on message keys if we're communicating with our other devices - if (ourNumber === number) { - options.messageKeysLimit = false; - } - - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - address, - options - ); - window.log.info('retrying prekey whisper message'); - return this.decryptPreKeyWhisperMessage( - ciphertext, - sessionCipher, - address - ).then(plaintext => { - const envelope = { - source: number, - sourceDevice: device, - receivedAt, - timestamp: { - toNumber() { - return sentAt; - }, - }, - }; - - // Before June, all incoming messages were still DataMessage: - // - iOS: Michael Kirk says that they were sending Legacy messages until June - // - Desktop: https://github.com/signalapp/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f - // - Android: https://github.com/signalapp/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958 - // - // var d = new Date('2017-06-01T07:00:00.000Z'); - // d.getTime(); - const startOfJune = 1496300400000; - if (sentAt < startOfJune) { - return this.innerHandleLegacyMessage(envelope, plaintext); - } - - // This is ugly. But we don't know what kind of proto we need to decode... - try { - // Simply decoding as a Content message may throw - const content = textsecure.protobuf.Content.decode(plaintext); - - // But it might also result in an invalid object, so we try to detect that - if (this.validateRetryContentMessage(content)) { - return this.innerHandleContentMessage(envelope, plaintext); - } - } catch (e) { - return this.innerHandleLegacyMessage(envelope, plaintext); - } - - return this.innerHandleLegacyMessage(envelope, plaintext); - }); - }, async handleEndSession(number) { window.log.info('got end session'); const deviceIds = await textsecure.storage.protocol.getDeviceIds(number); diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 15410be90c..6c7eb94d35 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -1,4 +1,4 @@ -/* global textsecure, libsignal, window, btoa */ +/* global textsecure, libsignal, window, btoa, _ */ /* eslint-disable more/no-then */ @@ -8,7 +8,8 @@ function OutgoingMessage( numbers, message, silent, - callback + callback, + options = {} ) { if (message instanceof textsecure.protobuf.DataMessage) { const content = new textsecure.protobuf.Content(); @@ -26,6 +27,12 @@ function OutgoingMessage( this.numbersCompleted = 0; this.errors = []; this.successfulNumbers = []; + this.failoverNumbers = []; + this.unidentifiedDeliveries = []; + + const { numberInfo, senderCertificate } = options; + this.numberInfo = numberInfo; + this.senderCertificate = senderCertificate; } OutgoingMessage.prototype = { @@ -35,7 +42,9 @@ OutgoingMessage.prototype = { if (this.numbersCompleted >= this.numbers.length) { this.callback({ successfulNumbers: this.successfulNumbers, + failoverNumbers: this.failoverNumbers, errors: this.errors, + unidentifiedDeliveries: this.unidentifiedDeliveries, }); } }, @@ -57,7 +66,7 @@ OutgoingMessage.prototype = { this.errors[this.errors.length] = error; this.numberCompleted(); }, - reloadDevicesAndSend(number, recurse) { + reloadDevicesAndSend(number, recurse, failover) { return () => textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => { if (deviceIds.length === 0) { @@ -67,7 +76,7 @@ OutgoingMessage.prototype = { null ); } - return this.doSendMessage(number, deviceIds, recurse); + return this.doSendMessage(number, deviceIds, recurse, failover); }); }, @@ -109,51 +118,108 @@ OutgoingMessage.prototype = { }) ); + const { numberInfo } = this; + const info = numberInfo && numberInfo[number] ? numberInfo[number] : {}; + const { accessKey } = info || {}; + if (updateDevices === undefined) { - return this.server.getKeysForNumber(number).then(handleResult); - } - let promise = Promise.resolve(); - updateDevices.forEach(device => { - promise = promise.then(() => - this.server - .getKeysForNumber(number, device) - .then(handleResult) - .catch(e => { - if (e.name === 'HTTPError' && e.code === 404) { - if (device !== 1) { - return this.removeDeviceIdsForNumber(number, [device]); + if (accessKey) { + return this.server + .getKeysForNumberUnauth(number, '*', { accessKey }) + .catch(error => { + if (error.code === 401 || error.code === 403) { + if (this.failoverNumbers.indexOf(number) === -1) { + this.failoverNumbers.push(number); } - throw new textsecure.UnregisteredUserError(number, e); - } else { - throw e; + return this.server.getKeysForNumber(number, '*'); } + throw error; }) - ); + .then(handleResult); + } + + return this.server.getKeysForNumber(number, '*').then(handleResult); + } + + let promise = Promise.resolve(); + updateDevices.forEach(deviceId => { + promise = promise.then(() => { + let innerPromise; + + if (accessKey) { + innerPromise = this.server + .getKeysForNumberUnauth(number, deviceId, { accessKey }) + .then(handleResult) + .catch(error => { + if (error.code === 401 || error.code === 403) { + if (this.failoverNumbers.indexOf(number) === -1) { + this.failoverNumbers.push(number); + } + return this.server + .getKeysForNumber(number, deviceId) + .then(handleResult); + } + throw error; + }); + } else { + innerPromise = this.server + .getKeysForNumber(number, deviceId) + .then(handleResult); + } + + return innerPromise.catch(e => { + if (e.name === 'HTTPError' && e.code === 404) { + if (deviceId !== 1) { + return this.removeDeviceIdsForNumber(number, [deviceId]); + } + throw new textsecure.UnregisteredUserError(number, e); + } else { + throw e; + } + }); + }); }); return promise; }, - transmitMessage(number, jsonData, timestamp) { - return this.server - .sendMessages(number, jsonData, timestamp, this.silent) - .catch(e => { - if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) { - // 409 and 410 should bubble and be handled by doSendMessage - // 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.SendMessageNetworkError( - number, - jsonData, - e, - timestamp - ); + transmitMessage(number, jsonData, timestamp, { accessKey } = {}) { + let promise; + + if (accessKey) { + promise = this.server.sendMessagesUnauth( + number, + jsonData, + timestamp, + this.silent, + { accessKey } + ); + } else { + promise = this.server.sendMessages( + number, + jsonData, + timestamp, + this.silent + ); + } + + return promise.catch(e => { + if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) { + // 409 and 410 should bubble and be handled by doSendMessage + // 404 should throw UnregisteredUserError + // all other network errors can be retried later. + if (e.code === 404) { + throw new textsecure.UnregisteredUserError(number, e); } - throw e; - }); + throw new textsecure.SendMessageNetworkError( + number, + jsonData, + e, + timestamp + ); + } + throw e; + }); }, getPaddedMessageLength(messageLength) { @@ -179,15 +245,42 @@ OutgoingMessage.prototype = { return this.plaintext; }, - doSendMessage(number, deviceIds, recurse) { + doSendMessage(number, deviceIds, recurse, failover) { const ciphers = {}; const plaintext = this.getPlaintext(); + const { numberInfo, senderCertificate } = this; + const info = numberInfo && numberInfo[number] ? numberInfo[number] : {}; + const { accessKey } = info || {}; + + if (accessKey && !senderCertificate) { + return Promise.reject( + new Error( + 'OutgoingMessage.doSendMessage: accessKey was provided, but senderCertificate was not' + ) + ); + } + + // If failover is true, we don't send an unidentified sender message + const sealedSender = Boolean(!failover && accessKey && senderCertificate); + + // We don't send to ourselves if unless sealedSender is enabled + const ourNumber = textsecure.storage.user.getNumber(); + const ourDeviceId = textsecure.storage.user.getDeviceId(); + if (number === ourNumber && !sealedSender) { + // eslint-disable-next-line no-param-reassign + deviceIds = _.reject( + deviceIds, + deviceId => + // because we store our own device ID as a string at least sometimes + deviceId === ourDeviceId || deviceId === parseInt(ourDeviceId, 10) + ); + } + return Promise.all( - deviceIds.map(deviceId => { + deviceIds.map(async deviceId => { const address = new libsignal.SignalProtocolAddress(number, deviceId); - const ourNumber = textsecure.storage.user.getNumber(); const options = {}; // No limit on message keys if we're communicating with our other devices @@ -195,26 +288,80 @@ OutgoingMessage.prototype = { options.messageKeysLimit = false; } + // If failover is true, we don't send an unidentified sender message + if (sealedSender) { + const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher( + textsecure.storage.protocol + ); + ciphers[address.getDeviceId()] = secretSessionCipher; + + const ciphertext = await secretSessionCipher.encrypt( + address, + senderCertificate, + plaintext + ); + return { + type: textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER, + destinationDeviceId: address.getDeviceId(), + destinationRegistrationId: await secretSessionCipher.getRemoteRegistrationId( + address + ), + content: window.Signal.Crypto.arrayBufferToBase64(ciphertext), + }; + } + const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, address, options ); ciphers[address.getDeviceId()] = sessionCipher; - return sessionCipher.encrypt(plaintext).then(ciphertext => ({ + + const ciphertext = await sessionCipher.encrypt(plaintext); + return { type: ciphertext.type, destinationDeviceId: address.getDeviceId(), destinationRegistrationId: ciphertext.registrationId, content: btoa(ciphertext.body), - })); + }; }) ) - .then(jsonData => - this.transmitMessage(number, jsonData, this.timestamp).then(() => { - this.successfulNumbers[this.successfulNumbers.length] = number; - this.numberCompleted(); - }) - ) + .then(jsonData => { + if (sealedSender) { + return this.transmitMessage(number, jsonData, this.timestamp, { + accessKey, + }).then( + () => { + this.unidentifiedDeliveries.push(number); + this.successfulNumbers.push(number); + this.numberCompleted(); + }, + error => { + if (error.code === 401 || error.code === 403) { + if (this.failoverNumbers.indexOf(number) === -1) { + this.failoverNumbers.push(number); + } + if (info) { + info.accessKey = null; + } + + // Set final parameter to true to ensure we don't hit this codepath a + // second time. + return this.doSendMessage(number, deviceIds, recurse, true); + } + + throw error; + } + ); + } + + return this.transmitMessage(number, jsonData, this.timestamp).then( + () => { + this.successfulNumbers.push(number); + this.numberCompleted(); + } + ); + }) .catch(error => { if ( error instanceof Error && @@ -237,7 +384,9 @@ OutgoingMessage.prototype = { } else { p = Promise.all( error.response.staleDevices.map(deviceId => - ciphers[deviceId].closeOpenSessionForDevice() + ciphers[deviceId].closeOpenSessionForDevice( + new libsignal.SignalProtocolAddress(number, deviceId) + ) ) ); } @@ -248,7 +397,9 @@ OutgoingMessage.prototype = { ? error.response.staleDevices : error.response.missingDevices; return this.getKeysForNumber(number, resetDevices).then( - this.reloadDevicesAndSend(number, error.code === 409) + // For now, we we won't retry unidentified delivery if we get here; new + // devices could have been added which don't support it. + this.reloadDevicesAndSend(number, error.code === 409, true) ); }); } else if (error.message === 'Identity key changed') { @@ -305,28 +456,28 @@ OutgoingMessage.prototype = { return promise; }, - sendToNumber(number) { - return this.getStaleDeviceIdsForNumber(number).then(updateDevices => - this.getKeysForNumber(number, updateDevices) - .then(this.reloadDevicesAndSend(number, true)) - .catch(error => { - if (error.message === 'Identity key changed') { - // eslint-disable-next-line no-param-reassign - error = new textsecure.OutgoingIdentityKeyError( - number, - error.originalMessage, - error.timestamp, - error.identityKey - ); - this.registerError(number, 'Identity key changed', error); - } else { - this.registerError( - number, - `Failed to retrieve new device keys for number ${number}`, - error - ); - } - }) - ); + async sendToNumber(number) { + try { + const updateDevices = await this.getStaleDeviceIdsForNumber(number); + await this.getKeysForNumber(number, updateDevices); + await this.reloadDevicesAndSend(number, true)(); + } catch (error) { + if (error.message === 'Identity key changed') { + // eslint-disable-next-line no-param-reassign + const newError = new textsecure.OutgoingIdentityKeyError( + number, + error.originalMessage, + error.timestamp, + error.identityKey + ); + this.registerError(number, 'Identity key changed', newError); + } else { + this.registerError( + number, + `Failed to retrieve new device keys for number ${number}`, + error + ); + } + } }, }; diff --git a/libtextsecure/protobufs.js b/libtextsecure/protobufs.js index 476280b5bc..d2da5e2cc1 100644 --- a/libtextsecure/protobufs.js +++ b/libtextsecure/protobufs.js @@ -35,4 +35,7 @@ loadProtoBufs('SignalService.proto'); loadProtoBufs('SubProtocol.proto'); loadProtoBufs('DeviceMessages.proto'); + + // Metadata-specific protos + loadProtoBufs('UnidentifiedDelivery.proto'); })(); diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 30106e9272..f92b65a54f 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -190,71 +190,6 @@ MessageSender.prototype = { ); }, - retransmitMessage(number, jsonData, timestamp) { - const outgoing = new OutgoingMessage(this.server); - return outgoing.transmitMessage(number, jsonData, timestamp); - }, - - validateRetryContentMessage(content) { - // We want at least one field set, but not more than one - let count = 0; - count += content.syncMessage ? 1 : 0; - count += content.dataMessage ? 1 : 0; - count += content.callMessage ? 1 : 0; - count += content.nullMessage ? 1 : 0; - if (count !== 1) { - return false; - } - - // It's most likely that dataMessage will be populated, so we look at it in detail - const data = content.dataMessage; - if ( - data && - !data.attachments.length && - !data.body && - !data.expireTimer && - !data.flags && - !data.group - ) { - return false; - } - - return true; - }, - - getRetryProto(message, timestamp) { - // If message was sent before v0.41.3 was released on Aug 7, then it was most - // certainly a DataMessage - // - // var d = new Date('2017-08-07T07:00:00.000Z'); - // d.getTime(); - const august7 = 1502089200000; - if (timestamp < august7) { - return textsecure.protobuf.DataMessage.decode(message); - } - - // This is ugly. But we don't know what kind of proto we need to decode... - try { - // Simply decoding as a Content message may throw - const proto = textsecure.protobuf.Content.decode(message); - - // But it might also result in an invalid object, so we try to detect that - if (this.validateRetryContentMessage(proto)) { - return proto; - } - - return textsecure.protobuf.DataMessage.decode(message); - } catch (e) { - // If this call throws, something has really gone wrong, we'll fail to send - return textsecure.protobuf.DataMessage.decode(message); - } - }, - - tryMessageAgain(number, encodedMessage, timestamp) { - const proto = this.getRetryProto(encodedMessage, timestamp); - return this.sendIndividualProto(number, proto, timestamp); - }, - queueJobForNumber(number, runJob) { const taskWithTimeout = textsecure.createTaskWithTimeout( runJob, @@ -321,8 +256,10 @@ MessageSender.prototype = { }); }, - sendMessage(attrs) { + sendMessage(attrs, options) { const message = new Message(attrs); + const silent = false; + return Promise.all([ this.uploadAttachments(message), this.uploadThumbnails(message), @@ -340,12 +277,21 @@ MessageSender.prototype = { } else { resolve(res); } - } + }, + silent, + options ); }) ); }, - sendMessageProto(timestamp, numbers, message, callback, silent) { + sendMessageProto( + timestamp, + numbers, + message, + callback, + silent, + options = {} + ) { const rejections = textsecure.storage.get('signedKeyRotationRejected', 0); if (rejections > 5) { throw new textsecure.SignedPreKeyRotationError( @@ -361,7 +307,8 @@ MessageSender.prototype = { numbers, message, silent, - callback + callback, + options ); numbers.forEach(number => { @@ -369,20 +316,7 @@ MessageSender.prototype = { }); }, - retrySendMessageProto(numbers, encodedMessage, timestamp) { - const proto = textsecure.protobuf.DataMessage.decode(encodedMessage); - return new Promise((resolve, reject) => { - this.sendMessageProto(timestamp, numbers, proto, res => { - if (res.errors.length > 0) { - reject(res); - } else { - resolve(res); - } - }); - }); - }, - - sendIndividualProto(number, proto, timestamp, silent) { + sendIndividualProto(number, proto, timestamp, silent, options = {}) { return new Promise((resolve, reject) => { const callback = res => { if (res.errors.length > 0) { @@ -391,7 +325,14 @@ MessageSender.prototype = { resolve(res); } }; - this.sendMessageProto(timestamp, [number], proto, callback, silent); + this.sendMessageProto( + timestamp, + [number], + proto, + callback, + silent, + options + ); }); }, @@ -412,7 +353,10 @@ MessageSender.prototype = { encodedDataMessage, timestamp, destination, - expirationStartTimestamp + expirationStartTimestamp, + sentTo = [], + unidentifiedDeliveries = [], + options ) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); @@ -432,6 +376,27 @@ MessageSender.prototype = { if (expirationStartTimestamp) { sentMessage.expirationStartTimestamp = expirationStartTimestamp; } + + const unidentifiedLookup = unidentifiedDeliveries.reduce( + (accumulator, item) => { + // eslint-disable-next-line no-param-reassign + accumulator[item] = true; + return accumulator; + }, + Object.create(null) + ); + + // 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 => { + const status = new textsecure.protobuf.SyncMessage.Sent.UnidentifiedDeliveryStatus(); + status.destination = number; + status.unidentified = Boolean(unidentifiedLookup[number]); + return status; + }); + } + const syncMessage = this.createSyncMessage(); syncMessage.sent = sentMessage; const contentMessage = new textsecure.protobuf.Content(); @@ -442,18 +407,24 @@ MessageSender.prototype = { myNumber, contentMessage, Date.now(), - silent + silent, + options ); }, - getProfile(number) { + async getProfile(number, { accessKey } = {}) { + if (accessKey) { + return this.server.getProfileUnauth(number, { accessKey }); + } + return this.server.getProfile(number); }, + getAvatar(path) { return this.server.getAvatar(path); }, - sendRequestConfigurationSyncMessage() { + sendRequestConfigurationSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { @@ -469,13 +440,14 @@ MessageSender.prototype = { myNumber, contentMessage, Date.now(), - silent + silent, + options ); } return Promise.resolve(); }, - sendRequestGroupSyncMessage() { + sendRequestGroupSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { @@ -491,14 +463,15 @@ MessageSender.prototype = { myNumber, contentMessage, Date.now(), - silent + silent, + options ); } return Promise.resolve(); }, - sendRequestContactSyncMessage() { + sendRequestContactSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { @@ -514,13 +487,37 @@ MessageSender.prototype = { myNumber, contentMessage, Date.now(), - silent + silent, + options ); } return Promise.resolve(); }, - sendReadReceipts(sender, timestamps) { + sendDeliveryReceipt(recipientId, timestamp, options) { + const myNumber = textsecure.storage.user.getNumber(); + const myDevice = textsecure.storage.user.getDeviceId(); + if (myNumber === recipientId && (myDevice === 1 || myDevice === '1')) { + return Promise.resolve(); + } + + const receiptMessage = new textsecure.protobuf.ReceiptMessage(); + receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.DELIVERY; + receiptMessage.timestamp = [timestamp]; + + const contentMessage = new textsecure.protobuf.Content(); + contentMessage.receiptMessage = receiptMessage; + + const silent = true; + return this.sendIndividualProto( + recipientId, + contentMessage, + Date.now(), + silent, + options + ); + }, + sendReadReceipts(sender, timestamps, options) { const receiptMessage = new textsecure.protobuf.ReceiptMessage(); receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ; receiptMessage.timestamp = timestamps; @@ -529,9 +526,15 @@ MessageSender.prototype = { contentMessage.receiptMessage = receiptMessage; const silent = true; - return this.sendIndividualProto(sender, contentMessage, Date.now(), silent); + return this.sendIndividualProto( + sender, + contentMessage, + Date.now(), + silent, + options + ); }, - syncReadMessages(reads) { + syncReadMessages(reads, options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice !== 1 && myDevice !== '1') { @@ -551,13 +554,14 @@ MessageSender.prototype = { myNumber, contentMessage, Date.now(), - silent + silent, + options ); } return Promise.resolve(); }, - syncVerification(destination, state, identityKey) { + syncVerification(destination, state, identityKey, options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); const now = Date.now(); @@ -580,7 +584,14 @@ MessageSender.prototype = { contentMessage.nullMessage = nullMessage; // We want the NullMessage to look like a normal outgoing message; not silent - const promise = this.sendIndividualProto(destination, contentMessage, now); + const silent = false; + const promise = this.sendIndividualProto( + destination, + contentMessage, + now, + silent, + options + ); return promise.then(() => { const verified = new textsecure.protobuf.Verified(); @@ -595,12 +606,18 @@ MessageSender.prototype = { const secondMessage = new textsecure.protobuf.Content(); secondMessage.syncMessage = syncMessage; - const silent = true; - return this.sendIndividualProto(myNumber, secondMessage, now, silent); + const innerSilent = true; + return this.sendIndividualProto( + myNumber, + secondMessage, + now, + innerSilent, + options + ); }); }, - sendGroupProto(providedNumbers, proto, timestamp = Date.now()) { + sendGroupProto(providedNumbers, proto, timestamp = Date.now(), options = {}) { const me = textsecure.storage.user.getNumber(); const numbers = providedNumbers.filter(number => number !== me); if (numbers.length === 0) { @@ -618,7 +635,14 @@ MessageSender.prototype = { } }; - this.sendMessageProto(timestamp, numbers, proto, callback, silent); + this.sendMessageProto( + timestamp, + numbers, + proto, + callback, + silent, + options + ); }); }, @@ -629,22 +653,27 @@ MessageSender.prototype = { quote, timestamp, expireTimer, - profileKey + profileKey, + options ) { - return this.sendMessage({ - recipients: [number], - body: messageText, - timestamp, - attachments, - quote, - needsSync: true, - expireTimer, - profileKey, - }); + return this.sendMessage( + { + recipients: [number], + body: messageText, + timestamp, + attachments, + quote, + needsSync: true, + expireTimer, + profileKey, + }, + options + ); }, - resetSession(number, timestamp) { + resetSession(number, timestamp, options) { window.log.info('resetting secure session'); + const silent = false; const proto = new textsecure.protobuf.DataMessage(); proto.body = 'TERMINATE'; proto.flags = textsecure.protobuf.DataMessage.Flags.END_SESSION; @@ -677,9 +706,13 @@ MessageSender.prototype = { window.log.info( 'finished closing local sessions, now sending to contact' ); - return this.sendIndividualProto(number, proto, timestamp).catch( - logError('resetSession/sendToContact error:') - ); + return this.sendIndividualProto( + number, + proto, + timestamp, + silent, + options + ).catch(logError('resetSession/sendToContact error:')); }) .then(() => deleteAllSessions(number).catch( @@ -688,9 +721,15 @@ MessageSender.prototype = { ); const buffer = proto.toArrayBuffer(); - const sendSync = this.sendSyncMessage(buffer, timestamp, number).catch( - logError('resetSession/sendSync error:') - ); + const sendSync = this.sendSyncMessage( + buffer, + timestamp, + number, + null, + [], + [], + options + ).catch(logError('resetSession/sendSync error:')); return Promise.all([sendToContact, sendSync]); }, @@ -702,7 +741,8 @@ MessageSender.prototype = { quote, timestamp, expireTimer, - profileKey + profileKey, + options ) { return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => { if (targetNumbers === undefined) { @@ -715,24 +755,27 @@ MessageSender.prototype = { return Promise.reject(new Error('No other members in the group')); } - return this.sendMessage({ - recipients: numbers, - body: messageText, - timestamp, - attachments, - quote, - needsSync: true, - expireTimer, - profileKey, - group: { - id: groupId, - type: textsecure.protobuf.GroupContext.Type.DELIVER, + return this.sendMessage( + { + recipients: numbers, + body: messageText, + timestamp, + attachments, + quote, + needsSync: true, + expireTimer, + profileKey, + group: { + id: groupId, + type: textsecure.protobuf.GroupContext.Type.DELIVER, + }, }, - }); + options + ); }); }, - createGroup(targetNumbers, name, avatar) { + createGroup(targetNumbers, name, avatar, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); @@ -748,12 +791,14 @@ MessageSender.prototype = { return this.makeAttachmentPointer(avatar).then(attachment => { proto.group.avatar = attachment; - return this.sendGroupProto(numbers, proto).then(() => proto.group.id); + return this.sendGroupProto(numbers, proto, Date.now(), options).then( + () => proto.group.id + ); }); }); }, - updateGroup(groupId, name, avatar, targetNumbers) { + updateGroup(groupId, name, avatar, targetNumbers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); @@ -771,12 +816,14 @@ MessageSender.prototype = { return this.makeAttachmentPointer(avatar).then(attachment => { proto.group.avatar = attachment; - return this.sendGroupProto(numbers, proto).then(() => proto.group.id); + return this.sendGroupProto(numbers, proto, Date.now(), options).then( + () => proto.group.id + ); }); }); }, - addNumberToGroup(groupId, number) { + addNumberToGroup(groupId, number, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); @@ -789,11 +836,11 @@ MessageSender.prototype = { return Promise.reject(new Error('Unknown Group')); proto.group.members = numbers; - return this.sendGroupProto(numbers, proto); + return this.sendGroupProto(numbers, proto, Date.now(), options); }); }, - setGroupName(groupId, name) { + setGroupName(groupId, name, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); @@ -805,11 +852,11 @@ MessageSender.prototype = { return Promise.reject(new Error('Unknown Group')); proto.group.members = numbers; - return this.sendGroupProto(numbers, proto); + return this.sendGroupProto(numbers, proto, Date.now(), options); }); }, - setGroupAvatar(groupId, avatar) { + setGroupAvatar(groupId, avatar, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); @@ -822,12 +869,12 @@ MessageSender.prototype = { return this.makeAttachmentPointer(avatar).then(attachment => { proto.group.avatar = attachment; - return this.sendGroupProto(numbers, proto); + return this.sendGroupProto(numbers, proto, Date.now(), options); }); }); }, - leaveGroup(groupId) { + leaveGroup(groupId, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); @@ -838,14 +885,15 @@ MessageSender.prototype = { return Promise.reject(new Error('Unknown Group')); return textsecure.storage.groups .deleteGroup(groupId) - .then(() => this.sendGroupProto(numbers, proto)); + .then(() => this.sendGroupProto(numbers, proto, Date.now(), options)); }); }, sendExpirationTimerUpdateToGroup( groupId, expireTimer, timestamp, - profileKey + profileKey, + options ) { return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => { if (targetNumbers === undefined) @@ -856,34 +904,41 @@ MessageSender.prototype = { if (numbers.length === 0) { return Promise.reject(new Error('No other members in the group')); } - return this.sendMessage({ - recipients: numbers, - timestamp, - needsSync: true, - expireTimer, - profileKey, - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - group: { - id: groupId, - type: textsecure.protobuf.GroupContext.Type.DELIVER, + return this.sendMessage( + { + recipients: numbers, + timestamp, + needsSync: true, + expireTimer, + profileKey, + flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + group: { + id: groupId, + type: textsecure.protobuf.GroupContext.Type.DELIVER, + }, }, - }); + options + ); }); }, sendExpirationTimerUpdateToNumber( number, expireTimer, timestamp, - profileKey + profileKey, + options ) { - return this.sendMessage({ - recipients: [number], - timestamp, - needsSync: true, - expireTimer, - profileKey, - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - }); + return this.sendMessage( + { + recipients: [number], + timestamp, + needsSync: true, + expireTimer, + profileKey, + flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + }, + options + ); }, }; @@ -927,6 +982,7 @@ textsecure.MessageSender = function MessageSenderWrapper( this.getAvatar = sender.getAvatar.bind(sender); this.syncReadMessages = sender.syncReadMessages.bind(sender); this.syncVerification = sender.syncVerification.bind(sender); + this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender); this.sendReadReceipts = sender.sendReadReceipts.bind(sender); }; diff --git a/libtextsecure/sync_request.js b/libtextsecure/sync_request.js index 73c153164c..fe85d62e04 100644 --- a/libtextsecure/sync_request.js +++ b/libtextsecure/sync_request.js @@ -1,4 +1,4 @@ -/* global Event, textsecure, window */ +/* global Event, textsecure, window, ConversationController */ /* eslint-disable more/no-then */ @@ -23,12 +23,15 @@ this.ongroup = this.onGroupSyncComplete.bind(this); receiver.addEventListener('groupsync', this.ongroup); + const ourNumber = textsecure.storage.user.getNumber(); + const { wrap, sendOptions } = ConversationController.prepareForSend( + ourNumber + ); window.log.info('SyncRequest created. Sending contact sync message...'); - sender - .sendRequestContactSyncMessage() + wrap(sender.sendRequestContactSyncMessage(sendOptions)) .then(() => { window.log.info('SyncRequest now sending group sync messsage...'); - return sender.sendRequestGroupSyncMessage(); + return wrap(sender.sendRequestGroupSyncMessage(sendOptions)); }) .catch(error => { window.log.error( diff --git a/libtextsecure/test/message_receiver_test.js b/libtextsecure/test/message_receiver_test.js index f25b0f6dd7..88def8cd4b 100644 --- a/libtextsecure/test/message_receiver_test.js +++ b/libtextsecure/test/message_receiver_test.js @@ -90,8 +90,11 @@ describe('MessageReceiver', () => { done(); }); const messageReceiver = new textsecure.MessageReceiver( - 'ws://localhost:8080', - window + 'username', + 'password', + 'signalingKey' + // 'ws://localhost:8080', + // window, ); }); }); diff --git a/main.js b/main.js index 1add765f0a..da72f143b5 100644 --- a/main.js +++ b/main.js @@ -149,6 +149,7 @@ function prepareURL(pathSegments, moreKeys) { appInstance: process.env.NODE_APP_INSTANCE, proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, importMode: importMode ? true : undefined, // for stringify() + serverTrustRoot: config.get('serverTrustRoot'), ...moreKeys, }, }); diff --git a/preload.js b/preload.js index 4cd3d53a33..f8a2483c7c 100644 --- a/preload.js +++ b/preload.js @@ -27,6 +27,7 @@ window.isImportMode = () => config.importMode; window.getExpiration = () => config.buildExpiration; window.getNodeVersion = () => config.node_version; window.getHostName = () => config.hostname; +window.getServerTrustRoot = () => config.serverTrustRoot; window.isBeforeVersion = (toCheck, baseVersion) => { try { @@ -215,6 +216,7 @@ window.filesize = require('filesize'); window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance(); window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; window.loadImage = require('blueimp-load-image'); +window.getGuid = require('uuid/v4'); window.React = require('react'); window.ReactDOM = require('react-dom'); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index e1beb304cc..d2dbafa511 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -6,20 +6,24 @@ option java_outer_classname = "SignalServiceProtos"; message Envelope { enum Type { - UNKNOWN = 0; - CIPHERTEXT = 1; - KEY_EXCHANGE = 2; - PREKEY_BUNDLE = 3; - RECEIPT = 5; + UNKNOWN = 0; + CIPHERTEXT = 1; + KEY_EXCHANGE = 2; + PREKEY_BUNDLE = 3; + RECEIPT = 5; + UNIDENTIFIED_SENDER = 6; } - optional Type type = 1; - optional string source = 2; - optional uint32 sourceDevice = 7; - optional string relay = 3; - optional uint64 timestamp = 5; - optional bytes legacyMessage = 6; // Contains an encrypted DataMessage - optional bytes content = 8; // Contains an encrypted Content + optional Type type = 1; + optional string source = 2; + optional uint32 sourceDevice = 7; + optional string relay = 3; + optional uint64 timestamp = 5; + optional bytes legacyMessage = 6; // Contains an encrypted DataMessage + optional bytes content = 8; // Contains an encrypted Content + optional string serverGuid = 9; + optional uint64 serverTimestamp = 10; + } message Content { @@ -191,10 +195,16 @@ message Verified { message SyncMessage { message Sent { - optional string destination = 1; - optional uint64 timestamp = 2; - optional DataMessage message = 3; - optional uint64 expirationStartTimestamp = 4; + message UnidentifiedDeliveryStatus { + optional string destination = 1; + optional bool unidentified = 2; + } + + optional string destination = 1; + optional uint64 timestamp = 2; + optional DataMessage message = 3; + optional uint64 expirationStartTimestamp = 4; + repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5; } message Contacts { @@ -229,7 +239,8 @@ message SyncMessage { } message Configuration { - optional bool readReceipts = 1; + optional bool readReceipts = 1; + optional bool unidentifiedDeliveryIndicators = 2; } optional Sent sent = 1; diff --git a/protos/UnidentifiedDelivery.proto b/protos/UnidentifiedDelivery.proto new file mode 100644 index 0000000000..da9295aa6b --- /dev/null +++ b/protos/UnidentifiedDelivery.proto @@ -0,0 +1,45 @@ +package signalservice; + +option java_package = "org.whispersystems.libsignal.protocol"; +option java_outer_classname = "WhisperProtos"; + +message ServerCertificate { + message Certificate { + optional uint32 id = 1; + optional bytes key = 2; + } + + optional bytes certificate = 1; + optional bytes signature = 2; +} + +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 bytes certificate = 1; + optional bytes signature = 2; +} + +message UnidentifiedSenderMessage { + + message Message { + enum Type { + PREKEY_MESSAGE = 1; + MESSAGE = 2; + } + + optional Type type = 1; + optional SenderCertificate senderCertificate = 2; + optional bytes content = 3; + } + + optional bytes ephemeralPublic = 1; + optional bytes encryptedStatic = 2; + optional bytes encryptedMessage = 3; +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 785991e377..b964a338f9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1482,7 +1482,6 @@ width: 12px; height: 12px; display: inline-block; - margin-left: 6px; margin-bottom: 2px; } @@ -1500,20 +1499,30 @@ } .module-message-detail__contact__status-icon--sent { - @include color-svg('../images/check-circle-outline.svg', $color-light-35); + @include color-svg('../images/check-circle-outline.svg', $color-gray-60); } .module-message-detail__contact__status-icon--delivered { - @include color-svg('../images/double-check.svg', $color-light-35); + @include color-svg('../images/double-check.svg', $color-gray-60); width: 18px; } .module-message-detail__contact__status-icon--read { - @include color-svg('../images/read.svg', $color-light-35); + @include color-svg('../images/read.svg', $color-gray-60); width: 18px; } .module-message-detail__contact__status-icon--error { @include color-svg('../images/error.svg', $color-core-red); } +.module-message-detail__contact__unidentified-delivery-icon { + margin-left: 6px; + margin-right: 10px; + + width: 20px; + height: 20px; + display: inline-block; + @include color-svg('../images/unidentified-delivery.svg', $color-gray-60); +} + .module-message-detail__contact__error-buttons { text-align: right; } diff --git a/test/crypto_test.js b/test/crypto_test.js index fb7894207c..e864411ee5 100644 --- a/test/crypto_test.js +++ b/test/crypto_test.js @@ -1,104 +1,119 @@ 'use strict'; -describe('Crypto', function() { - it('roundtrip symmetric encryption succeeds', async function() { - var message = 'this is my message'; - var plaintext = new dcodeIO.ByteBuffer.wrap( - message, - 'binary' - ).toArrayBuffer(); - var key = textsecure.crypto.getRandomBytes(32); +describe('Crypto', () => { + describe('accessKey/profileKey', () => { + it('verification roundtrips', async () => { + const profileKey = await Signal.Crypto.getRandomBytes(32); + const accessKey = await Signal.Crypto.deriveAccessKey(profileKey); - var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); - var decrypted = await Signal.Crypto.decryptSymmetric(key, encrypted); + const verifier = await Signal.Crypto.getAccessKeyVerifier(accessKey); - var equal = Signal.Crypto.constantTimeEqual(plaintext, decrypted); - if (!equal) { - throw new Error('The output and input did not match!'); - } + const correct = await Signal.Crypto.verifyAccessKey(accessKey, verifier); + + assert.strictEqual(correct, true); + }); }); - it('roundtrip fails if nonce is modified', async function() { - var message = 'this is my message'; - var plaintext = new dcodeIO.ByteBuffer.wrap( - message, - 'binary' - ).toArrayBuffer(); - var key = textsecure.crypto.getRandomBytes(32); + describe('symmetric encryption', () => { + it('roundtrips', async () => { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap( + message, + 'binary' + ).toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); - var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); - var uintArray = new Uint8Array(encrypted); - uintArray[2] = 9; + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var decrypted = await Signal.Crypto.decryptSymmetric(key, encrypted); - try { - var decrypted = await Signal.Crypto.decryptSymmetric( - key, - uintArray.buffer - ); - } catch (error) { - assert.strictEqual( - error.message, - 'decryptSymmetric: Failed to decrypt; MAC verification failed' - ); - return; - } + var equal = Signal.Crypto.constantTimeEqual(plaintext, decrypted); + if (!equal) { + throw new Error('The output and input did not match!'); + } + }); - throw new Error('Expected error to be thrown'); - }); + it('roundtrip fails if nonce is modified', async () => { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap( + message, + 'binary' + ).toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); - it('fails if mac is modified', async function() { - var message = 'this is my message'; - var plaintext = new dcodeIO.ByteBuffer.wrap( - message, - 'binary' - ).toArrayBuffer(); - var key = textsecure.crypto.getRandomBytes(32); + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var uintArray = new Uint8Array(encrypted); + uintArray[2] = 9; - var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); - var uintArray = new Uint8Array(encrypted); - uintArray[uintArray.length - 3] = 9; + try { + var decrypted = await Signal.Crypto.decryptSymmetric( + key, + uintArray.buffer + ); + } catch (error) { + assert.strictEqual( + error.message, + 'decryptSymmetric: Failed to decrypt; MAC verification failed' + ); + return; + } - try { - var decrypted = await Signal.Crypto.decryptSymmetric( - key, - uintArray.buffer - ); - } catch (error) { - assert.strictEqual( - error.message, - 'decryptSymmetric: Failed to decrypt; MAC verification failed' - ); - return; - } + throw new Error('Expected error to be thrown'); + }); - throw new Error('Expected error to be thrown'); - }); + it('roundtrip fails if mac is modified', async () => { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap( + message, + 'binary' + ).toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); - it('fails if encrypted contents are modified', async function() { - var message = 'this is my message'; - var plaintext = new dcodeIO.ByteBuffer.wrap( - message, - 'binary' - ).toArrayBuffer(); - var key = textsecure.crypto.getRandomBytes(32); + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var uintArray = new Uint8Array(encrypted); + uintArray[uintArray.length - 3] = 9; - var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); - var uintArray = new Uint8Array(encrypted); - uintArray[35] = 9; + try { + var decrypted = await Signal.Crypto.decryptSymmetric( + key, + uintArray.buffer + ); + } catch (error) { + assert.strictEqual( + error.message, + 'decryptSymmetric: Failed to decrypt; MAC verification failed' + ); + return; + } - try { - var decrypted = await Signal.Crypto.decryptSymmetric( - key, - uintArray.buffer - ); - } catch (error) { - assert.strictEqual( - error.message, - 'decryptSymmetric: Failed to decrypt; MAC verification failed' - ); - return; - } + throw new Error('Expected error to be thrown'); + }); - throw new Error('Expected error to be thrown'); + it('roundtrip fails if encrypted contents are modified', async () => { + var message = 'this is my message'; + var plaintext = new dcodeIO.ByteBuffer.wrap( + message, + 'binary' + ).toArrayBuffer(); + var key = textsecure.crypto.getRandomBytes(32); + + var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); + var uintArray = new Uint8Array(encrypted); + uintArray[35] = 9; + + try { + var decrypted = await Signal.Crypto.decryptSymmetric( + key, + uintArray.buffer + ); + } catch (error) { + assert.strictEqual( + error.message, + 'decryptSymmetric: Failed to decrypt; MAC verification failed' + ); + return; + } + + throw new Error('Expected error to be thrown'); + }); }); }); diff --git a/test/index.html b/test/index.html index a4cc93b767..64964f8962 100644 --- a/test/index.html +++ b/test/index.html @@ -383,6 +383,8 @@ + + diff --git a/test/metadata/SecretSessionCipher_test.js b/test/metadata/SecretSessionCipher_test.js new file mode 100644 index 0000000000..e1aff12276 --- /dev/null +++ b/test/metadata/SecretSessionCipher_test.js @@ -0,0 +1,405 @@ +/* global libsignal, textsecure */ + +'use strict'; + +const { + SecretSessionCipher, + createCertificateValidator, + _createSenderCertificateFromBuffer, + _createServerCertificateFromBuffer, +} = window.Signal.Metadata; +const { + bytesFromString, + stringFromBytes, + arrayBufferToBase64, +} = window.Signal.Crypto; + +function InMemorySignalProtocolStore() { + this.store = {}; +} + +function toString(thing) { + if (typeof thing === 'string') { + return thing; + } + return arrayBufferToBase64(thing); +} + +InMemorySignalProtocolStore.prototype = { + Direction: { + SENDING: 1, + RECEIVING: 2, + }, + + getIdentityKeyPair() { + return Promise.resolve(this.get('identityKey')); + }, + getLocalRegistrationId() { + return Promise.resolve(this.get('registrationId')); + }, + put(key, value) { + if ( + key === undefined || + value === undefined || + key === null || + value === null + ) + throw new Error('Tried to store undefined/null'); + this.store[key] = value; + }, + get(key, defaultValue) { + if (key === null || key === undefined) + throw new Error('Tried to get value for undefined/null key'); + if (key in this.store) { + return this.store[key]; + } + + return defaultValue; + }, + remove(key) { + if (key === null || key === undefined) + throw new Error('Tried to remove value for undefined/null key'); + delete this.store[key]; + }, + + isTrustedIdentity(identifier, identityKey) { + if (identifier === null || identifier === undefined) { + throw new Error('tried to check identity key for undefined/null key'); + } + if (!(identityKey instanceof ArrayBuffer)) { + throw new Error('Expected identityKey to be an ArrayBuffer'); + } + const trusted = this.get(`identityKey${identifier}`); + if (trusted === undefined) { + return Promise.resolve(true); + } + return Promise.resolve(toString(identityKey) === toString(trusted)); + }, + loadIdentityKey(identifier) { + if (identifier === null || identifier === undefined) + throw new Error('Tried to get identity key for undefined/null key'); + return Promise.resolve(this.get(`identityKey${identifier}`)); + }, + saveIdentity(identifier, identityKey) { + if (identifier === null || identifier === undefined) + throw new Error('Tried to put identity key for undefined/null key'); + + const address = libsignal.SignalProtocolAddress.fromString(identifier); + + const existing = this.get(`identityKey${address.getName()}`); + this.put(`identityKey${address.getName()}`, identityKey); + + if (existing && toString(identityKey) !== toString(existing)) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }, + + /* Returns a prekeypair object or undefined */ + loadPreKey(keyId) { + let res = this.get(`25519KeypreKey${keyId}`); + if (res !== undefined) { + res = { pubKey: res.pubKey, privKey: res.privKey }; + } + return Promise.resolve(res); + }, + storePreKey(keyId, keyPair) { + return Promise.resolve(this.put(`25519KeypreKey${keyId}`, keyPair)); + }, + removePreKey(keyId) { + return Promise.resolve(this.remove(`25519KeypreKey${keyId}`)); + }, + + /* Returns a signed keypair object or undefined */ + loadSignedPreKey(keyId) { + let res = this.get(`25519KeysignedKey${keyId}`); + if (res !== undefined) { + res = { pubKey: res.pubKey, privKey: res.privKey }; + } + return Promise.resolve(res); + }, + storeSignedPreKey(keyId, keyPair) { + return Promise.resolve(this.put(`25519KeysignedKey${keyId}`, keyPair)); + }, + removeSignedPreKey(keyId) { + return Promise.resolve(this.remove(`25519KeysignedKey${keyId}`)); + }, + + loadSession(identifier) { + return Promise.resolve(this.get(`session${identifier}`)); + }, + storeSession(identifier, record) { + return Promise.resolve(this.put(`session${identifier}`, record)); + }, + removeSession(identifier) { + return Promise.resolve(this.remove(`session${identifier}`)); + }, + removeAllSessions(identifier) { + // eslint-disable-next-line no-restricted-syntax + for (const id in this.store) { + if (id.startsWith(`session${identifier}`)) { + delete this.store[id]; + } + } + return Promise.resolve(); + }, +}; + +describe('SecretSessionCipher', () => { + it('successfully roundtrips', async () => { + const aliceStore = new InMemorySignalProtocolStore(); + const bobStore = new InMemorySignalProtocolStore(); + + await _initializeSessions(aliceStore, bobStore); + + const aliceIdentityKey = await aliceStore.getIdentityKeyPair(); + + const trustRoot = await libsignal.Curve.async.generateKeyPair(); + const senderCertificate = await _createSenderCertificateFor( + trustRoot, + '+14151111111', + 1, + aliceIdentityKey.pubKey, + 31337 + ); + const aliceCipher = new SecretSessionCipher(aliceStore); + + const ciphertext = await aliceCipher.encrypt( + new libsignal.SignalProtocolAddress('+14152222222', 1), + senderCertificate, + bytesFromString('smert za smert') + ); + + const bobCipher = new SecretSessionCipher(bobStore); + + const decryptResult = await bobCipher.decrypt( + createCertificateValidator(trustRoot.pubKey), + ciphertext, + 31335 + ); + + assert.strictEqual( + stringFromBytes(decryptResult.content), + 'smert za smert' + ); + assert.strictEqual(decryptResult.sender.toString(), '+14151111111.1'); + }); + + it('fails when untrusted', async () => { + const aliceStore = new InMemorySignalProtocolStore(); + const bobStore = new InMemorySignalProtocolStore(); + + await _initializeSessions(aliceStore, bobStore); + + const aliceIdentityKey = await aliceStore.getIdentityKeyPair(); + + const trustRoot = await libsignal.Curve.async.generateKeyPair(); + const falseTrustRoot = await libsignal.Curve.async.generateKeyPair(); + const senderCertificate = await _createSenderCertificateFor( + falseTrustRoot, + '+14151111111', + 1, + aliceIdentityKey.pubKey, + 31337 + ); + const aliceCipher = new SecretSessionCipher(aliceStore); + + const ciphertext = await aliceCipher.encrypt( + new libsignal.SignalProtocolAddress('+14152222222', 1), + senderCertificate, + bytesFromString('и вот я') + ); + + const bobCipher = new SecretSessionCipher(bobStore); + + try { + await bobCipher.decrypt( + createCertificateValidator(trustRoot.pubKey), + ciphertext, + 31335 + ); + throw new Error('It did not fail!'); + } catch (error) { + assert.strictEqual(error.message, 'Invalid signature'); + } + }); + + it('fails when expired', async () => { + const aliceStore = new InMemorySignalProtocolStore(); + const bobStore = new InMemorySignalProtocolStore(); + + await _initializeSessions(aliceStore, bobStore); + + const aliceIdentityKey = await aliceStore.getIdentityKeyPair(); + + const trustRoot = await libsignal.Curve.async.generateKeyPair(); + const senderCertificate = await _createSenderCertificateFor( + trustRoot, + '+14151111111', + 1, + aliceIdentityKey.pubKey, + 31337 + ); + const aliceCipher = new SecretSessionCipher(aliceStore); + + const ciphertext = await aliceCipher.encrypt( + new libsignal.SignalProtocolAddress('+14152222222', 1), + senderCertificate, + bytesFromString('и вот я') + ); + + const bobCipher = new SecretSessionCipher(bobStore); + + try { + await bobCipher.decrypt( + createCertificateValidator(trustRoot.pubKey), + ciphertext, + 31338 + ); + throw new Error('It did not fail!'); + } catch (error) { + assert.strictEqual(error.message, 'Certificate is expired'); + } + }); + + it('fails when wrong identity', async () => { + const aliceStore = new InMemorySignalProtocolStore(); + const bobStore = new InMemorySignalProtocolStore(); + + await _initializeSessions(aliceStore, bobStore); + + const trustRoot = await libsignal.Curve.async.generateKeyPair(); + const randomKeyPair = await libsignal.Curve.async.generateKeyPair(); + const senderCertificate = await _createSenderCertificateFor( + trustRoot, + '+14151111111', + 1, + randomKeyPair.pubKey, + 31337 + ); + const aliceCipher = new SecretSessionCipher(aliceStore); + + const ciphertext = await aliceCipher.encrypt( + new libsignal.SignalProtocolAddress('+14152222222', 1), + senderCertificate, + bytesFromString('smert za smert') + ); + + const bobCipher = new SecretSessionCipher(bobStore); + + try { + await bobCipher.decrypt( + createCertificateValidator(trustRoot.puKey), + ciphertext, + 31335 + ); + throw new Error('It did not fail!'); + } catch (error) { + assert.strictEqual(error.message, 'Invalid public key'); + } + }); + + // private SenderCertificate _createCertificateFor( + // ECKeyPair trustRoot + // String sender + // int deviceId + // ECPublicKey identityKey + // long expires + // ) + async function _createSenderCertificateFor( + trustRoot, + sender, + deviceId, + identityKey, + expires + ) { + const serverKey = await libsignal.Curve.async.generateKeyPair(); + + const serverCertificateCertificateProto = new textsecure.protobuf.ServerCertificate.Certificate(); + serverCertificateCertificateProto.id = 1; + serverCertificateCertificateProto.key = serverKey.pubKey; + const serverCertificateCertificateBytes = serverCertificateCertificateProto + .encode() + .toArrayBuffer(); + + const serverCertificateSignature = await libsignal.Curve.async.calculateSignature( + trustRoot.privKey, + serverCertificateCertificateBytes + ); + + const serverCertificateProto = new textsecure.protobuf.ServerCertificate(); + serverCertificateProto.certificate = serverCertificateCertificateBytes; + serverCertificateProto.signature = serverCertificateSignature; + const serverCertificate = _createServerCertificateFromBuffer( + serverCertificateProto.encode().toArrayBuffer() + ); + + const senderCertificateCertificateProto = new textsecure.protobuf.SenderCertificate.Certificate(); + senderCertificateCertificateProto.sender = sender; + senderCertificateCertificateProto.senderDevice = deviceId; + senderCertificateCertificateProto.identityKey = identityKey; + senderCertificateCertificateProto.expires = expires; + senderCertificateCertificateProto.signer = textsecure.protobuf.ServerCertificate.decode( + serverCertificate.serialized + ); + const senderCertificateBytes = senderCertificateCertificateProto + .encode() + .toArrayBuffer(); + + const senderCertificateSignature = await libsignal.Curve.async.calculateSignature( + serverKey.privKey, + senderCertificateBytes + ); + + const senderCertificateProto = new textsecure.protobuf.SenderCertificate(); + senderCertificateProto.certificate = senderCertificateBytes; + senderCertificateProto.signature = senderCertificateSignature; + return _createSenderCertificateFromBuffer( + senderCertificateProto.encode().toArrayBuffer() + ); + } + + // private void _initializeSessions( + // SignalProtocolStore aliceStore, SignalProtocolStore bobStore) + async function _initializeSessions(aliceStore, bobStore) { + const aliceAddress = new libsignal.SignalProtocolAddress('+14152222222', 1); + await aliceStore.put( + 'identityKey', + await libsignal.Curve.generateKeyPair() + ); + await bobStore.put('identityKey', await libsignal.Curve.generateKeyPair()); + + await aliceStore.put('registrationId', 57); + await bobStore.put('registrationId', 58); + + const bobPreKey = await libsignal.Curve.async.generateKeyPair(); + const bobIdentityKey = await bobStore.getIdentityKeyPair(); + const bobSignedPreKey = await libsignal.KeyHelper.generateSignedPreKey( + bobIdentityKey, + 2 + ); + + const bobBundle = { + identityKey: bobIdentityKey.pubKey, + registrationId: 1, + preKey: { + keyId: 1, + publicKey: bobPreKey.pubKey, + }, + signedPreKey: { + keyId: 2, + publicKey: bobSignedPreKey.keyPair.pubKey, + signature: bobSignedPreKey.signature, + }, + }; + const aliceSessionBuilder = new libsignal.SessionBuilder( + aliceStore, + aliceAddress + ); + await aliceSessionBuilder.processPreKey(bobBundle); + + await bobStore.storeSignedPreKey(2, bobSignedPreKey.keyPair); + await bobStore.storePreKey(1, bobPreKey); + } +}); diff --git a/test/modules/startup_test.js b/test/modules/startup_test.js index 8f79516bde..b4e2015232 100644 --- a/test/modules/startup_test.js +++ b/test/modules/startup_test.js @@ -12,6 +12,7 @@ describe('Startup', () => { }); it('should complete if user hasn’t previously synced', async () => { + const ourNumber = '+15551234567'; const deviceId = '2'; const sendRequestConfigurationSyncMessage = sandbox.spy(); const storagePutSpy = sandbox.spy(); @@ -25,15 +26,21 @@ describe('Startup', () => { }, put: storagePutSpy, }; + const prepareForSend = () => ({ + wrap: promise => promise, + sendOptions: {}, + }); const expected = { status: 'complete', }; const actual = await Startup.syncReadReceiptConfiguration({ + ourNumber, deviceId, sendRequestConfigurationSyncMessage, storage, + prepareForSend, }); assert.deepEqual(actual, expected); @@ -43,9 +50,14 @@ describe('Startup', () => { }); it('should be skipped if this is the primary device', async () => { + const ourNumber = '+15551234567'; const deviceId = '1'; const sendRequestConfigurationSyncMessage = () => {}; const storage = {}; + const prepareForSend = () => ({ + wrap: promise => promise, + sendOptions: {}, + }); const expected = { status: 'skipped', @@ -53,15 +65,18 @@ describe('Startup', () => { }; const actual = await Startup.syncReadReceiptConfiguration({ + ourNumber, deviceId, sendRequestConfigurationSyncMessage, storage, + prepareForSend, }); assert.deepEqual(actual, expected); }); it('should be skipped if user has previously synced', async () => { + const ourNumber = '+15551234567'; const deviceId = '2'; const sendRequestConfigurationSyncMessage = () => {}; const storage = { @@ -73,6 +88,10 @@ describe('Startup', () => { return true; }, }; + const prepareForSend = () => ({ + wrap: promise => promise, + sendOptions: {}, + }); const expected = { status: 'skipped', @@ -80,15 +99,18 @@ describe('Startup', () => { }; const actual = await Startup.syncReadReceiptConfiguration({ + ourNumber, deviceId, sendRequestConfigurationSyncMessage, storage, + prepareForSend, }); assert.deepEqual(actual, expected); }); it('should return error if sending of sync request fails', async () => { + const ourNumber = '+15551234567'; const deviceId = '2'; const sendRequestConfigurationSyncMessage = sandbox.stub(); @@ -105,11 +127,17 @@ describe('Startup', () => { }, put: storagePutSpy, }; + const prepareForSend = () => ({ + wrap: promise => promise, + sendOptions: {}, + }); const actual = await Startup.syncReadReceiptConfiguration({ + ourNumber, deviceId, sendRequestConfigurationSyncMessage, storage, + prepareForSend, }); assert.equal(actual.status, 'error'); diff --git a/ts/components/conversation/MessageDetail.md b/ts/components/conversation/MessageDetail.md index b88034de76..d6ea7c287d 100644 --- a/ts/components/conversation/MessageDetail.md +++ b/ts/components/conversation/MessageDetail.md @@ -126,3 +126,41 @@ i18n={util.i18n} /> ``` + +### Unidentified Delivery + +```jsx + console.log('onDelete'), + }} + contacts={[ + { + phoneNumber: '(202) 555-1001', + avatarPath: util.gifObjectUrl, + status: 'read', + isUnidentifiedDelivery: true, + }, + { + phoneNumber: '(202) 555-1002', + avatarPath: util.pngObjectUrl, + status: 'delivered', + isUnidentifiedDelivery: true, + }, + { + phoneNumber: '(202) 555-1003', + color: 'teal', + status: 'read', + }, + ]} + sentAt={Date.now()} + i18n={util.i18n} +/> +``` diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index cb830fdea5..fd43dd00db 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -15,8 +15,10 @@ interface Contact { avatarPath?: string; color: string; isOutgoingKeyError: boolean; + isUnidentifiedDelivery: boolean; errors?: Array; + onSendAnyway: () => void; onShowSafetyNumber: () => void; } @@ -94,6 +96,9 @@ export class MessageDetail extends React.Component { )} /> ) : null; + const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? ( +
+ ) : null; return (
@@ -114,6 +119,7 @@ export class MessageDetail extends React.Component { ))}
{errorComponent} + {unidentifiedDeliveryComponent} {statusComponent}
); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1ba85f16ee..2052fb7c04 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -164,7 +164,7 @@ "rule": "jQuery-$(", "path": "js/background.js", "line": " if ($('.dark-overlay').length) {", - "lineNumber": 264, + "lineNumber": 265, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -173,7 +173,7 @@ "rule": "jQuery-$(", "path": "js/background.js", "line": " $(document.body).prepend('
');", - "lineNumber": 267, + "lineNumber": 268, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -182,7 +182,7 @@ "rule": "jQuery-prepend(", "path": "js/background.js", "line": " $(document.body).prepend('
');", - "lineNumber": 267, + "lineNumber": 268, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Hard-coded value" @@ -191,7 +191,7 @@ "rule": "jQuery-$(", "path": "js/background.js", "line": " $('.dark-overlay').on('click', () => $('.dark-overlay').remove());", - "lineNumber": 268, + "lineNumber": 269, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -200,7 +200,7 @@ "rule": "jQuery-$(", "path": "js/background.js", "line": " removeDarkOverlay: () => $('.dark-overlay').remove(),", - "lineNumber": 270, + "lineNumber": 271, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -209,7 +209,7 @@ "rule": "jQuery-$(", "path": "js/background.js", "line": " $('body').append(clearDataView.el);", - "lineNumber": 273, + "lineNumber": 274, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -218,7 +218,7 @@ "rule": "jQuery-append(", "path": "js/background.js", "line": " $('body').append(clearDataView.el);", - "lineNumber": 273, + "lineNumber": 274, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -227,7 +227,7 @@ "rule": "jQuery-load(", "path": "js/background.js", "line": " await ConversationController.load();", - "lineNumber": 508, + "lineNumber": 509, "reasonCategory": "falseMatch", "updated": "2018-10-02T21:00:44.007Z" }, @@ -235,24 +235,32 @@ "rule": "jQuery-$(", "path": "js/background.js", "line": " el: $('body'),", - "lineNumber": 562, + "lineNumber": 572, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", + "updated": "2018-10-16T23:47:48.006Z", "reasonDetail": "Protected from arbitrary input" }, { "rule": "jQuery-wrap(", "path": "js/background.js", - "line": " const profileKey = dcodeIO.ByteBuffer.wrap(details.profileKey).toString(", - "lineNumber": 884, + "line": " wrap(", + "lineNumber": 830, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2018-10-18T22:23:00.485Z" + }, + { + "rule": "jQuery-wrap(", + "path": "js/background.js", + "line": " await wrap(", + "lineNumber": 1320, + "reasonCategory": "falseMatch", + "updated": "2018-10-26T22:43:23.229Z" }, { "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " async load() {", - "lineNumber": 198, + "lineNumber": 208, "reasonCategory": "falseMatch", "updated": "2018-10-02T21:00:44.007Z" }, @@ -260,7 +268,7 @@ "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " this._initialPromise = load();", - "lineNumber": 227, + "lineNumber": 237, "reasonCategory": "falseMatch", "updated": "2018-10-02T21:00:44.007Z" }, @@ -293,51 +301,59 @@ }, { "rule": "jQuery-wrap(", - "path": "js/models/conversations.js", - "line": " const identityKey = dcodeIO.ByteBuffer.wrap(", - "lineNumber": 1097, + "path": "js/models/messages.js", + "line": " this.send(wrap(promise));", + "lineNumber": 794, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2018-10-05T23:12:28.961Z" }, { "rule": "jQuery-wrap(", - "path": "js/models/conversations.js", - "line": " const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer();", - "lineNumber": 1157, + "path": "js/models/messages.js", + "line": " return wrap(", + "lineNumber": 996, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2018-10-05T23:12:28.961Z" }, { "rule": "jQuery-wrap(", - "path": "js/models/conversations.js", - "line": " const name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8');", - "lineNumber": 1170, + "path": "js/modules/crypto.js", + "line": " return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');", + "lineNumber": 271, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2018-10-05T23:12:28.961Z" }, { "rule": "jQuery-wrap(", - "path": "js/models/conversations.js", - "line": " const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer();", - "lineNumber": 1185, + "path": "js/modules/crypto.js", + "line": " return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();", + "lineNumber": 274, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2018-10-05T23:12:28.961Z" }, { "rule": "jQuery-wrap(", - "path": "js/modules/backup.js", - "line": " data: dcodeIO.ByteBuffer.wrap(val).toString('base64'),", - "lineNumber": 51, + "path": "js/modules/crypto.js", + "line": " return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();", + "lineNumber": 278, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2018-10-05T23:12:28.961Z" }, { "rule": "jQuery-wrap(", - "path": "js/modules/backup.js", - "line": " object[key] = dcodeIO.ByteBuffer.wrap(val.data, 'base64').toArrayBuffer();", - "lineNumber": 73, + "path": "js/modules/crypto.js", + "line": " return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();", + "lineNumber": 282, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2018-10-05T23:12:28.961Z" + }, + { + "rule": "jQuery-wrap(", + "path": "js/modules/crypto.js", + "line": " return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');", + "lineNumber": 285, + "reasonCategory": "falseMatch", + "updated": "2018-10-05T23:12:28.961Z" }, { "rule": "jQuery-append(", @@ -365,19 +381,11 @@ }, { "rule": "jQuery-wrap(", - "path": "js/modules/types/conversation.js", - "line": " return dcodeIO.ByteBuffer.wrap(arraybuffer).toString('base64');", - "lineNumber": 12, + "path": "js/modules/startup.js", + "line": " await wrap(sendRequestConfigurationSyncMessage(sendOptions));", + "lineNumber": 50, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" - }, - { - "rule": "jQuery-wrap(", - "path": "js/modules/types/conversation.js", - "line": " return dcodeIO.ByteBuffer.wrap(base64, 'base64').toArrayBuffer();", - "lineNumber": 16, - "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2018-10-05T23:12:28.961Z" }, { "rule": "jQuery-$(", @@ -2291,7 +2299,7 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " Promise.resolve(dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer());", - "lineNumber": 138, + "lineNumber": 145, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, @@ -2299,7 +2307,7 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " Promise.resolve(dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary'));", - "lineNumber": 140, + "lineNumber": 147, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, @@ -2307,7 +2315,7 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", - "lineNumber": 688, + "lineNumber": 774, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, @@ -2315,10 +2323,26 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", - "lineNumber": 713, + "lineNumber": 799, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, + { + "rule": "jQuery-wrap(", + "path": "libtextsecure/sync_request.js", + "line": " wrap(sender.sendRequestContactSyncMessage(sendOptions))", + "lineNumber": 31, + "reasonCategory": "falseMatch", + "updated": "2018-10-05T23:12:28.961Z" + }, + { + "rule": "jQuery-wrap(", + "path": "libtextsecure/sync_request.js", + "line": " return wrap(sender.sendRequestGroupSyncMessage(sendOptions));", + "lineNumber": 34, + "reasonCategory": "falseMatch", + "updated": "2018-10-05T23:12:28.961Z" + }, { "rule": "eval", "path": "node_modules/@protobufjs/inquire/index.js",