Sealed Sender support

https://signal.org/blog/sealed-sender/
This commit is contained in:
Scott Nonnenberg 2018-10-17 18:01:21 -07:00
parent 817cf5ed03
commit a7d78c0e9b
38 changed files with 2996 additions and 789 deletions

View file

@ -798,6 +798,11 @@
"message": "message":
"Are you sure? Clicking 'delete' will permanently remove this message from this device only." "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": { "deleteThisMessage": {
"message": "Delete this message" "message": "Delete this message"
}, },

View file

@ -325,6 +325,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion2, updateToSchemaVersion2,
updateToSchemaVersion3, updateToSchemaVersion3,
updateToSchemaVersion4, updateToSchemaVersion4,
// version 5 was dropped
]; ];
async function updateSchema(instance) { async function updateSchema(instance) {

View file

@ -6,5 +6,6 @@
"buildExpiration": 0, "buildExpiration": 0,
"certificateAuthority": "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", "-----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"
} }

View file

@ -1,4 +1,5 @@
{ {
"serverUrl": "https://textsecure-service.whispersystems.org", "serverUrl": "https://textsecure-service.whispersystems.org",
"cdnUrl": "https://cdn.signal.org" "cdnUrl": "https://cdn.signal.org",
"serverTrustRoot": "BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF"
} }

View file

@ -1,14 +1,15 @@
/* global Backbone: false */ /* global
/* global $: false */ $,
_,
/* global dcodeIO: false */ Backbone,
/* global ConversationController: false */ ConversationController,
/* global getAccountManager: false */ getAccountManager,
/* global Signal: false */ Signal,
/* global storage: false */ storage,
/* global textsecure: false */ textsecure,
/* global Whisper: false */ WebAPI
/* global _: false */ Whisper,
*/
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
(async function() { (async function() {
@ -553,7 +554,16 @@
window.log.info('listening for registration events'); window.log.info('listening for registration events');
Whisper.events.on('registration_done', () => { Whisper.events.on('registration_done', () => {
window.log.info('handling registration event'); window.log.info('handling registration event');
// listeners
Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion);
window.Signal.RefreshSenderCertificate.initialize({
events: Whisper.events,
storage,
navigator,
logger: window.log,
});
connect(true); connect(true);
}); });
@ -570,7 +580,15 @@
window.log.info('Import was interrupted, showing import error screen'); window.log.info('Import was interrupted, showing import error screen');
appView.openImporter(); appView.openImporter();
} else if (Whisper.Registration.everDone()) { } else if (Whisper.Registration.everDone()) {
// listeners
Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion);
window.Signal.RefreshSenderCertificate.initialize({
events: Whisper.events,
storage,
navigator,
logger: window.log,
});
connect(); connect();
appView.openInbox({ appView.openInbox({
initialLoadComplete, initialLoadComplete,
@ -713,6 +731,7 @@
connectCount += 1; connectCount += 1;
const options = { const options = {
retryCached: connectCount === 1, retryCached: connectCount === 1,
serverTrustRoot: window.getServerTrustRoot(),
}; };
Whisper.Notifications.disable(); // avoid notification flood until empty Whisper.Notifications.disable(); // avoid notification flood until empty
@ -755,14 +774,33 @@
window.getSyncRequest(); 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 deviceId = textsecure.storage.user.getDeviceId();
const ourNumber = textsecure.storage.user.getNumber();
const { sendRequestConfigurationSyncMessage } = textsecure.messaging; const { sendRequestConfigurationSyncMessage } = textsecure.messaging;
const status = await Signal.Startup.syncReadReceiptConfiguration({ const status = await Signal.Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId, deviceId,
sendRequestConfigurationSyncMessage, sendRequestConfigurationSyncMessage,
storage, 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') { if (firstRun === true && deviceId !== '1') {
const hasThemeSetting = Boolean(storage.get('theme-setting')); const hasThemeSetting = Boolean(storage.get('theme-setting'));
@ -786,14 +824,17 @@
}); });
if (Whisper.Import.isComplete()) { if (Whisper.Import.isComplete()) {
textsecure.messaging const { wrap, sendOptions } = ConversationController.prepareForSend(
.sendRequestConfigurationSyncMessage() textsecure.storage.user.getNumber()
.catch(error => { );
window.log.error( wrap(
'Import complete, but failed to send sync message', textsecure.messaging.sendRequestConfigurationSyncMessage(sendOptions)
error && error.stack ? error.stack : error ).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) { 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(); ev.confirm();
} }
@ -881,10 +935,10 @@
} }
if (details.profileKey) { if (details.profileKey) {
const profileKey = dcodeIO.ByteBuffer.wrap(details.profileKey).toString( const profileKey = window.Signal.Crypto.arrayBufferToBase64(
'base64' details.profileKey
); );
conversation.set({ profileKey }); conversation.setProfileKey(profileKey);
} }
if (typeof details.blocked !== 'undefined') { if (typeof details.blocked !== 'undefined') {
@ -1052,7 +1106,7 @@
return handleProfileUpdate({ data, confirm, messageDescriptor }); return handleProfileUpdate({ data, confirm, messageDescriptor });
} }
const message = createMessage(data); const message = await createMessage(data);
const isDuplicate = await isMessageDuplicate(message); const isDuplicate = await isMessageDuplicate(message);
if (isDuplicate) { if (isDuplicate) {
window.log.warn('Received duplicate message', message.idForLogging()); window.log.warn('Received duplicate message', message.idForLogging());
@ -1191,15 +1245,27 @@
function createSentMessage(data) { function createSentMessage(data) {
const now = Date.now(); 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({ return new Whisper.Message({
source: textsecure.storage.user.getNumber(), source: textsecure.storage.user.getNumber(),
sourceDevice: data.device, sourceDevice: data.device,
sent_at: data.timestamp, sent_at: data.timestamp,
sent_to: sentTo,
received_at: now, received_at: now,
conversationId: data.destination, conversationId: data.destination,
type: 'outgoing', type: 'outgoing',
sent: true, sent: true,
unidentifiedDeliveries: data.unidentifiedDeliveries || [],
expirationStartTimestamp: Math.min( expirationStartTimestamp: Math.min(
data.expirationStartTimestamp || data.timestamp || Date.now(), data.expirationStartTimestamp || data.timestamp || Date.now(),
Date.now() Date.now()
@ -1227,17 +1293,46 @@
} }
} }
function initIncomingMessage(data) { async function initIncomingMessage(data, options = {}) {
const { isError } = options;
const message = new Whisper.Message({ const message = new Whisper.Message({
source: data.source, source: data.source,
sourceDevice: data.sourceDevice, sourceDevice: data.sourceDevice,
sent_at: data.timestamp, sent_at: data.timestamp,
received_at: data.receivedAt || Date.now(), received_at: data.receivedAt || Date.now(),
conversationId: data.source, conversationId: data.source,
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: 'incoming', type: 'incoming',
unread: 1, 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; return message;
} }
@ -1322,7 +1417,7 @@
return; return;
} }
const envelope = ev.proto; const envelope = ev.proto;
const message = initIncomingMessage(envelope); const message = await initIncomingMessage(envelope, { isError: true });
await message.saveErrors(error || new Error('Error was null')); await message.saveErrors(error || new Error('Error was null'));
const id = message.get('conversationId'); const id = message.get('conversationId');

View file

@ -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) { async getAllGroupsInvolvingId(id) {
const groups = await window.Signal.Data.getAllGroupsInvolvingId(id, { const groups = await window.Signal.Data.getAllGroupsInvolvingId(id, {
ConversationCollection: Whisper.ConversationCollection, ConversationCollection: Whisper.ConversationCollection,

View file

@ -22848,7 +22848,7 @@ function _memset(ptr, value, num) {
} }
} }
while ((ptr|0) < (stop4|0)) { while ((ptr|0) < (stop4|0)) {
HEAP32[ptr>>2]=value4; HEAP32[((ptr)>>2)]=value4;
ptr = (ptr+4)|0; ptr = (ptr+4)|0;
} }
} }
@ -22904,7 +22904,7 @@ function _memcpy(dest, src, num) {
num = (num-1)|0; num = (num-1)|0;
} }
while ((num|0) >= 4) { while ((num|0) >= 4) {
HEAP32[dest>>2]=((HEAP32[src>>2])|0); HEAP32[((dest)>>2)]=((HEAP32[((src)>>2)])|0);
dest = (dest+4)|0; dest = (dest+4)|0;
src = (src+4)|0; src = (src+4)|0;
num = (num-4)|0; num = (num-4)|0;

View file

@ -1,6 +1,5 @@
/* global _: false */ /* global _: false */
/* global Backbone: false */ /* global Backbone: false */
/* global dcodeIO: false */
/* global libphonenumber: false */ /* global libphonenumber: false */
/* global ConversationController: false */ /* global ConversationController: false */
@ -33,8 +32,6 @@
deleteAttachmentData, deleteAttachmentData,
} = window.Signal.Migrations; } = window.Signal.Migrations;
// TODO: Factor out private and group subclasses of Conversation
const COLORS = [ const COLORS = [
'red', 'red',
'deep_orange', 'deep_orange',
@ -324,9 +321,20 @@
} }
}, },
sendVerifySyncMessage(number, state) { 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); const promise = textsecure.storage.protocol.loadIdentityKey(number);
return promise.then(key => return promise.then(key =>
textsecure.messaging.syncVerification(number, state, key) this.wrapSend(
textsecure.messaging.syncVerification(number, state, key, options)
)
); );
}, },
isVerified() { isVerified() {
@ -754,20 +762,118 @@
messageWithSchema.attachments.map(loadAttachmentData) messageWithSchema.attachments.map(loadAttachmentData)
); );
const options = this.getSendOptions();
return message.send( return message.send(
sendFunction( this.wrapSend(
destination, sendFunction(
body, destination,
attachmentsWithData, body,
quote, attachmentsWithData,
now, quote,
expireTimer, now,
profileKey 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() { async updateLastMessage() {
if (!this.id) { if (!this.id) {
return; return;
@ -901,14 +1007,17 @@
if (this.get('profileSharing')) { if (this.get('profileSharing')) {
profileKey = storage.get('profileKey'); profileKey = storage.get('profileKey');
} }
const sendOptions = this.getSendOptions();
const promise = sendFunc( const promise = sendFunc(
this.get('id'), this.get('id'),
this.get('expireTimer'), this.get('expireTimer'),
message.get('sent_at'), message.get('sent_at'),
profileKey profileKey,
sendOptions
); );
await message.send(promise); await message.send(this.wrapSend(promise));
return message; return message;
}, },
@ -935,7 +1044,12 @@
}); });
message.set({ id }); 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 }); message.set({ id });
const options = this.getSendOptions();
message.send( message.send(
textsecure.messaging.updateGroup( this.wrapSend(
this.id, textsecure.messaging.updateGroup(
this.get('name'), this.id,
this.get('avatar'), this.get('name'),
this.get('members') this.get('avatar'),
this.get('members'),
options
)
) )
); );
}, },
@ -993,7 +1111,10 @@
}); });
message.set({ id }); 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); read = read.filter(item => !item.hasErrors);
if (read.length && options.sendReadReceipts) { if (read.length && options.sendReadReceipts) {
window.log.info('Sending', read.length, 'read receipts'); window.log.info(`Sending ${read.length} read receipts`);
await textsecure.messaging.syncReadMessages(read); // 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')) { if (storage.get('read-receipt-setting')) {
await Promise.all( await Promise.all(
_.map(_.groupBy(read, 'sender'), async (receipts, sender) => { _.map(_.groupBy(read, 'sender'), async (receipts, sender) => {
const timestamps = _.map(receipts, 'timestamp'); 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 c = await ConversationController.getOrCreateAndWait(id, 'private');
const profile = await textsecure.messaging.getProfile(id);
const identityKey = dcodeIO.ByteBuffer.wrap(
profile.identityKey,
'base64'
).toArrayBuffer();
// 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( const changed = await textsecure.storage.protocol.saveIdentity(
`${id}.1`, `${id}.1`,
identityKey, identityKey,
@ -1116,49 +1298,68 @@
await sessionCipher.closeOpenSessionForDevice(); await sessionCipher.closeOpenSessionForDevice();
} }
try { c.set({
const c = ConversationController.get(id); hasFetchedProfile: true,
});
// Because we're no longer using Backbone-integrated saves, we need to manually if (
// clear the changed fields here so our hasChanged() check is useful. profile.unrestrictedUnidentifiedAccess &&
c.changed = {}; profile.unidentifiedAccess
await c.setProfileName(profile.name); ) {
await c.setProfileAvatar(profile.avatar); 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()) { window.log.info(
await window.Signal.Data.updateConversation(id, c.attributes, { `Setting unidentifiedDelivery to ${haveCorrectKey} for conversation ${c.idForLogging()}`
Conversation: Whisper.Conversation, );
}); c.set({
} unidentifiedDelivery: haveCorrectKey,
} catch (e) { unidentifiedDeliveryUnrestricted: false,
if (e.name === 'ProfileDecryptError') { });
// probably the profile key has changed. } else {
window.log.error( c.set({
'decryptProfile error:', unidentifiedDelivery: false,
id, unidentifiedDeliveryUnrestricted: false,
e && e.stack ? e.stack : e });
);
}
} }
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) { } catch (error) {
window.log.error( window.log.error(
'getProfile error:', 'getProfile error:',
id,
error && error.stack ? error.stack : error error && error.stack ? error.stack : error
); );
} finally {
if (c.hasChanged()) {
await window.Signal.Data.updateConversation(id, c.attributes, {
Conversation: Whisper.Conversation,
});
}
} }
}, },
async setProfileName(encryptedName) { async setProfileName(encryptedName) {
if (!encryptedName) {
return;
}
const key = this.get('profileKey'); const key = this.get('profileKey');
if (!key) { if (!key) {
return; return;
} }
// decode // decode
const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer(); const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key);
const data = dcodeIO.ByteBuffer.wrap( const data = window.Signal.Crypto.base64ToArrayBuffer(encryptedName);
encryptedName,
'base64'
).toArrayBuffer();
// decrypt // decrypt
const decrypted = await textsecure.crypto.decryptProfileName( const decrypted = await textsecure.crypto.decryptProfileName(
@ -1167,10 +1368,10 @@
); );
// encode // encode
const name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8'); const profileName = window.Signal.Crypto.stringFromBytes(decrypted);
// set // set
this.set({ profileName: name }); this.set({ profileName });
}, },
async setProfileAvatar(avatarPath) { async setProfileAvatar(avatarPath) {
if (!avatarPath) { if (!avatarPath) {
@ -1182,7 +1383,7 @@
if (!key) { if (!key) {
return; return;
} }
const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer(); const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key);
// decrypt // decrypt
const decrypted = await textsecure.crypto.decryptProfile( const decrypted = await textsecure.crypto.decryptProfile(
@ -1204,9 +1405,20 @@
} }
}, },
async setProfileKey(profileKey) { 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) { 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, { await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation, Conversation: Whisper.Conversation,
}); });

