Signal-Desktop/libtextsecure/message_receiver.js

855 lines
35 KiB
JavaScript
Raw Normal View History

/*
* vim: ts=4:sw=4:expandtab
*/
Certificate pinning via node XMLHttpRequest implementation (#1394) * Add certificate pinning on https service requests Make https requests to the server using node apis instead of browser apis, so we can specify our own CA list, which contains only our own CA. This protects us from MITM by a rogue CA. As a bonus, this let's us drop the use of non-standard ports and just use good ol' default 443 all the time, at least for http requests. // FREEBIE * Make certificateAuthorities an option on requests Modify node-based xhr implementation based on driverdan/node-XMLHttpRequest, adding support for setting certificate authorities on each request. This allows us to pin our master CA for requests to the server and cdn but not to the s3 attachment server, for instance. Also fix an exception when sending binary data in a request: it is submitted as an array buffer, and must be converted to a node Buffer since we are now using a node based request api. // FREEBIE * Import node-based xhr implementation Add a copy of https://github.com/driverdan/node-XMLHttpRequest@86ff70e, and expose it to the renderer in the preload script. In later commits this module will be extended to support custom certificate authorities. // FREEBIE * Support "arraybuffer" responseType on requests When fetching attachments, we want the result as binary data rather than a utf8 string. This lets our node-based XMLHttpRequest honor the responseType property if it is set on the xhr. Note that naively using the raw `.buffer` from a node Buffer won't work, since it is a reuseable backing buffer that is often much larger than the actual content defined by the Buffer's offset and length. Instead, we'll prepare a return buffer based on the response's content length header, and incrementally write chunks of data into it as they arrive. // FREEBIE * Switch to self-signed server endpoint * Log more error info on failed requests With the node-based xhr, relevant error info are stored in statusText and responseText when a request fails. // FREEBIE * Add node-based websocket w/ support for custom CA // FREEBIE * Support handling array buffers instead of blobs Our node-based websocket calls onmessage with an arraybuffer instead of a blob. For robustness (on the off chance we switch or update the socket implementation agian) I've kept the machinery for converting blobs to array buffers. // FREEBIE * Destroy all wacky server ports // FREEBIE
2017-09-01 15:58:58 +00:00
function MessageReceiver(url, username, password, signalingKey) {
this.count = 0;
this.url = url;
this.signalingKey = signalingKey;
this.username = username;
this.password = password;
Certificate pinning via node XMLHttpRequest implementation (#1394) * Add certificate pinning on https service requests Make https requests to the server using node apis instead of browser apis, so we can specify our own CA list, which contains only our own CA. This protects us from MITM by a rogue CA. As a bonus, this let's us drop the use of non-standard ports and just use good ol' default 443 all the time, at least for http requests. // FREEBIE * Make certificateAuthorities an option on requests Modify node-based xhr implementation based on driverdan/node-XMLHttpRequest, adding support for setting certificate authorities on each request. This allows us to pin our master CA for requests to the server and cdn but not to the s3 attachment server, for instance. Also fix an exception when sending binary data in a request: it is submitted as an array buffer, and must be converted to a node Buffer since we are now using a node based request api. // FREEBIE * Import node-based xhr implementation Add a copy of https://github.com/driverdan/node-XMLHttpRequest@86ff70e, and expose it to the renderer in the preload script. In later commits this module will be extended to support custom certificate authorities. // FREEBIE * Support "arraybuffer" responseType on requests When fetching attachments, we want the result as binary data rather than a utf8 string. This lets our node-based XMLHttpRequest honor the responseType property if it is set on the xhr. Note that naively using the raw `.buffer` from a node Buffer won't work, since it is a reuseable backing buffer that is often much larger than the actual content defined by the Buffer's offset and length. Instead, we'll prepare a return buffer based on the response's content length header, and incrementally write chunks of data into it as they arrive. // FREEBIE * Switch to self-signed server endpoint * Log more error info on failed requests With the node-based xhr, relevant error info are stored in statusText and responseText when a request fails. // FREEBIE * Add node-based websocket w/ support for custom CA // FREEBIE * Support handling array buffers instead of blobs Our node-based websocket calls onmessage with an arraybuffer instead of a blob. For robustness (on the off chance we switch or update the socket implementation agian) I've kept the machinery for converting blobs to array buffers. // FREEBIE * Destroy all wacky server ports // FREEBIE
2017-09-01 15:58:58 +00:00
this.server = new TextSecureServer(url, username, password);
var address = libsignal.SignalProtocolAddress.fromString(username);
this.number = address.getName();
this.deviceId = address.getDeviceId();
}
MessageReceiver.prototype = new textsecure.EventTarget();
MessageReceiver.prototype.extend({
constructor: MessageReceiver,
connect: function() {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
this.socket.close();
}
// initialize the socket and start listening for messages
this.socket = this.server.getMessageSocket();
this.socket.onclose = this.onclose.bind(this);
this.socket.onerror = this.onerror.bind(this);
this.socket.onopen = this.onopen.bind(this);
this.wsr = new WebSocketResource(this.socket, {
2015-12-04 18:25:50 +00:00
handleRequest: this.handleRequest.bind(this),
keepalive: { path: '/v1/keepalive', disconnect: true }
});
this.pending = this.queueAllCached();
// Ensures that an immediate 'empty' event from the websocket will fire only after
// all cached envelopes are processed.
this.incoming = [this.pending];
},
close: function() {
this.socket.close(3000, 'called close');
Full export, migration banner, and full migration workflow - behind flag (#1342) * Add support for backup and restore This first pass works for all stores except messages, pending some scaling improvements. // FREEBIE * Import of messages and attachments Properly sanitize filenames. Logging information that will help with debugging but won't threaten privacy (no contact or group names), where the on-disk directories have this information to make things human-readable FREEBIE * First fully operational single-action export and import! FREEBIE * Add migration export flow A banner alert leads to a blocking ui for the migration. We close the socket and wait for incoming messages to drain before starting the export. FREEBIE * A number of updates for the export flow 1. We don't immediately pop the directory selection dialog box, instead showing an explicit 'choose directory' button after explaining what is about to happen 2. We show a 'submit debug log' button on most steps of the process 3. We handle export errors and encourage the user to double-check their filesystem then submit their log 4. We are resilient to restarts during the process 5. We handle the user cancelling out of the directory selection dialog differently from other errors. 6. The export process is now serialized: non-messages, then messages. 7. After successful export, show where the data is on disk FREEBUE * Put migration behind a flag FREEBIE * Shut down websocket before proceeding with export FREEBIE * Add MigrationView to test/index.html to fix test FREEBIE * Remove 'Submit Debug Log' button when the export process is complete FREEBIE * Create a 'Signal Export' directory below user-chosen dir This cleans things up a bit so we don't litter the user's target directory with lots of stuff. FREEBIE * Clarify MessageReceiver.drain() method comments FREEBIE * A couple updates for clarity - event names, else handling Also the removal of wait(), which wasn't used anywhere. FREEBIE * A number of wording updates for the export flow FREEBIE * Export complete: put dir on its own line, make text selectable FREEBIE
2017-08-28 20:06:10 +00:00
return this.drain();
},
onopen: function() {
console.log('websocket open');
},
onerror: function(error) {
console.log('websocket error');
},
dispatchAndWait: function(event) {
return Promise.all(this.dispatchEvent(event));
},
onclose: function(ev) {
console.log('websocket closed', ev.code, ev.reason || '');
if (ev.code === 3000) {
return;
}
// possible 403 or network issue. Make an request to confirm
return this.server.getDevices(this.number)
.then(this.connect.bind(this)) // No HTTP error? Reconnect
.catch(function(e) {
var ev = new Event('error');
ev.error = e;
return this.dispatchAndWait(ev);
}.bind(this));
},
2015-12-04 18:25:50 +00:00
handleRequest: function(request) {
this.incoming = this.incoming || [];
// We do the message decryption here, instead of in the ordered pending queue,
// to avoid exposing the time it took us to process messages through the time-to-ack.
// TODO: handle different types of requests.
if (request.path !== '/api/v1/message') {
console.log('got request', request.verb, request.path);
request.respond(200, 'OK');
if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') {
this.onEmpty();
}
return;
}
this.incoming.push(textsecure.crypto.decryptWebsocketMessage(request.body, this.signalingKey).then(function(plaintext) {
var envelope = textsecure.protobuf.Envelope.decode(plaintext);
// After this point, decoding errors are not the server's
// fault, and we should handle them gracefully and tell the
// user they received an invalid message
if (this.isBlocked(envelope.source)) {
return request.respond(200, 'OK');
}
return this.addToCache(envelope, plaintext).then(function() {
request.respond(200, 'OK');
this.queueEnvelope(envelope);
}.bind(this), function(error) {
console.log(
'handleRequest error trying to add message to cache:',
error && error.stack ? error.stack : error
);
});
}.bind(this)).catch(function(e) {
request.respond(500, 'Bad encrypted websocket message');
console.log("Error handling incoming message:", e && e.stack ? e.stack : e);
var ev = new Event('error');
ev.error = e;
return this.dispatchAndWait(ev);
}.bind(this)));
},
addToQueue: function(task) {
var count = this.count += 1;
var current = this.pending = this.pending.then(task, task);
var cleanup = function() {
this.updateProgress(count);
// We want to clear out the promise chain whenever possible because it could
// lead to large memory usage over time:
// https://github.com/nodejs/node/issues/6673#issuecomment-244331609
if (this.pending === current) {
this.pending = Promise.resolve();
}
}.bind(this);
current.then(cleanup, cleanup);
return current;
},
onEmpty: function() {
var incoming = this.incoming;
this.incoming = [];
var dispatchEmpty = function() {
console.log('MessageReceiver: emitting \'empty\' event');
var ev = new Event('empty');
return this.dispatchAndWait(ev);
}.bind(this);
var queueDispatch = function() {
// resetting count to zero so everything queued after this starts over again
this.count = 0;
this.addToQueue(dispatchEmpty);
}.bind(this);
// We first wait for all recently-received messages (this.incoming) to be queued,
// then we add a task to emit the 'empty' event to the queue, so all message
// processing is complete by the time it runs.
Promise.all(incoming).then(queueDispatch, queueDispatch);
},
Full export, migration banner, and full migration workflow - behind flag (#1342) * Add support for backup and restore This first pass works for all stores except messages, pending some scaling improvements. // FREEBIE * Import of messages and attachments Properly sanitize filenames. Logging information that will help with debugging but won't threaten privacy (no contact or group names), where the on-disk directories have this information to make things human-readable FREEBIE * First fully operational single-action export and import! FREEBIE * Add migration export flow A banner alert leads to a blocking ui for the migration. We close the socket and wait for incoming messages to drain before starting the export. FREEBIE * A number of updates for the export flow 1. We don't immediately pop the directory selection dialog box, instead showing an explicit 'choose directory' button after explaining what is about to happen 2. We show a 'submit debug log' button on most steps of the process 3. We handle export errors and encourage the user to double-check their filesystem then submit their log 4. We are resilient to restarts during the process 5. We handle the user cancelling out of the directory selection dialog differently from other errors. 6. The export process is now serialized: non-messages, then messages. 7. After successful export, show where the data is on disk FREEBUE * Put migration behind a flag FREEBIE * Shut down websocket before proceeding with export FREEBIE * Add MigrationView to test/index.html to fix test FREEBIE * Remove 'Submit Debug Log' button when the export process is complete FREEBIE * Create a 'Signal Export' directory below user-chosen dir This cleans things up a bit so we don't litter the user's target directory with lots of stuff. FREEBIE * Clarify MessageReceiver.drain() method comments FREEBIE * A couple updates for clarity - event names, else handling Also the removal of wait(), which wasn't used anywhere. FREEBIE * A number of wording updates for the export flow FREEBIE * Export complete: put dir on its own line, make text selectable FREEBIE
2017-08-28 20:06:10 +00:00
drain: function() {
var incoming = this.incoming;
this.incoming = [];
var queueDispatch = function() {
return this.addToQueue(function() {
console.log('drained');
});
}.bind(this);
// This promise will resolve when there are no more messages to be processed.
return Promise.all(incoming).then(queueDispatch, queueDispatch);
},
updateProgress: function(count) {
// count by 10s
if (count % 10 !== 0) {
return;
}
var ev = new Event('progress');
ev.count = count;
this.dispatchEvent(ev);
},
queueAllCached: function() {
return this.getAllFromCache().then(function(items) {
for (var i = 0, max = items.length; i < max; i += 1) {
this.queueCached(items[i]);
}
}.bind(this));
},
queueCached: function(item) {
try {
var envelopePlaintext = item.envelope;
// Up until 0.42.6 we stored envelope and decrypted as strings in IndexedDB,
// so we need to be ready for them.
if (typeof envelopePlaintext === 'string') {
envelopePlaintext = this.stringToArrayBuffer(envelopePlaintext);
}
var envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext);
var decrypted = item.decrypted;
if (decrypted) {
var payloadPlaintext = decrypted;
if (typeof payloadPlaintext === 'string') {
payloadPlaintext = this.stringToArrayBuffer(payloadPlaintext);
}
this.queueDecryptedEnvelope(envelope, payloadPlaintext);
} else {
this.queueEnvelope(envelope);
}
}
catch (error) {
console.log('queueCached error handling item', item.id);
}
},
getEnvelopeId: function(envelope) {
return envelope.source + '.' + envelope.sourceDevice + ' ' + envelope.timestamp.toNumber();
},
stringToArrayBuffer: function(string) {
return new dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer();
},
getAllFromCache: function() {
console.log('getAllFromCache');
return textsecure.storage.unprocessed.getAll().then(function(items) {
console.log('getAllFromCache loaded', items.length, 'saved envelopes');
return Promise.all(_.map(items, function(item) {
var attempts = 1 + (item.attempts || 0);
if (attempts >= 5) {
console.log('getAllFromCache final attempt for envelope', item.id);
return textsecure.storage.unprocessed.remove(item.id);
} else {
return textsecure.storage.unprocessed.update(item.id, {attempts: attempts});
}
}.bind(this))).then(function() {
return items;
}, function(error) {
console.log(
'getAllFromCache error updating items after load:',
error && error.stack ? error.stack : error
);
return items;
});
}.bind(this));
},
addToCache: function(envelope, plaintext) {
var id = this.getEnvelopeId(envelope);
var data = {
id: id,
envelope: plaintext,
timestamp: Date.now(),
attempts: 1
};
return textsecure.storage.unprocessed.add(data);
},
updateCache: function(envelope, plaintext) {
var id = this.getEnvelopeId(envelope);
var data = {
decrypted: plaintext
};
return textsecure.storage.unprocessed.update(id, data);
},
removeFromCache: function(envelope) {
var id = this.getEnvelopeId(envelope);
return textsecure.storage.unprocessed.remove(id);
},
queueDecryptedEnvelope: function(envelope, plaintext) {
var id = this.getEnvelopeId(envelope);
console.log('queueing decrypted envelope', id);
var task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
var taskWithTimeout = textsecure.createTaskWithTimeout(task, 'queueEncryptedEnvelope ' + id);
var promise = this.addToQueue(taskWithTimeout);
return promise.catch(function(error) {
console.log('queueDecryptedEnvelope error handling envelope', id, ':', error && error.stack ? error.stack : error);
});
},
2015-12-04 18:25:50 +00:00
queueEnvelope: function(envelope) {
var id = this.getEnvelopeId(envelope);
console.log('queueing envelope', id);
var task = this.handleEnvelope.bind(this, envelope);
var taskWithTimeout = textsecure.createTaskWithTimeout(task, 'queueEnvelope ' + id);
var promise = this.addToQueue(taskWithTimeout);
return promise.catch(function(error) {
console.log('queueEnvelope error handling envelope', id, ':', error && error.stack ? error.stack : error);
});
},
// Same as handleEnvelope, just without the decryption step. Necessary for handling
// messages which were successfully decrypted, but application logic didn't finish
// processing.
handleDecryptedEnvelope: function(envelope, plaintext) {
// No decryption is required for delivery receipts, so the decrypted field of
// the Unprocessed model will never be set
if (envelope.content) {
return this.innerHandleContentMessage(envelope, plaintext);
} else if (envelope.legacyMessage) {
return this.innerHandleLegacyMessage(envelope, plaintext);
} else {
this.removeFromCache(envelope);
throw new Error('Received message with no content and no legacyMessage');
}
2015-12-04 18:25:50 +00:00
},
handleEnvelope: function(envelope) {
2015-12-04 18:25:50 +00:00
if (envelope.type === textsecure.protobuf.Envelope.Type.RECEIPT) {
return this.onDeliveryReceipt(envelope);
}
if (envelope.content) {
2015-12-04 18:25:50 +00:00
return this.handleContentMessage(envelope);
} else if (envelope.legacyMessage) {
return this.handleLegacyMessage(envelope);
} else {
this.removeFromCache(envelope);
2015-12-04 18:25:50 +00:00
throw new Error('Received message with no content and no legacyMessage');
}
},
getStatus: function() {
if (this.socket) {
return this.socket.readyState;
} else {
return -1;
}
},
onDeliveryReceipt: function (envelope) {
return new Promise(function(resolve, reject) {
var ev = new Event('receipt');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.proto = envelope;
this.dispatchAndWait(ev).then(resolve, reject);
}.bind(this));
},
unpad: function(paddedPlaintext) {
paddedPlaintext = new Uint8Array(paddedPlaintext);
var plaintext;
for (var i = paddedPlaintext.length - 1; i >= 0; i--) {
if (paddedPlaintext[i] == 0x80) {
plaintext = new Uint8Array(i);
plaintext.set(paddedPlaintext.subarray(0, i));
plaintext = plaintext.buffer;
break;
} else if (paddedPlaintext[i] !== 0x00) {
throw new Error('Invalid padding');
}
}
return plaintext;
},
decrypt: function(envelope, ciphertext) {
var promise;
var address = new libsignal.SignalProtocolAddress(envelope.source, envelope.sourceDevice);
var ourNumber = textsecure.storage.user.getNumber();
var number = address.toString().split('.')[0];
var options = {};
// No limit on message keys if we're communicating with our other devices
if (ourNumber === number) {
options.messageKeysLimit = false;
}
var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address, options);
switch(envelope.type) {
case textsecure.protobuf.Envelope.Type.CIPHERTEXT:
console.log('message from', this.getEnvelopeId(envelope));
promise = sessionCipher.decryptWhisperMessage(ciphertext).then(this.unpad);
break;
case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE:
console.log('prekey message from', this.getEnvelopeId(envelope));
promise = this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address);
break;
default:
promise = Promise.reject(new Error("Unknown message type"));
}
return promise.then(function(plaintext) {
return this.updateCache(envelope, plaintext).then(function() {
return plaintext;
}, function(error) {
console.log(
'decrypt failed to save decrypted message contents to cache:',
error && error.stack ? error.stack : error
);
return plaintext;
});
}.bind(this)).catch(function(error) {
if (error.message === 'Unknown identity key') {
// create an error that the UI will pick up and ask the
// user if they want to re-negotiate
var buffer = dcodeIO.ByteBuffer.wrap(ciphertext);
error = new textsecure.IncomingIdentityKeyError(
address.toString(),
buffer.toArrayBuffer(),
error.identityKey
);
}
var ev = new Event('error');
ev.error = error;
ev.proto = envelope;
ev.confirm = this.removeFromCache.bind(this, envelope);
var returnError = function() {
return Promise.reject(error);
};
return this.dispatchAndWait(ev).then(returnError, returnError);
}.bind(this));
},
decryptPreKeyWhisperMessage: function(ciphertext, sessionCipher, address) {
return sessionCipher.decryptPreKeyWhisperMessage(ciphertext).then(this.unpad).catch(function(e) {
if (e.message === 'Unknown identity key') {
// create an error that the UI will pick up and ask the
// user if they want to re-negotiate
var buffer = dcodeIO.ByteBuffer.wrap(ciphertext);
throw new textsecure.IncomingIdentityKeyError(
address.toString(),
buffer.toArrayBuffer(),
e.identityKey
);
}
throw e;
});
},
handleSentMessage: function(envelope, destination, timestamp, message, expirationStartTimestamp) {
var p = Promise.resolve();
if ((message.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) ==
textsecure.protobuf.DataMessage.Flags.END_SESSION ) {
p = this.handleEndSession(destination);
}
return p.then(function() {
return this.processDecrypted(envelope, message, this.number).then(function(message) {
var ev = new Event('sent');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
destination : destination,
timestamp : timestamp.toNumber(),
device : envelope.sourceDevice,
message : message
};
if (expirationStartTimestamp) {
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
}
return this.dispatchAndWait(ev);
}.bind(this));
}.bind(this));
},
2016-04-02 01:12:38 +00:00
handleDataMessage: function(envelope, message) {
console.log('data message from', this.getEnvelopeId(envelope));
var p = Promise.resolve();
if ((message.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) ==
textsecure.protobuf.DataMessage.Flags.END_SESSION ) {
p = this.handleEndSession(envelope.source);
}
return p.then(function() {
return this.processDecrypted(envelope, message, envelope.source).then(function(message) {
var ev = new Event('message');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
source : envelope.source,
sourceDevice : envelope.sourceDevice,
timestamp : envelope.timestamp.toNumber(),
receivedAt : envelope.receivedAt,
message : message
};
return this.dispatchAndWait(ev);
}.bind(this));
}.bind(this));
},
handleLegacyMessage: function (envelope) {
return this.decrypt(envelope, envelope.legacyMessage).then(function(plaintext) {
return this.innerHandleLegacyMessage(envelope, plaintext);
}.bind(this));
},
innerHandleLegacyMessage: function (envelope, plaintext) {
var message = textsecure.protobuf.DataMessage.decode(plaintext);
return this.handleDataMessage(envelope, message);
},
handleContentMessage: function (envelope) {
return this.decrypt(envelope, envelope.content).then(function(plaintext) {
return this.innerHandleContentMessage(envelope, plaintext);
}.bind(this));
},
innerHandleContentMessage: function(envelope, plaintext) {
var content = textsecure.protobuf.Content.decode(plaintext);
if (content.syncMessage) {
return this.handleSyncMessage(envelope, content.syncMessage);
} else if (content.dataMessage) {
return this.handleDataMessage(envelope, content.dataMessage);
} else if (content.nullMessage) {
return this.handleNullMessage(envelope, content.nullMessage);
} else {
this.removeFromCache(envelope);
throw new Error('Unsupported content message');
}
},
handleNullMessage: function(envelope, nullMessage) {
console.log('null message from', this.getEnvelopeId(envelope));
this.removeFromCache(envelope);
},
handleSyncMessage: function(envelope, syncMessage) {
if (envelope.source !== this.number) {
throw new Error('Received sync message from another number');
}
if (envelope.sourceDevice == this.deviceId) {
throw new Error('Received sync message from our own device');
}
if (syncMessage.sent) {
var sentMessage = syncMessage.sent;
console.log('sent message to',
sentMessage.destination,
sentMessage.timestamp.toNumber(),
'from',
this.getEnvelopeId(envelope)
);
return this.handleSentMessage(
envelope,
sentMessage.destination,
sentMessage.timestamp,
sentMessage.message,
sentMessage.expirationStartTimestamp
);
} else if (syncMessage.contacts) {
return this.handleContacts(envelope, syncMessage.contacts);
} else if (syncMessage.groups) {
return this.handleGroups(envelope, syncMessage.groups);
} else if (syncMessage.blocked) {
return this.handleBlocked(envelope, syncMessage.blocked);
} else if (syncMessage.request) {
console.log('Got SyncMessage Request');
return this.removeFromCache(envelope);
} else if (syncMessage.read && syncMessage.read.length) {
console.log('read messages',
'from', envelope.source + '.' + envelope.sourceDevice);
return this.handleRead(envelope, syncMessage.read);
} else if (syncMessage.verified) {
return this.handleVerified(envelope, syncMessage.verified);
} else {
throw new Error('Got empty SyncMessage');
}
},
handleVerified: function(envelope, verified) {
var ev = new Event('verified');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.verified = {
state: verified.state,
destination: verified.destination,
identityKey: verified.identityKey.toArrayBuffer()
};
return this.dispatchAndWait(ev);
},
handleRead: function(envelope, read) {
var results = [];
for (var i = 0; i < read.length; ++i) {
var ev = new Event('read');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.timestamp = envelope.timestamp.toNumber();
ev.read = {
timestamp : read[i].timestamp.toNumber(),
sender : read[i].sender
}
results.push(this.dispatchAndWait(ev));
}
return Promise.all(results);
},
handleContacts: function(envelope, contacts) {
2015-12-16 23:24:07 +00:00
console.log('contact sync');
var attachmentPointer = contacts.blob;
return this.handleAttachment(attachmentPointer).then(function() {
var results = [];
var contactBuffer = new ContactBuffer(attachmentPointer.data);
var contactDetails = contactBuffer.next();
while (contactDetails !== undefined) {
var ev = new Event('contact');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.contactDetails = contactDetails;
results.push(this.dispatchAndWait(ev));
contactDetails = contactBuffer.next();
}
var ev = new Event('contactsync');
ev.confirm = this.removeFromCache.bind(this, envelope);
results.push(this.dispatchAndWait(ev));
return Promise.all(results);
}.bind(this));
},
handleGroups: function(envelope, groups) {
2015-12-16 23:24:07 +00:00
console.log('group sync');
var attachmentPointer = groups.blob;
return this.handleAttachment(attachmentPointer).then(function() {
var groupBuffer = new GroupBuffer(attachmentPointer.data);
var groupDetails = groupBuffer.next();
var promises = [];
while (groupDetails !== undefined) {
var promise = (function(groupDetails) {
groupDetails.id = groupDetails.id.toBinary();
2016-02-11 22:12:09 +00:00
if (groupDetails.active) {
return textsecure.storage.groups.getGroup(groupDetails.id).
then(function(existingGroup) {
if (existingGroup === undefined) {
return textsecure.storage.groups.createNewGroup(
groupDetails.members, groupDetails.id
);
} else {
return textsecure.storage.groups.updateNumbers(
groupDetails.id, groupDetails.members
);
}
}).then(function() { return groupDetails });
} else {
return Promise.resolve(groupDetails);
}
})(groupDetails).then(function(groupDetails) {
var ev = new Event('group');
ev.confirm = this.removeFromCache.bind(this, envelope);
2016-02-11 22:12:09 +00:00
ev.groupDetails = groupDetails;
return this.dispatchAndWait(ev);
}.bind(this)).catch(function(e) {
2016-02-11 22:12:09 +00:00
console.log('error processing group', e);
});
groupDetails = groupBuffer.next();
promises.push(promise);
}
Promise.all(promises).then(function() {
var ev = new Event('groupsync');
ev.confirm = this.removeFromCache.bind(this, envelope);
return this.dispatchAndWait(ev);
}.bind(this));
}.bind(this));
},
handleBlocked: function(envelope, blocked) {
textsecure.storage.put('blocked', blocked.numbers);
},
isBlocked: function(number) {
return textsecure.storage.get('blocked', []).indexOf(number) >= 0;
},
handleAttachment: function(attachment) {
attachment.id = attachment.id.toString();
attachment.key = attachment.key.toArrayBuffer();
if (attachment.digest) {
attachment.digest = attachment.digest.toArrayBuffer();
}
function decryptAttachment(encrypted) {
return textsecure.crypto.decryptAttachment(
encrypted,
attachment.key,
attachment.digest
);
}
function updateAttachment(data) {
attachment.data = data;
}
return this.server.getAttachment(attachment.id)
.then(decryptAttachment)
.then(updateAttachment);
},
validateRetryContentMessage: function(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
var 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
var data = content.dataMessage;
if (data && !data.attachments.length && !data.body && !data.expireTimer && !data.flags && !data.group) {
return false;
}
return true;
},
tryMessageAgain: function(from, ciphertext, message) {
var address = libsignal.SignalProtocolAddress.fromString(from);
var sentAt = message.sent_at || Date.now();
var receivedAt = message.received_at || Date.now();
var ourNumber = textsecure.storage.user.getNumber();
var number = address.getName();
var device = address.getDeviceId();
var options = {};
// No limit on message keys if we're communicating with our other devices
if (ourNumber === number) {
options.messageKeysLimit = false;
}
var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address, options);
console.log('retrying prekey whisper message');
return this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address).then(function(plaintext) {
var envelope = {
source: number,
sourceDevice: device,
receivedAt: receivedAt,
timestamp: {
toNumber: function() {
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/WhisperSystems/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f
// - Android: https://github.com/WhisperSystems/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958
//
// var d = new Date('2017-06-01T07:00:00.000Z');
// d.getTime();
var 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
var 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);
}.bind(this));
},
handleEndSession: function(number) {
console.log('got end session');
return textsecure.storage.protocol.getDeviceIds(number).then(function(deviceIds) {
return Promise.all(deviceIds.map(function(deviceId) {
var address = new libsignal.SignalProtocolAddress(number, deviceId);
var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address);
console.log('closing session for', address.toString());
return sessionCipher.closeOpenSessionForDevice();
}));
});
},
processDecrypted: function(envelope, decrypted, source) {
// Now that its decrypted, validate the message and clean it up for consumer processing
// Note that messages may (generally) only perform one action and we ignore remaining fields
// after the first action.
if (decrypted.flags == null) {
decrypted.flags = 0;
}
if (decrypted.expireTimer == null) {
decrypted.expireTimer = 0;
}
2016-10-04 08:42:50 +00:00
if (decrypted.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
decrypted.body = null;
decrypted.attachments = [];
decrypted.group = null;
return Promise.resolve(decrypted);
2016-10-04 08:42:50 +00:00
} else if (decrypted.flags & textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE ) {
decrypted.body = null;
decrypted.attachments = [];
Profiles (#1453) * Add AES-GCM encryption for profiles With tests. * Add profileKey to DataMessage protobuf // FREEBIE * Decrypt and save profile names // FREEBIE * Save incoming profile keys * Move pad/unpad to crypto module // FREEBIE * Support fetching avatars from the cdn // FREEBIE * Translate failed authentication errors When AES-GCM authentication fails, webcrypto returns a very generic error. The same error is thrown for invalid length inputs, but our earlier checks in decryptProfile should rule out those failure modes and leave us safe to assume that we either had bad ciphertext or the wrong key. // FREEBIE * Handle profile avatars (wip) and log decrypt errors // FREEBIE * Display profile avatars Synced contact avatars will still override profile avatars. * Display profile names in convo list Only if we don't have a synced contact name. // FREEBIE * Make cdn url an environment config Use different ones for staging and production // FREEBIE * Display profile name in conversation header * Display profile name in group messages * Update conversation header if profile avatar changes // FREEBIE * Style profile names small with ~ * Save profileKeys from contact sync messages // FREEBIE * Save profile keys from provisioning messages For standalone accounts, generate a random profile key. // FREEBIE * Special case for one-time sync of our profile key Android will use a contact sync message to sync a profile key from Android clients who have just upgraded and generated their profile key. Normally we should receive this data in a provisioning message. // FREEBIE * Infer profile sharing from synced data messages * Populate profile keys on outgoing messages Requires that `profileSharing` be set on the conversation. // FREEBIE * Support for the profile key update flag When receiving a message with this flag, don't init a message record, just process the profile key and move on. // FREEBIE * Display profile names in group member list * Refresh contact's profile on profile key changes // FREEBIE * Catch errors on profile save // FREEBIE * Save our own synced contact info Don't return early if we get a contact sync for our own number // FREEBIE
2017-09-11 16:50:35 +00:00
} else if (decrypted.flags & textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE) {
decrypted.body = null;
decrypted.attachments = [];
2016-10-04 08:42:50 +00:00
} else if (decrypted.flags != 0) {
throw new Error("Unknown flags in message");
}
var promises = [];
if (decrypted.group !== null) {
decrypted.group.id = decrypted.group.id.toBinary();
if (decrypted.group.type == textsecure.protobuf.GroupContext.Type.UPDATE) {
if (decrypted.group.avatar !== null) {
promises.push(this.handleAttachment(decrypted.group.avatar));
}
}
promises.push(textsecure.storage.groups.getNumbers(decrypted.group.id).then(function(existingGroup) {
if (existingGroup === undefined) {
if (decrypted.group.type != textsecure.protobuf.GroupContext.Type.UPDATE) {
decrypted.group.members = [source];
console.log("Got message for unknown group");
}
return textsecure.storage.groups.createNewGroup(decrypted.group.members, decrypted.group.id);
} else {
var fromIndex = existingGroup.indexOf(source);
if (fromIndex < 0) {
//TODO: This could be indication of a race...
console.log("Sender was not a member of the group they were sending from");
}
switch(decrypted.group.type) {
case textsecure.protobuf.GroupContext.Type.UPDATE:
decrypted.body = null;
decrypted.attachments = [];
return textsecure.storage.groups.updateNumbers(
decrypted.group.id, decrypted.group.members
);
break;
case textsecure.protobuf.GroupContext.Type.QUIT:
decrypted.body = null;
decrypted.attachments = [];
if (source === this.number) {
return textsecure.storage.groups.deleteGroup(decrypted.group.id);
} else {
return textsecure.storage.groups.removeNumber(decrypted.group.id, source);
}
case textsecure.protobuf.GroupContext.Type.DELIVER:
decrypted.group.name = null;
decrypted.group.members = [];
decrypted.group.avatar = null;
break;
default:
this.removeFromCache(envelope);
throw new Error("Unknown group message type");
}
}
}.bind(this)));
}
for (var i in decrypted.attachments) {
promises.push(this.handleAttachment(decrypted.attachments[i]));
}
return Promise.all(promises).then(function() {
return decrypted;
});
}
});
window.textsecure = window.textsecure || {};
Certificate pinning via node XMLHttpRequest implementation (#1394) * Add certificate pinning on https service requests Make https requests to the server using node apis instead of browser apis, so we can specify our own CA list, which contains only our own CA. This protects us from MITM by a rogue CA. As a bonus, this let's us drop the use of non-standard ports and just use good ol' default 443 all the time, at least for http requests. // FREEBIE * Make certificateAuthorities an option on requests Modify node-based xhr implementation based on driverdan/node-XMLHttpRequest, adding support for setting certificate authorities on each request. This allows us to pin our master CA for requests to the server and cdn but not to the s3 attachment server, for instance. Also fix an exception when sending binary data in a request: it is submitted as an array buffer, and must be converted to a node Buffer since we are now using a node based request api. // FREEBIE * Import node-based xhr implementation Add a copy of https://github.com/driverdan/node-XMLHttpRequest@86ff70e, and expose it to the renderer in the preload script. In later commits this module will be extended to support custom certificate authorities. // FREEBIE * Support "arraybuffer" responseType on requests When fetching attachments, we want the result as binary data rather than a utf8 string. This lets our node-based XMLHttpRequest honor the responseType property if it is set on the xhr. Note that naively using the raw `.buffer` from a node Buffer won't work, since it is a reuseable backing buffer that is often much larger than the actual content defined by the Buffer's offset and length. Instead, we'll prepare a return buffer based on the response's content length header, and incrementally write chunks of data into it as they arrive. // FREEBIE * Switch to self-signed server endpoint * Log more error info on failed requests With the node-based xhr, relevant error info are stored in statusText and responseText when a request fails. // FREEBIE * Add node-based websocket w/ support for custom CA // FREEBIE * Support handling array buffers instead of blobs Our node-based websocket calls onmessage with an arraybuffer instead of a blob. For robustness (on the off chance we switch or update the socket implementation agian) I've kept the machinery for converting blobs to array buffers. // FREEBIE * Destroy all wacky server ports // FREEBIE
2017-09-01 15:58:58 +00:00
textsecure.MessageReceiver = function(url, username, password, signalingKey) {
var messageReceiver = new MessageReceiver(url, username, password, signalingKey);
this.addEventListener = messageReceiver.addEventListener.bind(messageReceiver);
this.removeEventListener = messageReceiver.removeEventListener.bind(messageReceiver);
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
this.close = messageReceiver.close.bind(messageReceiver);
messageReceiver.connect();
textsecure.replay.registerFunction(messageReceiver.tryMessageAgain.bind(messageReceiver), textsecure.replay.Type.INIT_SESSION);
};
textsecure.MessageReceiver.prototype = {
constructor: textsecure.MessageReceiver
};