Merge pull request #1117 from msgmaxim/sender-keys

Medium groups with sender keys (essentials)
pull/1132/head
Maxim Shishmarev 5 years ago committed by GitHub
commit f3a8f4328e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -175,6 +175,9 @@ module.exports = {
getMessagesWithFileAttachments,
removeKnownAttachments,
getSenderKeys,
createOrUpdateSenderKeys,
};
function generateUUID() {
@ -810,6 +813,7 @@ const LOKI_SCHEMA_VERSIONS = [
updateToLokiSchemaVersion1,
updateToLokiSchemaVersion2,
updateToLokiSchemaVersion3,
updateToLokiSchemaVersion4,
];
async function updateToLokiSchemaVersion1(currentVersion, instance) {
@ -1005,6 +1009,77 @@ async function updateToLokiSchemaVersion3(currentVersion, instance) {
console.log('updateToLokiSchemaVersion3: success!');
}
const SENDER_KEYS_TABLE = 'senderKeys';
async function createOrUpdateSenderKeys(data) {
const { groupId, senderIdentity } = data;
await db.run(
`INSERT OR REPLACE INTO ${SENDER_KEYS_TABLE} (
groupId,
senderIdentity,
json
) values (
$groupId,
$senderIdentity,
$json
);`,
{
$groupId: groupId,
$senderIdentity: senderIdentity,
$json: objectToJSON(data),
}
);
}
async function updateToLokiSchemaVersion4(currentVersion, instance) {
if (currentVersion >= 4) {
return;
}
console.log('updateToLokiSchemaVersion4: starting...');
await instance.run('BEGIN TRANSACTION;');
// We don't bother migrating values, any old messages that
// we might receive as a result will we filtered out anyway
await instance.run(`DROP TABLE lastHashes;`);
await instance.run(
`CREATE TABLE lastHashes(
id TEXT,
snode TEXT,
hash TEXT,
expiresAt INTEGER,
PRIMARY KEY (id, snode)
);`
);
// Create a table for Sender Keys
await instance.run(
`CREATE TABLE ${SENDER_KEYS_TABLE} (
groupId TEXT,
senderIdentity TEXT,
json TEXT,
PRIMARY KEY (groupId, senderIdentity)
);`
);
// Add senderIdentity field to `unprocessed` needed
// for medium size groups
await instance.run(`ALTER TABLE unprocessed ADD senderIdentity TEXT`);
await instance.run(
`INSERT INTO loki_schema (
version
) values (
4
);`
);
await instance.run('COMMIT TRANSACTION;');
console.log('updateToLokiSchemaVersion4: success!');
}
async function updateLokiSchema(instance) {
const result = await instance.get(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';"
@ -1685,6 +1760,22 @@ async function bulkAdd(table, array) {
await promise;
}
async function getSenderKeys(groupId, senderIdentity) {
const row = await db.get(
`SELECT * FROM ${SENDER_KEYS_TABLE} WHERE groupId = $groupId AND senderIdentity = $senderIdentity;`,
{
$groupId: groupId,
$senderIdentity: senderIdentity,
}
);
if (!row) {
return null;
}
return jsonToObject(row.json);
}
async function getById(table, id) {
const row = await db.get(`SELECT * FROM ${table} WHERE id = $id;`, {
$id: id,
@ -2265,19 +2356,24 @@ async function saveSeenMessageHashes(arrayOfHashes) {
}
async function updateLastHash(data) {
const { snode, hash, expiresAt } = data;
const { convoId, snode, hash, expiresAt } = data;
const id = convoId;
await db.run(
`INSERT OR REPLACE INTO lastHashes (
id,
snode,
hash,
expiresAt
) values (
$id,
$snode,
$hash,
$expiresAt
)`,
{
$id: id,
$snode: snode,
$hash: hash,
$expiresAt: expiresAt,
@ -2473,16 +2569,20 @@ async function getMessagesBySentAt(sentAt) {
return map(rows, row => jsonToObject(row.json));
}
async function getLastHashBySnode(snode) {
const row = await db.get('SELECT * FROM lastHashes WHERE snode = $snode;', {
$snode: snode,
});
async function getLastHashBySnode(convoId, snode) {
const row = await db.get(
'SELECT * FROM lastHashes WHERE snode = $snode AND id = $id;',
{
$snode: snode,
$id: convoId,
}
);
if (!row) {
return null;
}
return row.lastHash;
return row.hash;
}
async function getSeenMessagesByHashList(hashes) {
@ -2537,7 +2637,7 @@ async function getNextExpiringMessage() {
}
async function saveUnprocessed(data, { forceSave } = {}) {
const { id, timestamp, version, attempts, envelope } = data;
const { id, timestamp, version, attempts, envelope, senderIdentity } = data;
if (!id) {
throw new Error('saveUnprocessed: id was falsey');
}
@ -2549,13 +2649,15 @@ async function saveUnprocessed(data, { forceSave } = {}) {
timestamp,
version,
attempts,
envelope
envelope,
senderIdentity
) values (
$id,
$timestamp,
$version,
$attempts,
$envelope
$envelope,
$senderIdentity
);`,
{
$id: id,
@ -2563,6 +2665,7 @@ async function saveUnprocessed(data, { forceSave } = {}) {
$version: version,
$attempts: attempts,
$envelope: envelope,
$senderIdentity: senderIdentity,
}
);
@ -2574,7 +2677,8 @@ async function saveUnprocessed(data, { forceSave } = {}) {
timestamp = $timestamp,
version = $version,
attempts = $attempts,
envelope = $envelope
envelope = $envelope,
senderIdentity = $senderIdentity
WHERE id = $id;`,
{
$id: id,
@ -2582,6 +2686,7 @@ async function saveUnprocessed(data, { forceSave } = {}) {
$version: version,
$attempts: attempts,
$envelope: envelope,
$senderIdentity: senderIdentity,
}
);
@ -2611,14 +2716,21 @@ async function updateUnprocessedAttempts(id, attempts) {
});
}
async function updateUnprocessedWithData(id, data = {}) {
const { source, sourceDevice, serverTimestamp, decrypted } = data;
const {
source,
sourceDevice,
serverTimestamp,
decrypted,
senderIdentity,
} = data;
await db.run(
`UPDATE unprocessed SET
source = $source,
sourceDevice = $sourceDevice,
serverTimestamp = $serverTimestamp,
decrypted = $decrypted
decrypted = $decrypted,
senderIdentity = $senderIdentity
WHERE id = $id;`,
{
$id: id,
@ -2626,6 +2738,7 @@ async function updateUnprocessedWithData(id, data = {}) {
$sourceDevice: sourceDevice,
$serverTimestamp: serverTimestamp,
$decrypted: decrypted,
$senderIdentity: senderIdentity,
}
);
}
@ -2774,6 +2887,7 @@ function getRemoveConfigurationPromises() {
db.run('DELETE FROM contactSignedPreKeys;'),
db.run('DELETE FROM servers;'),
db.run('DELETE FROM lastHashes;'),
db.run(`DELETE FROM ${SENDER_KEYS_TABLE};`),
db.run('DELETE FROM seenMessages;'),
];
}

@ -3,10 +3,6 @@
{
"url": "http://public.loki.foundation:38157/",
"ip_url": "http://144.76.164.202:38157/"
},
{
"url": "http://storage.testnetseed1.loki.network:38157/",
"ip_url": "http://116.203.32.199:38157/"
}
],
"openDevTools": true,

@ -730,6 +730,59 @@
});
};
window.createMediumSizeGroup = async (groupName, members) => {
// Create Group Identity
const identityKeys = await libsignal.KeyHelper.generateIdentityKeyPair();
const groupId = StringView.arrayBufferToHex(identityKeys.pubKey);
const ourIdentity = await textsecure.storage.user.getNumber();
const senderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
const groupSecretKeyHex = StringView.arrayBufferToHex(
identityKeys.privKey
);
// Constructing a "create group" message
const proto = new textsecure.protobuf.DataMessage();
const groupUpdate = new textsecure.protobuf.MediumGroupUpdate();
groupUpdate.groupId = groupId;
groupUpdate.groupSecretKey = groupSecretKeyHex;
groupUpdate.senderKey = senderKey;
groupUpdate.members = [ourIdentity, ...members];
groupUpdate.groupName = groupName;
proto.mediumGroupUpdate = groupUpdate;
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: groupSecretKeyHex,
});
const convo = await window.ConversationController.getOrCreateAndWait(
groupId,
Message.GROUP
);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
// Subscribe to this group id
messageReceiver.pollForAdditionalId(groupId);
// TODO: include ourselves so that our lined devices work as well!
await textsecure.messaging.updateMediumGroup(members, proto);
};
window.doCreateGroup = async (groupName, members) => {
const keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
const groupId = StringView.arrayBufferToHex(keypair.pubKey);

@ -1735,10 +1735,19 @@
profileKey,
options
);
case Message.GROUP:
case Message.GROUP: {
let dest = destination;
let numbers = groupNumbers;
if (this.get('is_medium_group')) {
dest = this.id;
numbers = [destination];
options.isMediumGroup = true;
}
return textsecure.messaging.sendMessageToGroup(
destination,
groupNumbers,
dest,
numbers,
messageBody,
finalAttachments,
quote,
@ -1748,6 +1757,7 @@
profileKey,
options
);
}
default:
throw new TypeError(
`Invalid conversation type: '${conversationType}'`

@ -207,6 +207,9 @@ module.exports = {
getLegacyMessagesNeedingUpgrade,
getMessagesWithVisualMediaAttachments,
getMessagesWithFileAttachments,
getSenderKeys,
createOrUpdateSenderKeys,
};
// When IPC arguments are prepared for the cross-process send, they are JSON.stringified.
@ -721,6 +724,16 @@ async function removeAllItems() {
await channels.removeAllItems();
}
// Sender Keys
async function getSenderKeys(groupId, senderIdentity) {
return channels.getSenderKeys(groupId, senderIdentity);
}
async function createOrUpdateSenderKeys(data) {
await channels.createOrUpdateSenderKeys(data);
}
// Sessions
async function createOrUpdateSession(data) {
@ -1071,8 +1084,8 @@ async function getMessagesByConversation(
return new MessageCollection(messages);
}
async function getLastHashBySnode(snode) {
return channels.getLastHashBySnode(snode);
async function getLastHashBySnode(convoId, snode) {
return channels.getLastHashBySnode(convoId, snode);
}
async function getSeenMessagesByHashList(hashes) {

@ -32,6 +32,43 @@ const calcNonce = (messageEventData, pubKey, data64, timestamp, ttl) => {
return callWorker('calcPoW', timestamp, ttl, pubKey, data64, difficulty);
};
// we don't throw or catch here
// mark private (_ prefix) since no error handling is done here...
async function _retrieveNextMessages(nodeData, pubkey) {
const params = {
pubKey: pubkey,
lastHash: nodeData.lastHash || '',
};
const options = {
timeout: 40000,
ourPubKey: pubkey,
};
// let exceptions bubble up
const result = await lokiRpc(
`https://${nodeData.ip}`,
nodeData.port,
'retrieve',
params,
options,
'/storage_rpc/v1',
nodeData
);
if (result === false) {
// make a note of it because of caller doesn't care...
log.warn(
`loki_message:::_retrieveNextMessages - lokiRpc could not talk to ${
nodeData.ip
}:${nodeData.port}`
);
return [];
}
return result.messages || [];
}
class LokiMessageAPI {
constructor(ourKey) {
this.jobQueue = new window.JobQueue();
@ -271,7 +308,93 @@ class LokiMessageAPI {
return false;
}
async _openRetrieveConnection(pSwarmPool, stopPollingPromise, callback) {
async pollNodeForGroupId(groupId, nodeParam, onMessages) {
const node = nodeParam;
const lastHash = await window.Signal.Data.getLastHashBySnode(
groupId,
node.address
);
node.lastHash = lastHash;
log.debug(
`[last hash] lashHash for group id ${groupId.substr(0, 5)}: node ${
node.port
}`,
lastHash
);
// eslint-disable-next-line no-constant-condition
while (true) {
try {
let messages = await _retrieveNextMessages(node, groupId);
if (messages.length > 0) {
const lastMessage = _.last(messages);
// TODO: this is for groups, so need to specify ID
// Is this too early to update last hash??
await lokiSnodeAPI.updateLastHash(
groupId,
node.address,
lastMessage.hash,
lastMessage.expiration
);
log.debug(
`Updated lashHash for group id ${groupId.substr(0, 5)}: node ${
node.port
}`,
lastMessage.hash
);
node.lastHash = lastMessage.hash;
messages = await this.jobQueue.add(() =>
filterIncomingMessages(messages)
);
// At this point we still know what servier identity the messages
// are associated with, so we save it in messages' conversationId field:
const modifiedMessages = messages.map(m => {
// eslint-disable-next-line no-param-reassign
m.conversationId = groupId;
return m;
});
onMessages(modifiedMessages);
}
} catch (e) {
log.warn('');
log.warn(
'pollNodeForGroupId - retrieve error:',
e.code,
e.message,
`on ${node.ip}:${node.port}`
);
// TODO: Handle unreachable nodes and wrong swarms
}
await primitives.sleepFor(5000);
}
}
async pollForGroupId(groupId, onMessages) {
log.info(`Starting to poll for group id: ${groupId}`);
// Get nodes for groupId
const nodes = await lokiSnodeAPI.refreshSwarmNodesForPubKey(groupId);
log.info('Nodes for group id:', nodes);
_.sampleSize(nodes, 3).forEach(node =>
this.pollNodeForGroupId(groupId, node, onMessages)
);
}
async _openRetrieveConnection(pSwarmPool, stopPollingPromise, onMessages) {
const swarmPool = pSwarmPool; // lint
let stopPollingResult = false;
@ -299,8 +422,8 @@ class LokiMessageAPI {
// so the user facing UI can report unhandled errors
// except in this case of living inside http-resource pollServer
// because it just restarts more connections...
let messages = await _retrieveNextMessages(nodeData, this.ourKey);
let messages = await this._retrieveNextMessages(nodeData);
// this only tracks retrieval failures
// won't include parsing failures...
successiveFailures = 0;
@ -308,6 +431,7 @@ class LokiMessageAPI {
const lastMessage = _.last(messages);
nodeData.lastHash = lastMessage.hash;
await lokiSnodeAPI.updateLastHash(
this.ourKey,
address,
lastMessage.hash,
lastMessage.expiration
@ -317,7 +441,7 @@ class LokiMessageAPI {
);
}
// Execute callback even with empty array to signal online status
callback(messages);
onMessages(messages);
} catch (e) {
log.warn(
'loki_message:::_openRetrieveConnection - retrieve error:',
@ -327,11 +451,15 @@ class LokiMessageAPI {
);
if (e instanceof textsecure.WrongSwarmError) {
const { newSwarm } = e;
// Is this a security concern that we replace the list of snodes
// based on a response from a single snode?
await lokiSnodeAPI.updateSwarmNodes(this.ourKey, newSwarm);
// FIXME: restart all openRetrieves when this happens...
// FIXME: lokiSnode should handle this
for (let i = 0; i < newSwarm.length; i += 1) {
const lastHash = await window.Signal.Data.getLastHashBySnode(
this.ourKey,
newSwarm[i]
);
swarmPool[newSwarm[i]] = {
@ -379,46 +507,26 @@ class LokiMessageAPI {
}
// we don't throw or catch here
// mark private (_ prefix) since no error handling is done here...
async _retrieveNextMessages(nodeData) {
const params = {
pubKey: this.ourKey,
lastHash: nodeData.lastHash || '',
};
const options = {
timeout: 40000,
ourPubKey: this.ourKey,
};
// let exceptions bubble up
const result = await lokiRpc(
`https://${nodeData.ip}`,
nodeData.port,
'retrieve',
params,
options,
'/storage_rpc/v1',
nodeData
);
if (result === false) {
// make a note of it because of caller doesn't care...
log.warn(
`loki_message:::_retrieveNextMessages - lokiRpc could not talk to ${
nodeData.ip
}:${nodeData.port}`
);
}
return result.messages || [];
}
// we don't throw or catch here
async startLongPolling(numConnections, stopPolling, callback) {
async startLongPolling(numConnections, stopPolling, onMessages) {
// load from local DB
let nodes = await lokiSnodeAPI.getSwarmNodesForPubKey(this.ourKey, {
fetchHashes: true,
});
// Start polling for medium size groups as well (they might be in different swarms)
{
const convos = window
.getConversations()
.filter(c => c.get('is_medium_group'));
const self = this;
convos.forEach(c => {
self.pollForGroupId(c.id, onMessages);
// TODO: unsubscribe if the group is deleted
});
}
if (nodes.length < numConnections) {
log.warn(
'loki_message:::startLongPolling - Not enough SwarmNodes for our pubkey in local database, getting current list from blockchain'
@ -462,7 +570,7 @@ class LokiMessageAPI {
for (let i = 0; i < numConnections; i += 1) {
promises.push(
// eslint-disable-next-line more/no-then
this._openRetrieveConnection(pools[i], stopPolling, callback).then(
this._openRetrieveConnection(pools[i], stopPolling, onMessages).then(
stoppedPolling => {
unresolved -= 1;
log.info(

@ -1,5 +1,5 @@
/* global log, libloki, textsecure, getStoragePubKey, lokiSnodeAPI, StringView,
libsignal, window, TextDecoder, TextEncoder, dcodeIO, process, crypto */
libsignal, window, TextDecoder, TextEncoder, dcodeIO, process */
const nodeFetch = require('node-fetch');
const https = require('https');
@ -14,40 +14,11 @@ const endpointBase = '/storage_rpc/v1';
// Request index for debugging
let onionReqIdx = 0;
const encryptForNode = async (node, payload) => {
const encryptForNode = async (node, payloadStr) => {
const textEncoder = new TextEncoder();
const plaintext = textEncoder.encode(payload);
const plaintext = textEncoder.encode(payloadStr);
const ephemeral = await libloki.crypto.generateEphemeralKeyPair();
const snPubkey = StringView.hexToArrayBuffer(node.pubkey_x25519);
const ephemeralSecret = await libsignal.Curve.async.calculateAgreement(
snPubkey,
ephemeral.privKey
);
const salt = window.Signal.Crypto.bytesFromString('LOKI');
const key = await crypto.subtle.importKey(
'raw',
salt,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
const symmetricKey = await crypto.subtle.sign(
{ name: 'HMAC', hash: 'SHA-256' },
key,
ephemeralSecret
);
const ciphertext = await window.libloki.crypto.EncryptGCM(
symmetricKey,
plaintext
);
return { ciphertext, symmetricKey, ephemeral_key: ephemeral.pubKey };
return libloki.crypto.encryptForPubkey(node.pubkey_x25519, plaintext);
};
// Returns the actual ciphertext, symmetric key that will be used
@ -65,7 +36,7 @@ const encryptForRelay = async (node, nextNode, ctx) => {
const reqJson = {
ciphertext: dcodeIO.ByteBuffer.wrap(payload).toString('base64'),
ephemeral_key: StringView.arrayBufferToHex(ctx.ephemeral_key),
ephemeral_key: StringView.arrayBufferToHex(ctx.ephemeralKey),
destination: nextNode.pubkey_ed25519,
};
@ -101,7 +72,7 @@ const sendOnionRequest = async (reqIdx, nodePath, targetNode, plaintext) => {
const payload = {
ciphertext: ciphertextBase64,
ephemeral_key: StringView.arrayBufferToHex(guardCtx.ephemeral_key),
ephemeral_key: StringView.arrayBufferToHex(guardCtx.ephemeralKey),
};
const fetchOptions = {

@ -0,0 +1,297 @@
/* global
Signal,
libsignal,
StringView,
dcodeIO,
libloki,
log,
crypto
*/
/* eslint-disable more/no-then */
const toHex = buffer => StringView.arrayBufferToHex(buffer);
const fromHex = hex => dcodeIO.ByteBuffer.wrap(hex, 'hex').toArrayBuffer();
async function saveSenderKeysInner(
groupId,
senderIdentity,
chainKey,
keyIdx,
messageKeys
) {
const ratchet = {
chainKey,
messageKeys,
idx: keyIdx,
};
await Signal.Data.createOrUpdateSenderKeys({
groupId,
senderIdentity,
ratchet,
});
log.debug(
`Saving sender keys for groupId ${groupId}, sender ${senderIdentity}`
);
}
// Save somebody else's key
async function saveSenderKeys(groupId, senderIdentity, chainKey) {
// New key, so index 0
const keyIdx = 0;
const messageKeys = {};
await saveSenderKeysInner(
groupId,
senderIdentity,
chainKey,
keyIdx,
messageKeys
);
}
async function createSenderKeyForGroup(groupId, senderIdentity) {
// Generate Chain Key (32 random bytes)
const rootChainKey = await libsignal.crypto.getRandomBytes(32);
const rootChainKeyHex = toHex(rootChainKey);
const keyIdx = 0;
const messageKeys = {};
await saveSenderKeysInner(
groupId,
senderIdentity,
rootChainKeyHex,
keyIdx,
messageKeys
);
return rootChainKeyHex;
}
async function hmacSHA256(keybuf, data) {
const key = await crypto.subtle.importKey(
'raw',
keybuf,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
return crypto.subtle.sign({ name: 'HMAC', hash: 'SHA-256' }, key, data);
}
async function stepRatchet(ratchet) {
const { chainKey, keyIdx, messageKeys } = ratchet;
const byteArray = new Uint8Array(1);
byteArray[0] = 1;
const messageKey = await hmacSHA256(chainKey, byteArray.buffer);
byteArray[0] = 2;
const nextChainKey = await hmacSHA256(chainKey, byteArray.buffer);
const nextKeyIdx = keyIdx + 1;
return { nextChainKey, messageKey, nextKeyIdx, messageKeys };
}
async function stepRatchetOnce(groupId, senderIdentity) {
const ratchet = await loadChainKey(groupId, senderIdentity);
if (!ratchet) {
log.error(
`Could not find ratchet for groupId ${groupId} sender: ${senderIdentity}`
);
return null;
}
const { nextChainKey, messageKey, nextKeyIdx } = await stepRatchet(ratchet);
// Don't need to remember message keys for a sending ratchet
const messageKeys = {};
const nextChainKeyHex = toHex(nextChainKey);
await saveSenderKeysInner(
groupId,
senderIdentity,
nextChainKeyHex,
nextKeyIdx,
messageKeys
);
return { messageKey, keyIdx: nextKeyIdx };
}
// Advance the ratchet until idx
async function advanceRatchet(groupId, senderIdentity, idx) {
const ratchet = await loadChainKey(groupId, senderIdentity);
if (!ratchet) {
log.error(
`Could not find ratchet for groupId ${groupId} sender: ${senderIdentity}`
);
return null;
}
// Normally keyIdx will be 1 behind, in which case we stepRatchet one time only
if (idx < ratchet.keyIdx) {
// If the request is for some old index, retrieve the key generated earlier and
// remove it from the database (there is no need to advance the ratchet)
const messageKey = ratchet.messageKeys[idx];
if (messageKey) {
delete ratchet.messageKeys[idx];
// TODO: just pass in the ratchet?
const chainKeyHex = toHex(ratchet.chainKey);
await saveSenderKeysInner(
groupId,
senderIdentity,
chainKeyHex,
ratchet.keyIdx,
ratchet.messageKeys
);
return fromHex(messageKey);
}
log.error('[idx] not found key for idx: ', idx);
// I probably want a better error handling than this
return null;
}
const { messageKeys } = ratchet;
let curMessageKey;
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { nextKeyIdx, nextChainKey, messageKey } = await stepRatchet(ratchet);
ratchet.chainKey = nextChainKey;
ratchet.keyIdx = nextKeyIdx;
if (nextKeyIdx === idx) {
curMessageKey = messageKey;
break;
} else if (nextKeyIdx > idx) {
log.error('Developer error: nextKeyIdx > idx');
} else {
// Store keys for skipped nextKeyIdx, we might need them to decrypt
// messages that arrive out-of-order
messageKeys[nextKeyIdx] = toHex(messageKey);
}
}
const chainKeyHex = toHex(ratchet.chainKey);
await saveSenderKeysInner(
groupId,
senderIdentity,
chainKeyHex,
idx,
messageKeys
);
return curMessageKey;
}
async function loadChainKey(groupId, senderIdentity) {
const senderKeyEntry = await Signal.Data.getSenderKeys(
groupId,
senderIdentity
);
if (!senderKeyEntry) {
// TODO: we should try to request the key from the sender in this case
log.error(
`Sender key not found for group ${groupId} sender ${senderIdentity}`
);
// TODO: throw instead?
return null;
}
const {
chainKey: chainKeyHex,
idx: keyIdx,
messageKeys,
} = senderKeyEntry.ratchet;
if (!chainKeyHex) {
log.error('Chain key not found');
return null;
}
// TODO: This could fail if the data is not hex, handle
// this case
const chainKey = fromHex(chainKeyHex);
return { chainKey, keyIdx, messageKeys };
}
const jobQueue = {};
function queueJobForNumber(number, runJob) {
const runPrevious = jobQueue[number] || Promise.resolve();
const runCurrent = runPrevious.then(runJob, runJob);
jobQueue[number] = runCurrent;
runCurrent.then(() => {
if (jobQueue[number] === runCurrent) {
delete jobQueue[number];
}
});
return runCurrent;
}
async function decryptWithSenderKey(
ciphertext,
curKeyIdx,
groupId,
senderIdentity
) {
// We only want to serialize jobs with the same pair (groupId, senderIdentity)
const id = groupId + senderIdentity;
return queueJobForNumber(id, () =>
decryptWithSenderKeyInner(ciphertext, curKeyIdx, groupId, senderIdentity)
);
}
async function decryptWithSenderKeyInner(
ciphertext,
curKeyIdx,
groupId,
senderIdentity
) {
const messageKey = await advanceRatchet(groupId, senderIdentity, curKeyIdx);
// TODO: this might fail, handle this
const plaintext = await libloki.crypto.DecryptGCM(messageKey, ciphertext);
return plaintext;
}
async function encryptWithSenderKey(plaintext, groupId, ourIdentity) {
// We only want to serialize jobs with the same pair (groupId, ourIdentity)
const id = groupId + ourIdentity;
return queueJobForNumber(id, () =>
encryptWithSenderKeyInner(plaintext, groupId, ourIdentity)
);
}
async function encryptWithSenderKeyInner(plaintext, groupId, ourIdentity) {
const { messageKey, keyIdx } = await stepRatchetOnce(groupId, ourIdentity);
const ciphertext = await libloki.crypto.EncryptGCM(messageKey, plaintext);
return { ciphertext, keyIdx };
}
module.exports = {
createSenderKeyForGroup,
encryptWithSenderKey,
decryptWithSenderKey,
saveSenderKeys,
};

@ -654,9 +654,10 @@ class LokiSnodeAPI {
this.randomSnodePool = _.without(this.randomSnodePool, snode);
}
async updateLastHash(snodeAddress, hash, expiresAt) {
async updateLastHash(convoId, snodeAddress, hash, expiresAt) {
// FIXME: handle rejections
await window.Signal.Data.updateLastHash({
convoId,
snode: snodeAddress,
hash,
expiresAt,
@ -677,6 +678,7 @@ class LokiSnodeAPI {
const node = swarmNodes[j];
// FIXME make a batch function call
const lastHash = await window.Signal.Data.getLastHashBySnode(
pubKey,
node.address
);
log.debug(
@ -694,6 +696,7 @@ class LokiSnodeAPI {
return swarmNodes;
} catch (e) {
log.error('getSwarmNodesForPubKey expection: ', e);
throw new window.textsecure.ReplayableError({
message: 'Could not get conversation',
});

@ -255,27 +255,15 @@ function _createUnidentifiedSenderMessageContent(
}
SecretSessionCipher.prototype = {
// public byte[] encrypt(
// SignalProtocolAddress destinationAddress,
// SenderCertificate senderCertificate,
// byte[] paddedPlaintext
// )
async encrypt(
destinationAddress,
senderCertificate,
paddedPlaintext,
cipher
) {
async encrypt(destinationPubkey, senderCertificate, innerEncryptedMessage) {
// Capture this.xxx variables to replicate Java's implicit this syntax
const signalProtocolStore = this.storage;
const _calculateEphemeralKeys = this._calculateEphemeralKeys.bind(this);
const _encryptWithSecretKeys = this._encryptWithSecretKeys.bind(this);
const _calculateStaticKeys = this._calculateStaticKeys.bind(this);
const message = await cipher.encrypt(paddedPlaintext);
const ourIdentity = await signalProtocolStore.getIdentityKeyPair();
const ourIdentity = await this.storage.getIdentityKeyPair();
const theirIdentity = dcodeIO.ByteBuffer.wrap(
destinationAddress.getName(),
destinationPubkey,
'hex'
).toArrayBuffer();
@ -306,9 +294,9 @@ SecretSessionCipher.prototype = {
staticSalt
);
const content = _createUnidentifiedSenderMessageContent(
message.type,
innerEncryptedMessage.type,
senderCertificate,
fromEncodedBinaryToArrayBuffer(message.body)
fromEncodedBinaryToArrayBuffer(innerEncryptedMessage.body)
);
const messageBytes = await _encryptWithSecretKeys(
staticKeys.cipherKey,

@ -7,7 +7,8 @@
TextEncoder,
TextDecoder,
crypto,
dcodeIO
dcodeIO,
libloki
*/
// eslint-disable-next-line func-names
@ -34,6 +35,50 @@
return ivAndCiphertext;
}
async function deriveSymmetricKey(pubkey, seckey) {
const ephemeralSecret = await libsignal.Curve.async.calculateAgreement(
pubkey,
seckey
);
const salt = window.Signal.Crypto.bytesFromString('LOKI');
const key = await crypto.subtle.importKey(
'raw',
salt,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
const symmetricKey = await crypto.subtle.sign(
{ name: 'HMAC', hash: 'SHA-256' },
key,
ephemeralSecret
);
return symmetricKey;
}
async function encryptForPubkey(pubkeyX25519, payloadBytes) {
const ephemeral = await libloki.crypto.generateEphemeralKeyPair();
const snPubkey = StringView.hexToArrayBuffer(pubkeyX25519);
const symmetricKey = await deriveSymmetricKey(snPubkey, ephemeral.privKey);
const ciphertext = await EncryptGCM(symmetricKey, payloadBytes);
return { ciphertext, symmetricKey, ephemeralKey: ephemeral.pubKey };
}
async function decryptForPubkey(seckeyX25519, ephemKey, ciphertext) {
const symmetricKey = await deriveSymmetricKey(ephemKey, seckeyX25519);
const plaintext = await DecryptGCM(symmetricKey, ciphertext);
return plaintext;
}
async function EncryptGCM(symmetricKey, plaintext) {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
@ -471,6 +516,8 @@
PairingType,
LokiSessionCipher,
generateEphemeralKeyPair,
encryptForPubkey,
decryptForPubkey,
_decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey,
sha512,
};

@ -83,6 +83,14 @@
}
};
this.pollForAdditionalId = async groupId => {
await server.pollForGroupId(groupId, messages => {
messages.forEach(m => {
this.handleMessage(m.data, { conversationId: m.conversationId });
});
});
};
this.pollServer = async () => {
// bg.connect calls mr connect after storage system is ready
window.log.info('http-resource pollServer start');
@ -95,7 +103,9 @@
messages => {
connected = true;
messages.forEach(message => {
this.handleMessage(message.data);
this.handleMessage(message.data, {
conversationId: message.conversationId,
});
});
}
);

@ -19,6 +19,7 @@
/* global WebAPI: false */
/* global ConversationController: false */
/* global Signal: false */
/* global log: false */
/* eslint-disable more/no-then */
/* eslint-disable no-unreachable */
@ -170,6 +171,11 @@ MessageReceiver.prototype.extend({
this.calledClose
);
},
pollForAdditionalId(id) {
this.httpPollingResource.pollForAdditionalId(id);
},
handleRequest(request, options) {
const { onSuccess, onFailure } = options;
this.incoming = this.incoming || [];
@ -189,12 +195,33 @@ MessageReceiver.prototype.extend({
}
const promise = Promise.resolve(request.body.toArrayBuffer()) // textsecure.crypto
.then(plaintext => {
.then(plaintextImmutable => {
let plaintext = plaintextImmutable;
const 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
// The message is for a medium size group
if (options.conversationId) {
const ourNumber = textsecure.storage.user.getNumber();
const senderIdentity = envelope.source;
if (senderIdentity === ourNumber) {
// Ignoring our own message
return request.respond(200, 'OK');
}
// Sender identity will be lost if we load from cache, because
// plaintext (and protobuf.Envelope) does not have that field...
envelope.source = options.conversationId;
plaintext = textsecure.protobuf.Envelope.encode(
envelope
).toArrayBuffer();
envelope.senderIdentity = senderIdentity;
}
if (this.isBlocked(envelope.source)) {
return request.respond(200, 'OK');
}
@ -334,6 +361,7 @@ MessageReceiver.prototype.extend({
envelope.id = envelope.serverGuid || item.id;
envelope.source = envelope.source || item.source;
envelope.sourceDevice = envelope.sourceDevice || item.sourceDevice;
envelope.senderIdentity = envelope.senderIdentity || item.senderIdentity;
envelope.serverTimestamp =
envelope.serverTimestamp || item.serverTimestamp;
envelope.preKeyBundleMessage =
@ -447,6 +475,11 @@ MessageReceiver.prototype.extend({
timestamp: Date.now(),
attempts: 1,
};
if (envelope.senderIdentity) {
data.senderIdentity = envelope.senderIdentity;
}
return textsecure.storage.unprocessed.add(data);
},
async updateCache(envelope, plaintext) {
@ -463,6 +496,11 @@ MessageReceiver.prototype.extend({
item.sourceDevice = envelope.sourceDevice;
item.serverTimestamp = envelope.serverTimestamp;
// For medium-size closed groups
if (envelope.senderIdentity) {
item.senderIdentity = envelope.senderIdentity;
}
if (item.version === 2) {
item.decrypted = await MessageReceiver.arrayBufferToStringBase64(
plaintext
@ -599,6 +637,88 @@ MessageReceiver.prototype.extend({
return plaintext;
},
async postDecrypt(envelope, plaintext) {
const { isMe, isBlocked } = plaintext || {};
if (isMe || isBlocked) {
this.removeFromCache(envelope);
return null;
}
let conversation;
try {
conversation = await window.ConversationController.getOrCreateAndWait(
envelope.source,
'private'
);
} catch (e) {
window.log.info('Error getting conversation: ', envelope.source);
}
// Type here can actually be UNIDENTIFIED_SENDER even if
// the underlying message is FRIEND_REQUEST
if (envelope.type !== textsecure.protobuf.Envelope.Type.FRIEND_REQUEST) {
// If we got here there is a valid session, which meants friend request
// is complete (if it wasn't already)
if (conversation) {
const isFriendRequestAccept = await conversation.onFriendRequestAccepted();
if (isFriendRequestAccept) {
await conversation.notifyFriendRequest(envelope.source, 'accepted');
}
}
}
this.updateCache(envelope, plaintext).catch(error => {
window.log.error(
'decrypt failed to save decrypted message contents to cache:',
error && error.stack ? error.stack : error
);
});
return plaintext;
},
async decryptForMediumGroup(envelope, ciphertextObj) {
const groupId = envelope.source;
const identity = await window.Signal.Data.getIdentityKeyById(groupId);
const secretKeyHex = identity.secretKey;
if (!secretKeyHex) {
throw new Error(`Secret key is empty for group ${groupId}!`);
}
const { senderIdentity } = envelope;
const {
ciphertext: ciphertext2,
ephemeralKey,
} = textsecure.protobuf.MediumGroupContent.decode(ciphertextObj);
const ephemKey = ephemeralKey.toArrayBuffer();
const secretKey = dcodeIO.ByteBuffer.wrap(
secretKeyHex,
'hex'
).toArrayBuffer();
const res = await libloki.crypto.decryptForPubkey(
secretKey,
ephemKey,
ciphertext2.toArrayBuffer()
);
const {
ciphertext,
keyIdx,
} = textsecure.protobuf.MediumGroupCiphertext.decode(res);
const plaintext = await window.SenderKeyAPI.decryptWithSenderKey(
ciphertext.toArrayBuffer(),
keyIdx,
groupId,
senderIdentity
);
return plaintext;
},
async decrypt(envelope, ciphertext) {
let promise;
@ -627,6 +747,9 @@ MessageReceiver.prototype.extend({
.decryptWhisperMessage(ciphertext)
.then(this.unpad);
break;
case textsecure.protobuf.Envelope.Type.MEDIUM_GROUP_CIPHERTEXT:
promise = this.decryptForMediumGroup(envelope, ciphertext);
break;
case textsecure.protobuf.Envelope.Type.FRIEND_REQUEST: {
window.log.info('friend-request message from ', envelope.source);
@ -731,50 +854,7 @@ MessageReceiver.prototype.extend({
}
return promise
.then(async plaintext => {
const { isMe, isBlocked } = plaintext || {};
if (isMe || isBlocked) {
this.removeFromCache(envelope);
return null;
}
let conversation;
try {
conversation = await window.ConversationController.getOrCreateAndWait(
envelope.source,
'private'
);
} catch (e) {
window.log.info('Error getting conversation: ', envelope.source);
}
// Type here can actually be UNIDENTIFIED_SENDER even if
// the underlying message is FRIEND_REQUEST
if (
envelope.type !== textsecure.protobuf.Envelope.Type.FRIEND_REQUEST
) {
// If we got here there is a valid session, which meants friend request
// is complete (if it wasn't already)
if (conversation) {
const isFriendRequestAccept = await conversation.onFriendRequestAccepted();
if (isFriendRequestAccept) {
await conversation.notifyFriendRequest(
envelope.source,
'accepted'
);
}
}
}
this.updateCache(envelope, plaintext).catch(error => {
window.log.error(
'decrypt failed to save decrypted message contents to cache:',
error && error.stack ? error.stack : error
);
});
return plaintext;
})
.then(plaintext => this.postDecrypt(envelope, plaintext))
.catch(error => {
let errorToThrow = error;
@ -1115,162 +1195,258 @@ MessageReceiver.prototype.extend({
await conversation.setLokiProfile(newProfile);
},
handleDataMessage(envelope, msg) {
async handleMediumGroupUpdate(envelope, groupUpdate) {
const {
groupId,
groupSecretKey,
senderKey,
members,
groupName,
} = groupUpdate;
const convoExists = window.ConversationController.get(groupId, 'group');
if (convoExists) {
// If the group already exists, check that `members` is empty,
// and if so, it is sender key message
// TODO: introduce TYPE into this message instead?
if (!members || !members.length) {
log.info('[sender key] got a new sender key from:', envelope.source);
// We probably don't need to await here
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
this.removeFromCache(envelope);
return;
}
log.error(`Conversation for groupId ${groupId} already exists`);
}
const convo = await window.ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: groupSecretKey,
});
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
// TODO: Check that we are even a part of this group?
const ourIdentity = await textsecure.storage.user.getNumber();
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
{
// TODO: Send own key to every member
const otherMembers = _.without(members, ourIdentity);
const proto = new textsecure.protobuf.DataMessage();
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
update.groupId = groupId;
update.senderKey = ownSenderKey;
proto.mediumGroupUpdate = update;
// TODO: send to our linked devices too?
// Don't need to await here
// TODO: Some of the members might not have a session with us, so
// we should send a session request
textsecure.messaging.updateMediumGroup(otherMembers, proto);
}
// Subscribe to this group
this.pollForAdditionalId(groupId);
// All further messages (maybe rather than 'control' messages) should come to this group's swarm
this.removeFromCache(envelope);
},
async handleDataMessage(envelope, msg) {
window.log.info('data message from', this.getEnvelopeId(envelope));
let p = Promise.resolve();
if (msg.mediumGroupUpdate) {
this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
// TODO: investigate the meaning of this return value
return true;
}
// eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
p = this.handleEndSession(envelope.source);
await this.handleEndSession(envelope.source);
}
return p.then(() =>
this.processDecrypted(envelope, msg).then(async message => {
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const ourPubKey = textsecure.storage.user.getNumber();
const isMe = envelope.source === ourPubKey;
const conversation = window.ConversationController.get(envelope.source);
const isLeavingGroup = Boolean(
message.group &&
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
);
const friendRequest =
envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST;
const { UNPAIRING_REQUEST } = textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST);
if (!friendRequest && isUnpairingRequest) {
// TODO: move high-level pairing logic to libloki.multidevice.xx
const unpairingRequestIsLegit = async () => {
const isSecondary = textsecure.storage.get('isSecondaryDevice');
if (!isSecondary) {
return false;
}
const primaryPubKey = window.storage.get('primaryDevicePubKey');
// TODO: allow unpairing from any paired device?
if (envelope.source !== primaryPubKey) {
return false;
}
const primaryMapping = await lokiFileServerAPI.getUserDeviceMapping(
primaryPubKey
);
// If we don't have a mapping on the primary then we have been unlinked
if (!primaryMapping) {
return true;
}
const message = await this.processDecrypted(envelope, msg);
// We expect the primary device to have updated its mapping
// before sending the unpairing request
const found = primaryMapping.authorisations.find(
authorisation => authorisation.secondaryDevicePubKey === ourPubKey
);
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const ourPubKey = textsecure.storage.user.getNumber();
const isMe = envelope.source === ourPubKey;
const conversation = window.ConversationController.get(envelope.source);
const isLeavingGroup = Boolean(
message.group &&
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
);
const friendRequest =
envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST;
const { UNPAIRING_REQUEST } = textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST);
// our pubkey should NOT be in the primary device mapping
return !found;
};
if (!friendRequest && isUnpairingRequest) {
// TODO: move high-level pairing logic to libloki.multidevice.xx
const legit = await unpairingRequestIsLegit();
const unpairingRequestIsLegit = async () => {
const isSecondary = textsecure.storage.get('isSecondaryDevice');
if (!isSecondary) {
return false;
}
const primaryPubKey = window.storage.get('primaryDevicePubKey');
// TODO: allow unpairing from any paired device?
if (envelope.source !== primaryPubKey) {
return false;
}
this.removeFromCache(envelope);
const primaryMapping = await lokiFileServerAPI.getUserDeviceMapping(
primaryPubKey
);
if (legit) {
// remove our device mapping annotations from file server
await lokiFileServerAPI.clearOurDeviceMappingAnnotations();
// Delete the account and restart
try {
await window.Signal.Logs.deleteAll();
await window.Signal.Data.removeAll();
await window.Signal.Data.close();
await window.Signal.Data.removeDB();
await window.Signal.Data.removeOtherData();
// TODO generate an empty db with a flag
// to display a message about the unpairing
// after the app restarts
} catch (error) {
window.log.error(
'Something went wrong deleting all data:',
error && error.stack ? error.stack : error
);
}
window.restart();
}
// If we don't have a mapping on the primary then we have been unlinked
if (!primaryMapping) {
return true;
}
// Check if we need to update any profile names
if (!isMe && conversation) {
if (message.profile) {
await this.updateProfile(
conversation,
message.profile,
message.profileKey
);
}
}
// We expect the primary device to have updated its mapping
// before sending the unpairing request
const found = primaryMapping.authorisations.find(
authorisation => authorisation.secondaryDevicePubKey === ourPubKey
);
// If we got a friend request message or
// if we're not friends with the current user that sent this private message
// Check to see if we need to auto accept their friend request
const isGroupMessage = !!groupId;
if (friendRequest || (!isGroupMessage && !conversation.isFriend())) {
if (isMe) {
window.log.info('refusing to add a friend request to ourselves');
throw new Error('Cannot add a friend request for ourselves!');
} else {
const senderPubKey = envelope.source;
// fetch the device mapping from the server
const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping(
senderPubKey
);
// auto-accept friend request if the device is paired to one of our friend
const autoAccepted = await this.handleSecondaryDeviceFriendRequest(
senderPubKey,
deviceMapping
);
if (autoAccepted) {
// sending a message back = accepting friend request
// Directly setting friend request status to skip the pending state
await conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
window.libloki.api.sendBackgroundMessage(envelope.source);
return this.removeFromCache(envelope);
}
}
}
// our pubkey should NOT be in the primary device mapping
return !found;
};
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
window.log.warn(
`Message ${this.getEnvelopeId(
envelope
)} ignored; destined for blocked group`
const legit = await unpairingRequestIsLegit();
this.removeFromCache(envelope);
if (legit) {
// remove our device mapping annotations from file server
await lokiFileServerAPI.clearOurDeviceMappingAnnotations();
// Delete the account and restart
try {
await window.Signal.Logs.deleteAll();
await window.Signal.Data.removeAll();
await window.Signal.Data.close();
await window.Signal.Data.removeDB();
await window.Signal.Data.removeOtherData();
// TODO generate an empty db with a flag
// to display a message about the unpairing
// after the app restarts
} catch (error) {
window.log.error(
'Something went wrong deleting all data:',
error && error.stack ? error.stack : error
);
return this.removeFromCache(envelope);
}
window.restart();
}
}
if (!friendRequest && this.isMessageEmpty(message)) {
window.log.warn(
`Message ${this.getEnvelopeId(envelope)} ignored; it was empty`
// Check if we need to update any profile names
if (!isMe && conversation) {
if (message.profile) {
await this.updateProfile(
conversation,
message.profile,
message.profileKey
);
}
}
// If we got a friend request message or
// if we're not friends with the current user that sent this private message
// Check to see if we need to auto accept their friend request
const isGroupMessage = !!groupId;
if (friendRequest || (!isGroupMessage && !conversation.isFriend())) {
if (isMe) {
window.log.info('refusing to add a friend request to ourselves');
throw new Error('Cannot add a friend request for ourselves!');
} else {
const senderPubKey = envelope.source;
// fetch the device mapping from the server
const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping(
senderPubKey
);
// auto-accept friend request if the device is paired to one of our friend
const autoAccepted = await this.handleSecondaryDeviceFriendRequest(
senderPubKey,
deviceMapping
);
if (autoAccepted) {
// sending a message back = accepting friend request
// Directly setting friend request status to skip the pending state
await conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
window.libloki.api.sendBackgroundMessage(envelope.source);
return this.removeFromCache(envelope);
}
}
}
const ev = new Event('message');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
friendRequest,
source: envelope.source,
sourceDevice: envelope.sourceDevice,
timestamp: envelope.timestamp.toNumber(),
receivedAt: envelope.receivedAt,
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
message,
};
return this.dispatchAndWait(ev);
})
);
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
window.log.warn(
`Message ${this.getEnvelopeId(
envelope
)} ignored; destined for blocked group`
);
return this.removeFromCache(envelope);
}
if (!friendRequest && this.isMessageEmpty(message)) {
window.log.warn(
`Message ${this.getEnvelopeId(envelope)} ignored; it was empty`
);
return this.removeFromCache(envelope);
}
const ev = new Event('message');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
friendRequest,
source: envelope.source,
sourceDevice: envelope.sourceDevice,
timestamp: envelope.timestamp.toNumber(),
receivedAt: envelope.receivedAt,
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
message,
};
return this.dispatchAndWait(ev);
},
isMessageEmpty({
body,
@ -1306,19 +1482,16 @@ MessageReceiver.prototype.extend({
const message = textsecure.protobuf.DataMessage.decode(plaintext);
return this.handleDataMessage(envelope, message);
},
handleContentMessage(envelope) {
return this.decrypt(envelope, envelope.content).then(plaintext => {
if (!plaintext) {
window.log.warn('handleContentMessage: plaintext was falsey');
return null;
} else if (
plaintext instanceof ArrayBuffer &&
plaintext.byteLength === 0
) {
return null;
}
return this.innerHandleContentMessage(envelope, plaintext);
});
async handleContentMessage(envelope) {
const plaintext = await this.decrypt(envelope, envelope.content);
if (!plaintext) {
window.log.warn('handleContentMessage: plaintext was falsey');
return null;
} else if (plaintext instanceof ArrayBuffer && plaintext.byteLength === 0) {
return null;
}
return this.innerHandleContentMessage(envelope, plaintext);
},
async innerHandleContentMessage(envelope, plaintext) {
const content = textsecure.protobuf.Content.decode(plaintext);
@ -1885,6 +2058,10 @@ textsecure.MessageReceiver = function MessageReceiverWrapper(
this.downloadAttachment = messageReceiver.downloadAttachment.bind(
messageReceiver
);
this.pollForAdditionalId = messageReceiver.pollForAdditionalId.bind(
messageReceiver
);
this.stopProcessing = messageReceiver.stopProcessing.bind(messageReceiver);
messageReceiver.connect();

@ -7,6 +7,7 @@
StringView,
lokiMessageAPI,
i18n,
log
*/
/* eslint-disable more/no-then */
@ -30,6 +31,82 @@ const getTTLForType = type => {
}
};
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,
@ -66,11 +143,13 @@ function OutgoingMessage(
messageType,
isPing,
isPublic,
isMediumGroup,
publicSendData,
} =
options || {};
this.numberInfo = numberInfo;
this.isPublic = isPublic;
this.isMediumGroup = !!isMediumGroup;
this.isGroup = !!(
this.message &&
this.message.dataMessage &&
@ -115,11 +194,12 @@ OutgoingMessage.prototype = {
this.errors[this.errors.length] = error;
this.numberCompleted();
},
reloadDevicesAndSend(number, recurse) {
reloadDevicesAndSend(primaryPubKey) {
const ourNumber = textsecure.storage.user.getNumber();
return () =>
return (
libloki.storage
.getAllDevicePubKeysForPrimaryPubKey(number)
.getAllDevicePubKeysForPrimaryPubKey(primaryPubKey)
// Don't send to ourselves
.then(devicesPubKeys =>
devicesPubKeys.filter(pubKey => pubKey !== ourNumber)
@ -127,10 +207,11 @@ OutgoingMessage.prototype = {
.then(devicesPubKeys => {
if (devicesPubKeys.length === 0) {
// eslint-disable-next-line no-param-reassign
devicesPubKeys = [number];
devicesPubKeys = [primaryPubKey];
}
return this.doSendMessage(number, devicesPubKeys, recurse);
});
return this.doSendMessage(primaryPubKey, devicesPubKeys);
})
);
},
getKeysForNumber(number, updateDevices) {
@ -243,58 +324,8 @@ OutgoingMessage.prototype = {
}
},
getPaddedMessageLength(messageLength) {
const messageLengthWithTerminator = messageLength + 1;
let messagePartCount = Math.floor(messageLengthWithTerminator / 160);
if (messageLengthWithTerminator % 160 !== 0) {
messagePartCount += 1;
}
return messagePartCount * 160;
},
convertMessageToText(messageBuffer) {
const plaintext = new Uint8Array(
this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
);
plaintext.set(new Uint8Array(messageBuffer));
plaintext[messageBuffer.byteLength] = 0x80;
return plaintext;
},
getPlaintext(messageBuffer) {
return this.convertMessageToText(messageBuffer);
},
async wrapInWebsocketMessage(outgoingObject) {
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: this.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;
},
async buildMessage(devicePubKey) {
const updatedDevices = await this.getStaleDeviceIdsForNumber(devicePubKey);
const updatedDevices = await getStaleDeviceIdsForNumber(devicePubKey);
const keysFound = await this.getKeysForNumber(devicePubKey, updatedDevices);
let isMultiDeviceRequest = false;
@ -375,7 +406,7 @@ OutgoingMessage.prototype = {
messageBuffer = this.message.toArrayBuffer();
}
const plaintext = this.getPlaintext(messageBuffer);
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) {
@ -429,10 +460,11 @@ OutgoingMessage.prototype = {
);
}
const innerCiphertext = await sessionCipher.encrypt(plaintext);
const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher(
textsecure.storage.protocol
);
// ciphers[address.getDeviceId()] = secretSessionCipher;
const senderCert = new textsecure.protobuf.SenderCertificate();
@ -440,10 +472,9 @@ OutgoingMessage.prototype = {
senderCert.senderDevice = deviceId;
const ciphertext = await secretSessionCipher.encrypt(
address,
address.getName(),
senderCert,
plaintext,
sessionCipher
innerCiphertext
);
const type = textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER;
const content = window.Signal.Crypto.arrayBufferToBase64(ciphertext);
@ -460,20 +491,89 @@ OutgoingMessage.prototype = {
};
},
// Send a message to a public group
sendPublicMessage(number) {
return this.transmitMessage(
async sendPublicMessage(number) {
await this.transmitMessage(
number,
this.message.dataMessage,
this.timestamp,
0 // ttl
)
.then(() => {
this.successfulNumbers[this.successfulNumbers.length] = number;
this.numberCompleted();
})
.catch(error => {
throw error;
});
);
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(),
isFriendRequest: false,
isSessionRequest: false,
};
// 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 or a session chat (one to one)
async sendSessionMessage(outgoingObjects) {
@ -489,7 +589,10 @@ OutgoingMessage.prototype = {
isSessionRequest,
} = outgoingObject;
try {
const socketMessage = await this.wrapInWebsocketMessage(outgoingObject);
const socketMessage = wrapInWebsocketMessage(
outgoingObject,
this.timestamp
);
await this.transmitMessage(
destination,
socketMessage,
@ -526,45 +629,23 @@ OutgoingMessage.prototype = {
return this.encryptMessage(clearMessage);
},
// eslint-disable-next-line no-unused-vars
doSendMessage(number, devicesPubKeys, recurse) {
async doSendMessage(primaryPubKey, devicesPubKeys) {
if (this.isPublic) {
return this.sendPublicMessage(number);
await this.sendPublicMessage(primaryPubKey);
return;
}
this.numbers = devicesPubKeys;
return Promise.all(
devicesPubKeys.map(devicePubKey => this.buildAndEncrypt(devicePubKey))
)
.then(outgoingObjects => this.sendSessionMessage(outgoingObjects))
.catch(error => {
// TODO(loki): handle http errors properly
// - retry later if 400
// - ignore if 409 (conflict) means the hash already exists
throw error;
});
},
if (this.isMediumGroup) {
await this.sendMediumGroupMessage(primaryPubKey);
return;
}
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);
});
const outgoingObjects = await Promise.all(
devicesPubKeys.map(pk => this.buildAndEncrypt(pk, primaryPubKey))
);
this.sendSessionMessage(outgoingObjects);
},
removeDeviceIdsForNumber(number, deviceIdsToRemove) {
@ -586,7 +667,7 @@ OutgoingMessage.prototype = {
} catch (e) {
// do nothing
}
return this.reloadDevicesAndSend(number, true)().catch(error => {
return this.reloadDevicesAndSend(number).catch(error => {
conversation.resetPendingSend();
if (error.message === 'Identity key changed') {
// eslint-disable-next-line no-param-reassign

@ -449,6 +449,7 @@ MessageSender.prototype = {
haveSession ||
keysFound ||
options.isPublic ||
options.isMediumGroup ||
options.messageType === 'friend-request'
) {
this.queueJobForNumber(number, () => outgoing.sendToNumber(number));
@ -1128,6 +1129,16 @@ MessageSender.prototype = {
numbers = [groupId];
}
const profile = this.getOurProfile();
let group;
// Medium groups don't need this info
if (!options.isMediumGroup) {
group = {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
};
}
const attrs = {
recipients: numbers,
body: messageText,
@ -1139,26 +1150,37 @@ MessageSender.prototype = {
expireTimer,
profileKey,
profile,
group: {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
group,
};
if (numbers.length === 0) {
return Promise.resolve({
return {
successfulNumbers: [],
failoverNumbers: [],
errors: [],
unidentifiedDeliveries: [],
dataMessage: await this.getMessageProtoObj(attrs),
});
};
}
return this.sendMessage(attrs, options);
},
updateGroup(groupId, name, avatar, members, admins, recipients, options) {
async updateMediumGroup(members, groupUpdateProto) {
await this.sendGroupProto(members, groupUpdateProto, Date.now(), {
isPublic: false,
});
},
async updateGroup(
groupId,
name,
avatar,
members,
admins,
recipients,
options
) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
@ -1172,14 +1194,14 @@ MessageSender.prototype = {
textsecure.storage.user.getNumber();
proto.group.admins = [primaryDeviceKey];
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
// TODO: re-enable this once we have attachments
proto.group.avatar = null;
return this.sendGroupProto(recipients, proto, Date.now(), options).then(
() => proto.group.id
);
});
const attachment = await this.makeAttachmentPointer(avatar);
proto.group.avatar = attachment;
// TODO: re-enable this once we have attachments
proto.group.avatar = null;
await this.sendGroupProto(recipients, proto, Date.now(), options);
return proto.group.id;
},
addNumberToGroup(groupId, newNumbers, options) {
@ -1323,6 +1345,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
this.sendMessageToGroup = sender.sendMessageToGroup.bind(sender);
this.sendTypingMessage = sender.sendTypingMessage.bind(sender);
this.updateGroup = sender.updateGroup.bind(sender);
this.updateMediumGroup = sender.updateMediumGroup.bind(sender);
this.addNumberToGroup = sender.addNumberToGroup.bind(sender);
this.setGroupName = sender.setGroupName.bind(sender);
this.setGroupAvatar = sender.setGroupAvatar.bind(sender);

@ -22,6 +22,7 @@
"start-prod-multi": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod1 electron .",
"start-swarm-test": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=1 electron .",
"start-swarm-test-2": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=2 electron .",
"start-swarm-test-3": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=3 electron .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt",

@ -317,6 +317,8 @@ window.WebAPI = initializeWebAPI();
window.seedNodeList = JSON.parse(config.seedNodeList);
const LokiSnodeAPI = require('./js/modules/loki_snode_api');
window.SenderKeyAPI = require('./js/modules/loki_sender_key_api');
window.lokiSnodeAPI = new LokiSnodeAPI({
serverUrl: config.serverUrl,
localUrl: config.localUrl,

@ -12,6 +12,7 @@ message Envelope {
PREKEY_BUNDLE = 3; //Used By Signal. DO NOT TOUCH! we don't use this at all.
RECEIPT = 5;
UNIDENTIFIED_SENDER = 6;
MEDIUM_GROUP_CIPHERTEXT = 7;
FRIEND_REQUEST = 101; // contains prekeys + message and is using simple encryption
}
@ -39,6 +40,30 @@ message Content {
optional PairingAuthorisationMessage pairingAuthorisation = 103;
}
message MediumGroupCiphertext {
optional bytes ciphertext = 1;
optional string source = 2;
optional uint32 keyIdx = 3;
}
message MediumGroupContent {
optional bytes ciphertext = 1;
optional bytes ephemeralKey = 2;
}
message MediumGroupUpdate {
optional string groupName = 1;
optional string groupId = 2; // should this be bytes?
optional string groupSecretKey = 3;
optional string senderKey = 4;
repeated string members = 5;
}
message SenderKeyUpdate {
optional string groupId = 1;
optional string senderKey = 2;
}
message LokiAddressMessage {
enum Type {
HOST_REACHABLE = 0;
@ -219,7 +244,8 @@ message DataMessage {
repeated Contact contact = 9;
repeated Preview preview = 10;
optional LokiProfile profile = 101; // Loki: The profile of the current user
optional GroupInvitation groupInvitation = 102; // Loki: Invitation to a public chat
optional GroupInvitation groupInvitation = 102; // Loki: Invitation to a public chat
optional MediumGroupUpdate mediumGroupUpdate = 103; // Loki
}
message NullMessage {

Loading…
Cancel
Save