View file

@ -599,10 +599,25 @@
: null, : null,
}; };
}, },
isUnidentifiedDelivery(contactId, lookup) {
if (this.isIncoming()) {
return this.get('unidentifiedDeliveryReceived');
}
return Boolean(lookup[contactId]);
},
getPropsForMessageDetail() { getPropsForMessageDetail() {
const newIdentity = i18n('newIdentity'); const newIdentity = i18n('newIdentity');
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; 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 // Older messages don't have the recipients included on the message, so we fall
// back to the conversation's current recipients // back to the conversation's current recipients
const phoneNumbers = this.isIncoming() const phoneNumbers = this.isIncoming()
@ -628,12 +643,16 @@
const isOutgoingKeyError = Boolean( const isOutgoingKeyError = Boolean(
_.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR) _.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR)
); );
const isUnidentifiedDelivery =
storage.get('unidentifiedDeliveryIndicators') &&
this.isUnidentifiedDelivery(id, unidentifiedLookup);
return { return {
...this.findAndFormatContact(id), ...this.findAndFormatContact(id),
status: this.getStatus(id), status: this.getStatus(id),
errors: errorsForContact, errors: errorsForContact,
isOutgoingKeyError, isOutgoingKeyError,
isUnidentifiedDelivery,
onSendAnyway: () => onSendAnyway: () =>
this.trigger('force-send', { this.trigger('force-send', {
contact: this.findContact(id), contact: this.findContact(id),
@ -696,11 +715,12 @@
const quoteWithData = await loadQuoteData(this.get('quote')); const quoteWithData = await loadQuoteData(this.get('quote'));
const conversation = this.getConversation(); const conversation = this.getConversation();
const options = conversation.getSendOptions();
let promise; let promise;
if (conversation.isPrivate()) { if (conversation.isPrivate()) {
const [number] = numbers; const [number] = numbers;
promise = textsecure.messaging.sendMessageToNumber( promise = textsecure.messaging.sendMessageToNumber(
number, number,
this.get('body'), this.get('body'),
@ -708,28 +728,33 @@
quoteWithData, quoteWithData,
this.get('sent_at'), this.get('sent_at'),
this.get('expireTimer'), this.get('expireTimer'),
profileKey profileKey,
options
); );
} else { } else {
// Because this is a partial group send, we manually construct the request like // Because this is a partial group send, we manually construct the request like
// sendMessageToGroup does. // sendMessageToGroup does.
promise = textsecure.messaging.sendMessage({
recipients: numbers, promise = textsecure.messaging.sendMessage(
body: this.get('body'), {
timestamp: this.get('sent_at'), recipients: numbers,
attachments: attachmentsWithData, body: this.get('body'),
quote: quoteWithData, timestamp: this.get('sent_at'),
needsSync: !this.get('synced'), attachments: attachmentsWithData,
expireTimer: this.get('expireTimer'), quote: quoteWithData,
profileKey, needsSync: !this.get('synced'),
group: { expireTimer: this.get('expireTimer'),
id: this.get('conversationId'), profileKey,
type: textsecure.protobuf.GroupContext.Type.DELIVER, group: {
id: this.get('conversationId'),
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
}, },
}); options
);
} }
return this.send(promise); return this.send(conversation.wrapSend(promise));
}, },
isReplayableError(e) { isReplayableError(e) {
return ( return (
@ -752,6 +777,9 @@
); );
const quoteWithData = await loadQuoteData(this.get('quote')); const quoteWithData = await loadQuoteData(this.get('quote'));
const { wrap, sendOptions } = ConversationController.prepareForSend(
number
);
const promise = textsecure.messaging.sendMessageToNumber( const promise = textsecure.messaging.sendMessageToNumber(
number, number,
this.get('body'), this.get('body'),
@ -759,10 +787,11 @@
quoteWithData, quoteWithData,
this.get('sent_at'), this.get('sent_at'),
this.get('expireTimer'), this.get('expireTimer'),
profileKey profileKey,
sendOptions
); );
this.send(promise); this.send(wrap(promise));
} }
}, },
removeOutgoingErrors(number) { removeOutgoingErrors(number) {
@ -860,11 +889,13 @@
sent_to: _.union(sentTo, result.successfulNumbers), sent_to: _.union(sentTo, result.successfulNumbers),
sent: true, sent: true,
expirationStartTimestamp: Date.now(), expirationStartTimestamp: Date.now(),
unidentifiedDeliveries: result.unidentifiedDeliveries,
}); });
await window.Signal.Data.saveMessage(this.attributes, { await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message, Message: Whisper.Message,
}); });
this.trigger('sent', this); this.trigger('sent', this);
this.sendSyncMessage(); this.sendSyncMessage();
}) })
@ -909,6 +940,7 @@
sent_to: _.union(sentTo, result.successfulNumbers), sent_to: _.union(sentTo, result.successfulNumbers),
sent: true, sent: true,
expirationStartTimestamp, expirationStartTimestamp,
unidentifiedDeliveries: result.unidentifiedDeliveries,
}); });
promises.push(this.sendSyncMessage()); promises.push(this.sendSyncMessage());
} else { } else {
@ -950,28 +982,36 @@
}, },
sendSyncMessage() { sendSyncMessage() {
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourNumber
);
this.syncPromise = this.syncPromise || Promise.resolve(); this.syncPromise = this.syncPromise || Promise.resolve();
this.syncPromise = this.syncPromise.then(() => { this.syncPromise = this.syncPromise.then(() => {
const dataMessage = this.get('dataMessage'); const dataMessage = this.get('dataMessage');
if (this.get('synced') || !dataMessage) { if (this.get('synced') || !dataMessage) {
return Promise.resolve(); return Promise.resolve();
} }
return textsecure.messaging return wrap(
.sendSyncMessage( textsecure.messaging.sendSyncMessage(
dataMessage, dataMessage,
this.get('sent_at'), this.get('sent_at'),
this.get('destination'), this.get('destination'),
this.get('expirationStartTimestamp') this.get('expirationStartTimestamp'),
this.get('sent_to'),
this.get('unidentifiedDeliveries'),
sendOptions
) )
.then(() => { ).then(() => {
this.set({ this.set({
synced: true, synced: true,
dataMessage: null, dataMessage: null,
});
return window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
}); });
return window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
});
}); });
}, },
@ -1238,7 +1278,7 @@
if (source === textsecure.storage.user.getNumber()) { if (source === textsecure.storage.user.getNumber()) {
conversation.set({ profileSharing: true }); conversation.set({ profileSharing: true });
} else if (conversation.isPrivate()) { } else if (conversation.isPrivate()) {
conversation.set({ profileKey }); conversation.setProfileKey(profileKey);
} else { } else {
ConversationController.getOrCreateAndWait(source, 'private').then( ConversationController.getOrCreateAndWait(source, 'private').then(
sender => { sender => {

View file

@ -1,6 +1,5 @@
/* global Signal: false */ /* global Signal: false */
/* global Whisper: false */ /* global Whisper: false */
/* global dcodeIO: false */
/* global _: false */ /* global _: false */
/* global textsecure: false */ /* global textsecure: false */
/* global i18n: false */ /* global i18n: false */
@ -48,7 +47,7 @@ function stringify(object) {
object[key] = { object[key] = {
type: 'ArrayBuffer', type: 'ArrayBuffer',
encoding: 'base64', encoding: 'base64',
data: dcodeIO.ByteBuffer.wrap(val).toString('base64'), data: crypto.arrayBufferToBase64(val),
}; };
} else if (val instanceof Object) { } else if (val instanceof Object) {
object[key] = stringify(val); object[key] = stringify(val);
@ -70,7 +69,7 @@ function unstringify(object) {
val.encoding === 'base64' && val.encoding === 'base64' &&
typeof val.data === 'string' typeof val.data === 'string'
) { ) {
object[key] = dcodeIO.ByteBuffer.wrap(val.data, 'base64').toArrayBuffer(); object[key] = crypto.base64ToArrayBuffer(val.data);
} else if (val instanceof Object) { } else if (val instanceof Object) {
object[key] = unstringify(object[key]); object[key] = unstringify(object[key]);
} }

View file

@ -1,39 +1,82 @@
/* eslint-env browser */ /* eslint-env browser */
/* global dcodeIO */
/* eslint-disable camelcase */ /* eslint-disable camelcase, no-bitwise */
module.exports = { module.exports = {
encryptSymmetric, arrayBufferToBase64,
decryptSymmetric, base64ToArrayBuffer,
bytesFromString,
concatenateBytes,
constantTimeEqual, 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 IV_LENGTH = 16;
const MAC_LENGTH = 16; const MAC_LENGTH = 16;
const NONCE_LENGTH = 16; const NONCE_LENGTH = 16;
async function encryptSymmetric(key, plaintext) { async function encryptSymmetric(key, plaintext) {
const iv = _getZeros(IV_LENGTH); const iv = getZeroes(IV_LENGTH);
const nonce = _getRandomBytes(NONCE_LENGTH); const nonce = getRandomBytes(NONCE_LENGTH);
const cipherKey = await _hmac_SHA256(key, nonce); const cipherKey = await hmacSha256(key, nonce);
const macKey = await _hmac_SHA256(key, cipherKey); const macKey = await hmacSha256(key, cipherKey);
const cipherText = await _encrypt_aes256_CBC_PKCSPadding( const cipherText = await _encrypt_aes256_CBC_PKCSPadding(
cipherKey, cipherKey,
iv, iv,
plaintext plaintext
); );
const mac = _getFirstBytes( const mac = _getFirstBytes(await hmacSha256(macKey, cipherText), MAC_LENGTH);
await _hmac_SHA256(macKey, cipherText),
MAC_LENGTH
);
return _concatData([nonce, cipherText, mac]); return concatenateBytes(nonce, cipherText, mac);
} }
async function decryptSymmetric(key, data) { async function decryptSymmetric(key, data) {
const iv = _getZeros(IV_LENGTH); const iv = getZeroes(IV_LENGTH);
const nonce = _getFirstBytes(data, NONCE_LENGTH); const nonce = _getFirstBytes(data, NONCE_LENGTH);
const cipherText = _getBytes( const cipherText = _getBytes(
@ -43,11 +86,11 @@ async function decryptSymmetric(key, data) {
); );
const theirMac = _getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH); const theirMac = _getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH);
const cipherKey = await _hmac_SHA256(key, nonce); const cipherKey = await hmacSha256(key, nonce);
const macKey = await _hmac_SHA256(key, cipherKey); const macKey = await hmacSha256(key, cipherKey);
const ourMac = _getFirstBytes( const ourMac = _getFirstBytes(
await _hmac_SHA256(macKey, cipherText), await hmacSha256(macKey, cipherText),
MAC_LENGTH MAC_LENGTH
); );
if (!constantTimeEqual(theirMac, ourMac)) { if (!constantTimeEqual(theirMac, ourMac)) {
@ -73,56 +116,135 @@ function constantTimeEqual(left, right) {
return result === 0; 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 extractable = false;
const cryptoKey = await window.crypto.subtle.importKey( const cryptoKey = await window.crypto.subtle.importKey(
'raw', 'raw',
key, key,
{ name: 'HMAC', hash: { name: 'SHA-256' } }, algorithm,
extractable, extractable,
['sign'] ['sign']
); );
return window.crypto.subtle.sign( return window.crypto.subtle.sign(algorithm, cryptoKey, plaintext);
{ name: 'HMAC', hash: 'SHA-256' },
cryptoKey,
data
);
} }
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 extractable = false;
const cryptoKey = await window.crypto.subtle.importKey( const cryptoKey = await window.crypto.subtle.importKey(
'raw', 'raw',
key, key,
{ name: 'AES-CBC' }, algorithm,
extractable, extractable,
['encrypt'] ['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 extractable = false;
const cryptoKey = await window.crypto.subtle.importKey( const cryptoKey = await window.crypto.subtle.importKey(
'raw', 'raw',
key, key,
{ name: 'AES-CBC' }, algorithm,
extractable, extractable,
['decrypt'] ['decrypt']
); );
return window.crypto.subtle.decrypt(algorithm, cryptoKey, plaintext);
return window.crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, data);
} }
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); const bytes = new Uint8Array(n);
window.crypto.getRandomValues(bytes); window.crypto.getRandomValues(bytes);
return bytes; return bytes;
} }
function _getZeros(n) { function getZeroes(n) {
const result = new Uint8Array(n); const result = new Uint8Array(n);
const value = 0; const value = 0;
@ -133,17 +255,43 @@ function _getZeros(n) {
return result; return result;
} }
function _getFirstBytes(data, n) { function highBitsToInt(byte) {
const source = new Uint8Array(data); return (byte & 0xff) >> 4;
return source.subarray(0, n);
} }
function _getBytes(data, start, n) { function intsToByteHighAndLow(highValue, lowValue) {
const source = new Uint8Array(data); return ((highValue << 4) | lowValue) & 0xff;
return source.subarray(start, start + n);
} }
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( const length = elements.reduce(
(total, element) => total + element.byteLength, (total, element) => total + element.byteLength,
0 0
@ -161,5 +309,45 @@ function _concatData(elements) {
throw new Error('problem concatenating!'); 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);
} }

View file

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

View file

@ -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<SignalProtocolAddress, byte[]> 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,
};

View file

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

View file

@ -11,6 +11,8 @@ const Settings = require('./settings');
const Startup = require('./startup'); const Startup = require('./startup');
const Util = require('../../ts/util'); const Util = require('../../ts/util');
const { migrateToSQL } = require('./migrate_to_sql'); const { migrateToSQL } = require('./migrate_to_sql');
const Metadata = require('./metadata/SecretSessionCipher');
const RefreshSenderCertificate = require('./refresh_sender_certificate');
// Components // Components
const { const {
@ -216,6 +218,7 @@ exports.setup = (options = {}) => {
}; };
return { return {
Metadata,
Backbone, Backbone,
Components, Components,
Crypto, Crypto,
@ -225,6 +228,7 @@ exports.setup = (options = {}) => {
Migrations, Migrations,
Notifications, Notifications,
OS, OS,
RefreshSenderCertificate,
Settings, Settings,
Startup, Startup,
Types, Types,

View file

@ -4,20 +4,28 @@ const Errors = require('./types/errors');
const Settings = require('./settings'); const Settings = require('./settings');
exports.syncReadReceiptConfiguration = async ({ exports.syncReadReceiptConfiguration = async ({
ourNumber,
deviceId, deviceId,
sendRequestConfigurationSyncMessage, sendRequestConfigurationSyncMessage,
storage, storage,
prepareForSend,
}) => { }) => {
if (!is.string(deviceId)) { 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)) { if (!is.string(ourNumber)) {
throw new TypeError("'sendRequestConfigurationSyncMessage' is required"); throw new TypeError('ourNumber is required');
} }
if (!is.object(storage)) { if (!is.object(storage)) {
throw new TypeError("'storage' is required"); throw new TypeError('storage is required');
} }
const isPrimaryDevice = deviceId === '1'; const isPrimaryDevice = deviceId === '1';
@ -38,7 +46,8 @@ exports.syncReadReceiptConfiguration = async ({
} }
try { try {
await sendRequestConfigurationSyncMessage(); const { wrap, sendOptions } = prepareForSend(ourNumber);
await wrap(sendRequestConfigurationSyncMessage(sendOptions));
storage.put(settingName, true); storage.put(settingName, true);
} catch (error) { } catch (error) {
return { return {

View file

@ -1,21 +1,14 @@
/* global dcodeIO, crypto */ /* global crypto */
const { isFunction, isNumber } = require('lodash'); const { isFunction, isNumber } = require('lodash');
const { createLastMessageUpdate } = require('../../../ts/types/Conversation'); const { createLastMessageUpdate } = require('../../../ts/types/Conversation');
const { arrayBufferToBase64, base64ToArrayBuffer } = require('../crypto');
async function computeHash(arraybuffer) { async function computeHash(arraybuffer) {
const hash = await crypto.subtle.digest({ name: 'SHA-512' }, arraybuffer); const hash = await crypto.subtle.digest({ name: 'SHA-512' }, arraybuffer);
return arrayBufferToBase64(hash); 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 }) { function buildAvatarUpdater({ field }) {
return async (conversation, data, options = {}) => { return async (conversation, data, options = {}) => {
if (!conversation) { if (!conversation) {

View file

@ -80,6 +80,8 @@ function _ensureStringed(thing) {
return res; return res;
} else if (thing === null) { } else if (thing === null) {
return null; return null;
} else if (thing === undefined) {
return undefined;
} }
throw new Error(`unsure of how to jsonify object of type ${typeof thing}`); 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) { function _promiseAjax(providedUrl, options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = providedUrl || `${options.host}/${options.path}`; const url = providedUrl || `${options.host}/${options.path}`;
log.info(options.type, url); log.info(
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
);
const timeout = const timeout =
typeof options.timeout !== 'undefined' ? options.timeout : 10000; typeof options.timeout !== 'undefined' ? options.timeout : 10000;
@ -188,15 +192,26 @@ function _promiseAjax(providedUrl, options) {
fetchOptions.headers['Content-Length'] = contentLength; 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 user = _getString(options.user);
const password = _getString(options.password); const password = _getString(options.password);
const auth = _btoa(`${user}:${password}`); const auth = _btoa(`${user}:${password}`);
fetchOptions.headers.Authorization = `Basic ${auth}`; fetchOptions.headers.Authorization = `Basic ${auth}`;
} }
if (options.contentType) { if (options.contentType) {
fetchOptions.headers['Content-Type'] = options.contentType; fetchOptions.headers['Content-Type'] = options.contentType;
} }
fetch(url, fetchOptions) fetch(url, fetchOptions)
.then(response => { .then(response => {
let resultPromise; let resultPromise;
@ -292,12 +307,14 @@ function HTTPError(message, providedCode, response, stack) {
const URL_CALLS = { const URL_CALLS = {
accounts: 'v1/accounts', accounts: 'v1/accounts',
attachment: 'v1/attachments',
deliveryCert: 'v1/certificate/delivery',
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
devices: 'v1/devices', devices: 'v1/devices',
keys: 'v2/keys', keys: 'v2/keys',
signed: 'v2/keys/signed',
messages: 'v1/messages', messages: 'v1/messages',
attachment: 'v1/attachments',
profile: 'v1/profile', profile: 'v1/profile',
signed: 'v2/keys/signed',
}; };
module.exports = { module.exports = {
@ -335,16 +352,21 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
getAttachment, getAttachment,
getAvatar, getAvatar,
getDevices, getDevices,
getSenderCertificate,
registerSupportForUnauthenticatedDelivery,
getKeysForNumber, getKeysForNumber,
getKeysForNumberUnauth,
getMessageSocket, getMessageSocket,
getMyKeys, getMyKeys,
getProfile, getProfile,
getProfileUnauth,
getProvisioningSocket, getProvisioningSocket,
putAttachment, putAttachment,
registerKeys, registerKeys,
requestVerificationSMS, requestVerificationSMS,
requestVerificationVoice, requestVerificationVoice,
sendMessages, sendMessages,
sendMessagesUnauth,
setSignedPreKey, setSignedPreKey,
}; };
@ -366,6 +388,8 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
type: param.httpType, type: param.httpType,
user: username, user: username,
validateResponse: param.validateResponse, validateResponse: param.validateResponse,
unauthenticated: param.unauthenticated,
accessKey: param.accessKey,
}).catch(e => { }).catch(e => {
const { code } = e; const { code } = e;
if (code === 200) { 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) { function getProfile(number) {
return _ajax({ return _ajax({
call: 'profile', call: 'profile',
@ -413,6 +454,16 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
responseType: 'json', responseType: 'json',
}); });
} }
function getProfileUnauth(number, { accessKey } = {}) {
return _ajax({
call: 'profile',
httpType: 'GET',
urlParameters: `/${number}`,
responseType: 'json',
unauthenticated: true,
accessKey,
});
}
function getAvatar(path) { function getAvatar(path) {
// Using _outerAJAX, since it's not hardcoded to the Signal Server. Unlike our // Using _outerAJAX, since it's not hardcoded to the Signal Server. Unlike our
@ -449,13 +500,19 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
newPassword, newPassword,
signalingKey, signalingKey,
registrationId, registrationId,
deviceName deviceName,
options = {}
) { ) {
const { accessKey } = options;
const jsonData = { const jsonData = {
signalingKey: _btoa(_getString(signalingKey)), signalingKey: _btoa(_getString(signalingKey)),
supportsSms: false, supportsSms: false,
fetchesMessages: true, fetchesMessages: true,
registrationId, registrationId,
unidentifiedAccessKey: accessKey
? _btoa(_getString(accessKey))
: undefined,
unrestrictedUnidentifiedAccess: false,
}; };
let call; let call;
@ -552,6 +609,43 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
}).then(res => res.count); }).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 = '*') { function getKeysForNumber(number, deviceId = '*') {
return _ajax({ return _ajax({
call: 'keys', call: 'keys',
@ -559,41 +653,46 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
urlParameters: `/${number}/${deviceId}`, urlParameters: `/${number}/${deviceId}`,
responseType: 'json', responseType: 'json',
validateResponse: { identityKey: 'string', devices: 'object' }, validateResponse: { identityKey: 'string', devices: 'object' },
}).then(res => { }).then(handleKeys);
if (res.devices.constructor !== Array) { }
throw new Error('Invalid response');
} function getKeysForNumberUnauth(
res.identityKey = _base64ToBytes(res.identityKey); number,
res.devices.forEach(device => { deviceId = '*',
if ( { accessKey } = {}
!_validateResponse(device, { signedPreKey: 'object' }) || ) {
!_validateResponse(device.signedPreKey, { return _ajax({
publicKey: 'string', call: 'keys',
signature: 'string', httpType: 'GET',
}) urlParameters: `/${number}/${deviceId}`,
) { responseType: 'json',
throw new Error('Invalid signedPreKey'); validateResponse: { identityKey: 'string', devices: 'object' },
} unauthenticated: true,
if (device.preKey) { accessKey,
if ( }).then(handleKeys);
!_validateResponse(device, { preKey: 'object' }) || }
!_validateResponse(device.preKey, { publicKey: 'string' })
) { function sendMessagesUnauth(
throw new Error('Invalid preKey'); destination,
} messageArray,
// eslint-disable-next-line no-param-reassign timestamp,
device.preKey.publicKey = _base64ToBytes(device.preKey.publicKey); silent,
} { accessKey } = {}
// eslint-disable-next-line no-param-reassign ) {
device.signedPreKey.publicKey = _base64ToBytes( const jsonData = { messages: messageArray, timestamp };
device.signedPreKey.publicKey
); if (silent) {
// eslint-disable-next-line no-param-reassign jsonData.silent = true;
device.signedPreKey.signature = _base64ToBytes( }
device.signedPreKey.signature
); return _ajax({
}); call: 'messages',
return res; httpType: 'PUT',
urlParameters: `/${destination}`,
jsonData,
responseType: 'json',
unauthenticated: true,
accessKey,
}); });
} }

View file

@ -6,7 +6,8 @@
btoa, btoa,
getString, getString,
libphonenumber, libphonenumber,
Event Event,
ConversationController
*/ */
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -52,19 +53,29 @@
const confirmKeys = this.confirmKeys.bind(this); const confirmKeys = this.confirmKeys.bind(this);
const registrationDone = this.registrationDone.bind(this); const registrationDone = this.registrationDone.bind(this);
return this.queueTask(() => return this.queueTask(() =>
libsignal.KeyHelper.generateIdentityKeyPair().then(identityKeyPair => { libsignal.KeyHelper.generateIdentityKeyPair().then(
const profileKey = textsecure.crypto.getRandomBytes(32); async identityKeyPair => {
return createAccount( const profileKey = textsecure.crypto.getRandomBytes(32);
number, const accessKey = await window.Signal.Crypto.deriveAccessKey(
verificationCode, profileKey
identityKeyPair, );
profileKey
) return createAccount(
.then(clearSessionsAndPreKeys) number,
.then(generateKeys) verificationCode,
.then(keys => registerKeys(keys).then(() => confirmKeys(keys))) identityKeyPair,
.then(registrationDone); profileKey,
}) null,
null,
null,
{ accessKey }
)
.then(clearSessionsAndPreKeys)
.then(generateKeys)
.then(keys => registerKeys(keys).then(() => confirmKeys(keys)))
.then(() => registrationDone(number));
}
)
); );
}, },
registerSecondDevice(setProvisioningUrl, confirmNumber, progressCallback) { registerSecondDevice(setProvisioningUrl, confirmNumber, progressCallback) {
@ -147,7 +158,9 @@
confirmKeys(keys) confirmKeys(keys)
) )
) )
.then(registrationDone); .then(() =>
registrationDone(provisionMessage.number)
);
} }
) )
) )
@ -185,8 +198,6 @@
const store = textsecure.storage.protocol; const store = textsecure.storage.protocol;
const { server, cleanSignedPreKeys } = this; const { server, cleanSignedPreKeys } = this;
// TODO: harden this against missing identity key? Otherwise, we get
// retries every five seconds.
return store return store
.getIdentityKeyPair() .getIdentityKeyPair()
.then( .then(
@ -196,6 +207,8 @@
signedKeyId signedKeyId
), ),
() => { () => {
// We swallow any error here, because we don't want to get into
// a loop of repeated retries.
window.log.error( window.log.error(
'Failed to get identity key. Canceling key rotation.' 'Failed to get identity key. Canceling key rotation.'
); );
@ -329,8 +342,10 @@
profileKey, profileKey,
deviceName, deviceName,
userAgent, userAgent,
readReceipts readReceipts,
options = {}
) { ) {
const { accessKey } = options;
const signalingKey = libsignal.crypto.getRandomBytes(32 + 20); const signalingKey = libsignal.crypto.getRandomBytes(32 + 20);
let password = btoa(getString(libsignal.crypto.getRandomBytes(16))); let password = btoa(getString(libsignal.crypto.getRandomBytes(16)));
password = password.substring(0, password.length - 2); password = password.substring(0, password.length - 2);
@ -345,7 +360,8 @@
password, password,
signalingKey, signalingKey,
registrationId, registrationId,
deviceName deviceName,
{ accessKey }
) )
.then(response => { .then(response => {
if (previousNumber && previousNumber !== number) { if (previousNumber && previousNumber !== number) {
@ -499,8 +515,12 @@
); );
}); });
}, },
registrationDone() { async registrationDone(number) {
window.log.info('registration done'); window.log.info('registration done');
// Ensure that we always have a conversation for ourself
await ConversationController.getOrCreateAndWait(number, 'private');
this.dispatchEvent(new Event('registration')); this.dispatchEvent(new Event('registration'));
}, },
}); });

View file

@ -22848,7 +22848,7 @@ function _memset(ptr, value, num) {
} }
} }
while ((ptr|0) < (stop4|0)) { while ((ptr|0) < (stop4|0)) {
HEAP32[ptr>>2]=value4; HEAP32[((ptr)>>2)]=value4;
ptr = (ptr+4)|0; ptr = (ptr+4)|0;
} }
} }
@ -22904,7 +22904,7 @@ function _memcpy(dest, src, num) {
num = (num-1)|0; num = (num-1)|0;
} }
while ((num|0) >= 4) { while ((num|0) >= 4) {
HEAP32[dest>>2]=((HEAP32[src>>2])|0); HEAP32[((dest)>>2)]=((HEAP32[((src)>>2)])|0);
dest = (dest+4)|0; dest = (dest+4)|0;
src = (src+4)|0; src = (src+4)|0;
num = (num-4)|0; num = (num-4)|0;
@ -35093,7 +35093,6 @@ Curve25519Worker.prototype = {
if (pubKey.byteLength == 33) { if (pubKey.byteLength == 33) {
return pubKey.slice(1); return pubKey.slice(1);
} else { } else {
console.error("WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey");
return pubKey; return pubKey;
} }
} }
@ -35179,7 +35178,10 @@ Curve25519Worker.prototype = {
}, },
calculateSignature: function(privKey, message) { calculateSignature: function(privKey, message) {
return curve.Ed25519Sign(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 // HKDF for TextSecure has a bit of additional handling - salts always end up being 32 bytes
Internal.HKDF = function(input, salt, info) { 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)); return Internal.crypto.HKDF(input, salt, util.toArrayBuffer(info));
}; };
@ -35460,7 +35458,7 @@ Internal.protoText = function() {
/* vim: ts=4:sw=4 */ /* vim: ts=4:sw=4 */
var Internal = Internal || {}; var Internal = Internal || {};
Internal.protobuf = function() { Internal.protobuf = (function() {
'use strict'; 'use strict';
function loadProtoBufs(filename) { function loadProtoBufs(filename) {
@ -35473,7 +35471,7 @@ Internal.protobuf = function() {
WhisperMessage : protocolMessages.WhisperMessage, WhisperMessage : protocolMessages.WhisperMessage,
PreKeyWhisperMessage : protocolMessages.PreKeyWhisperMessage PreKeyWhisperMessage : protocolMessages.PreKeyWhisperMessage
}; };
}(); })();
/* /*
* vim: ts=4:sw=4 * vim: ts=4:sw=4
@ -35888,6 +35886,7 @@ SessionBuilder.prototype = {
if (message.preKeyId && !preKeyPair) { if (message.preKeyId && !preKeyPair) {
console.log('Invalid prekey id', message.preKeyId); console.log('Invalid prekey id', message.preKeyId);
} }
return this.initSession(false, preKeyPair, signedPreKeyPair, return this.initSession(false, preKeyPair, signedPreKeyPair,
message.identityKey.toArrayBuffer(), message.identityKey.toArrayBuffer(),
message.baseKey.toArrayBuffer(), undefined, message.registrationId message.baseKey.toArrayBuffer(), undefined, message.registrationId
@ -36028,6 +36027,7 @@ SessionCipher.prototype = {
return Internal.SessionRecord.deserialize(serialized); return Internal.SessionRecord.deserialize(serialized);
}); });
}, },
// encoding is an optional parameter - wrap() will only translate if one is provided
encrypt: function(buffer, encoding) { encrypt: function(buffer, encoding) {
buffer = dcodeIO.ByteBuffer.wrap(buffer, encoding).toArrayBuffer(); buffer = dcodeIO.ByteBuffer.wrap(buffer, encoding).toArrayBuffer();
return Internal.SessionLock.queueJobForNumber(this.remoteAddress.toString(), function() { 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() { getRemoteRegistrationId: function() {
return Internal.SessionLock.queueJobForNumber(this.remoteAddress.toString(), function() { return Internal.SessionLock.queueJobForNumber(this.remoteAddress.toString(), function() {
return this.getRecord(this.remoteAddress.toString()).then(function(record) { 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 // returns a Promise that resolves to a ciphertext object
this.encrypt = cipher.encrypt.bind(cipher); this.encrypt = cipher.encrypt.bind(cipher);
this.getRecord = cipher.getRecord.bind(cipher);
// returns a Promise that inits a session if necessary and resolves // returns a Promise that inits a session if necessary and resolves
// to a decrypted plaintext array buffer // to a decrypted plaintext array buffer

View file

@ -123,6 +123,13 @@ function MessageReceiver(username, password, signalingKey, options = {}) {
this.password = password; this.password = password;
this.server = WebAPI.connect({ username, 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); const address = libsignal.SignalProtocolAddress.fromString(username);
this.number = address.getName(); this.number = address.getName();
this.deviceId = address.getDeviceId(); this.deviceId = address.getDeviceId();
@ -279,6 +286,8 @@ MessageReceiver.prototype.extend({
return request.respond(200, 'OK'); return request.respond(200, 'OK');
} }
envelope.id = envelope.serverGuid || window.getGuid();
return this.addToCache(envelope, plaintext).then( return this.addToCache(envelope, plaintext).then(
async () => { async () => {
request.respond(200, 'OK'); request.respond(200, 'OK');
@ -437,9 +446,13 @@ MessageReceiver.prototype.extend({
} }
}, },
getEnvelopeId(envelope) { getEnvelopeId(envelope) {
return `${envelope.source}.${ if (envelope.source) {
envelope.sourceDevice return `${envelope.source}.${
} ${envelope.timestamp.toNumber()}`; envelope.sourceDevice
} ${envelope.timestamp.toNumber()} (${envelope.id})`;
}
return envelope.id;
}, },
async getAllFromCache() { async getAllFromCache() {
window.log.info('getAllFromCache'); window.log.info('getAllFromCache');
@ -482,7 +495,7 @@ MessageReceiver.prototype.extend({
); );
}, },
async addToCache(envelope, plaintext) { async addToCache(envelope, plaintext) {
const id = this.getEnvelopeId(envelope); const { id } = envelope;
const data = { const data = {
id, id,
version: 2, version: 2,
@ -493,7 +506,7 @@ MessageReceiver.prototype.extend({
return textsecure.storage.unprocessed.add(data); return textsecure.storage.unprocessed.add(data);
}, },
async updateCache(envelope, plaintext) { async updateCache(envelope, plaintext) {
const id = this.getEnvelopeId(envelope); const { id } = envelope;
const item = await textsecure.storage.unprocessed.get(id); const item = await textsecure.storage.unprocessed.get(id);
if (!item) { if (!item) {
window.log.error( window.log.error(
@ -517,11 +530,11 @@ MessageReceiver.prototype.extend({
return textsecure.storage.unprocessed.save(item.attributes); return textsecure.storage.unprocessed.save(item.attributes);
}, },
removeFromCache(envelope) { removeFromCache(envelope) {
const id = this.getEnvelopeId(envelope); const { id } = envelope;
return textsecure.storage.unprocessed.remove(id); return textsecure.storage.unprocessed.remove(id);
}, },
queueDecryptedEnvelope(envelope, plaintext) { queueDecryptedEnvelope(envelope, plaintext) {
const id = this.getEnvelopeId(envelope); const { id } = envelope;
window.log.info('queueing decrypted envelope', id); window.log.info('queueing decrypted envelope', id);
const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext); const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
@ -626,6 +639,8 @@ MessageReceiver.prototype.extend({
return plaintext; return plaintext;
}, },
decrypt(envelope, ciphertext) { decrypt(envelope, ciphertext) {
const { serverTrustRoot } = this;
let promise; let promise;
const address = new libsignal.SignalProtocolAddress( const address = new libsignal.SignalProtocolAddress(
envelope.source, envelope.source,
@ -646,6 +661,14 @@ MessageReceiver.prototype.extend({
address, address,
options 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) { switch (envelope.type) {
case textsecure.protobuf.Envelope.Type.CIPHERTEXT: case textsecure.protobuf.Envelope.Type.CIPHERTEXT:
@ -662,13 +685,76 @@ MessageReceiver.prototype.extend({
address address
); );
break; 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: default:
promise = Promise.reject(new Error('Unknown message type')); promise = Promise.reject(new Error('Unknown message type'));
} }
return promise return promise
.then(plaintext => .then(plaintext => {
this.updateCache(envelope, plaintext).then( const { isMe } = plaintext || {};
if (isMe) {
return this.removeFromCache(envelope);
}
return this.updateCache(envelope, plaintext).then(
() => plaintext, () => plaintext,
error => { error => {
window.log.error( window.log.error(
@ -677,8 +763,8 @@ MessageReceiver.prototype.extend({
); );
return plaintext; return plaintext;
} }
) );
) })
.catch(error => { .catch(error => {
let errorToThrow = error; let errorToThrow = error;
@ -720,13 +806,14 @@ MessageReceiver.prototype.extend({
throw e; throw e;
} }
}, },
handleSentMessage( handleSentMessage(envelope, sentContainer, msg) {
envelope, const {
destination, destination,
timestamp, timestamp,
msg, expirationStartTimestamp,
expirationStartTimestamp unidentifiedStatus,
) { } = sentContainer;
let p = Promise.resolve(); let p = Promise.resolve();
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) { if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
@ -757,6 +844,7 @@ MessageReceiver.prototype.extend({
destination, destination,
timestamp: timestamp.toNumber(), timestamp: timestamp.toNumber(),
device: envelope.sourceDevice, device: envelope.sourceDevice,
unidentifiedStatus,
message, message,
}; };
if (expirationStartTimestamp) { if (expirationStartTimestamp) {
@ -799,6 +887,7 @@ MessageReceiver.prototype.extend({
sourceDevice: envelope.sourceDevice, sourceDevice: envelope.sourceDevice,
timestamp: envelope.timestamp.toNumber(), timestamp: envelope.timestamp.toNumber(),
receivedAt: envelope.receivedAt, receivedAt: envelope.receivedAt,
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
message, message,
}; };
return this.dispatchAndWait(ev); return this.dispatchAndWait(ev);
@ -806,18 +895,26 @@ MessageReceiver.prototype.extend({
); );
}, },
handleLegacyMessage(envelope) { handleLegacyMessage(envelope) {
return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => {
this.innerHandleLegacyMessage(envelope, plaintext) if (!plaintext) {
); window.log.warn('handleLegacyMessage: plaintext was falsey');
return null;
}
return this.innerHandleLegacyMessage(envelope, plaintext);
});
}, },
innerHandleLegacyMessage(envelope, plaintext) { innerHandleLegacyMessage(envelope, plaintext) {
const message = textsecure.protobuf.DataMessage.decode(plaintext); const message = textsecure.protobuf.DataMessage.decode(plaintext);
return this.handleDataMessage(envelope, message); return this.handleDataMessage(envelope, message);
}, },
handleContentMessage(envelope) { handleContentMessage(envelope) {
return this.decrypt(envelope, envelope.content).then(plaintext => return this.decrypt(envelope, envelope.content).then(plaintext => {
this.innerHandleContentMessage(envelope, plaintext) if (!plaintext) {
); window.log.warn('handleContentMessage: plaintext was falsey');
return null;
}
return this.innerHandleContentMessage(envelope, plaintext);
});
}, },
innerHandleContentMessage(envelope, plaintext) { innerHandleContentMessage(envelope, plaintext) {
const content = textsecure.protobuf.Content.decode(plaintext); const content = textsecure.protobuf.Content.decode(plaintext);
@ -895,13 +992,7 @@ MessageReceiver.prototype.extend({
'from', 'from',
this.getEnvelopeId(envelope) this.getEnvelopeId(envelope)
); );
return this.handleSentMessage( return this.handleSentMessage(envelope, sentMessage, sentMessage.message);
envelope,
sentMessage.destination,
sentMessage.timestamp,
sentMessage.message,
sentMessage.expirationStartTimestamp
);
} else if (syncMessage.contacts) { } else if (syncMessage.contacts) {
return this.handleContacts(envelope, syncMessage.contacts); return this.handleContacts(envelope, syncMessage.contacts);
} else if (syncMessage.groups) { } else if (syncMessage.groups) {
@ -922,11 +1013,10 @@ MessageReceiver.prototype.extend({
throw new Error('Got empty SyncMessage'); throw new Error('Got empty SyncMessage');
}, },
handleConfiguration(envelope, configuration) { handleConfiguration(envelope, configuration) {
window.log.info('got configuration sync message');
const ev = new Event('configuration'); const ev = new Event('configuration');
ev.confirm = this.removeFromCache.bind(this, envelope); ev.confirm = this.removeFromCache.bind(this, envelope);
ev.configuration = { ev.configuration = configuration;
readReceipts: configuration.readReceipts,
};
return this.dispatchAndWait(ev); return this.dispatchAndWait(ev);
}, },
handleVerified(envelope, verified) { handleVerified(envelope, verified) {
@ -1075,102 +1165,6 @@ MessageReceiver.prototype.extend({
.then(decryptAttachment) .then(decryptAttachment)
.then(updateAttachment); .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) { async handleEndSession(number) {
window.log.info('got end session'); window.log.info('got end session');
const deviceIds = await textsecure.storage.protocol.getDeviceIds(number); const deviceIds = await textsecure.storage.protocol.getDeviceIds(number);

View file

@ -1,4 +1,4 @@
/* global textsecure, libsignal, window, btoa */ /* global textsecure, libsignal, window, btoa, _ */
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -8,7 +8,8 @@ function OutgoingMessage(
numbers, numbers,
message, message,
silent, silent,
callback callback,
options = {}
) { ) {
if (message instanceof textsecure.protobuf.DataMessage) { if (message instanceof textsecure.protobuf.DataMessage) {
const content = new textsecure.protobuf.Content(); const content = new textsecure.protobuf.Content();
@ -26,6 +27,12 @@ function OutgoingMessage(
this.numbersCompleted = 0; this.numbersCompleted = 0;
this.errors = []; this.errors = [];
this.successfulNumbers = []; this.successfulNumbers = [];
this.failoverNumbers = [];
this.unidentifiedDeliveries = [];
const { numberInfo, senderCertificate } = options;
this.numberInfo = numberInfo;
this.senderCertificate = senderCertificate;
} }
OutgoingMessage.prototype = { OutgoingMessage.prototype = {
@ -35,7 +42,9 @@ OutgoingMessage.prototype = {
if (this.numbersCompleted >= this.numbers.length) { if (this.numbersCompleted >= this.numbers.length) {
this.callback({ this.callback({
successfulNumbers: this.successfulNumbers, successfulNumbers: this.successfulNumbers,
failoverNumbers: this.failoverNumbers,
errors: this.errors, errors: this.errors,
unidentifiedDeliveries: this.unidentifiedDeliveries,
}); });
} }
}, },
@ -57,7 +66,7 @@ OutgoingMessage.prototype = {
this.errors[this.errors.length] = error; this.errors[this.errors.length] = error;
this.numberCompleted(); this.numberCompleted();
}, },
reloadDevicesAndSend(number, recurse) { reloadDevicesAndSend(number, recurse, failover) {
return () => return () =>
textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => { textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => {
if (deviceIds.length === 0) { if (deviceIds.length === 0) {
@ -67,7 +76,7 @@ OutgoingMessage.prototype = {
null 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) { if (updateDevices === undefined) {
return this.server.getKeysForNumber(number).then(handleResult); if (accessKey) {
} return this.server
let promise = Promise.resolve(); .getKeysForNumberUnauth(number, '*', { accessKey })
updateDevices.forEach(device => { .catch(error => {
promise = promise.then(() => if (error.code === 401 || error.code === 403) {
this.server if (this.failoverNumbers.indexOf(number) === -1) {
.getKeysForNumber(number, device) this.failoverNumbers.push(number);
.then(handleResult)
.catch(e => {
if (e.name === 'HTTPError' && e.code === 404) {
if (device !== 1) {
return this.removeDeviceIdsForNumber(number, [device]);
} }
throw new textsecure.UnregisteredUserError(number, e); return this.server.getKeysForNumber(number, '*');
} else {
throw e;
} }
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; return promise;
}, },
transmitMessage(number, jsonData, timestamp) { transmitMessage(number, jsonData, timestamp, { accessKey } = {}) {
return this.server let promise;
.sendMessages(number, jsonData, timestamp, this.silent)
.catch(e => { if (accessKey) {
if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) { promise = this.server.sendMessagesUnauth(
// 409 and 410 should bubble and be handled by doSendMessage number,
// 404 should throw UnregisteredUserError jsonData,
// all other network errors can be retried later. timestamp,
if (e.code === 404) { this.silent,
throw new textsecure.UnregisteredUserError(number, e); { accessKey }
} );
throw new textsecure.SendMessageNetworkError( } else {
number, promise = this.server.sendMessages(
jsonData, number,
e, jsonData,
timestamp 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) { getPaddedMessageLength(messageLength) {
@ -179,15 +245,42 @@ OutgoingMessage.prototype = {
return this.plaintext; return this.plaintext;
}, },
doSendMessage(number, deviceIds, recurse) { doSendMessage(number, deviceIds, recurse, failover) {
const ciphers = {}; const ciphers = {};
const plaintext = this.getPlaintext(); 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( return Promise.all(
deviceIds.map(deviceId => { deviceIds.map(async deviceId => {
const address = new libsignal.SignalProtocolAddress(number, deviceId); const address = new libsignal.SignalProtocolAddress(number, deviceId);
const ourNumber = textsecure.storage.user.getNumber();
const options = {}; const options = {};
// No limit on message keys if we're communicating with our other devices // No limit on message keys if we're communicating with our other devices
@ -195,26 +288,80 @@ OutgoingMessage.prototype = {
options.messageKeysLimit = false; 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( const sessionCipher = new libsignal.SessionCipher(
textsecure.storage.protocol, textsecure.storage.protocol,
address, address,
options options
); );
ciphers[address.getDeviceId()] = sessionCipher; ciphers[address.getDeviceId()] = sessionCipher;
return sessionCipher.encrypt(plaintext).then(ciphertext => ({
const ciphertext = await sessionCipher.encrypt(plaintext);
return {
type: ciphertext.type, type: ciphertext.type,
destinationDeviceId: address.getDeviceId(), destinationDeviceId: address.getDeviceId(),
destinationRegistrationId: ciphertext.registrationId, destinationRegistrationId: ciphertext.registrationId,
content: btoa(ciphertext.body), content: btoa(ciphertext.body),
})); };
}) })
) )
.then(jsonData => .then(jsonData => {
this.transmitMessage(number, jsonData, this.timestamp).then(() => { if (sealedSender) {
this.successfulNumbers[this.successfulNumbers.length] = number; return this.transmitMessage(number, jsonData, this.timestamp, {
this.numberCompleted(); 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 => { .catch(error => {
if ( if (
error instanceof Error && error instanceof Error &&
@ -237,7 +384,9 @@ OutgoingMessage.prototype = {
} else { } else {
p = Promise.all( p = Promise.all(
error.response.staleDevices.map(deviceId => 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.staleDevices
: error.response.missingDevices; : error.response.missingDevices;
return this.getKeysForNumber(number, resetDevices).then( 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') { } else if (error.message === 'Identity key changed') {
@ -305,28 +456,28 @@ OutgoingMessage.prototype = {
return promise; return promise;
}, },
sendToNumber(number) { async sendToNumber(number) {
return this.getStaleDeviceIdsForNumber(number).then(updateDevices => try {
this.getKeysForNumber(number, updateDevices) const updateDevices = await this.getStaleDeviceIdsForNumber(number);
.then(this.reloadDevicesAndSend(number, true)) await this.getKeysForNumber(number, updateDevices);
.catch(error => { await this.reloadDevicesAndSend(number, true)();
if (error.message === 'Identity key changed') { } catch (error) {
// eslint-disable-next-line no-param-reassign if (error.message === 'Identity key changed') {
error = new textsecure.OutgoingIdentityKeyError( // eslint-disable-next-line no-param-reassign
number, const newError = new textsecure.OutgoingIdentityKeyError(
error.originalMessage, number,
error.timestamp, error.originalMessage,
error.identityKey error.timestamp,
); error.identityKey
this.registerError(number, 'Identity key changed', error); );
} else { this.registerError(number, 'Identity key changed', newError);
this.registerError( } else {
number, this.registerError(
`Failed to retrieve new device keys for number ${number}`, number,
error `Failed to retrieve new device keys for number ${number}`,
); error
} );
}) }
); }
}, },
}; };

View file

@ -35,4 +35,7 @@
loadProtoBufs('SignalService.proto'); loadProtoBufs('SignalService.proto');
loadProtoBufs('SubProtocol.proto'); loadProtoBufs('SubProtocol.proto');
loadProtoBufs('DeviceMessages.proto'); loadProtoBufs('DeviceMessages.proto');
// Metadata-specific protos
loadProtoBufs('UnidentifiedDelivery.proto');
})(); })();

View file

@ -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) { queueJobForNumber(number, runJob) {
const taskWithTimeout = textsecure.createTaskWithTimeout( const taskWithTimeout = textsecure.createTaskWithTimeout(
runJob, runJob,
@ -321,8 +256,10 @@ MessageSender.prototype = {
}); });
}, },
sendMessage(attrs) { sendMessage(attrs, options) {
const message = new Message(attrs); const message = new Message(attrs);
const silent = false;
return Promise.all([ return Promise.all([
this.uploadAttachments(message), this.uploadAttachments(message),
this.uploadThumbnails(message), this.uploadThumbnails(message),
@ -340,12 +277,21 @@ MessageSender.prototype = {
} else { } else {
resolve(res); resolve(res);
} }
} },
silent,
options
); );
}) })
); );
}, },
sendMessageProto(timestamp, numbers, message, callback, silent) { sendMessageProto(
timestamp,
numbers,
message,
callback,
silent,
options = {}
) {
const rejections = textsecure.storage.get('signedKeyRotationRejected', 0); const rejections = textsecure.storage.get('signedKeyRotationRejected', 0);
if (rejections > 5) { if (rejections > 5) {
throw new textsecure.SignedPreKeyRotationError( throw new textsecure.SignedPreKeyRotationError(
@ -361,7 +307,8 @@ MessageSender.prototype = {
numbers, numbers,
message, message,
silent, silent,
callback callback,
options
); );
numbers.forEach(number => { numbers.forEach(number => {
@ -369,20 +316,7 @@ MessageSender.prototype = {
}); });
}, },
retrySendMessageProto(numbers, encodedMessage, timestamp) { sendIndividualProto(number, proto, timestamp, silent, options = {}) {
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) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const callback = res => { const callback = res => {
if (res.errors.length > 0) { if (res.errors.length > 0) {
@ -391,7 +325,14 @@ MessageSender.prototype = {
resolve(res); resolve(res);
} }
}; };
this.sendMessageProto(timestamp, [number], proto, callback, silent); this.sendMessageProto(
timestamp,
[number],
proto,
callback,
silent,
options
);
}); });
}, },
@ -412,7 +353,10 @@ MessageSender.prototype = {
encodedDataMessage, encodedDataMessage,
timestamp, timestamp,
destination, destination,
expirationStartTimestamp expirationStartTimestamp,
sentTo = [],
unidentifiedDeliveries = [],
options
) { ) {
const myNumber = textsecure.storage.user.getNumber(); const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId(); const myDevice = textsecure.storage.user.getDeviceId();
@ -432,6 +376,27 @@ MessageSender.prototype = {
if (expirationStartTimestamp) { if (expirationStartTimestamp) {
sentMessage.expirationStartTimestamp = 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(); const syncMessage = this.createSyncMessage();
syncMessage.sent = sentMessage; syncMessage.sent = sentMessage;
const contentMessage = new textsecure.protobuf.Content(); const contentMessage = new textsecure.protobuf.Content();
@ -442,18 +407,24 @@ MessageSender.prototype = {
myNumber, myNumber,
contentMessage, contentMessage,
Date.now(), Date.now(),
silent silent,
options
); );
}, },
getProfile(number) { async getProfile(number, { accessKey } = {}) {
if (accessKey) {
return this.server.getProfileUnauth(number, { accessKey });
}
return this.server.getProfile(number); return this.server.getProfile(number);
}, },
getAvatar(path) { getAvatar(path) {
return this.server.getAvatar(path); return this.server.getAvatar(path);
}, },
sendRequestConfigurationSyncMessage() { sendRequestConfigurationSyncMessage(options) {
const myNumber = textsecure.storage.user.getNumber(); const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId(); const myDevice = textsecure.storage.user.getDeviceId();
if (myDevice !== 1 && myDevice !== '1') { if (myDevice !== 1 && myDevice !== '1') {
@ -469,13 +440,14 @@ MessageSender.prototype = {
myNumber, myNumber,
contentMessage, contentMessage,
Date.now(), Date.now(),
silent silent,
options
); );
} }
return Promise.resolve(); return Promise.resolve();
}, },
sendRequestGroupSyncMessage() { sendRequestGroupSyncMessage(options) {
const myNumber = textsecure.storage.user.getNumber(); const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId(); const myDevice = textsecure.storage.user.getDeviceId();
if (myDevice !== 1 && myDevice !== '1') { if (myDevice !== 1 && myDevice !== '1') {
@ -491,14 +463,15 @@ MessageSender.prototype = {
myNumber, myNumber,
contentMessage, contentMessage,
Date.now(), Date.now(),
silent silent,
options
); );
} }
return Promise.resolve(); return Promise.resolve();
}, },
sendRequestContactSyncMessage() { sendRequestContactSyncMessage(options) {
const myNumber = textsecure.storage.user.getNumber(); const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId(); const myDevice = textsecure.storage.user.getDeviceId();
if (myDevice !== 1 && myDevice !== '1') { if (myDevice !== 1 && myDevice !== '1') {
@ -514,13 +487,37 @@ MessageSender.prototype = {
myNumber, myNumber,
contentMessage, contentMessage,
Date.now(), Date.now(),
silent silent,
options
); );
} }
return Promise.resolve(); 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(); const receiptMessage = new textsecure.protobuf.ReceiptMessage();
receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ; receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ;
receiptMessage.timestamp = timestamps; receiptMessage.timestamp = timestamps;
@ -529,9 +526,15 @@ MessageSender.prototype = {
contentMessage.receiptMessage = receiptMessage; contentMessage.receiptMessage = receiptMessage;
const silent = true; 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 myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId(); const myDevice = textsecure.storage.user.getDeviceId();
if (myDevice !== 1 && myDevice !== '1') { if (myDevice !== 1 && myDevice !== '1') {
@ -551,13 +554,14 @@ MessageSender.prototype = {
myNumber, myNumber,
contentMessage, contentMessage,
Date.now(), Date.now(),
silent silent,
options
); );
} }
return Promise.resolve(); return Promise.resolve();
}, },
syncVerification(destination, state, identityKey) { syncVerification(destination, state, identityKey, options) {
const myNumber = textsecure.storage.user.getNumber(); const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId(); const myDevice = textsecure.storage.user.getDeviceId();
const now = Date.now(); const now = Date.now();
@ -580,7 +584,14 @@ MessageSender.prototype = {
contentMessage.nullMessage = nullMessage; contentMessage.nullMessage = nullMessage;
// We want the NullMessage to look like a normal outgoing message; not silent // 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(() => { return promise.then(() => {
const verified = new textsecure.protobuf.Verified(); const verified = new textsecure.protobuf.Verified();
@ -595,12 +606,18 @@ MessageSender.prototype = {
const secondMessage = new textsecure.protobuf.Content(); const secondMessage = new textsecure.protobuf.Content();
secondMessage.syncMessage = syncMessage; secondMessage.syncMessage = syncMessage;
const silent = true; const innerSilent = true;
return this.sendIndividualProto(myNumber, secondMessage, now, silent); 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 me = textsecure.storage.user.getNumber();
const numbers = providedNumbers.filter(number => number !== me); const numbers = providedNumbers.filter(number => number !== me);
if (numbers.length === 0) { 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, quote,
timestamp, timestamp,
expireTimer, expireTimer,
profileKey profileKey,
options
) { ) {
return this.sendMessage({ return this.sendMessage(
recipients: [number], {
body: messageText, recipients: [number],
timestamp, body: messageText,
attachments, timestamp,
quote, attachments,
needsSync: true, quote,
expireTimer, needsSync: true,
profileKey, expireTimer,
}); profileKey,
},
options
);
}, },
resetSession(number, timestamp) { resetSession(number, timestamp, options) {
window.log.info('resetting secure session'); window.log.info('resetting secure session');
const silent = false;
const proto = new textsecure.protobuf.DataMessage(); const proto = new textsecure.protobuf.DataMessage();
proto.body = 'TERMINATE'; proto.body = 'TERMINATE';
proto.flags = textsecure.protobuf.DataMessage.Flags.END_SESSION; proto.flags = textsecure.protobuf.DataMessage.Flags.END_SESSION;
@ -677,9 +706,13 @@ MessageSender.prototype = {
window.log.info( window.log.info(
'finished closing local sessions, now sending to contact' 'finished closing local sessions, now sending to contact'
); );
return this.sendIndividualProto(number, proto, timestamp).catch( return this.sendIndividualProto(
logError('resetSession/sendToContact error:') number,
); proto,
timestamp,
silent,
options
).catch(logError('resetSession/sendToContact error:'));
}) })
.then(() => .then(() =>
deleteAllSessions(number).catch( deleteAllSessions(number).catch(
@ -688,9 +721,15 @@ MessageSender.prototype = {
); );
const buffer = proto.toArrayBuffer(); const buffer = proto.toArrayBuffer();
const sendSync = this.sendSyncMessage(buffer, timestamp, number).catch( const sendSync = this.sendSyncMessage(
logError('resetSession/sendSync error:') buffer,
); timestamp,
number,
null,
[],
[],
options
).catch(logError('resetSession/sendSync error:'));
return Promise.all([sendToContact, sendSync]); return Promise.all([sendToContact, sendSync]);
}, },
@ -702,7 +741,8 @@ MessageSender.prototype = {
quote, quote,
timestamp, timestamp,
expireTimer, expireTimer,
profileKey profileKey,
options
) { ) {
return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => { return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => {
if (targetNumbers === undefined) { if (targetNumbers === undefined) {
@ -715,24 +755,27 @@ MessageSender.prototype = {
return Promise.reject(new Error('No other members in the group')); return Promise.reject(new Error('No other members in the group'));
} }
return this.sendMessage({ return this.sendMessage(
recipients: numbers, {
body: messageText, recipients: numbers,
timestamp, body: messageText,
attachments, timestamp,
quote, attachments,
needsSync: true, quote,
expireTimer, needsSync: true,
profileKey, expireTimer,
group: { profileKey,
id: groupId, group: {
type: textsecure.protobuf.GroupContext.Type.DELIVER, id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
}, },
}); options
);
}); });
}, },
createGroup(targetNumbers, name, avatar) { createGroup(targetNumbers, name, avatar, options) {
const proto = new textsecure.protobuf.DataMessage(); const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext(); proto.group = new textsecure.protobuf.GroupContext();
@ -748,12 +791,14 @@ MessageSender.prototype = {
return this.makeAttachmentPointer(avatar).then(attachment => { return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = 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(); const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext(); proto.group = new textsecure.protobuf.GroupContext();
@ -771,12 +816,14 @@ MessageSender.prototype = {
return this.makeAttachmentPointer(avatar).then(attachment => { return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = 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(); const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext(); proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId); proto.group.id = stringToArrayBuffer(groupId);
@ -789,11 +836,11 @@ MessageSender.prototype = {
return Promise.reject(new Error('Unknown Group')); return Promise.reject(new Error('Unknown Group'));
proto.group.members = numbers; 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(); const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext(); proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId); proto.group.id = stringToArrayBuffer(groupId);
@ -805,11 +852,11 @@ MessageSender.prototype = {
return Promise.reject(new Error('Unknown Group')); return Promise.reject(new Error('Unknown Group'));
proto.group.members = numbers; 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(); const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext(); proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId); proto.group.id = stringToArrayBuffer(groupId);
@ -822,12 +869,12 @@ MessageSender.prototype = {
return this.makeAttachmentPointer(avatar).then(attachment => { return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = 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(); const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext(); proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId); proto.group.id = stringToArrayBuffer(groupId);
@ -838,14 +885,15 @@ MessageSender.prototype = {
return Promise.reject(new Error('Unknown Group')); return Promise.reject(new Error('Unknown Group'));
return textsecure.storage.groups return textsecure.storage.groups
.deleteGroup(groupId) .deleteGroup(groupId)
.then(() => this.sendGroupProto(numbers, proto)); .then(() => this.sendGroupProto(numbers, proto, Date.now(), options));
}); });
}, },
sendExpirationTimerUpdateToGroup( sendExpirationTimerUpdateToGroup(
groupId, groupId,
expireTimer, expireTimer,
timestamp, timestamp,
profileKey profileKey,
options
) { ) {
return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => { return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => {
if (targetNumbers === undefined) if (targetNumbers === undefined)
@ -856,34 +904,41 @@ MessageSender.prototype = {
if (numbers.length === 0) { if (numbers.length === 0) {
return Promise.reject(new Error('No other members in the group')); return Promise.reject(new Error('No other members in the group'));
} }
return this.sendMessage({ return this.sendMessage(
recipients: numbers, {
timestamp, recipients: numbers,
needsSync: true, timestamp,
expireTimer, needsSync: true,
profileKey, expireTimer,
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, profileKey,
group: { flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
id: groupId, group: {
type: textsecure.protobuf.GroupContext.Type.DELIVER, id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
}, },
}); options
);
}); });
}, },
sendExpirationTimerUpdateToNumber( sendExpirationTimerUpdateToNumber(
number, number,
expireTimer, expireTimer,
timestamp, timestamp,
profileKey profileKey,
options
) { ) {
return this.sendMessage({ return this.sendMessage(
recipients: [number], {
timestamp, recipients: [number],
needsSync: true, timestamp,
expireTimer, needsSync: true,
profileKey, expireTimer,
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, 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.getAvatar = sender.getAvatar.bind(sender);
this.syncReadMessages = sender.syncReadMessages.bind(sender); this.syncReadMessages = sender.syncReadMessages.bind(sender);
this.syncVerification = sender.syncVerification.bind(sender); this.syncVerification = sender.syncVerification.bind(sender);
this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender);
this.sendReadReceipts = sender.sendReadReceipts.bind(sender); this.sendReadReceipts = sender.sendReadReceipts.bind(sender);
}; };

View file

@ -1,4 +1,4 @@
/* global Event, textsecure, window */ /* global Event, textsecure, window, ConversationController */
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -23,12 +23,15 @@
this.ongroup = this.onGroupSyncComplete.bind(this); this.ongroup = this.onGroupSyncComplete.bind(this);
receiver.addEventListener('groupsync', this.ongroup); 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...'); window.log.info('SyncRequest created. Sending contact sync message...');
sender wrap(sender.sendRequestContactSyncMessage(sendOptions))
.sendRequestContactSyncMessage()
.then(() => { .then(() => {
window.log.info('SyncRequest now sending group sync messsage...'); window.log.info('SyncRequest now sending group sync messsage...');
return sender.sendRequestGroupSyncMessage(); return wrap(sender.sendRequestGroupSyncMessage(sendOptions));
}) })
.catch(error => { .catch(error => {
window.log.error( window.log.error(

View file

@ -90,8 +90,11 @@ describe('MessageReceiver', () => {
done(); done();
}); });
const messageReceiver = new textsecure.MessageReceiver( const messageReceiver = new textsecure.MessageReceiver(
'ws://localhost:8080', 'username',
window 'password',
'signalingKey'
// 'ws://localhost:8080',
// window,
); );
}); });
}); });

View file

@ -149,6 +149,7 @@ function prepareURL(pathSegments, moreKeys) {
appInstance: process.env.NODE_APP_INSTANCE, appInstance: process.env.NODE_APP_INSTANCE,
proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy,
importMode: importMode ? true : undefined, // for stringify() importMode: importMode ? true : undefined, // for stringify()
serverTrustRoot: config.get('serverTrustRoot'),
...moreKeys, ...moreKeys,
}, },
}); });

View file

@ -27,6 +27,7 @@ window.isImportMode = () => config.importMode;
window.getExpiration = () => config.buildExpiration; window.getExpiration = () => config.buildExpiration;
window.getNodeVersion = () => config.node_version; window.getNodeVersion = () => config.node_version;
window.getHostName = () => config.hostname; window.getHostName = () => config.hostname;
window.getServerTrustRoot = () => config.serverTrustRoot;
window.isBeforeVersion = (toCheck, baseVersion) => { window.isBeforeVersion = (toCheck, baseVersion) => {
try { try {
@ -215,6 +216,7 @@ window.filesize = require('filesize');
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance(); window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();
window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat;
window.loadImage = require('blueimp-load-image'); window.loadImage = require('blueimp-load-image');
window.getGuid = require('uuid/v4');
window.React = require('react'); window.React = require('react');
window.ReactDOM = require('react-dom'); window.ReactDOM = require('react-dom');

View file

@ -6,20 +6,24 @@ option java_outer_classname = "SignalServiceProtos";
message Envelope { message Envelope {
enum Type { enum Type {
UNKNOWN = 0; UNKNOWN = 0;
CIPHERTEXT = 1; CIPHERTEXT = 1;
KEY_EXCHANGE = 2; KEY_EXCHANGE = 2;
PREKEY_BUNDLE = 3; PREKEY_BUNDLE = 3;
RECEIPT = 5; RECEIPT = 5;
UNIDENTIFIED_SENDER = 6;
} }
optional Type type = 1; optional Type type = 1;
optional string source = 2; optional string source = 2;
optional uint32 sourceDevice = 7; optional uint32 sourceDevice = 7;
optional string relay = 3; optional string relay = 3;
optional uint64 timestamp = 5; optional uint64 timestamp = 5;
optional bytes legacyMessage = 6; // Contains an encrypted DataMessage optional bytes legacyMessage = 6; // Contains an encrypted DataMessage
optional bytes content = 8; // Contains an encrypted Content optional bytes content = 8; // Contains an encrypted Content
optional string serverGuid = 9;
optional uint64 serverTimestamp = 10;
} }
message Content { message Content {
@ -191,10 +195,16 @@ message Verified {
message SyncMessage { message SyncMessage {
message Sent { message Sent {
optional string destination = 1; message UnidentifiedDeliveryStatus {
optional uint64 timestamp = 2; optional string destination = 1;
optional DataMessage message = 3; optional bool unidentified = 2;
optional uint64 expirationStartTimestamp = 4; }
optional string destination = 1;
optional uint64 timestamp = 2;
optional DataMessage message = 3;
optional uint64 expirationStartTimestamp = 4;
repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5;
} }
message Contacts { message Contacts {
@ -229,7 +239,8 @@ message SyncMessage {
} }
message Configuration { message Configuration {
optional bool readReceipts = 1; optional bool readReceipts = 1;
optional bool unidentifiedDeliveryIndicators = 2;
} }
optional Sent sent = 1; optional Sent sent = 1;

View file

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

View file

@ -1482,7 +1482,6 @@
width: 12px; width: 12px;
height: 12px; height: 12px;
display: inline-block; display: inline-block;
margin-left: 6px;
margin-bottom: 2px; margin-bottom: 2px;
} }
@ -1500,20 +1499,30 @@
} }
.module-message-detail__contact__status-icon--sent { .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 { .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; width: 18px;
} }
.module-message-detail__contact__status-icon--read { .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; width: 18px;
} }
.module-message-detail__contact__status-icon--error { .module-message-detail__contact__status-icon--error {
@include color-svg('../images/error.svg', $color-core-red); @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 { .module-message-detail__contact__error-buttons {
text-align: right; text-align: right;
} }

View file

@ -1,104 +1,119 @@
'use strict'; 'use strict';
describe('Crypto', function() { describe('Crypto', () => {
it('roundtrip symmetric encryption succeeds', async function() { describe('accessKey/profileKey', () => {
var message = 'this is my message'; it('verification roundtrips', async () => {
var plaintext = new dcodeIO.ByteBuffer.wrap( const profileKey = await Signal.Crypto.getRandomBytes(32);
message, const accessKey = await Signal.Crypto.deriveAccessKey(profileKey);
'binary'
).toArrayBuffer();
var key = textsecure.crypto.getRandomBytes(32);
var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); const verifier = await Signal.Crypto.getAccessKeyVerifier(accessKey);
var decrypted = await Signal.Crypto.decryptSymmetric(key, encrypted);
var equal = Signal.Crypto.constantTimeEqual(plaintext, decrypted); const correct = await Signal.Crypto.verifyAccessKey(accessKey, verifier);
if (!equal) {
throw new Error('The output and input did not match!'); assert.strictEqual(correct, true);
} });
}); });
it('roundtrip fails if nonce is modified', async function() { describe('symmetric encryption', () => {
var message = 'this is my message'; it('roundtrips', async () => {
var plaintext = new dcodeIO.ByteBuffer.wrap( var message = 'this is my message';
message, var plaintext = new dcodeIO.ByteBuffer.wrap(
'binary' message,
).toArrayBuffer(); 'binary'
var key = textsecure.crypto.getRandomBytes(32); ).toArrayBuffer();
var key = textsecure.crypto.getRandomBytes(32);
var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
var uintArray = new Uint8Array(encrypted); var decrypted = await Signal.Crypto.decryptSymmetric(key, encrypted);
uintArray[2] = 9;
try { var equal = Signal.Crypto.constantTimeEqual(plaintext, decrypted);
var decrypted = await Signal.Crypto.decryptSymmetric( if (!equal) {
key, throw new Error('The output and input did not match!');
uintArray.buffer }
); });
} catch (error) {
assert.strictEqual(
error.message,
'decryptSymmetric: Failed to decrypt; MAC verification failed'
);
return;
}
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 encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
var message = 'this is my message'; var uintArray = new Uint8Array(encrypted);
var plaintext = new dcodeIO.ByteBuffer.wrap( uintArray[2] = 9;
message,
'binary'
).toArrayBuffer();
var key = textsecure.crypto.getRandomBytes(32);
var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); try {
var uintArray = new Uint8Array(encrypted); var decrypted = await Signal.Crypto.decryptSymmetric(
uintArray[uintArray.length - 3] = 9; key,
uintArray.buffer
);
} catch (error) {
assert.strictEqual(
error.message,
'decryptSymmetric: Failed to decrypt; MAC verification failed'
);
return;
}
try { throw new Error('Expected error to be thrown');
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'); 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 encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
var message = 'this is my message'; var uintArray = new Uint8Array(encrypted);
var plaintext = new dcodeIO.ByteBuffer.wrap( uintArray[uintArray.length - 3] = 9;
message,
'binary'
).toArrayBuffer();
var key = textsecure.crypto.getRandomBytes(32);
var encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); try {
var uintArray = new Uint8Array(encrypted); var decrypted = await Signal.Crypto.decryptSymmetric(
uintArray[35] = 9; key,
uintArray.buffer
);
} catch (error) {
assert.strictEqual(
error.message,
'decryptSymmetric: Failed to decrypt; MAC verification failed'
);
return;
}
try { throw new Error('Expected error to be thrown');
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'); 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');
});
}); });
}); });

View file

@ -383,6 +383,8 @@
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script> <script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/clear_data_view.js'></script> <script type='text/javascript' src='../js/views/clear_data_view.js'></script>
<script type="text/javascript" src="metadata/SecretSessionCipher_test.js"></script>
<script type="text/javascript" src="views/whisper_view_test.js"></script> <script type="text/javascript" src="views/whisper_view_test.js"></script>
<script type="text/javascript" src="views/group_update_view_test.js"></script> <script type="text/javascript" src="views/group_update_view_test.js"></script>
<script type="text/javascript" src="views/attachment_view_test.js"></script> <script type="text/javascript" src="views/attachment_view_test.js"></script>

View file

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

View file

@ -12,6 +12,7 @@ describe('Startup', () => {
}); });
it('should complete if user hasnt previously synced', async () => { it('should complete if user hasnt previously synced', async () => {
const ourNumber = '+15551234567';
const deviceId = '2'; const deviceId = '2';
const sendRequestConfigurationSyncMessage = sandbox.spy(); const sendRequestConfigurationSyncMessage = sandbox.spy();
const storagePutSpy = sandbox.spy(); const storagePutSpy = sandbox.spy();
@ -25,15 +26,21 @@ describe('Startup', () => {
}, },
put: storagePutSpy, put: storagePutSpy,
}; };
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const expected = { const expected = {
status: 'complete', status: 'complete',
}; };
const actual = await Startup.syncReadReceiptConfiguration({ const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId, deviceId,
sendRequestConfigurationSyncMessage, sendRequestConfigurationSyncMessage,
storage, storage,
prepareForSend,
}); });
assert.deepEqual(actual, expected); assert.deepEqual(actual, expected);
@ -43,9 +50,14 @@ describe('Startup', () => {
}); });
it('should be skipped if this is the primary device', async () => { it('should be skipped if this is the primary device', async () => {
const ourNumber = '+15551234567';
const deviceId = '1'; const deviceId = '1';
const sendRequestConfigurationSyncMessage = () => {}; const sendRequestConfigurationSyncMessage = () => {};
const storage = {}; const storage = {};
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const expected = { const expected = {
status: 'skipped', status: 'skipped',
@ -53,15 +65,18 @@ describe('Startup', () => {
}; };
const actual = await Startup.syncReadReceiptConfiguration({ const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId, deviceId,
sendRequestConfigurationSyncMessage, sendRequestConfigurationSyncMessage,
storage, storage,
prepareForSend,
}); });
assert.deepEqual(actual, expected); assert.deepEqual(actual, expected);
}); });
it('should be skipped if user has previously synced', async () => { it('should be skipped if user has previously synced', async () => {
const ourNumber = '+15551234567';
const deviceId = '2'; const deviceId = '2';
const sendRequestConfigurationSyncMessage = () => {}; const sendRequestConfigurationSyncMessage = () => {};
const storage = { const storage = {
@ -73,6 +88,10 @@ describe('Startup', () => {
return true; return true;
}, },
}; };
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const expected = { const expected = {
status: 'skipped', status: 'skipped',
@ -80,15 +99,18 @@ describe('Startup', () => {
}; };
const actual = await Startup.syncReadReceiptConfiguration({ const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId, deviceId,
sendRequestConfigurationSyncMessage, sendRequestConfigurationSyncMessage,
storage, storage,
prepareForSend,
}); });
assert.deepEqual(actual, expected); assert.deepEqual(actual, expected);
}); });
it('should return error if sending of sync request fails', async () => { it('should return error if sending of sync request fails', async () => {
const ourNumber = '+15551234567';
const deviceId = '2'; const deviceId = '2';
const sendRequestConfigurationSyncMessage = sandbox.stub(); const sendRequestConfigurationSyncMessage = sandbox.stub();
@ -105,11 +127,17 @@ describe('Startup', () => {
}, },
put: storagePutSpy, put: storagePutSpy,
}; };
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const actual = await Startup.syncReadReceiptConfiguration({ const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId, deviceId,
sendRequestConfigurationSyncMessage, sendRequestConfigurationSyncMessage,
storage, storage,
prepareForSend,
}); });
assert.equal(actual.status, 'error'); assert.equal(actual.status, 'error');

View file

@ -126,3 +126,41 @@
i18n={util.i18n} i18n={util.i18n}
/> />
``` ```
### Unidentified Delivery
```jsx
<MessageDetail
message={{
disableMenu: true,
direction: 'outgoing',
timestamp: Date.now(),
conversationColor: 'pink',
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
status: 'read',
onDelete: () => 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}
/>
```

View file

@ -15,8 +15,10 @@ interface Contact {
avatarPath?: string; avatarPath?: string;
color: string; color: string;
isOutgoingKeyError: boolean; isOutgoingKeyError: boolean;
isUnidentifiedDelivery: boolean;
errors?: Array<Error>; errors?: Array<Error>;
onSendAnyway: () => void; onSendAnyway: () => void;
onShowSafetyNumber: () => void; onShowSafetyNumber: () => void;
} }
@ -94,6 +96,9 @@ export class MessageDetail extends React.Component<Props> {
)} )}
/> />
) : null; ) : null;
const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
<div className="module-message-detail__contact__unidentified-delivery-icon" />
) : null;
return ( return (
<div key={contact.phoneNumber} className="module-message-detail__contact"> <div key={contact.phoneNumber} className="module-message-detail__contact">
@ -114,6 +119,7 @@ export class MessageDetail extends React.Component<Props> {
))} ))}
</div> </div>
{errorComponent} {errorComponent}
{unidentifiedDeliveryComponent}
{statusComponent} {statusComponent}
</div> </div>
); );

View file

@ -164,7 +164,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/background.js", "path": "js/background.js",
"line": " if ($('.dark-overlay').length) {", "line": " if ($('.dark-overlay').length) {",
"lineNumber": 264, "lineNumber": 265,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -173,7 +173,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/background.js", "path": "js/background.js",
"line": " $(document.body).prepend('<div class=\"dark-overlay\"></div>');", "line": " $(document.body).prepend('<div class=\"dark-overlay\"></div>');",
"lineNumber": 267, "lineNumber": 268,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -182,7 +182,7 @@
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/background.js", "path": "js/background.js",
"line": " $(document.body).prepend('<div class=\"dark-overlay\"></div>');", "line": " $(document.body).prepend('<div class=\"dark-overlay\"></div>');",
"lineNumber": 267, "lineNumber": 268,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Hard-coded value" "reasonDetail": "Hard-coded value"
@ -191,7 +191,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/background.js", "path": "js/background.js",
"line": " $('.dark-overlay').on('click', () => $('.dark-overlay').remove());", "line": " $('.dark-overlay').on('click', () => $('.dark-overlay').remove());",
"lineNumber": 268, "lineNumber": 269,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -200,7 +200,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/background.js", "path": "js/background.js",
"line": " removeDarkOverlay: () => $('.dark-overlay').remove(),", "line": " removeDarkOverlay: () => $('.dark-overlay').remove(),",
"lineNumber": 270, "lineNumber": 271,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -209,7 +209,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/background.js", "path": "js/background.js",
"line": " $('body').append(clearDataView.el);", "line": " $('body').append(clearDataView.el);",
"lineNumber": 273, "lineNumber": 274,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -218,7 +218,7 @@
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/background.js", "path": "js/background.js",
"line": " $('body').append(clearDataView.el);", "line": " $('body').append(clearDataView.el);",
"lineNumber": 273, "lineNumber": 274,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -227,7 +227,7 @@
"rule": "jQuery-load(", "rule": "jQuery-load(",
"path": "js/background.js", "path": "js/background.js",
"line": " await ConversationController.load();", "line": " await ConversationController.load();",
"lineNumber": 508, "lineNumber": 509,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-02T21:00:44.007Z"
}, },
@ -235,24 +235,32 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/background.js", "path": "js/background.js",
"line": " el: $('body'),", "line": " el: $('body'),",
"lineNumber": 562, "lineNumber": 572,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-10-16T23:47:48.006Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/background.js", "path": "js/background.js",
"line": " const profileKey = dcodeIO.ByteBuffer.wrap(details.profileKey).toString(", "line": " wrap(",
"lineNumber": 884, "lineNumber": 830,
"reasonCategory": "falseMatch", "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(", "rule": "jQuery-load(",
"path": "js/conversation_controller.js", "path": "js/conversation_controller.js",
"line": " async load() {", "line": " async load() {",
"lineNumber": 198, "lineNumber": 208,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-02T21:00:44.007Z"
}, },
@ -260,7 +268,7 @@
"rule": "jQuery-load(", "rule": "jQuery-load(",
"path": "js/conversation_controller.js", "path": "js/conversation_controller.js",
"line": " this._initialPromise = load();", "line": " this._initialPromise = load();",
"lineNumber": 227, "lineNumber": 237,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-02T21:00:44.007Z"
}, },
@ -293,51 +301,59 @@
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/models/conversations.js", "path": "js/models/messages.js",
"line": " const identityKey = dcodeIO.ByteBuffer.wrap(", "line": " this.send(wrap(promise));",
"lineNumber": 1097, "lineNumber": 794,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/models/conversations.js", "path": "js/models/messages.js",
"line": " const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer();", "line": " return wrap(",
"lineNumber": 1157, "lineNumber": 996,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/models/conversations.js", "path": "js/modules/crypto.js",
"line": " const name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8');", "line": " return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');",
"lineNumber": 1170, "lineNumber": 271,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/models/conversations.js", "path": "js/modules/crypto.js",
"line": " const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer();", "line": " return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();",
"lineNumber": 1185, "lineNumber": 274,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/backup.js", "path": "js/modules/crypto.js",
"line": " data: dcodeIO.ByteBuffer.wrap(val).toString('base64'),", "line": " return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();",
"lineNumber": 51, "lineNumber": 278,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/backup.js", "path": "js/modules/crypto.js",
"line": " object[key] = dcodeIO.ByteBuffer.wrap(val.data, 'base64').toArrayBuffer();", "line": " return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();",
"lineNumber": 73, "lineNumber": 282,
"reasonCategory": "falseMatch", "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(", "rule": "jQuery-append(",
@ -365,19 +381,11 @@
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/types/conversation.js", "path": "js/modules/startup.js",
"line": " return dcodeIO.ByteBuffer.wrap(arraybuffer).toString('base64');", "line": " await wrap(sendRequestConfigurationSyncMessage(sendOptions));",
"lineNumber": 12, "lineNumber": 50,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-05T23:12:28.961Z"
},
{
"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"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
@ -2291,7 +2299,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js", "path": "libtextsecure/message_receiver.js",
"line": " Promise.resolve(dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer());", "line": " Promise.resolve(dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer());",
"lineNumber": 138, "lineNumber": 145,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
@ -2299,7 +2307,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js", "path": "libtextsecure/message_receiver.js",
"line": " Promise.resolve(dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary'));", "line": " Promise.resolve(dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary'));",
"lineNumber": 140, "lineNumber": 147,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
@ -2307,7 +2315,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js", "path": "libtextsecure/message_receiver.js",
"line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);",
"lineNumber": 688, "lineNumber": 774,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
@ -2315,10 +2323,26 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js", "path": "libtextsecure/message_receiver.js",
"line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);",
"lineNumber": 713, "lineNumber": 799,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "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", "rule": "eval",
"path": "node_modules/@protobufjs/inquire/index.js", "path": "node_modules/@protobufjs/inquire/index.js",