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

@ -1,6 +1,5 @@
/* global _: false */
/* global Backbone: false */
/* global dcodeIO: false */
/* global libphonenumber: false */
/* global ConversationController: false */
@ -33,8 +32,6 @@
deleteAttachmentData,
} = window.Signal.Migrations;
// TODO: Factor out private and group subclasses of Conversation
const COLORS = [
'red',
'deep_orange',
@ -324,9 +321,20 @@
}
},
sendVerifySyncMessage(number, state) {
// Because syncVerification sends a (null) message to the target of the verify and
// a sync message to our own devices, we need to send the accessKeys down for both
// contacts. So we merge their sendOptions.
const { sendOptions } = ConversationController.prepareForSend(
this.ourNumber
);
const recipientSendOptions = this.getSendOptions();
const options = Object.assign({}, sendOptions, recipientSendOptions);
const promise = textsecure.storage.protocol.loadIdentityKey(number);
return promise.then(key =>
textsecure.messaging.syncVerification(number, state, key)
this.wrapSend(
textsecure.messaging.syncVerification(number, state, key, options)
)
);
},
isVerified() {
@ -754,20 +762,118 @@
messageWithSchema.attachments.map(loadAttachmentData)
);
const options = this.getSendOptions();
return message.send(
sendFunction(
destination,
body,
attachmentsWithData,
quote,
now,
expireTimer,
profileKey
this.wrapSend(
sendFunction(
destination,
body,
attachmentsWithData,
quote,
now,
expireTimer,
profileKey,
options
)
)
);
});
},
wrapSend(promise) {
return promise.then(
async result => {
// success
if (
result &&
result.failoverNumbers &&
result.failoverNumbers.length
) {
await this.handleFailover(result.failoverNumbers);
}
return result;
},
async result => {
// failure
if (
result &&
result.failoverNumbers &&
result.failoverNumbers.length
) {
await this.handleFailover(result.failoverNumbers);
}
throw result;
}
);
},
handleFailover(numberArray) {
return Promise.all(
(numberArray || []).map(async number => {
const conversation = ConversationController.get(number);
if (conversation && conversation.get('unidentifiedDelivery')) {
window.log.info(
`Marking unidentifiedDelivery false for conversation ${conversation.idForLogging()}`
);
conversation.set({ unidentifiedDelivery: false });
await window.Signal.Data.updateConversation(
conversation.id,
conversation.attributes,
{ Conversation: Whisper.Conversation }
);
}
})
);
},
getSendOptions() {
const senderCertificate = storage.get('senderCertificate');
const numberInfo = this.getNumberInfo();
return {
senderCertificate,
numberInfo,
};
},
getNumberInfo() {
const UD = 'unidentifiedDelivery';
const UNRESTRICTED_UD = 'unidentifiedDeliveryUnrestricted';
// We don't want to enable unidentified delivery for send unless it is
// also enabled for our own account.
const me = ConversationController.getOrCreate(this.ourNumber, 'private');
if (!me.get(UD) && !me.get(UNRESTRICTED_UD)) {
return null;
}
if (this.isPrivate()) {
const accessKey = this.get('accessKey');
const unidentifiedDelivery = this.get(UD);
const unrestricted = this.get(UNRESTRICTED_UD);
if (!unidentifiedDelivery && !unrestricted) {
return null;
}
return {
[this.id]: {
accessKey:
accessKey && !unrestricted
? accessKey
: window.Signal.Crypto.arrayBufferToBase64(
window.Signal.Crypto.getRandomBytes(16)
),
},
};
}
const infoArray = this.contactCollection.map(conversation =>
conversation.getNumberInfo()
);
return Object.assign({}, ...infoArray);
},
async updateLastMessage() {
if (!this.id) {
return;
@ -901,14 +1007,17 @@
if (this.get('profileSharing')) {
profileKey = storage.get('profileKey');
}
const sendOptions = this.getSendOptions();
const promise = sendFunc(
this.get('id'),
this.get('expireTimer'),
message.get('sent_at'),
profileKey
profileKey,
sendOptions
);
await message.send(promise);
await message.send(this.wrapSend(promise));
return message;
},
@ -935,7 +1044,12 @@
});
message.set({ id });
message.send(textsecure.messaging.resetSession(this.id, now));
const options = this.getSendOptions();
message.send(
this.wrapSend(
textsecure.messaging.resetSession(this.id, now, options)
)
);
}
},
@ -962,12 +1076,16 @@
});
message.set({ id });
const options = this.getSendOptions();
message.send(
textsecure.messaging.updateGroup(
this.id,
this.get('name'),
this.get('avatar'),
this.get('members')
this.wrapSend(
textsecure.messaging.updateGroup(
this.id,
this.get('name'),
this.get('avatar'),
this.get('members'),
options
)
)
);
},
@ -993,7 +1111,10 @@
});
message.set({ id });
message.send(textsecure.messaging.leaveGroup(this.id));
const options = this.getSendOptions();
message.send(
this.wrapSend(textsecure.messaging.leaveGroup(this.id, options))
);
}
},
@ -1054,14 +1175,32 @@
read = read.filter(item => !item.hasErrors);
if (read.length && options.sendReadReceipts) {
window.log.info('Sending', read.length, 'read receipts');
await textsecure.messaging.syncReadMessages(read);
window.log.info(`Sending ${read.length} read receipts`);
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
// to a contact, we need accessKeys for both.
const prep = ConversationController.prepareForSend(this.ourNumber);
const recipientSendOptions = this.getSendOptions();
const sendOptions = Object.assign(
{},
prep.sendOptions,
recipientSendOptions
);
await this.wrapSend(
textsecure.messaging.syncReadMessages(read, sendOptions)
);
if (storage.get('read-receipt-setting')) {
await Promise.all(
_.map(_.groupBy(read, 'sender'), async (receipts, sender) => {
const timestamps = _.map(receipts, 'timestamp');
await textsecure.messaging.sendReadReceipts(sender, timestamps);
await this.wrapSend(
textsecure.messaging.sendReadReceipts(
sender,
timestamps,
sendOptions
)
);
})
);
}
@ -1092,13 +1231,56 @@
);
}
try {
const profile = await textsecure.messaging.getProfile(id);
const identityKey = dcodeIO.ByteBuffer.wrap(
profile.identityKey,
'base64'
).toArrayBuffer();
const c = await ConversationController.getOrCreateAndWait(id, 'private');
// Because we're no longer using Backbone-integrated saves, we need to manually
// clear the changed fields here so our hasChanged() check is useful.
c.changed = {};
try {
if (c.get('profileKey') && !c.get('accessKey')) {
const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer(
c.get('profileKey')
);
const buffer = await window.Signal.Crypto.deriveAccessKey(
profileKeyBuffer
);
c.set({
accessKey: window.Signal.Crypto.arrayBufferToBase64(buffer),
});
}
const firstProfileFetch = !c.get('hasFetchedProfile');
const accessKey = c.get('accessKey');
let profile;
if (c.get('unidentifiedDelivery') || firstProfileFetch) {
try {
profile = await textsecure.messaging.getProfile(id, {
accessKey:
accessKey ||
window.Signal.Crypto.arrayBufferToBase64(
window.window.Signal.Crypto.getRandomBytes(16)
),
});
} catch (error) {
if (error.code === 401 || error.code === 403) {
c.set({
unidentifiedDelivery: false,
unidentifiedDeliveryUnrestricted: false,
});
profile = await textsecure.messaging.getProfile(id);
} else {
throw error;
}
}
} else {
profile = await textsecure.messaging.getProfile(id);
}
const identityKey = window.Signal.Crypto.base64ToArrayBuffer(
profile.identityKey
);
const changed = await textsecure.storage.protocol.saveIdentity(
`${id}.1`,
identityKey,
@ -1116,49 +1298,68 @@
await sessionCipher.closeOpenSessionForDevice();
}
try {
const c = ConversationController.get(id);
c.set({
hasFetchedProfile: true,
});
// Because we're no longer using Backbone-integrated saves, we need to manually
// clear the changed fields here so our hasChanged() check is useful.
c.changed = {};
await c.setProfileName(profile.name);
await c.setProfileAvatar(profile.avatar);
if (
profile.unrestrictedUnidentifiedAccess &&
profile.unidentifiedAccess
) {
c.set({
unidentifiedDelivery: true,
unidentifiedDeliveryUnrestricted: true,
});
} else if (accessKey && profile.unidentifiedAccess) {
const haveCorrectKey = await window.Signal.Crypto.verifyAccessKey(
window.Signal.Crypto.base64ToArrayBuffer(accessKey),
window.Signal.Crypto.base64ToArrayBuffer(profile.unidentifiedAccess)
);
if (c.hasChanged()) {
await window.Signal.Data.updateConversation(id, c.attributes, {
Conversation: Whisper.Conversation,
});
}
} catch (e) {
if (e.name === 'ProfileDecryptError') {
// probably the profile key has changed.
window.log.error(
'decryptProfile error:',
id,
e && e.stack ? e.stack : e
);
}
window.log.info(
`Setting unidentifiedDelivery to ${haveCorrectKey} for conversation ${c.idForLogging()}`
);
c.set({
unidentifiedDelivery: haveCorrectKey,
unidentifiedDeliveryUnrestricted: false,
});
} else {
c.set({
unidentifiedDelivery: false,
unidentifiedDeliveryUnrestricted: false,
});
}
await c.setProfileName(profile.name);
// This might throw if we can't pull the avatar down, so we do it last
await c.setProfileAvatar(profile.avatar);
} catch (error) {
window.log.error(
'getProfile error:',
id,
error && error.stack ? error.stack : error
);
} finally {
if (c.hasChanged()) {
await window.Signal.Data.updateConversation(id, c.attributes, {
Conversation: Whisper.Conversation,
});
}
}
},
async setProfileName(encryptedName) {
if (!encryptedName) {
return;
}
const key = this.get('profileKey');
if (!key) {
return;
}
// decode
const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer();
const data = dcodeIO.ByteBuffer.wrap(
encryptedName,
'base64'
).toArrayBuffer();
const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key);
const data = window.Signal.Crypto.base64ToArrayBuffer(encryptedName);
// decrypt
const decrypted = await textsecure.crypto.decryptProfileName(
@ -1167,10 +1368,10 @@
);
// encode
const name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8');
const profileName = window.Signal.Crypto.stringFromBytes(decrypted);
// set
this.set({ profileName: name });
this.set({ profileName });
},
async setProfileAvatar(avatarPath) {
if (!avatarPath) {
@ -1182,7 +1383,7 @@
if (!key) {
return;
}
const keyBuffer = dcodeIO.ByteBuffer.wrap(key, 'base64').toArrayBuffer();
const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key);
// decrypt
const decrypted = await textsecure.crypto.decryptProfile(
@ -1204,9 +1405,20 @@
}
},
async setProfileKey(profileKey) {
// profileKey is now being saved as a string
// profileKey is a string so we can compare it directly
if (this.get('profileKey') !== profileKey) {
this.set({ profileKey });
const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer(
profileKey
);
const accessKeyBuffer = await window.Signal.Crypto.deriveAccessKey(
profileKeyBuffer
);
const accessKey = window.Signal.Crypto.arrayBufferToBase64(
accessKeyBuffer
);
this.set({ profileKey, accessKey });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});

View file

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