Merge pull request #539 from neuroscr/multidevice-publicchat

[multi-device] public chat
This commit is contained in:
Beaudan Campbell-Brown 2019-11-15 12:10:56 +11:00 committed by GitHub
commit a2e8e6480f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 392 additions and 79 deletions

View file

@ -1445,7 +1445,10 @@
return handleProfileUpdate({ data, confirm, messageDescriptor });
}
const ourNumber = textsecure.storage.user.getNumber();
const primaryDeviceKey = window.storage.get('primaryDevicePubKey');
const allOurDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
primaryDeviceKey
);
const descriptorId = await textsecure.MessageReceiver.arrayBufferToString(
messageDescriptor.id
);
@ -1453,7 +1456,7 @@
if (
messageDescriptor.type === 'group' &&
descriptorId.match(/^publicChat:/) &&
data.source === ourNumber
allOurDevices.includes(data.source)
) {
// Public chat messages from ourselves should be outgoing
message = await createSentMessage(data);

View file

@ -1268,7 +1268,11 @@
this.trigger('sent', this);
if (this.get('type') !== 'friend-request') {
this.sendSyncMessage();
const c = this.getConversation();
// Don't bother sending sync messages to public chats
if (!c.isPublic()) {
this.sendSyncMessage();
}
}
})
.catch(result => {

View file

@ -1,5 +1,6 @@
/* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController,
clearTimeout, MessageController, libsignal, StringView, window, _, dcodeIO, Buffer */
clearTimeout, MessageController, libsignal, StringView, window, _, lokiFileServerAPI,
dcodeIO, Buffer */
const EventEmitter = require('events');
const nodeFetch = require('node-fetch');
const { URL, URLSearchParams } = require('url');
@ -16,12 +17,17 @@ const ATTACHMENT_TYPE = 'net.app.core.oembed';
const LOKI_ATTACHMENT_TYPE = 'attachment';
const LOKI_PREVIEW_TYPE = 'preview';
// not quite a singleton yet (one for chat and one per file server)
class LokiAppDotNetAPI extends EventEmitter {
constructor(ourKey) {
super();
this.ourKey = ourKey;
this.servers = [];
this.myPrivateKey = false;
// Multidevice states
this.slavePrimaryMap = {};
this.primaryUserProfileName = {};
}
async close() {
@ -162,6 +168,55 @@ class LokiAppDotNetServerAPI {
}
}
this.token = token;
// verify token info
const tokenRes = await this.serverRequest('token');
// if no problems and we have data
if (
!tokenRes.err &&
tokenRes.response &&
tokenRes.response.data &&
tokenRes.response.data.user
) {
// get our profile name and write it to the network
const ourNumber = textsecure.storage.user.getNumber();
const profileConvo = ConversationController.get(ourNumber);
const profileName = profileConvo.getProfileName();
// update profile name as needed
if (tokenRes.response.data.user.name !== profileName) {
if (profileName) {
// will need this when we add an annotation
/*
const privKey = await this.serverAPI.chatAPI.getPrivateKey();
// we might need an annotation that sets the homeserver for media
// better to include this with each attachment...
const objToSign = {
name: profileName,
version: 1,
annotations: [],
};
const sig = await libsignal.Curve.async.calculateSignature(
privKey,
JSON.stringify(objToSign)
);
*/
await this.serverRequest('users/me', {
method: 'PATCH',
objBody: {
name: profileName,
},
});
// no big deal if it fails...
// } else {
// should we update the local from the server?
// guessing no because there will be multiple servers
}
// update our avatar if needed
}
}
return token;
}
@ -338,6 +393,36 @@ class LokiAppDotNetServerAPI {
return res.response.data.annotations || [];
}
async getUsers(pubKeys) {
if (!pubKeys) {
log.warn('No pubKeys provided to getUsers!');
return [];
}
// ok to call without
if (!pubKeys.length) {
return [];
}
if (pubKeys.length > 200) {
log.warn('Too many pubKeys given to getUsers!');
}
const res = await this.serverRequest('users', {
method: 'GET',
params: {
ids: pubKeys.join(','),
include_user_annotations: 1,
},
});
if (res.err || !res.response || !res.response.data) {
if (res.err) {
log.error(`Error ${res.err}`);
}
return [];
}
return res.response.data || [];
}
// Only one annotation at a time
async setSelfAnnotation(type, value) {
const annotation = { type };
@ -481,53 +566,6 @@ class LokiPublicChannelAPI {
}
await this.conversation.setModerators(moderators || []);
// get token info
const tokenRes = await this.serverRequest('token');
// if no problems and we have data
if (
!tokenRes.err &&
tokenRes.response &&
tokenRes.response.data &&
tokenRes.response.data.user
) {
// get our profile name and write it to the network
const profileConvo = ConversationController.get(ourNumber);
const profileName = profileConvo.getProfileName();
// update profile name as needed
if (tokenRes.response.data.user.name !== profileName) {
if (profileName) {
// will need this when we add an annotation
/*
const privKey = await this.serverAPI.chatAPI.getPrivateKey();
// we might need an annotation that sets the homeserver for media
// better to include this with each attachment...
const objToSign = {
name: profileName,
version: 1,
annotations: [],
};
const sig = await libsignal.Curve.async.calculateSignature(
privKey,
JSON.stringify(objToSign)
);
*/
await this.serverRequest('users/me', {
method: 'PATCH',
objBody: {
name: profileName,
},
});
// no big deal if it fails...
// } else {
// should we update the local from the server?
// guessing no because there will be multiple servers
}
// update our avatar if needed
}
}
}
// delete a message on the server
@ -798,9 +836,17 @@ class LokiPublicChannelAPI {
params,
});
if (!res.err && res.response) {
let receivedAt = new Date().getTime();
res.response.data.reverse().forEach(async adnMessage => {
if (res.err || !res.response) {
return;
}
let receivedAt = new Date().getTime();
const pubKeys = [];
let pendingMessages = [];
// the signature forces this to be async
pendingMessages = await Promise.all(
res.response.data.reverse().map(async adnMessage => {
// still update our last received if deleted, not signed or not valid
this.lastGot = !this.lastGot
? adnMessage.id
@ -813,17 +859,17 @@ class LokiPublicChannelAPI {
!adnMessage.text ||
adnMessage.is_deleted
) {
return; // Invalid or delete message
return false; // Invalid or delete message
}
const messengerData = await this.getMessengerData(adnMessage);
if (messengerData === false) {
return;
return false;
}
const { timestamp, quote, attachments, preview } = messengerData;
if (!timestamp) {
return; // Invalid message
return false; // Invalid message
}
// Duplicate check
@ -841,9 +887,10 @@ class LokiPublicChannelAPI {
// Filter out any messages that we got previously
if (this.lastMessagesCache.some(isDuplicate)) {
return; // Duplicate message
return false; // Duplicate message
}
// FIXME: maybe move after the de-multidev-decode
// Add the message to the lastMessage cache and keep the last 5 recent messages
this.lastMessagesCache = [
...this.lastMessagesCache,
@ -856,6 +903,12 @@ class LokiPublicChannelAPI {
const from = adnMessage.user.name || 'Anonymous'; // profileName
// track sources for multidevice support
if (pubKeys.indexOf(`@${adnMessage.user.username}`) === -1) {
pubKeys.push(`@${adnMessage.user.username}`);
}
// generate signal message object
const messageData = {
serverId: adnMessage.id,
clientVerified: true,
@ -863,6 +916,7 @@ class LokiPublicChannelAPI {
source: adnMessage.user.username,
sourceDevice: 1,
timestamp,
serverTimestamp: timestamp,
receivedAt,
isPublic: true,
@ -890,15 +944,91 @@ class LokiPublicChannelAPI {
};
receivedAt += 1; // Ensure different arrival times
// now process any user meta data updates
// - update their conversation with a potentially new avatar
return messageData;
})
);
this.conversation.setLastRetrievedMessage(this.lastGot);
// do we really need this?
if (!pendingMessages.length) {
return;
}
// get list of verified primary PKs
const verifiedPrimaryPKs = await lokiFileServerAPI.verifyPrimaryPubKeys(
pubKeys
);
// access slavePrimaryMap set by verifyPrimaryPubKeys
const { slavePrimaryMap } = this.serverAPI.chatAPI;
// sort pending messages by if slave device or not
/* eslint-disable no-param-reassign */
const slaveMessages = pendingMessages.reduce((retval, messageData) => {
// if a known slave, queue
if (slavePrimaryMap[messageData.source]) {
// delay sending the message
if (retval[messageData.source] === undefined) {
retval[messageData.source] = [messageData];
} else {
retval[messageData.source].push(messageData);
}
} else {
// no user or isPrimary means not multidevice, send event now
this.serverAPI.chatAPI.emit('publicMessage', {
message: messageData,
});
}
return retval;
}, {});
/* eslint-enable no-param-reassign */
// now process any user meta data updates
// - update their conversation with a potentially new avatar
pendingMessages = []; // allow memory to be freed
// get actual chat server data (mainly the name rn) of primary device
const verifiedDeviceResults = await this.serverAPI.getUsers(
verifiedPrimaryPKs
);
// build map of userProfileName to primaryKeys
/* eslint-disable no-param-reassign */
this.primaryUserProfileName = verifiedDeviceResults.reduce(
(mapOut, user) => {
mapOut[user.username] = user.name;
return mapOut;
},
{}
);
/* eslint-enable no-param-reassign */
// process remaining messages
const ourNumber = textsecure.storage.user.getNumber();
Object.keys(slaveMessages).forEach(slaveKey => {
// prevent our own device sent messages from coming back in
if (slaveKey === ourNumber) {
// we originally sent these
return;
}
// look up primary device once
const primaryPubKey = slavePrimaryMap[slaveKey];
// send out remaining messages for this merged identity
slaveMessages[slaveKey].forEach(messageDataP => {
const messageData = messageDataP; // for linter
if (slavePrimaryMap[messageData.source]) {
// rewrite source, profile
messageData.source = primaryPubKey;
messageData.message.profile.displayName = this.primaryUserProfileName[
primaryPubKey
];
}
this.serverAPI.chatAPI.emit('publicMessage', {
message: messageData,
});
});
this.conversation.setLastRetrievedMessage(this.lastGot);
}
});
}
static getPreviewFromAnnotation(annotation) {

View file

@ -1,3 +1,4 @@
/* global window, log, libloki */
/* global storage: false */
/* global Signal: false */
/* global log: false */
@ -6,6 +7,8 @@ const LokiAppDotNetAPI = require('./loki_app_dot_net_api');
const DEVICE_MAPPING_ANNOTATION_KEY = 'network.loki.messenger.devicemapping';
// can have multiple of these objects instances as each user can have a
// different home server
class LokiFileServerAPI {
constructor(ourKey) {
this.ourKey = ourKey;
@ -43,6 +46,166 @@ class LokiFileServerAPI {
return this._setOurDeviceMapping(authorisations, isPrimary);
}
async getDeviceMappingForUsers(pubKeys) {
const users = await this._server.getUsers(pubKeys);
return users;
}
async verifyUserObjectDeviceMap(pubKeys, isRequest, iterator) {
const users = await this.getDeviceMappingForUsers(pubKeys);
// go through each user and find deviceMap annotations
const notFoundUsers = [];
await Promise.all(
users.map(async user => {
let found = false;
if (!user.annotations || !user.annotations.length) {
log.info(
`verifyUserObjectDeviceMap no annotation for ${user.username}`
);
return;
}
const mappingNote = user.annotations.find(
note => note.type === DEVICE_MAPPING_ANNOTATION_KEY
);
const { authorisations } = mappingNote.value;
if (!Array.isArray(authorisations)) {
return;
}
await Promise.all(
authorisations.map(async auth => {
// only skip, if in secondary search mode
if (isRequest && auth.secondaryDevicePubKey !== user.username) {
// this is not the authorization we're looking for
log.info(
`Request and ${auth.secondaryDevicePubKey} != ${user.username}`
);
return;
}
const valid = await libloki.crypto.validateAuthorisation(auth);
if (valid && iterator(user.username, auth)) {
found = true;
}
})
); // end map authorisations
if (!found) {
notFoundUsers.push(user.username);
}
})
); // end map users
// log.info('done with users', users.length);
return notFoundUsers;
}
// verifies list of pubKeys for any deviceMappings
// returns the relevant primary pubKeys
async verifyPrimaryPubKeys(pubKeys) {
const newSlavePrimaryMap = {}; // new slave to primary map
// checkSig disabled for now
// const checkSigs = {}; // cache for authorisation
const primaryPubKeys = [];
// go through multiDeviceResults and get primary Pubkey
await this.verifyUserObjectDeviceMap(pubKeys, true, (slaveKey, auth) => {
// if we already have this key for a different device
if (
newSlavePrimaryMap[slaveKey] &&
newSlavePrimaryMap[slaveKey] !== auth.primaryDevicePubKey
) {
log.warn(
`file server user annotation primaryKey mismatch, had ${
newSlavePrimaryMap[slaveKey]
} now ${auth.primaryDevicePubKey} for ${slaveKey}`
);
return;
}
// at this point it's valid
// add to primaryPubKeys
if (primaryPubKeys.indexOf(`@${auth.primaryDevicePubKey}`) === -1) {
primaryPubKeys.push(`@${auth.primaryDevicePubKey}`);
}
// add authorisation cache
/*
if (checkSigs[`${auth.primaryDevicePubKey}_${slaveKey}`] !== undefined) {
log.warn(
`file server ${auth.primaryDevicePubKey} to ${slaveKey} double signed`
);
}
checkSigs[`${auth.primaryDevicePubKey}_${slaveKey}`] = auth;
*/
// add map to newSlavePrimaryMap
newSlavePrimaryMap[slaveKey] = auth.primaryDevicePubKey;
}); // end verifyUserObjectDeviceMap
// no valid primary pubkeys to check
if (!primaryPubKeys.length) {
// log.warn(`no valid primary pubkeys to check ${pubKeys}`);
return [];
}
const verifiedPrimaryPKs = [];
// get a list of all of primary pubKeys to verify the secondaryDevice assertion
const notFoundUsers = await this.verifyUserObjectDeviceMap(
primaryPubKeys,
false,
primaryKey => {
// add to verified list if we don't already have it
if (verifiedPrimaryPKs.indexOf(`@${primaryKey}`) === -1) {
verifiedPrimaryPKs.push(`@${primaryKey}`);
}
// assuming both are ordered the same way
// make sure our secondary and primary authorization match
/*
if (
JSON.stringify(checkSigs[
`${auth.primaryDevicePubKey}_${auth.secondaryDevicePubKey}`
]) !== JSON.stringify(auth)
) {
// should hopefully never happen
// it did, old pairing data, I think...
log.warn(
`Valid authorizations from ${
auth.secondaryDevicePubKey
} does not match ${primaryKey}`
);
return false;
}
*/
return true;
}
); // end verifyUserObjectDeviceMap
// remove from newSlavePrimaryMap if no valid mapping is found
notFoundUsers.forEach(primaryPubKey => {
Object.keys(newSlavePrimaryMap).forEach(slaveKey => {
if (newSlavePrimaryMap[slaveKey] === primaryPubKey) {
log.warn(
`removing unverifible ${slaveKey} to ${primaryPubKey} mapping`
);
delete newSlavePrimaryMap[slaveKey];
}
});
});
// FIXME: move to a return value since we're only scoped to pubkeys given
// make new map final
window.lokiPublicChatAPI.slavePrimaryMap = newSlavePrimaryMap;
log.info(
`Updated device mappings ${JSON.stringify(
window.lokiPublicChatAPI.slavePrimaryMap
)}`
);
return verifiedPrimaryPKs;
}
_setOurDeviceMapping(authorisations, isPrimary) {
const content = {
isPrimary: isPrimary ? '1' : '0',

View file

@ -175,16 +175,13 @@
return signature;
}
async function validateAuthorisation(authorisation) {
async function verifyAuthorisation(authorisation) {
const {
primaryDevicePubKey,
secondaryDevicePubKey,
requestSignature,
grantSignature,
} = authorisation;
const alreadySecondaryDevice = !!window.storage.get('isSecondaryDevice');
const ourPubKey = textsecure.storage.user.getNumber();
const isRequest = !grantSignature;
const isGrant = !!grantSignature;
if (!primaryDevicePubKey || !secondaryDevicePubKey) {
window.log.warn(
@ -196,19 +193,6 @@
'Received a pairing request with missing request signature. Ignored.'
);
return false;
} else if (isRequest && alreadySecondaryDevice) {
window.log.warn(
'Received a pairing request while being a secondary device. Ignored.'
);
return false;
} else if (isRequest && authorisation.primaryDevicePubKey !== ourPubKey) {
window.log.warn(
'Received a pairing request addressed to another pubkey. Ignored.'
);
return false;
} else if (isRequest && authorisation.secondaryDevicePubKey === ourPubKey) {
window.log.warn('Received a pairing request from ourselves. Ignored.');
return false;
}
const verify = async (signature, signatureType) => {
const encoding = typeof signature === 'string' ? 'base64' : undefined;
@ -228,6 +212,7 @@
window.log.error(e);
return false;
}
// can't have grant without requestSignature?
if (isGrant) {
try {
await verify(grantSignature, PairingType.GRANT);
@ -242,6 +227,33 @@
return true;
}
// FIXME: rename to include the fact it's relative to YOUR device
async function validateAuthorisation(authorisation) {
const {
primaryDevicePubKey,
secondaryDevicePubKey,
grantSignature,
} = authorisation;
const alreadySecondaryDevice = !!window.storage.get('isSecondaryDevice');
const ourPubKey = textsecure.storage.user.getNumber();
const isRequest = !grantSignature;
if (isRequest && alreadySecondaryDevice) {
window.log.warn(
'Received a pairing request while being a secondary device. Ignored.'
);
return false;
} else if (isRequest && primaryDevicePubKey !== ourPubKey) {
window.log.warn(
'Received a pairing request addressed to another pubkey. Ignored.'
);
return false;
} else if (isRequest && secondaryDevicePubKey === ourPubKey) {
window.log.warn('Received a pairing request from ourselves. Ignored.');
return false;
}
return this.verifyAuthorisation(authorisation);
}
async function verifyPairingSignature(
primaryDevicePubKey,
secondaryPubKey,
@ -307,6 +319,7 @@
decryptToken,
generateSignatureForPairing,
verifyPairingSignature,
verifyAuthorisation,
validateAuthorisation,
PairingType,
// for testing