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

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

View file

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

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 => {

View file

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

View file

@ -1,39 +1,82 @@
/* eslint-env browser */
/* global dcodeIO */
/* eslint-disable camelcase */
/* eslint-disable camelcase, no-bitwise */
module.exports = {
encryptSymmetric,
decryptSymmetric,
arrayBufferToBase64,
base64ToArrayBuffer,
bytesFromString,
concatenateBytes,
constantTimeEqual,
decryptAesCtr,
decryptSymmetric,
deriveAccessKey,
encryptAesCtr,
encryptSymmetric,
fromEncodedBinaryToArrayBuffer,
getAccessKeyVerifier,
getRandomBytes,
getViewOfArrayBuffer,
getZeroes,
highBitsToInt,
hmacSha256,
intsToByteHighAndLow,
splitBytes,
stringFromBytes,
trimBytes,
verifyAccessKey,
};
// High-level Operations
async function deriveAccessKey(profileKey) {
const iv = getZeroes(12);
const plaintext = getZeroes(16);
const accessKey = await _encrypt_aes_gcm(profileKey, iv, plaintext);
return _getFirstBytes(accessKey, 16);
}
async function getAccessKeyVerifier(accessKey) {
const plaintext = getZeroes(32);
const hmac = await hmacSha256(accessKey, plaintext);
return hmac;
}
async function verifyAccessKey(accessKey, theirVerifier) {
const ourVerifier = await getAccessKeyVerifier(accessKey);
if (constantTimeEqual(ourVerifier, theirVerifier)) {
return true;
}
return false;
}
const IV_LENGTH = 16;
const MAC_LENGTH = 16;
const NONCE_LENGTH = 16;
async function encryptSymmetric(key, plaintext) {
const iv = _getZeros(IV_LENGTH);
const nonce = _getRandomBytes(NONCE_LENGTH);
const iv = getZeroes(IV_LENGTH);
const nonce = getRandomBytes(NONCE_LENGTH);
const cipherKey = await _hmac_SHA256(key, nonce);
const macKey = await _hmac_SHA256(key, cipherKey);
const cipherKey = await hmacSha256(key, nonce);
const macKey = await hmacSha256(key, cipherKey);
const cipherText = await _encrypt_aes256_CBC_PKCSPadding(
cipherKey,
iv,
plaintext
);
const mac = _getFirstBytes(
await _hmac_SHA256(macKey, cipherText),
MAC_LENGTH
);
const mac = _getFirstBytes(await hmacSha256(macKey, cipherText), MAC_LENGTH);
return _concatData([nonce, cipherText, mac]);
return concatenateBytes(nonce, cipherText, mac);
}
async function decryptSymmetric(key, data) {
const iv = _getZeros(IV_LENGTH);
const iv = getZeroes(IV_LENGTH);
const nonce = _getFirstBytes(data, NONCE_LENGTH);
const cipherText = _getBytes(
@ -43,11 +86,11 @@ async function decryptSymmetric(key, data) {
);
const theirMac = _getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH);
const cipherKey = await _hmac_SHA256(key, nonce);
const macKey = await _hmac_SHA256(key, cipherKey);
const cipherKey = await hmacSha256(key, nonce);
const macKey = await hmacSha256(key, cipherKey);
const ourMac = _getFirstBytes(
await _hmac_SHA256(macKey, cipherText),
await hmacSha256(macKey, cipherText),
MAC_LENGTH
);
if (!constantTimeEqual(theirMac, ourMac)) {
@ -73,56 +116,135 @@ function constantTimeEqual(left, right) {
return result === 0;
}
async function _hmac_SHA256(key, data) {
// Encryption
async function hmacSha256(key, plaintext) {
const algorithm = {
name: 'HMAC',
hash: 'SHA-256',
};
const extractable = false;
const cryptoKey = await window.crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
algorithm,
extractable,
['sign']
);
return window.crypto.subtle.sign(
{ name: 'HMAC', hash: 'SHA-256' },
cryptoKey,
data
);
return window.crypto.subtle.sign(algorithm, cryptoKey, plaintext);
}
async function _encrypt_aes256_CBC_PKCSPadding(key, iv, data) {
async function _encrypt_aes256_CBC_PKCSPadding(key, iv, plaintext) {
const algorithm = {
name: 'AES-CBC',
iv,
};
const extractable = false;
const cryptoKey = await window.crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
algorithm,
extractable,
['encrypt']
);
return window.crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, data);
return window.crypto.subtle.encrypt(algorithm, cryptoKey, plaintext);
}
async function _decrypt_aes256_CBC_PKCSPadding(key, iv, data) {
async function _decrypt_aes256_CBC_PKCSPadding(key, iv, plaintext) {
const algorithm = {
name: 'AES-CBC',
iv,
};
const extractable = false;
const cryptoKey = await window.crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
algorithm,
extractable,
['decrypt']
);
return window.crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, data);
return window.crypto.subtle.decrypt(algorithm, cryptoKey, plaintext);
}
function _getRandomBytes(n) {
async function encryptAesCtr(key, plaintext, counter) {
const extractable = false;
const algorithm = {
name: 'AES-CTR',
counter: new Uint8Array(counter),
length: 128,
};
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
algorithm,
extractable,
['encrypt']
);
const ciphertext = await crypto.subtle.encrypt(
algorithm,
cryptoKey,
plaintext
);
return ciphertext;
}
async function decryptAesCtr(key, ciphertext, counter) {
const extractable = false;
const algorithm = {
name: 'AES-CTR',
counter: new Uint8Array(counter),
length: 128,
};
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
algorithm,
extractable,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
algorithm,
cryptoKey,
ciphertext
);
return plaintext;
}
async function _encrypt_aes_gcm(key, iv, plaintext) {
const algorithm = {
name: 'AES-GCM',
iv,
};
const extractable = false;
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
algorithm,
extractable,
['encrypt']
);
return crypto.subtle.encrypt(algorithm, cryptoKey, plaintext);
}
// Utility
function getRandomBytes(n) {
const bytes = new Uint8Array(n);
window.crypto.getRandomValues(bytes);
return bytes;
}
function _getZeros(n) {
function getZeroes(n) {
const result = new Uint8Array(n);
const value = 0;
@ -133,17 +255,43 @@ function _getZeros(n) {
return result;
}
function _getFirstBytes(data, n) {
const source = new Uint8Array(data);
return source.subarray(0, n);
function highBitsToInt(byte) {
return (byte & 0xff) >> 4;
}
function _getBytes(data, start, n) {
const source = new Uint8Array(data);
return source.subarray(start, start + n);
function intsToByteHighAndLow(highValue, lowValue) {
return ((highValue << 4) | lowValue) & 0xff;
}
function _concatData(elements) {
function trimBytes(buffer, length) {
return _getFirstBytes(buffer, length);
}
function arrayBufferToBase64(arrayBuffer) {
return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');
}
function base64ToArrayBuffer(base64string) {
return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();
}
function fromEncodedBinaryToArrayBuffer(key) {
return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();
}
function bytesFromString(string) {
return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();
}
function stringFromBytes(buffer) {
return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');
}
function getViewOfArrayBuffer(buffer, start, finish) {
const source = new Uint8Array(buffer);
const result = source.slice(start, finish);
return result.buffer;
}
function concatenateBytes(...elements) {
const length = elements.reduce(
(total, element) => total + element.byteLength,
0
@ -161,5 +309,45 @@ function _concatData(elements) {
throw new Error('problem concatenating!');
}
return result;
return result.buffer;
}
function splitBytes(buffer, ...lengths) {
const total = lengths.reduce((acc, length) => acc + length, 0);
if (total !== buffer.byteLength) {
throw new Error(
`Requested lengths total ${total} does not match source total ${
buffer.byteLength
}`
);
}
const source = new Uint8Array(buffer);
const results = [];
let position = 0;
for (let i = 0, max = lengths.length; i < max; i += 1) {
const length = lengths[i];
const result = new Uint8Array(length);
const section = source.slice(position, position + length);
result.set(section);
position += result.byteLength;
results.push(result);
}
return results;
}
// Internal-only
function _getFirstBytes(data, n) {
const source = new Uint8Array(data);
return source.subarray(0, n);
}
function _getBytes(data, start, n) {
const source = new Uint8Array(data);
return source.subarray(start, start + n);
}

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

View file

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

View file

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

View file

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