rm-outgoing-message
parent
1b21191c3c
commit
9bfccd408f
@ -1,618 +0,0 @@
|
||||
/* global
|
||||
textsecure,
|
||||
libsignal,
|
||||
window,
|
||||
libloki,
|
||||
StringView,
|
||||
lokiMessageAPI,
|
||||
log
|
||||
*/
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
/* eslint-disable no-unreachable */
|
||||
const NUM_SEND_CONNECTIONS = 3;
|
||||
|
||||
const getTTLForType = type => {
|
||||
switch (type) {
|
||||
case 'device-unpairing':
|
||||
return 4 * 24 * 60 * 60 * 1000; // 4 days for device unpairing
|
||||
case 'onlineBroadcast':
|
||||
return 60 * 1000; // 1 minute for online broadcast message
|
||||
case 'pairing-request':
|
||||
return 2 * 60 * 1000; // 2 minutes for pairing requests
|
||||
default:
|
||||
return 24 * 60 * 60 * 1000; // 1 day default for any other message
|
||||
}
|
||||
};
|
||||
|
||||
function _getPaddedMessageLength(messageLength) {
|
||||
const messageLengthWithTerminator = messageLength + 1;
|
||||
let messagePartCount = Math.floor(messageLengthWithTerminator / 160);
|
||||
|
||||
if (messageLengthWithTerminator % 160 !== 0) {
|
||||
messagePartCount += 1;
|
||||
}
|
||||
|
||||
return messagePartCount * 160;
|
||||
}
|
||||
|
||||
function _convertMessageToText(messageBuffer) {
|
||||
const plaintext = new Uint8Array(
|
||||
_getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
|
||||
);
|
||||
plaintext.set(new Uint8Array(messageBuffer));
|
||||
plaintext[messageBuffer.byteLength] = 0x80;
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
function _getPlaintext(messageBuffer) {
|
||||
return _convertMessageToText(messageBuffer);
|
||||
}
|
||||
|
||||
function wrapInWebsocketMessage(outgoingObject, timestamp) {
|
||||
const source =
|
||||
outgoingObject.type ===
|
||||
textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER
|
||||
? null
|
||||
: outgoingObject.ourKey;
|
||||
|
||||
const messageEnvelope = new textsecure.protobuf.Envelope({
|
||||
type: outgoingObject.type,
|
||||
source,
|
||||
sourceDevice: outgoingObject.sourceDevice,
|
||||
timestamp,
|
||||
content: outgoingObject.content,
|
||||
});
|
||||
const requestMessage = new textsecure.protobuf.WebSocketRequestMessage({
|
||||
id: new Uint8Array(libsignal.crypto.getRandomBytes(1))[0], // random ID for now
|
||||
verb: 'PUT',
|
||||
path: '/api/v1/message',
|
||||
body: messageEnvelope.encode().toArrayBuffer(),
|
||||
});
|
||||
const websocketMessage = new textsecure.protobuf.WebSocketMessage({
|
||||
type: textsecure.protobuf.WebSocketMessage.Type.REQUEST,
|
||||
request: requestMessage,
|
||||
});
|
||||
const bytes = new Uint8Array(websocketMessage.encode().toArrayBuffer());
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getStaleDeviceIdsForNumber(number) {
|
||||
return textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => {
|
||||
if (deviceIds.length === 0) {
|
||||
return [1];
|
||||
}
|
||||
const updateDevices = [];
|
||||
return Promise.all(
|
||||
deviceIds.map(deviceId => {
|
||||
const address = new libsignal.SignalProtocolAddress(number, deviceId);
|
||||
const sessionCipher = new libsignal.SessionCipher(
|
||||
textsecure.storage.protocol,
|
||||
address
|
||||
);
|
||||
return sessionCipher.hasOpenSession().then(hasSession => {
|
||||
if (!hasSession) {
|
||||
updateDevices.push(deviceId);
|
||||
}
|
||||
});
|
||||
})
|
||||
).then(() => updateDevices);
|
||||
});
|
||||
}
|
||||
|
||||
function OutgoingMessage(
|
||||
server,
|
||||
timestamp,
|
||||
numbers,
|
||||
message,
|
||||
silent,
|
||||
callback,
|
||||
options = {}
|
||||
) {
|
||||
if (message instanceof textsecure.protobuf.DataMessage) {
|
||||
const content = new textsecure.protobuf.Content();
|
||||
content.dataMessage = message;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
message = content;
|
||||
}
|
||||
this.server = server;
|
||||
this.timestamp = timestamp;
|
||||
this.numbers = numbers;
|
||||
this.message = message; // ContentMessage proto
|
||||
this.callback = callback;
|
||||
this.silent = silent;
|
||||
|
||||
this.numbersCompleted = 0;
|
||||
this.errors = [];
|
||||
this.successfulNumbers = [];
|
||||
this.fallBackEncryption = false;
|
||||
this.failoverNumbers = [];
|
||||
this.unidentifiedDeliveries = [];
|
||||
|
||||
const {
|
||||
numberInfo,
|
||||
senderCertificate,
|
||||
online,
|
||||
messageType,
|
||||
isPublic,
|
||||
isMediumGroup,
|
||||
publicSendData,
|
||||
autoSession,
|
||||
} = options || {};
|
||||
this.numberInfo = numberInfo;
|
||||
this.isPublic = isPublic;
|
||||
this.isMediumGroup = !!isMediumGroup;
|
||||
this.isGroup = !!(
|
||||
this.message &&
|
||||
this.message.dataMessage &&
|
||||
this.message.dataMessage.group
|
||||
);
|
||||
this.publicSendData = publicSendData;
|
||||
this.senderCertificate = senderCertificate;
|
||||
this.online = online;
|
||||
this.messageType = messageType || 'outgoing';
|
||||
this.autoSession = autoSession || false;
|
||||
}
|
||||
|
||||
OutgoingMessage.prototype = {
|
||||
constructor: OutgoingMessage,
|
||||
numberCompleted() {
|
||||
this.numbersCompleted += 1;
|
||||
if (this.numbersCompleted >= this.numbers.length) {
|
||||
this.callback({
|
||||
successfulNumbers: this.successfulNumbers,
|
||||
failoverNumbers: this.failoverNumbers,
|
||||
errors: this.errors,
|
||||
unidentifiedDeliveries: this.unidentifiedDeliveries,
|
||||
messageType: this.messageType,
|
||||
});
|
||||
}
|
||||
},
|
||||
registerError(number, reason, error) {
|
||||
if (!error || (error.name === 'HTTPError' && error.code !== 404)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error = new textsecure.OutgoingMessageError(
|
||||
number,
|
||||
this.message.toArrayBuffer(),
|
||||
this.timestamp,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.number = number;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.reason = reason;
|
||||
this.errors[this.errors.length] = error;
|
||||
this.numberCompleted();
|
||||
},
|
||||
reloadDevicesAndSend(primaryPubKey, multiDevice = true) {
|
||||
const ourNumber = textsecure.storage.user.getNumber();
|
||||
|
||||
if (!multiDevice) {
|
||||
if (primaryPubKey === ourNumber) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.doSendMessage(primaryPubKey, [primaryPubKey]);
|
||||
}
|
||||
|
||||
return (
|
||||
window.libsession.Protocols.MultiDeviceProtocol.getAllDevices(
|
||||
primaryPubKey
|
||||
)
|
||||
// Don't send to ourselves
|
||||
.then(devicesPubKeys =>
|
||||
devicesPubKeys.filter(pubKey => pubKey.key !== ourNumber)
|
||||
)
|
||||
.then(devicesPubKeys => {
|
||||
if (devicesPubKeys.length === 0) {
|
||||
// No need to start the sending of message without a recipient
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.doSendMessage(primaryPubKey, devicesPubKeys);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
getKeysForNumber(number, updateDevices) {
|
||||
const handleResult = response =>
|
||||
Promise.all(
|
||||
response.devices.map(device => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
device.identityKey = response.identityKey;
|
||||
if (
|
||||
updateDevices === undefined ||
|
||||
updateDevices.indexOf(device.deviceId) > -1
|
||||
) {
|
||||
const address = new libsignal.SignalProtocolAddress(
|
||||
number,
|
||||
device.deviceId
|
||||
);
|
||||
const builder = new libsignal.SessionBuilder(
|
||||
textsecure.storage.protocol,
|
||||
address
|
||||
);
|
||||
if (device.registrationId === 0) {
|
||||
window.log.info('device registrationId 0!');
|
||||
}
|
||||
return builder
|
||||
.processPreKey(device)
|
||||
.then(async () => {
|
||||
// TODO: only remove the keys that were used above!
|
||||
await libloki.storage.removeContactPreKeyBundle(number);
|
||||
return true;
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.message === 'Identity key changed') {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.timestamp = this.timestamp;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.originalMessage = this.message.toArrayBuffer();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.identityKey = device.identityKey;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
);
|
||||
let promise = Promise.resolve(true);
|
||||
updateDevices.forEach(device => {
|
||||
promise = promise.then(() =>
|
||||
Promise.all([
|
||||
textsecure.storage.protocol.loadContactPreKey(number),
|
||||
textsecure.storage.protocol.loadContactSignedPreKey(number),
|
||||
])
|
||||
.then(keys => {
|
||||
const [preKey, signedPreKey] = keys;
|
||||
if (preKey === undefined || signedPreKey === undefined) {
|
||||
return false;
|
||||
}
|
||||
const identityKey = StringView.hexToArrayBuffer(number);
|
||||
return handleResult({
|
||||
identityKey,
|
||||
devices: [
|
||||
{ deviceId: device, preKey, signedPreKey, registrationId: 0 },
|
||||
],
|
||||
}).then(results => results.every(value => value === true));
|
||||
})
|
||||
.catch(e => {
|
||||
throw e;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return promise;
|
||||
},
|
||||
|
||||
// Default ttl to 24 hours if no value provided
|
||||
async transmitMessage(number, data, timestamp, ttl = 24 * 60 * 60 * 1000) {
|
||||
const pubKey = number;
|
||||
|
||||
try {
|
||||
// TODO: Make NUM_CONCURRENT_CONNECTIONS a global constant
|
||||
const options = {
|
||||
numConnections: NUM_SEND_CONNECTIONS,
|
||||
};
|
||||
options.isPublic = this.isPublic;
|
||||
if (this.isPublic) {
|
||||
options.publicSendData = this.publicSendData;
|
||||
}
|
||||
await lokiMessageAPI.sendMessage(pubKey, data, timestamp, ttl, options);
|
||||
} catch (e) {
|
||||
if (e.name === 'HTTPError' && e.code !== 409 && e.code !== 410) {
|
||||
throw new textsecure.SendMessageNetworkError(number, '', e, timestamp);
|
||||
} else if (e.name === 'TimedOutError') {
|
||||
throw new textsecure.PoWError(number, e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async buildMessage(devicePubKey) {
|
||||
const updatedDevices = await getStaleDeviceIdsForNumber(devicePubKey);
|
||||
const keysFound = await this.getKeysForNumber(devicePubKey, updatedDevices);
|
||||
|
||||
// Check if we need to attach the preKeys
|
||||
const enableFallBackEncryption = !keysFound;
|
||||
const flags = this.message.dataMessage
|
||||
? this.message.dataMessage.get_flags()
|
||||
: null;
|
||||
// END_SESSION means Session reset message
|
||||
const isEndSession =
|
||||
flags === textsecure.protobuf.DataMessage.Flags.END_SESSION;
|
||||
const isSessionRequest = false;
|
||||
|
||||
if (enableFallBackEncryption || isEndSession) {
|
||||
// Encrypt them with the fallback
|
||||
const pkb = await libloki.storage.getPreKeyBundleForContact(devicePubKey);
|
||||
this.message.preKeyBundleMessage = new textsecure.protobuf.PreKeyBundleMessage(
|
||||
pkb
|
||||
);
|
||||
window.log.info('attaching prekeys to outgoing message');
|
||||
}
|
||||
|
||||
const messageBuffer = this.message.toArrayBuffer();
|
||||
const logDetails = {
|
||||
message: this.message,
|
||||
};
|
||||
|
||||
const ourPubKey = textsecure.storage.user.getNumber();
|
||||
const ourPrimaryPubkey = window.storage.get('primaryDevicePubKey');
|
||||
const secondaryPubKeys =
|
||||
(await window.libsession.Protocols.MultiDeviceProtocol.getSecondaryDevices(
|
||||
ourPubKey
|
||||
)) || [];
|
||||
let aliasedPubkey = devicePubKey;
|
||||
if (devicePubKey === ourPubKey) {
|
||||
aliasedPubkey = 'OUR_PUBKEY'; // should not happen
|
||||
} else if (devicePubKey === ourPrimaryPubkey) {
|
||||
aliasedPubkey = 'OUR_PRIMARY_PUBKEY';
|
||||
} else if (secondaryPubKeys.some(device => device.key === devicePubKey)) {
|
||||
aliasedPubkey = 'OUR SECONDARY PUBKEY';
|
||||
}
|
||||
libloki.api.debug.logSessionMessageSending(
|
||||
`Sending :${this.messageType} message to ${aliasedPubkey} details:`,
|
||||
logDetails
|
||||
);
|
||||
|
||||
const plaintext = _getPlaintext(messageBuffer);
|
||||
|
||||
// No limit on message keys if we're communicating with our other devices
|
||||
// FIXME options not used at all; if (ourPubkey === number) {
|
||||
// options.messageKeysLimit = false;
|
||||
// }
|
||||
const ttl = getTTLForType(this.messageType);
|
||||
const ourKey = textsecure.storage.user.getNumber();
|
||||
|
||||
return {
|
||||
ttl,
|
||||
ourKey,
|
||||
sourceDevice: 1,
|
||||
plaintext,
|
||||
pubKey: devicePubKey,
|
||||
isSessionRequest,
|
||||
enableFallBackEncryption,
|
||||
};
|
||||
},
|
||||
|
||||
async encryptMessage(clearMessage) {
|
||||
if (clearMessage === null) {
|
||||
window.log.warn(
|
||||
'clearMessage is null on encryptMessage... Returning null'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
ttl,
|
||||
ourKey,
|
||||
sourceDevice,
|
||||
plaintext,
|
||||
pubKey,
|
||||
isSessionRequest,
|
||||
enableFallBackEncryption,
|
||||
} = clearMessage;
|
||||
// Session doesn't use the deviceId scheme, it's always 1.
|
||||
// Instead, there are multiple device public keys.
|
||||
const deviceId = 1;
|
||||
|
||||
const address = new libsignal.SignalProtocolAddress(pubKey, deviceId);
|
||||
|
||||
let sessionCipher;
|
||||
|
||||
if (enableFallBackEncryption) {
|
||||
sessionCipher = new libloki.crypto.FallBackSessionCipher(address);
|
||||
} else {
|
||||
sessionCipher = new libsignal.SessionCipher(
|
||||
textsecure.storage.protocol,
|
||||
address
|
||||
);
|
||||
}
|
||||
|
||||
const innerCiphertext = await sessionCipher.encrypt(plaintext);
|
||||
|
||||
const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher(
|
||||
textsecure.storage.protocol
|
||||
);
|
||||
|
||||
const senderCert = new textsecure.protobuf.SenderCertificate();
|
||||
|
||||
senderCert.sender = ourKey;
|
||||
senderCert.senderDevice = deviceId;
|
||||
|
||||
const ciphertext = await secretSessionCipher.encrypt(
|
||||
address.getName(),
|
||||
senderCert,
|
||||
innerCiphertext
|
||||
);
|
||||
const type = textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER;
|
||||
const content = window.Signal.Crypto.arrayBufferToBase64(ciphertext);
|
||||
|
||||
return {
|
||||
type,
|
||||
ttl,
|
||||
ourKey,
|
||||
sourceDevice,
|
||||
content,
|
||||
pubKey,
|
||||
isSessionRequest,
|
||||
};
|
||||
},
|
||||
// Send a message to a public group
|
||||
async sendPublicMessage(number) {
|
||||
await this.transmitMessage(
|
||||
number,
|
||||
this.message.dataMessage,
|
||||
this.timestamp,
|
||||
0 // ttl
|
||||
);
|
||||
|
||||
this.successfulNumbers[this.successfulNumbers.length] = number;
|
||||
this.numberCompleted();
|
||||
},
|
||||
async sendMediumGroupMessage(groupId) {
|
||||
const ttl = getTTLForType(this.messageType);
|
||||
|
||||
const plaintext = this.message.toArrayBuffer();
|
||||
|
||||
const ourIdentity = textsecure.storage.user.getNumber();
|
||||
|
||||
const {
|
||||
ciphertext,
|
||||
keyIdx,
|
||||
} = await window.SenderKeyAPI.encryptWithSenderKey(
|
||||
plaintext,
|
||||
groupId,
|
||||
ourIdentity
|
||||
);
|
||||
|
||||
if (!ciphertext) {
|
||||
log.error('could not encrypt for medium group');
|
||||
return;
|
||||
}
|
||||
|
||||
const source = ourIdentity;
|
||||
|
||||
// We should include ciphertext idx in the message
|
||||
const content = new textsecure.protobuf.MediumGroupCiphertext({
|
||||
ciphertext,
|
||||
source,
|
||||
keyIdx,
|
||||
});
|
||||
|
||||
// Encrypt for the group's identity key to hide source and key idx:
|
||||
const {
|
||||
ciphertext: ciphertextOuter,
|
||||
ephemeralKey,
|
||||
} = await libloki.crypto.encryptForPubkey(
|
||||
groupId,
|
||||
content.encode().toArrayBuffer()
|
||||
);
|
||||
|
||||
const contentOuter = new textsecure.protobuf.MediumGroupContent({
|
||||
ciphertext: ciphertextOuter,
|
||||
ephemeralKey,
|
||||
});
|
||||
|
||||
log.debug(
|
||||
'Group ciphertext: ',
|
||||
window.Signal.Crypto.arrayBufferToBase64(ciphertext)
|
||||
);
|
||||
|
||||
const outgoingObject = {
|
||||
type: textsecure.protobuf.Envelope.Type.MEDIUM_GROUP_CIPHERTEXT,
|
||||
ttl,
|
||||
ourKey: ourIdentity,
|
||||
sourceDevice: 1,
|
||||
content: contentOuter.encode().toArrayBuffer(),
|
||||
};
|
||||
|
||||
// TODO: Rather than using sealed sender, we just generate a key pair, perform an ECDH against
|
||||
// the group's public key and encrypt using the derived key
|
||||
|
||||
const socketMessage = wrapInWebsocketMessage(
|
||||
outgoingObject,
|
||||
this.timestamp
|
||||
);
|
||||
|
||||
await this.transmitMessage(groupId, socketMessage, this.timestamp, ttl);
|
||||
|
||||
this.successfulNumbers[this.successfulNumbers.length] = groupId;
|
||||
this.numberCompleted();
|
||||
},
|
||||
// Send a message to a private group member or a session chat (one to one)
|
||||
async sendSessionMessage(outgoingObjects) {
|
||||
// TODO: handle multiple devices/messages per transmit
|
||||
const promises = outgoingObjects.map(async outgoingObject => {
|
||||
if (!outgoingObject) {
|
||||
return;
|
||||
}
|
||||
const { pubKey: destination, ttl } = outgoingObject;
|
||||
|
||||
try {
|
||||
const socketMessage = wrapInWebsocketMessage(
|
||||
outgoingObject,
|
||||
this.timestamp
|
||||
);
|
||||
await this.transmitMessage(
|
||||
destination,
|
||||
socketMessage,
|
||||
this.timestamp,
|
||||
ttl
|
||||
);
|
||||
this.successfulNumbers.push(destination);
|
||||
} catch (e) {
|
||||
e.number = destination;
|
||||
this.errors.push(e);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
this.numbersCompleted += this.successfulNumbers.length;
|
||||
this.numberCompleted();
|
||||
},
|
||||
async buildAndEncrypt(devicePubKey) {
|
||||
const clearMessage = await this.buildMessage(devicePubKey);
|
||||
return this.encryptMessage(clearMessage);
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async doSendMessage(primaryPubKey, devicesPubKeys) {
|
||||
if (this.isPublic) {
|
||||
await this.sendPublicMessage(primaryPubKey);
|
||||
return;
|
||||
}
|
||||
this.numbers = devicesPubKeys;
|
||||
|
||||
if (this.isMediumGroup) {
|
||||
await this.sendMediumGroupMessage(primaryPubKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const outgoingObjects = await Promise.all(
|
||||
devicesPubKeys.map(pk => this.buildAndEncrypt(pk, primaryPubKey))
|
||||
);
|
||||
|
||||
this.sendSessionMessage(outgoingObjects);
|
||||
},
|
||||
|
||||
removeDeviceIdsForNumber(number, deviceIdsToRemove) {
|
||||
let promise = Promise.resolve();
|
||||
// eslint-disable-next-line no-restricted-syntax, guard-for-in
|
||||
for (const j in deviceIdsToRemove) {
|
||||
promise = promise.then(() => {
|
||||
const encodedNumber = `${number}.${deviceIdsToRemove[j]}`;
|
||||
return textsecure.storage.protocol.removeSession(encodedNumber);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
},
|
||||
|
||||
sendToNumber(number, multiDevice = true) {
|
||||
return this.reloadDevicesAndSend(number, multiDevice).catch(error => {
|
||||
if (error.message === 'Identity key changed') {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error = new textsecure.OutgoingIdentityKeyError(
|
||||
number,
|
||||
error.originalMessage,
|
||||
error.timestamp,
|
||||
error.identityKey
|
||||
);
|
||||
this.registerError(number, 'Identity key changed', error);
|
||||
} else {
|
||||
this.registerError(
|
||||
number,
|
||||
`Failed to retrieve new device keys for number ${number}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
window.textsecure = window.textsecure || {};
|
||||
window.textsecure.OutgoingMessage = OutgoingMessage;
|
Loading…
Reference in New Issue